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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -19,15 +19,14 @@ target/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
examples/django-react-site/harness/test-results/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Build artifacts
|
||||
examples/django-react-desktop-app/frontend/dist/
|
||||
examples/django-react-site/harness/src/api/generated.*
|
||||
examples/django-react-site/harness/test-results/
|
||||
protocol/mizan-generate/bin/mizan-generate-*
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
@@ -1,98 +1,31 @@
|
||||
"""
|
||||
mizan - Django + React unified framework
|
||||
The mizan package surface: the `client` decorator with its context types, the
|
||||
`Channel` base and its registry, the form/shape/export submodules, and
|
||||
`wrap_asgi`, which mounts the WebSocket consumer alongside an HTTP application.
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. urls.py - HTTP endpoint
|
||||
```python
|
||||
from mizan import urls as mizan_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('api/mizan/', include(mizan_urls)),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. asgi.py - WebSocket support (optional)
|
||||
```python
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
### 3. Define server functions
|
||||
```python
|
||||
# apps/myapp/clients.py
|
||||
from mizan import client
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
# HTTP-only function (default)
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"Echo: {text}")
|
||||
|
||||
# Global context (singleton, SSR-hydrated)
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
|
||||
# WebSocket-enabled for real-time
|
||||
@client(websocket=True)
|
||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
||||
return MessageOutput(...)
|
||||
```
|
||||
|
||||
### 4. Auto-discover in apps.py
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
from mizan.setup import mizan_clients
|
||||
mizan_clients('apps')
|
||||
```
|
||||
|
||||
### 5. Frontend - generate types and use
|
||||
```bash
|
||||
npm run schemas
|
||||
```
|
||||
```tsx
|
||||
import { useEcho, useCurrentUser } from '@/api'
|
||||
|
||||
const user = useCurrentUser()
|
||||
const echo = useEcho()
|
||||
await echo({ text: 'hello' })
|
||||
```
|
||||
|
||||
## What You Get
|
||||
|
||||
| Backend | Frontend | Transport |
|
||||
|------------------------------------|-----------------------|------------|
|
||||
| `@client` | `useXxx()` hook | HTTP |
|
||||
| `@client(context='global')` | `useXxx()` + SSR | HTTP |
|
||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
`urls` and `Shape` resolve through `__getattr__` rather than at import time.
|
||||
"""
|
||||
|
||||
# All imports at module level (sorted)
|
||||
from . import channels
|
||||
from . import client as client_module
|
||||
from . import export
|
||||
from . import forms
|
||||
from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||
from mizan import channels
|
||||
from mizan import client as client_module
|
||||
from mizan import export
|
||||
from mizan import forms
|
||||
from mizan import setup
|
||||
from mizan.channels import Channel
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.client import (
|
||||
ComposedContext,
|
||||
GlobalContext,
|
||||
ReactContext,
|
||||
ServerFunction,
|
||||
client,
|
||||
compose,
|
||||
)
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
from .setup import (
|
||||
from mizan.setup import (
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
get_channel,
|
||||
@@ -105,11 +38,11 @@ from .setup import (
|
||||
def __getattr__(name):
|
||||
"""Lazy loading for modules that can't be imported at app load time."""
|
||||
if name == "urls":
|
||||
from .urls import urlpatterns as mizan_patterns
|
||||
from mizan.urls import urlpatterns as mizan_patterns
|
||||
|
||||
return mizan_patterns
|
||||
if name == "Shape":
|
||||
from .shapes import Shape
|
||||
from mizan.shapes import Shape
|
||||
|
||||
return Shape
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -117,17 +50,8 @@ def __getattr__(name):
|
||||
|
||||
def wrap_asgi(http_application):
|
||||
"""
|
||||
Wrap an ASGI application with mizan WebSocket support.
|
||||
|
||||
Usage in asgi.py:
|
||||
from django.core.asgi import get_asgi_application
|
||||
from mizan import wrap_asgi
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
This adds:
|
||||
- WebSocket routing at /ws/ for RPC and channels
|
||||
- Authentication middleware for WebSocket connections
|
||||
Route HTTP to `http_application` and /ws/ to the mizan consumer, with the
|
||||
channels auth middleware supplying `scope["user"]` on the socket branch.
|
||||
"""
|
||||
try:
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
@@ -140,7 +64,7 @@ def wrap_asgi(http_application):
|
||||
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
||||
)
|
||||
|
||||
from .channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
return ProtocolTypeRouter(
|
||||
{
|
||||
@@ -174,7 +98,7 @@ __all__ = [
|
||||
# ASGI
|
||||
"wrap_asgi",
|
||||
# Channels
|
||||
"ReactChannel",
|
||||
"Channel",
|
||||
"register_channel",
|
||||
# Shapes
|
||||
"Shape",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import inspect
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from importlib import import_module
|
||||
from inspect import isclass
|
||||
from typing import Protocol, Any
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_members(path):
|
||||
try:
|
||||
module = import_module(path)
|
||||
except ModuleNotFoundError:
|
||||
print('Could not import module "{}"'.format(path))
|
||||
except ModuleNotFoundError as exc:
|
||||
# Callers of this module write machine-read output to stdout, so the
|
||||
# diagnostic must not share that stream.
|
||||
print(f'Could not import module "{path}": {exc}', file=sys.stderr)
|
||||
return []
|
||||
|
||||
members = [
|
||||
@@ -21,7 +26,8 @@ def get_members(path):
|
||||
return members
|
||||
|
||||
|
||||
class DjangoAppVisitorHandler(Protocol):
|
||||
class DjangoAppVisitorHandler(ABC):
|
||||
@abstractmethod
|
||||
def on_module(
|
||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||
) -> None: ...
|
||||
@@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol):
|
||||
|
||||
class DjangoAppVisitor:
|
||||
"""
|
||||
Discovers Python modules under each Django app following conventions:
|
||||
- <app>/<module>.py -> url_prefix "<renamed>/"
|
||||
- <app>/<module>/**/*.py -> url_prefix "<renamed>/<subdirs...>/<module>/"
|
||||
Walks each installed app for modules named after `layer`:
|
||||
<app>/<layer>.py -> path_parts []
|
||||
<app>/<layer>/**/*.py -> path_parts [<subdirs...>, <stem>]
|
||||
|
||||
Example:
|
||||
<app>/<module>/forms/nksn.py -> url_prefix "<renamed>/forms/nksn/"
|
||||
module_path "<app>.module.forms.nksn"
|
||||
`apps_root` is the dotted package the apps live under, relative to
|
||||
BASE_DIR; "" means the apps sit directly at BASE_DIR.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -66,7 +71,6 @@ class DjangoAppVisitor:
|
||||
|
||||
app_module = f"{module_prefix}{app_name}"
|
||||
|
||||
# 1) Visit package: <app>/<module>/**/*.py
|
||||
layer_dir = app_dir / self.layer
|
||||
if layer_dir.is_dir():
|
||||
for py_file in layer_dir.rglob("*.py"):
|
||||
@@ -83,7 +87,6 @@ class DjangoAppVisitor:
|
||||
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
||||
)
|
||||
|
||||
# 2) Visit module module file: <app>/module.py
|
||||
layer_file = app_dir / f"{self.layer}.py"
|
||||
if layer_file.is_file():
|
||||
handler.on_module(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""
|
||||
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
||||
Origin-side cache keyed by HMAC digests of (context, params, user, rev).
|
||||
|
||||
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
||||
Scoped purge recomputes the key and deletes directly.
|
||||
Broad purge uses key-prefix scan (rare operation).
|
||||
|
||||
Usage:
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
There are no reverse indexes: a scoped purge recomputes the one key it needs
|
||||
and deletes it, and a purge with no params falls back to a key-prefix scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,7 +24,7 @@ _init_lock = threading.Lock()
|
||||
def get_cache() -> CacheBackend | None:
|
||||
"""
|
||||
Get the configured cache backend, or None if caching is disabled.
|
||||
Thread-safe.
|
||||
Thread-safe; the backend is built once on first call.
|
||||
"""
|
||||
global _cache_instance, _initialized
|
||||
if _initialized:
|
||||
@@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None:
|
||||
from mizan.setup.settings import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
# Both settings are required; one without the other is a
|
||||
# misconfiguration worth naming rather than silently ignoring.
|
||||
if settings.cache_secret and settings.cache_redis_url:
|
||||
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||
@@ -113,13 +111,11 @@ def cache_purge(
|
||||
rev: int = 0,
|
||||
) -> int:
|
||||
"""
|
||||
Purge cached entries for a context.
|
||||
Purge cached entries for a context and return how many were removed.
|
||||
|
||||
Scoped purge (params provided): recomputes the HMAC key and deletes
|
||||
it directly. One DELETE, no index needed.
|
||||
|
||||
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
||||
This is a rare operation (Tier 3 fallback in invalidation).
|
||||
With params and a secret, the exact key is recomputed and deleted — one
|
||||
DELETE. Without them, every key under the prefix "ctx:{context}:" is
|
||||
scanned and removed.
|
||||
"""
|
||||
if params is not None and len(params) > 0 and secret:
|
||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||
|
||||
@@ -1,81 +1,15 @@
|
||||
"""
|
||||
mizan.channels - Real-time WebSocket communication.
|
||||
|
||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||
Hooks are auto-generated with full TypeScript types.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```python
|
||||
# channels.py
|
||||
from pydantic import BaseModel
|
||||
from mizan import channels
|
||||
|
||||
class ChatChannel(channels.ReactChannel):
|
||||
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
timestamp: datetime
|
||||
|
||||
def authorize(self, params: Params) -> bool:
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params: Params) -> str:
|
||||
return f'chat_{params.room}'
|
||||
|
||||
def receive(self, params: Params, msg: ReactMessage) -> DjangoMessage | None:
|
||||
return self.DjangoMessage(
|
||||
user=self.user.email,
|
||||
text=msg.text,
|
||||
timestamp=now(),
|
||||
)
|
||||
|
||||
channels.register(ChatChannel, 'chat')
|
||||
```
|
||||
|
||||
```python
|
||||
# asgi.py
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": channels.get_websocket_application(),
|
||||
})
|
||||
```
|
||||
|
||||
## Frontend Usage (auto-generated)
|
||||
|
||||
```tsx
|
||||
import { useChatChannel } from '@/api/generated.channels'
|
||||
|
||||
function Chat({ room }) {
|
||||
const chat = useChatChannel({ room })
|
||||
|
||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
chat.messages // DjangoMessage[]
|
||||
chat.send({ text: 'Hello' }) // ReactMessage
|
||||
}
|
||||
```
|
||||
|
||||
## Server Push
|
||||
|
||||
```python
|
||||
await ChatChannel.push(room='general', message=ChatChannel.DjangoMessage(...))
|
||||
```
|
||||
"""
|
||||
"""WebSocket channels: the Channel base class, the channel registry, and
|
||||
the schema exports built from it."""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.ir import wire_to_pascal
|
||||
from mizan_core.registry import RegistryExtension, register_extension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
||||
from ninja import NinjaAPI
|
||||
@@ -84,36 +18,25 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
class Channel(abc.ABC):
|
||||
"""
|
||||
Base class for WebSocket channels.
|
||||
A WebSocket channel.
|
||||
|
||||
Define nested Pydantic classes for typed messaging:
|
||||
- Params: Query parameters for subscribing (optional)
|
||||
- ReactMessage: Messages from browser to server (optional)
|
||||
- DjangoMessage: Messages from server to browser (optional)
|
||||
Subclasses declare the wire types as nested Pydantic models:
|
||||
Params (subscription query parameters), ClientMessage (travels
|
||||
client -> server), ServerMessage (travels server -> client). Any
|
||||
slot left undeclared stays None and that direction is unavailable.
|
||||
|
||||
Implement required methods:
|
||||
- authorize(): Permission check for connection
|
||||
- group(): Which group to broadcast to
|
||||
|
||||
Optionally implement:
|
||||
- receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast
|
||||
- on_connect(): Called after successful connection
|
||||
- on_disconnect(): Called when connection closes
|
||||
authorize() and group() are abstract. receive(), on_connect() and
|
||||
on_disconnect() are the override points; each definition here records
|
||||
what happened and a subclass replaces or extends it.
|
||||
"""
|
||||
|
||||
# Nested classes (optional, defined by subclasses)
|
||||
Params: ClassVar[Type[BaseModel] | None] = None
|
||||
ReactMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
DjangoMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
ClientMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
ServerMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
|
||||
# Set by the framework when handling a connection
|
||||
# Set by the consumer when it builds an instance for a subscription.
|
||||
user: "AbstractBaseUser | AnonymousUser"
|
||||
_channel_layer: Any = None
|
||||
_channel_name: str = ""
|
||||
@@ -125,64 +48,58 @@ class ReactChannel:
|
||||
self._groups = set()
|
||||
self._params_dict = {}
|
||||
|
||||
@abc.abstractmethod
|
||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||
"""
|
||||
Permission check. Return True to allow connection, False to reject.
|
||||
|
||||
Override this to implement custom authorization logic.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} must implement authorize()"
|
||||
)
|
||||
"""Return True to allow the connection, False to reject it."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def group(self, params: BaseModel | None = None) -> str:
|
||||
"""
|
||||
Return the group name for broadcasting.
|
||||
|
||||
Messages returned from receive() are broadcast to this group.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||
"""Return the channel-layer group name this subscription broadcasts to."""
|
||||
|
||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||
"""
|
||||
Handle incoming ReactMessage.
|
||||
|
||||
Return a DjangoMessage to broadcast to the group, or None to skip.
|
||||
Override this to implement message handling.
|
||||
Handle one ClientMessage; a returned ServerMessage is broadcast to the
|
||||
group. A channel that accepts inbound frames overrides this — reaching
|
||||
the definition here means the frame has nowhere to go.
|
||||
"""
|
||||
logger.warning(
|
||||
"%s does not handle inbound %s; the frame is dropped",
|
||||
type(self).__name__,
|
||||
type(msg).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
async def on_connect(self, params: BaseModel | None = None) -> None:
|
||||
"""Called after successful connection and group join."""
|
||||
pass
|
||||
"""Runs after the group join; a subclass extends it via super()."""
|
||||
logger.debug(
|
||||
"%s subscription opened on %s",
|
||||
type(self).__name__,
|
||||
self._channel_name or "<no channel name>",
|
||||
)
|
||||
|
||||
async def on_disconnect(self) -> None:
|
||||
"""Called when the connection closes."""
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal Methods (used by the consumer)
|
||||
# -------------------------------------------------------------------------
|
||||
"""Runs as the subscription closes; a subclass extends it via super()."""
|
||||
logger.debug(
|
||||
"%s subscription closed, leaving %d group(s)",
|
||||
type(self).__name__,
|
||||
len(self._groups),
|
||||
)
|
||||
|
||||
async def _join_group(self, group_name: str) -> None:
|
||||
"""Join a channel layer group."""
|
||||
if self._channel_layer:
|
||||
await self._channel_layer.group_add(group_name, self._channel_name)
|
||||
self._groups.add(group_name)
|
||||
|
||||
async def _leave_group(self, group_name: str) -> None:
|
||||
"""Leave a channel layer group."""
|
||||
if self._channel_layer and group_name in self._groups:
|
||||
await self._channel_layer.group_discard(group_name, self._channel_name)
|
||||
self._groups.discard(group_name)
|
||||
|
||||
async def _leave_all_groups(self) -> None:
|
||||
"""Leave all joined groups."""
|
||||
for group_name in list(self._groups):
|
||||
await self._leave_group(group_name)
|
||||
|
||||
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
||||
"""Broadcast a message to a group."""
|
||||
if self._channel_layer:
|
||||
await self._channel_layer.group_send(
|
||||
group_name,
|
||||
@@ -195,20 +112,11 @@ class ReactChannel:
|
||||
},
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Class Methods for Server Push
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def push(cls, message: BaseModel, **params) -> None:
|
||||
"""
|
||||
Push a message from server code (views, tasks, signals).
|
||||
|
||||
Usage:
|
||||
await ChatChannel.push(
|
||||
room='general',
|
||||
message=ChatChannel.DjangoMessage(user='system', text='Hello')
|
||||
)
|
||||
Send a ServerMessage to every subscriber of the group named by the
|
||||
given params, from outside a subscription (views, tasks, signals).
|
||||
"""
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
@@ -219,16 +127,13 @@ class ReactChannel:
|
||||
)
|
||||
return
|
||||
|
||||
# Build params model if defined
|
||||
params_obj = None
|
||||
if cls.Params:
|
||||
params_obj = cls.Params(**params)
|
||||
|
||||
# Get group name
|
||||
instance = cls()
|
||||
group_name = instance.group(params_obj)
|
||||
|
||||
# Send to group
|
||||
await channel_layer.group_send(
|
||||
group_name,
|
||||
{
|
||||
@@ -241,63 +146,31 @@ class ReactChannel:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry
|
||||
# =============================================================================
|
||||
|
||||
_registry: dict[str, Type[ReactChannel]] = {}
|
||||
_registry: dict[str, Type[Channel]] = {}
|
||||
|
||||
|
||||
def register(channel_class: Type[ReactChannel], name: str) -> None:
|
||||
"""
|
||||
Register a channel.
|
||||
|
||||
Args:
|
||||
channel_class: The ReactChannel subclass to register
|
||||
name: URL-friendly name (used in subscriptions)
|
||||
"""
|
||||
def register(channel_class: Type[Channel], name: str) -> None:
|
||||
"""Register a channel class under a URL-friendly wire name."""
|
||||
if name in _registry:
|
||||
raise ValueError(f"Channel '{name}' is already registered")
|
||||
|
||||
channel_class._registered_name = name
|
||||
|
||||
# Validate the channel class
|
||||
if not hasattr(channel_class, "authorize"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||
if not hasattr(channel_class, "group"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||
|
||||
_registry[name] = channel_class
|
||||
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
||||
|
||||
|
||||
def get_channel(name: str) -> Type[ReactChannel] | None:
|
||||
def get_channel(name: str) -> Type[Channel] | None:
|
||||
"""Get a registered channel class by name."""
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
||||
"""Get all registered channel classes."""
|
||||
def get_registered_channels() -> dict[str, Type[Channel]]:
|
||||
"""Get a copy of the name -> channel-class registry."""
|
||||
return dict(_registry)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Consumer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_websocket_application():
|
||||
"""
|
||||
Get the WebSocket application for ASGI.
|
||||
|
||||
Usage in asgi.py:
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": channels.get_websocket_application(),
|
||||
})
|
||||
"""
|
||||
"""Build the ASGI application that serves every registered channel."""
|
||||
try:
|
||||
from channels.routing import URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
@@ -308,7 +181,7 @@ def get_websocket_application():
|
||||
"Install it with: pip install channels channels-redis"
|
||||
)
|
||||
|
||||
from .connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
return AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
@@ -319,42 +192,30 @@ def get_websocket_application():
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Export (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_channels_schema() -> dict:
|
||||
"""
|
||||
Get schema for all registered channels (for TypeScript generation).
|
||||
|
||||
Returns a dict suitable for the frontend code generator.
|
||||
"""
|
||||
"""JSON-schema per registered channel, keyed by wire name."""
|
||||
schema = {"channels": {}}
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
channel_schema = {
|
||||
"name": name,
|
||||
"params": None,
|
||||
"reactMessage": None,
|
||||
"djangoMessage": None,
|
||||
"clientMessage": None,
|
||||
"serverMessage": None,
|
||||
}
|
||||
|
||||
# Extract Params schema
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
if channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
if channel_class.ClientMessage:
|
||||
channel_schema[
|
||||
"reactMessage"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
"clientMessage"
|
||||
] = channel_class.ClientMessage.model_json_schema()
|
||||
|
||||
# Extract DjangoMessage schema
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
if channel_class.ServerMessage:
|
||||
channel_schema[
|
||||
"djangoMessage"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
"serverMessage"
|
||||
] = channel_class.ServerMessage.model_json_schema()
|
||||
|
||||
schema["channels"][name] = channel_schema
|
||||
|
||||
@@ -369,34 +230,37 @@ def _register_channel_schema_endpoint(
|
||||
input_cls: type | None,
|
||||
output_cls: type,
|
||||
) -> None:
|
||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||
"""
|
||||
Attach one operation to `api` whose annotations name `input_cls` and
|
||||
`output_cls`, so Ninja emits both into `components.schemas`.
|
||||
"""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
def schema_carrier(request, data):
|
||||
return output_cls.model_json_schema()
|
||||
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
schema_carrier.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
def schema_carrier(request):
|
||||
return output_cls.model_json_schema()
|
||||
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
schema_carrier
|
||||
)
|
||||
|
||||
|
||||
def get_channels_openapi_schema() -> dict:
|
||||
"""
|
||||
Get OpenAPI schema for all registered channels.
|
||||
OpenAPI document covering every registered channel's wire types, with the
|
||||
per-channel slot table under the `x-mizan-channels` extension key.
|
||||
|
||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
||||
This schema is consumed by openapi-typescript for type generation.
|
||||
Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation
|
||||
the Mizan IR emits, so the two documents name one type identically.
|
||||
"""
|
||||
from ninja import NinjaAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Create temporary Ninja API for schema generation only
|
||||
schema_api = NinjaAPI(
|
||||
title="mizan Channels",
|
||||
version="1.0.0",
|
||||
@@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict:
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
# Store dynamically created classes
|
||||
schema_classes: dict[str, type] = {}
|
||||
channel_metadata: list[dict] = []
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
pascal_name = name.replace("_", " ").title().replace(" ", "")
|
||||
pascal_name = wire_to_pascal(name)
|
||||
|
||||
channel_meta = {
|
||||
"name": name,
|
||||
"pascalName": pascal_name,
|
||||
"hasParams": False,
|
||||
"hasReactMessage": False,
|
||||
"hasDjangoMessage": False,
|
||||
"hasClientMessage": False,
|
||||
"hasServerMessage": False,
|
||||
}
|
||||
|
||||
# Register Params type
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
if channel_class.Params:
|
||||
params_name = f"{pascal_name}Params"
|
||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||
channel_meta["hasParams"] = True
|
||||
channel_meta["paramsType"] = params_name
|
||||
|
||||
# Create dummy endpoint to include in schema
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/params",
|
||||
@@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict:
|
||||
output_cls=BaseModel,
|
||||
)
|
||||
|
||||
# Register ReactMessage type
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
react_name = f"{pascal_name}ReactMessage"
|
||||
schema_classes[react_name] = type(
|
||||
react_name, (channel_class.ReactMessage,), {}
|
||||
if channel_class.ClientMessage:
|
||||
client_name = f"{pascal_name}ClientMessage"
|
||||
schema_classes[client_name] = type(
|
||||
client_name, (channel_class.ClientMessage,), {}
|
||||
)
|
||||
channel_meta["hasReactMessage"] = True
|
||||
channel_meta["reactMessageType"] = react_name
|
||||
channel_meta["hasClientMessage"] = True
|
||||
channel_meta["clientMessageType"] = client_name
|
||||
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/react",
|
||||
operation_id=f"{name}ReactMessage",
|
||||
summary=f"{pascal_name} React→Django message",
|
||||
input_cls=schema_classes[react_name],
|
||||
path=f"/channels/{name}/client",
|
||||
operation_id=f"{name}ClientMessage",
|
||||
summary=f"{pascal_name} client→server message",
|
||||
input_cls=schema_classes[client_name],
|
||||
output_cls=BaseModel,
|
||||
)
|
||||
|
||||
# Register DjangoMessage type
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
django_name = f"{pascal_name}DjangoMessage"
|
||||
schema_classes[django_name] = type(
|
||||
django_name, (channel_class.DjangoMessage,), {}
|
||||
if channel_class.ServerMessage:
|
||||
server_name = f"{pascal_name}ServerMessage"
|
||||
schema_classes[server_name] = type(
|
||||
server_name, (channel_class.ServerMessage,), {}
|
||||
)
|
||||
channel_meta["hasDjangoMessage"] = True
|
||||
channel_meta["djangoMessageType"] = django_name
|
||||
channel_meta["hasServerMessage"] = True
|
||||
channel_meta["serverMessageType"] = server_name
|
||||
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/django",
|
||||
operation_id=f"{name}DjangoMessage",
|
||||
summary=f"{pascal_name} Django→React message",
|
||||
path=f"/channels/{name}/server",
|
||||
operation_id=f"{name}ServerMessage",
|
||||
summary=f"{pascal_name} server→client message",
|
||||
input_cls=None,
|
||||
output_cls=schema_classes[django_name],
|
||||
output_cls=schema_classes[server_name],
|
||||
)
|
||||
|
||||
channel_metadata.append(channel_meta)
|
||||
|
||||
# Get OpenAPI schema from Ninja
|
||||
# path_prefix="" avoids URL reverse() — this API is never mounted
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add channel metadata extension
|
||||
schema["x-mizan-channels"] = channel_metadata
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Endpoint (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
_schema_router = None
|
||||
|
||||
|
||||
def _get_schema_router():
|
||||
"""Get the Ninja router for the channels schema endpoint."""
|
||||
global _schema_router
|
||||
if _schema_router is None:
|
||||
from ninja import Router
|
||||
@@ -502,17 +354,16 @@ def _get_schema_router():
|
||||
|
||||
@_schema_router.get("/schema/")
|
||||
def channels_schema(request):
|
||||
"""Get schema for all registered channels (for TypeScript generation)."""
|
||||
return get_channels_schema()
|
||||
|
||||
return _schema_router
|
||||
|
||||
|
||||
def get_urls():
|
||||
"""Get URL patterns for channels schema endpoint."""
|
||||
"""URL patterns serving the channels schema endpoint."""
|
||||
from ninja import NinjaAPI
|
||||
|
||||
api = NinjaAPI(urls_namespace="django_react_channels")
|
||||
api = NinjaAPI(urls_namespace="mizan_channels")
|
||||
api.add_router("/", _get_schema_router())
|
||||
return api.urls
|
||||
|
||||
@@ -523,17 +374,8 @@ def __getattr__(name):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Core Registry Extension
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _ChannelsExtension:
|
||||
"""
|
||||
Plugs the channel registry into mizan_core.registry as the 'channels'
|
||||
extension. Schema output goes under schema['channels'] in the unified
|
||||
registry export consumed by codegen.
|
||||
"""
|
||||
class _ChannelsExtension(RegistryExtension):
|
||||
"""Exposes the channel registry to mizan_core under the 'channels' key."""
|
||||
|
||||
def all(self) -> dict:
|
||||
return dict(_registry)
|
||||
@@ -546,13 +388,17 @@ class _ChannelsExtension:
|
||||
"type": "channel",
|
||||
"bidirectional": False,
|
||||
}
|
||||
if getattr(channel_class, "Params", None):
|
||||
if channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
if getattr(channel_class, "ReactMessage", None):
|
||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||
if channel_class.ClientMessage:
|
||||
channel_schema[
|
||||
"client_message"
|
||||
] = channel_class.ClientMessage.model_json_schema()
|
||||
channel_schema["bidirectional"] = True
|
||||
if getattr(channel_class, "DjangoMessage", None):
|
||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if channel_class.ServerMessage:
|
||||
channel_schema[
|
||||
"server_message"
|
||||
] = channel_class.ServerMessage.model_json_schema()
|
||||
out[name] = channel_schema
|
||||
return out
|
||||
|
||||
@@ -560,25 +406,15 @@ class _ChannelsExtension:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
from mizan_core.registry import register_extension as _register_extension
|
||||
_register_extension("channels", _ChannelsExtension())
|
||||
register_extension("channels", _ChannelsExtension())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exports
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# URLs
|
||||
"urls",
|
||||
# Base class
|
||||
"ReactChannel",
|
||||
# Registration
|
||||
"Channel",
|
||||
"register",
|
||||
"get_channel",
|
||||
"get_registered_channels",
|
||||
# ASGI application
|
||||
"get_websocket_application",
|
||||
# Schema export
|
||||
"get_channels_schema",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
WebSocket consumer for mizan.channels.
|
||||
|
||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||
WebSocket consumer multiplexing channel subscriptions and RPC calls over one
|
||||
socket.
|
||||
|
||||
Protocol:
|
||||
Browser sends:
|
||||
@@ -16,7 +15,7 @@ Protocol:
|
||||
|
||||
Server sends:
|
||||
# Channel messages
|
||||
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
|
||||
{"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}}
|
||||
|
||||
# RPC responses
|
||||
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
||||
@@ -25,15 +24,8 @@ Protocol:
|
||||
{"error": "..."}
|
||||
|
||||
Authentication:
|
||||
Supports both session (cookie) and JWT authentication:
|
||||
- Session: Handled automatically via AuthMiddlewareStack (cookies in handshake)
|
||||
- JWT: Pass token as query parameter: ws://...?token=<jwt>
|
||||
|
||||
The WebSocket URL for JWT auth would be: ws://localhost/ws/?token=<access_token>
|
||||
|
||||
Security:
|
||||
- Functions must be explicitly registered (no arbitrary code execution)
|
||||
- Pydantic validation runs BEFORE any function code
|
||||
Session cookies arrive through AuthMiddlewareStack during the handshake;
|
||||
a JWT arrives as a query parameter: ws://localhost/ws/?token=<access_token>
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -43,7 +35,8 @@ from urllib.parse import parse_qs
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
from . import get_channel
|
||||
|
||||
from mizan.channels import get_channel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,27 +44,23 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class WebSocketRequest:
|
||||
"""
|
||||
Minimal request adapter for WebSocket context.
|
||||
|
||||
Provides the interface expected by ServerFunction without full HttpRequest.
|
||||
This is intentionally minimal - only expose what's needed.
|
||||
|
||||
Note: Some Django libraries (e.g., allauth rate limiting) check request.method.
|
||||
We set method="POST" since WebSocket RPC calls are semantically similar to POST.
|
||||
The request surface ServerFunction reads, backed by a WebSocket scope
|
||||
instead of an HttpRequest.
|
||||
"""
|
||||
|
||||
# WebSocket RPC is semantically similar to POST (sends data, expects response)
|
||||
# Some Django libraries (allauth rate limiting) branch on request.method;
|
||||
# an RPC call carries data and expects a response, so POST is the match.
|
||||
method = "POST"
|
||||
|
||||
def __init__(self, scope: dict, channel_name: str = None):
|
||||
self.user = scope.get("user")
|
||||
self.session = scope.get("session", {})
|
||||
self.channel_name = channel_name # For push subscriptions
|
||||
self.channel_name = channel_name
|
||||
self._scope = scope
|
||||
|
||||
@property
|
||||
def META(self) -> dict:
|
||||
"""HTTP headers from WebSocket handshake."""
|
||||
"""HTTP headers from the WebSocket handshake, in WSGI key form."""
|
||||
headers = dict(self._scope.get("headers", []))
|
||||
return {
|
||||
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
||||
@@ -80,24 +69,15 @@ class WebSocketRequest:
|
||||
|
||||
|
||||
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
Multiplexed WebSocket consumer for django_react channels.
|
||||
|
||||
Manages multiple channel subscriptions over a single WebSocket connection.
|
||||
|
||||
Authentication:
|
||||
- Session auth via cookies (handled by AuthMiddlewareStack)
|
||||
- JWT auth via query parameter: ws://...?token=<jwt>
|
||||
"""
|
||||
"""Holds every channel subscription opened over one WebSocket connection."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Track subscriptions: {(channel_name, params_json): channel_instance}
|
||||
# {(channel_name, params_json): channel_instance}
|
||||
self._subscriptions: dict[tuple[str, str], Any] = {}
|
||||
|
||||
async def connect(self):
|
||||
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
||||
# Check for JWT token in query parameters
|
||||
await self._try_jwt_auth()
|
||||
|
||||
await self.accept()
|
||||
@@ -107,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _try_jwt_auth(self):
|
||||
"""
|
||||
Attempt JWT authentication from query parameter.
|
||||
Authenticate from a ?token=<jwt> query parameter, building a JWTUser
|
||||
from the token claims with no database query.
|
||||
|
||||
If a valid JWT token is provided via ?token=<jwt>, authenticate the user
|
||||
using JWTUser (no database query).
|
||||
|
||||
Security: If JWT is provided but invalid, we log it but don't reject
|
||||
the connection - the session auth may still be valid. However, if JWT
|
||||
IS valid, it takes precedence over session auth.
|
||||
An invalid token leaves the scope untouched so session auth still
|
||||
applies; a valid one overwrites whatever session auth resolved.
|
||||
"""
|
||||
# Parse query string for token
|
||||
query_string = self.scope.get("query_string", b"").decode()
|
||||
params = parse_qs(query_string)
|
||||
token_list = params.get("token", [])
|
||||
|
||||
if not token_list:
|
||||
return # No JWT provided, use session auth
|
||||
return
|
||||
|
||||
token = token_list[0]
|
||||
if not token:
|
||||
return
|
||||
|
||||
# Validate JWT and create JWTUser (no DB query)
|
||||
try:
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
@@ -136,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||
if payload is None:
|
||||
logger.debug("JWT token invalid or expired")
|
||||
return # Fall back to session auth
|
||||
return
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
self.scope["user"] = JWTUser(payload)
|
||||
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
||||
except Exception as e:
|
||||
@@ -157,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
||||
|
||||
async def receive_json(self, content: dict):
|
||||
"""Handle incoming JSON messages."""
|
||||
"""Route one incoming frame by its "action" field."""
|
||||
action = content.get("action")
|
||||
|
||||
if action == "subscribe":
|
||||
@@ -178,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
|
||||
async def _handle_subscribe(self, content: dict):
|
||||
"""Handle subscription request."""
|
||||
"""Authorize, join the group, and record the subscription."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
|
||||
# Get channel class
|
||||
channel_class = get_channel(channel_name)
|
||||
if not channel_class:
|
||||
await self.send_json(
|
||||
@@ -192,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create subscription key
|
||||
params_json = json.dumps(params_dict, sort_keys=True)
|
||||
sub_key = (channel_name, params_json)
|
||||
|
||||
# Check if already subscribed
|
||||
if sub_key in self._subscriptions:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -207,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create channel instance
|
||||
instance = channel_class()
|
||||
instance.user = self.scope.get("user")
|
||||
instance._channel_layer = self.channel_layer
|
||||
@@ -215,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
instance._registered_name = channel_name
|
||||
instance._params_dict = params_dict
|
||||
|
||||
# Parse params
|
||||
params_obj = None
|
||||
if channel_class.Params:
|
||||
try:
|
||||
@@ -229,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Check authorization
|
||||
try:
|
||||
if params_obj:
|
||||
authorized = instance.authorize(params_obj)
|
||||
@@ -254,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Get group and join
|
||||
try:
|
||||
if params_obj:
|
||||
group_name = instance.group(params_obj)
|
||||
@@ -271,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Store subscription
|
||||
self._subscriptions[sub_key] = instance
|
||||
|
||||
# Call on_connect hook
|
||||
try:
|
||||
await instance.on_connect(params_obj)
|
||||
except Exception as e:
|
||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||
|
||||
# Confirm subscription
|
||||
await self.send_json(
|
||||
{
|
||||
"subscribed": True,
|
||||
@@ -292,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||
|
||||
async def _handle_unsubscribe(self, content: dict):
|
||||
"""Handle unsubscription request."""
|
||||
"""Drop the subscription and leave its groups."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
|
||||
@@ -318,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"Unsubscribed from {channel_name}")
|
||||
|
||||
async def _handle_message(self, content: dict):
|
||||
"""Handle incoming message from browser."""
|
||||
"""Validate a ClientMessage, hand it to receive(), broadcast what comes back."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
data = content.get("data", {})
|
||||
@@ -338,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
channel_class = instance.__class__
|
||||
|
||||
# Check if channel accepts messages
|
||||
if not channel_class.ReactMessage:
|
||||
if not channel_class.ClientMessage:
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
@@ -348,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Parse message
|
||||
try:
|
||||
msg = channel_class.ReactMessage(**data)
|
||||
msg = channel_class.ClientMessage(**data)
|
||||
except Exception as e:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -360,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Parse params
|
||||
params_obj = None
|
||||
if channel_class.Params:
|
||||
params_obj = channel_class.Params(**params_dict)
|
||||
|
||||
# Handle message
|
||||
try:
|
||||
response = instance.receive(params_obj, msg)
|
||||
|
||||
# If handler returned a message, broadcast it
|
||||
if response is not None:
|
||||
if params_obj:
|
||||
group_name = instance.group(params_obj)
|
||||
@@ -389,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _handle_rpc(self, content: dict):
|
||||
"""
|
||||
Handle RPC (server function) call.
|
||||
Run a registered server function.
|
||||
|
||||
Protocol:
|
||||
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
Security:
|
||||
- Only functions with @client(websocket=True) are allowed
|
||||
- Pydantic validation happens BEFORE any function code runs
|
||||
- Function must be explicitly registered (no arbitrary code execution)
|
||||
- User context from WebSocket session is passed to function
|
||||
Only functions registered with @client(websocket=True) are reachable,
|
||||
and execute_function validates args against the function's Input model
|
||||
before any function body runs.
|
||||
"""
|
||||
from mizan.client.executor import execute_function, FunctionError
|
||||
from mizan_core.registry import get_function
|
||||
@@ -409,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
fn_name = content.get("fn")
|
||||
args = content.get("args", {})
|
||||
|
||||
# Validate request structure
|
||||
if not request_id:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -431,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Check if function exists and has websocket=True
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await self.send_json(
|
||||
@@ -446,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Only allow functions explicitly marked with websocket=True
|
||||
fn_meta = getattr(fn_class, "_meta", {})
|
||||
if not fn_meta.get("websocket"):
|
||||
await self.send_json(
|
||||
@@ -461,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create request adapter from WebSocket scope
|
||||
ws_request = WebSocketRequest(
|
||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||
)
|
||||
|
||||
# Execute function (Pydantic validation happens inside execute_function)
|
||||
# This is sync, so we need to run it in a thread pool
|
||||
# execute_function is sync, so it runs in a thread pool
|
||||
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
||||
ws_request,
|
||||
fn_name,
|
||||
args,
|
||||
)
|
||||
|
||||
# Send response
|
||||
if isinstance(result, FunctionError):
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -488,8 +439,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
}
|
||||
)
|
||||
else:
|
||||
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a
|
||||
# mutation sent over the socket invalidates exactly as one sent over HTTP
|
||||
# the {result, invalidate, merge} envelope the HTTP RPC path builds
|
||||
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
||||
|
||||
data = {"result": result.data}
|
||||
@@ -508,16 +458,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _handle_ctx(self, content: dict):
|
||||
"""
|
||||
Handle a context-bundle fetch.
|
||||
Fetch a context bundle through execute_context.
|
||||
|
||||
Protocol:
|
||||
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
|
||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
Dispatches through the same execute_context the HTTP view calls. That view's
|
||||
origin-side cache is a CDN concern with nothing in front of a socket, so it is not
|
||||
part of this path.
|
||||
"""
|
||||
from mizan.client.executor import execute_context, FunctionError
|
||||
|
||||
@@ -563,10 +509,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def channel_message(self, event: dict):
|
||||
"""
|
||||
Handle messages broadcast to a group.
|
||||
|
||||
Called when channel_layer.group_send() is used.
|
||||
Includes channel name and params so the client can route the message.
|
||||
Forward a group broadcast down the socket, carrying the channel name
|
||||
and params the client routes on.
|
||||
"""
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -579,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def push_message(self, event: dict):
|
||||
"""
|
||||
Handle push messages from server functions.
|
||||
Forward a topic push down the socket.
|
||||
|
||||
Called when push("topic", data) is used from a server function.
|
||||
The client receives this to update its local state.
|
||||
|
||||
Protocol:
|
||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
Wire shape: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
"""
|
||||
await self.send_json(
|
||||
{
|
||||
|
||||
@@ -1,68 +1,47 @@
|
||||
"""
|
||||
mizan Push - Server-initiated messages to clients.
|
||||
Topic-based server-initiated messages.
|
||||
|
||||
Simple API for pushing data to subscribed WebSocket connections.
|
||||
|
||||
Usage:
|
||||
# In a server function - push to all subscribers
|
||||
from mizan.push import push
|
||||
|
||||
push("room:42", {"type": "new_message", "data": {...}})
|
||||
|
||||
# Subscribe a connection to a topic (call during context fetch)
|
||||
from mizan.push import subscribe
|
||||
|
||||
subscribe(request, "room:42")
|
||||
A topic string ("room:42", "user:123:notifications") maps onto one channel
|
||||
layer group; subscribing a connection adds its channel name to that group,
|
||||
and pushing sends a "push.message" event to every member.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Lazy import to avoid import errors when channels is not installed
|
||||
# (e.g., during schema generation)
|
||||
if TYPE_CHECKING:
|
||||
from channels.layers import BaseChannelLayer
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
"""Get channel layer, returning None if channels is not installed."""
|
||||
def _get_channel_layer():
|
||||
"""The configured channel layer, or None when django-channels is absent."""
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
return get_channel_layer()
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.warning("django-channels is not installed, push is inert: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _async_to_sync(coro):
|
||||
"""Wrapper for async_to_sync that handles missing channels."""
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
return async_to_sync(coro)
|
||||
return get_channel_layer()
|
||||
|
||||
|
||||
def get_topic_group_name(topic: str) -> str:
|
||||
"""Convert a topic string to a valid channel layer group name."""
|
||||
# Channel layer group names must be valid ASCII alphanumeric + hyphens/underscores/periods
|
||||
# Replace colons with underscores
|
||||
"""
|
||||
Convert a topic to a channel layer group name. Group names allow ASCII
|
||||
alphanumerics plus hyphens, underscores and periods, so the topic
|
||||
separator becomes an underscore.
|
||||
"""
|
||||
return topic.replace(":", "_")
|
||||
|
||||
|
||||
def subscribe(request, topic: str) -> None:
|
||||
"""
|
||||
Subscribe this WebSocket connection to a topic.
|
||||
Add this WebSocket connection to a topic's group.
|
||||
|
||||
Call this in a context or server function to register the connection
|
||||
for push notifications on the given topic.
|
||||
|
||||
Args:
|
||||
request: The Django request (must have channel_name attribute from WebSocket)
|
||||
topic: Topic string, e.g., "room:42", "user:123:notifications"
|
||||
An HTTP request carries no channel_name, so there is nothing to add.
|
||||
"""
|
||||
channel_name = getattr(request, "channel_name", None)
|
||||
if not channel_name:
|
||||
# HTTP request, not WebSocket - can't subscribe
|
||||
return
|
||||
|
||||
channel_layer = _get_channel_layer()
|
||||
@@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None:
|
||||
return
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
_async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||
async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||
|
||||
|
||||
def unsubscribe(request, topic: str) -> None:
|
||||
"""
|
||||
Unsubscribe this WebSocket connection from a topic.
|
||||
|
||||
Args:
|
||||
request: The Django request (must have channel_name attribute from WebSocket)
|
||||
topic: Topic string to unsubscribe from
|
||||
"""
|
||||
"""Remove this WebSocket connection from a topic's group."""
|
||||
channel_name = getattr(request, "channel_name", None)
|
||||
if not channel_name:
|
||||
return
|
||||
@@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None:
|
||||
return
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
_async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||
async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||
|
||||
|
||||
def push(topic: str, data: dict | BaseModel) -> None:
|
||||
"""
|
||||
Push data to all connections subscribed to a topic.
|
||||
|
||||
Args:
|
||||
topic: Topic string, e.g., "room:42"
|
||||
data: Data to send (dict or Pydantic model)
|
||||
|
||||
Example:
|
||||
push("room:42", {
|
||||
"type": "new_message",
|
||||
"message": {"id": 1, "text": "Hello", "user": "alice@example.com"}
|
||||
})
|
||||
"""
|
||||
"""Send data to every connection subscribed to a topic."""
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
logger.warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
return
|
||||
|
||||
# Convert Pydantic model to dict if needed
|
||||
if isinstance(data, BaseModel):
|
||||
data = data.model_dump()
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
|
||||
_async_to_sync(channel_layer.group_send)(
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
group_name,
|
||||
{
|
||||
"type": "push.message", # Maps to push_message handler in consumer
|
||||
# The event's "type" selects the consumer method of the same name,
|
||||
# with dots translated to underscores.
|
||||
"type": "push.message",
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
},
|
||||
@@ -133,9 +93,12 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
|
||||
|
||||
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||
"""Async version of push for use in async contexts."""
|
||||
"""Send data to every connection subscribed to a topic, from the event loop."""
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
logger.warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(data, BaseModel):
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"""
|
||||
mizan.client - Server function implementation.
|
||||
|
||||
This subpackage contains everything needed to make server functions work:
|
||||
- The @client decorator (lives in mizan_core.client.function)
|
||||
- ServerFunction base class (mizan_core.client.function)
|
||||
- Function execution logic (.executor — Django-specific dispatch)
|
||||
- JWT authentication (.jwt — Django-specific session integration)
|
||||
|
||||
Usage:
|
||||
from mizan.client import client, ServerFunction, compose
|
||||
The server-function surface: the `client` decorator and `ServerFunction` base
|
||||
come from `mizan_core`; execution and dispatch are Django-specific and live in
|
||||
`mizan.client.executor`.
|
||||
"""
|
||||
|
||||
# Register the Django framework response base so view-path detection works
|
||||
# in mizan_core.client.function. Has to happen before any @client-decorated
|
||||
# code is evaluated.
|
||||
# Registering the Django response base has to happen before any
|
||||
# @client-decorated code is evaluated, or view-path detection in
|
||||
# mizan_core.client.function cannot recognize a returned HttpResponse.
|
||||
from django.http import HttpResponseBase as _HttpResponseBase
|
||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||
_set_response_base(_HttpResponseBase)
|
||||
@@ -39,7 +32,7 @@ from mizan_core.client.function import (
|
||||
create_form_functions,
|
||||
)
|
||||
|
||||
from .executor import (
|
||||
from mizan.client.executor import (
|
||||
execute_function,
|
||||
function_call_view,
|
||||
ErrorCode,
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
"""
|
||||
mizan Function Executor
|
||||
Dispatch for registered server functions over HTTP.
|
||||
|
||||
Handles execution of server functions.
|
||||
This is the core of the "Server Functions" feature - callable from React
|
||||
without REST boilerplate.
|
||||
|
||||
Security model:
|
||||
- All input validated against Pydantic schema BEFORE execution
|
||||
- Authentication: JWT (stateless) or Session (stateful) - auto-detected
|
||||
- JWT: Authorization header with Bearer token (no CSRF needed)
|
||||
- Session: Cookie-based with CSRF token (via X-CSRFToken header)
|
||||
- WebSocket RPC uses Origin header checking instead
|
||||
- No implicit function exposure - must be explicitly registered
|
||||
Input is validated against the function's Pydantic Input before the body ever
|
||||
runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or
|
||||
an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF;
|
||||
anything else falls through to session auth with CSRF enforced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,7 +14,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
@@ -31,9 +24,6 @@ from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
from mizan_core.registry import get_function, get_context_groups
|
||||
from mizan.setup.settings import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -99,50 +89,40 @@ def _check_auth_requirement(
|
||||
auth_requirement: str | Callable | None,
|
||||
) -> FunctionError | None:
|
||||
"""
|
||||
Check if the request meets the auth requirement.
|
||||
Test `request` against an auth requirement of 'required', 'staff',
|
||||
'superuser', a callable, or None. Returns a FunctionError on failure.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest (with user set)
|
||||
auth_requirement: 'required', 'staff', 'superuser', callable, or None
|
||||
|
||||
Returns:
|
||||
FunctionError if auth check fails, None if it passes.
|
||||
|
||||
Note: This uses request.user which may be a JWTUser (stateless) or
|
||||
Django User (from session). Either way, no additional DB query is made
|
||||
for the built-in checks. Custom callables may query DB if they choose.
|
||||
The built-in checks read only flags already on request.user — a JWTUser or
|
||||
a session User — so none of them hit the database. A callable may.
|
||||
"""
|
||||
if auth_requirement is None:
|
||||
return None
|
||||
|
||||
user = request.user
|
||||
|
||||
# Handle callable auth
|
||||
if callable(auth_requirement):
|
||||
try:
|
||||
result = auth_requirement(request)
|
||||
if result:
|
||||
return None # Authorized
|
||||
return None
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Access denied",
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Custom error message from the callable
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Access denied",
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
# Every string-based requirement implies authentication.
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
@@ -150,7 +130,6 @@ def _check_auth_requirement(
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
@@ -168,7 +147,7 @@ def _purge_cache_for_invalidation(
|
||||
invalidate: list,
|
||||
request: HttpRequest | None = None,
|
||||
) -> None:
|
||||
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
|
||||
"""Purge origin-side cache entries for invalidation targets, scoped by user when known."""
|
||||
cache = get_cache()
|
||||
if cache is None:
|
||||
return
|
||||
@@ -199,34 +178,27 @@ def _purge_cache_for_invalidation(
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Determine whether an affects target is a context name or function name.
|
||||
Classify an affects target as a context or a function inside one.
|
||||
|
||||
Returns:
|
||||
("context", "user", None) — full context invalidation
|
||||
("function", "user_profile", "user") — function within context
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
|
||||
# Check if it's a context name directly
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
|
||||
# Check if it's a function name within a context
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
|
||||
# Not a context or context function — treat as context name anyway
|
||||
# (it might be a non-context function or an as-yet-unregistered context)
|
||||
# An unregistered name is treated as a context so invalidation still
|
||||
# propagates rather than being silently dropped.
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _get_context_param_names(context_name: str) -> set[str]:
|
||||
"""
|
||||
Get the set of parameter names used by functions in a context.
|
||||
|
||||
Returns the union of all Input field names across context functions.
|
||||
"""
|
||||
"""Union of the Input field names across every function in a context."""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
param_names: set[str] = set()
|
||||
@@ -247,18 +219,14 @@ def _resolve_invalidation(
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve invalidation targets with three-tier auto-scoping.
|
||||
Turn a mutation's `affects` metadata into invalidation targets, returning
|
||||
None when there is nothing to invalidate.
|
||||
|
||||
Tier 1: Argument name matching — if the mutation's input args overlap
|
||||
with the context's params by name, auto-scope.
|
||||
Tier 2: Auth inference — Edge-side concern, not handled here.
|
||||
Tier 3: Broad fallback — invalidate all instances.
|
||||
A target is scoped to specific params when the mutation's input argument
|
||||
names overlap the context's param names; otherwise the whole context is
|
||||
invalidated. A function-level target is keyed by the function name.
|
||||
|
||||
Also handles function-level targeting: affects='user_profile' resolves
|
||||
to the function name (v1: runtime refetches the whole context anyway).
|
||||
|
||||
Returns a list suitable for both JSON body and header serialization.
|
||||
Returns None if no invalidation needed.
|
||||
The returned list serializes into both the JSON body and the header.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
@@ -275,7 +243,6 @@ def _resolve_invalidation(
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
# Function-level: use the function name as the invalidation key
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
@@ -284,11 +251,9 @@ def _resolve_invalidation(
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
# Resolve the context this target belongs to (for param lookup)
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
# Tier 1: argument name matching
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _get_context_param_names(ctx_for_params)
|
||||
matched = {
|
||||
@@ -299,7 +264,6 @@ def _resolve_invalidation(
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
# Tier 3: broad fallback
|
||||
result.append(target_name)
|
||||
|
||||
return result if result else None
|
||||
@@ -311,15 +275,12 @@ def _resolve_merges(
|
||||
result_data: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve merge targets from @client(merge=...).
|
||||
|
||||
Each entry is `{context, slot, value, params?}` — `slot` is the
|
||||
function-name inside the context bundle the value lands in, resolved
|
||||
server-side by matching the mutation's return type against each
|
||||
context-function's return type. Kernel does no shape inference.
|
||||
|
||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||
Entries whose slot can't be uniquely resolved are dropped.
|
||||
Turn a mutation's `merge` metadata into `{context, slot, value, params?}`
|
||||
entries. `slot` is the function-name inside the context bundle the value
|
||||
lands in, resolved here by matching the mutation's declared Output against
|
||||
each context-function's Output. Entries whose slot is ambiguous are
|
||||
dropped, and params are scoped the same way `_resolve_invalidation` scopes
|
||||
them.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
@@ -358,7 +319,7 @@ def _resolve_merges(
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||
"""Find the one function in `context_name` whose Output matches the mutation's, if unique."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
groups = get_context_groups()
|
||||
@@ -378,18 +339,17 @@ def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
Format invalidation targets as the X-Mizan-Invalidate header value:
|
||||
comma-separated contexts, each optionally followed by semicolon-separated
|
||||
`key=value` params. Keys and values are URL-encoded so a param can never
|
||||
contain a delimiter.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
Param values are URL-encoded to prevent delimiter collisions.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
→ "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}]
|
||||
→ "search;q=hello%20world"
|
||||
→ "search;q=hello%20world"
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -417,22 +377,17 @@ def execute_function(
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||
"""
|
||||
Execute a registered server function.
|
||||
Look up, authorize, validate, and run a registered server function.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest
|
||||
fn_name: Name of the registered function
|
||||
input_data: Input data to pass to the function
|
||||
|
||||
Returns:
|
||||
FunctionResult on success, FunctionError on failure
|
||||
Returns the function's HttpResponse untouched when it returned one,
|
||||
otherwise a FunctionResult or FunctionError.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
# Look up the function by name
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
# In DEBUG mode, include the name for easier debugging
|
||||
# Naming the missing function is a debugging aid, not something to
|
||||
# hand an unauthenticated caller in production.
|
||||
if settings.DEBUG:
|
||||
message = f"Function '{fn_name}' not found"
|
||||
else:
|
||||
@@ -442,7 +397,6 @@ def execute_function(
|
||||
message=message,
|
||||
)
|
||||
|
||||
# Reject private functions from RPC dispatch
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
if meta.get("private"):
|
||||
return FunctionError(
|
||||
@@ -450,36 +404,28 @@ def execute_function(
|
||||
message="Function is not client-callable",
|
||||
)
|
||||
|
||||
# Check auth requirement BEFORE executing
|
||||
# Auth is checked before the function body ever runs.
|
||||
auth_requirement = meta.get("auth")
|
||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
|
||||
# Instantiate the view with the request
|
||||
view = view_class(request)
|
||||
|
||||
# Check if this is a form function that handles input specially
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
is_form_multipart = meta.get("multipart", False)
|
||||
|
||||
# For form functions with Input=None, skip Pydantic validation
|
||||
# The form itself handles validation
|
||||
input_cls = view.Input
|
||||
if input_cls is None and is_form_multipart:
|
||||
# Form function - pass input_data directly (already parsed by view or will be)
|
||||
# Form functions carry Input=None; the Django form owns validation.
|
||||
validated_input = input_data
|
||||
elif input_cls is BaseModel:
|
||||
has_input = False
|
||||
validated_input = None
|
||||
else:
|
||||
# Check if it has any fields defined
|
||||
has_input = bool(input_cls.model_fields) if input_cls else False
|
||||
|
||||
# Validate input against Pydantic schema
|
||||
try:
|
||||
if input_data:
|
||||
# Ensure input_data is a dict (not array or other type)
|
||||
if not isinstance(input_data, dict):
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
@@ -488,11 +434,11 @@ def execute_function(
|
||||
)
|
||||
validated_input = input_cls(**input_data)
|
||||
elif has_input:
|
||||
# Check if function requires input fields
|
||||
input_schema = input_cls.model_json_schema()
|
||||
required_fields = input_schema.get("required", [])
|
||||
if required_fields:
|
||||
# Format as field errors for consistency
|
||||
# Shaped like Pydantic's own field errors so the client
|
||||
# has one error format to handle.
|
||||
errors = {field: ["Field required"] for field in required_fields}
|
||||
return FunctionError(
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
@@ -501,10 +447,8 @@ def execute_function(
|
||||
)
|
||||
validated_input = input_cls()
|
||||
else:
|
||||
# No input expected, create empty model
|
||||
validated_input = None
|
||||
except ValidationError as e:
|
||||
# Convert Pydantic errors to our format
|
||||
errors = {}
|
||||
for error in e.errors():
|
||||
field = ".".join(str(loc) for loc in error["loc"])
|
||||
@@ -518,7 +462,6 @@ def execute_function(
|
||||
details={"fields": errors},
|
||||
)
|
||||
|
||||
# Execute the function
|
||||
try:
|
||||
output = view.call(validated_input)
|
||||
except NotImplementedError as e:
|
||||
@@ -528,28 +471,24 @@ def execute_function(
|
||||
message=str(e),
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Functions can raise PermissionError for auth issues
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Permission denied",
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the full exception for debugging
|
||||
logger.exception(f"Error executing function {fn_name}")
|
||||
return FunctionError(
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
message="An internal error occurred",
|
||||
# Don't expose internal details in production
|
||||
# Internals are only named when debug logging is already on.
|
||||
details={"type": type(e).__name__}
|
||||
if logger.isEnabledFor(logging.DEBUG)
|
||||
else None,
|
||||
)
|
||||
|
||||
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
||||
from django.http import HttpResponseBase
|
||||
|
||||
if isinstance(output, HttpResponseBase):
|
||||
# View path — add invalidation header + purge origin cache
|
||||
invalidate = _resolve_invalidation(view_class, input_data)
|
||||
if invalidate:
|
||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||
@@ -557,9 +496,8 @@ def execute_function(
|
||||
output["Cache-Control"] = "no-store"
|
||||
return output
|
||||
|
||||
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
||||
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
||||
# out wire-ready without a per-shape branch.
|
||||
# to_jsonable_python walks BaseModel / list / dict recursively, so nested
|
||||
# shapes need no per-shape branch here.
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
return FunctionResult(data=to_jsonable_python(output))
|
||||
@@ -567,10 +505,9 @@ def execute_function(
|
||||
|
||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Attempt to authenticate the request using MWT (Mizan Web Token).
|
||||
|
||||
Checks the X-Mizan-Token header. If present and valid, sets request.user
|
||||
to an MWTUser. Returns True on success, False if no MWT header or invalid.
|
||||
Authenticate from the X-Mizan-Token header, setting request.user to an
|
||||
MWTUser on success. False means no header, no configured secret, or a
|
||||
token that did not verify.
|
||||
"""
|
||||
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
||||
if not token:
|
||||
@@ -607,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool:
|
||||
|
||||
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Attempt to authenticate the request using JWT.
|
||||
|
||||
If Authorization header contains a valid Bearer token, authenticates
|
||||
the request and sets request.user to a JWTUser. Returns True if JWT
|
||||
auth succeeded.
|
||||
|
||||
IMPORTANT: This is stateless - no database query is made. The JWTUser
|
||||
object is created from the token claims. If you need the full User
|
||||
object, query it explicitly in your function.
|
||||
|
||||
Security: If JWT is provided but invalid, we return False and do NOT
|
||||
fall back to session auth. The caller should reject the request.
|
||||
Authenticate from an Authorization Bearer token, setting request.user to a
|
||||
JWTUser built from the claims — no database query. False means no bearer
|
||||
header or a token that did not verify; the caller must then reject rather
|
||||
than fall back to session auth.
|
||||
"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
@@ -636,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
if payload is None:
|
||||
return False
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
request.user = JWTUser(payload)
|
||||
request._mizan_jwt_authenticated = True
|
||||
return True
|
||||
except Exception:
|
||||
logging.getLogger("mizan.jwt").warning(
|
||||
"JWT authentication failed unexpectedly", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -652,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
||||
|
||||
def _csrf_protect_unless_token(view_func):
|
||||
"""
|
||||
Decorator that applies CSRF protection unless token auth is used.
|
||||
|
||||
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
||||
Both are self-authenticating, so CSRF protection is not needed.
|
||||
|
||||
Security: If a token is provided but invalid, reject the request - do NOT
|
||||
fall back to session auth.
|
||||
Wrap a view so CSRF applies only on the session path. MWT is checked
|
||||
first, then JWT; both are self-authenticating. A token that is present but
|
||||
invalid rejects the request outright rather than falling back to session
|
||||
auth.
|
||||
"""
|
||||
csrf_protected_view = csrf_protect(view_func)
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -673,7 +600,6 @@ def _csrf_protect_unless_token(view_func):
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -682,7 +608,6 @@ def _csrf_protect_unless_token(view_func):
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
|
||||
# No token — session auth with CSRF
|
||||
return csrf_protected_view(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -691,52 +616,25 @@ def _csrf_protect_unless_token(view_func):
|
||||
@_csrf_protect_unless_token
|
||||
def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
"""
|
||||
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
||||
POST endpoint for server-function calls.
|
||||
|
||||
Authentication (auto-detected):
|
||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
||||
A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries
|
||||
`fn` as a form field alongside the form's own fields, and its parsed data
|
||||
and files are attached to the request for the form function to pick up.
|
||||
|
||||
Endpoint: POST /api/mizan/call/
|
||||
|
||||
Request body (JSON):
|
||||
{
|
||||
"fn": "function_name", // Function name
|
||||
"args": { ... } // Optional, depending on function
|
||||
}
|
||||
|
||||
Request body (multipart/form-data for form submit functions):
|
||||
fn: function_name
|
||||
<field>: <value>
|
||||
...
|
||||
|
||||
Response on success:
|
||||
{
|
||||
"error": false,
|
||||
"data": { ... } // Function output
|
||||
}
|
||||
|
||||
Response on error:
|
||||
{
|
||||
"error": true,
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Input validation failed",
|
||||
"details": { ... }
|
||||
}
|
||||
Success answers `{"result": ...}`, plus `invalidate` / `merge` when the
|
||||
function declared them; failure answers the FunctionError shape.
|
||||
"""
|
||||
# Only allow POST
|
||||
if request.method != "POST":
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Only POST method allowed",
|
||||
).to_response(status=405)
|
||||
|
||||
# Check content type to determine parsing method
|
||||
content_type = request.content_type or ""
|
||||
is_multipart = content_type.startswith("multipart/form-data")
|
||||
|
||||
if is_multipart:
|
||||
# Multipart form data - used by form submit functions
|
||||
fn_name = request.POST.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -744,15 +642,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Missing 'fn' field",
|
||||
).to_response()
|
||||
|
||||
# Get form data (excluding 'fn')
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
try:
|
||||
if request.body:
|
||||
body = json.loads(request.body)
|
||||
@@ -767,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Invalid JSON in request body",
|
||||
).to_response()
|
||||
|
||||
# Extract function name and args
|
||||
fn_name = body.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -777,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
input_data = body.get("args")
|
||||
|
||||
# Execute the function
|
||||
result = execute_function(request, fn_name, input_data)
|
||||
|
||||
# View path — function returned an HttpResponse directly
|
||||
# The function returned an HttpResponse directly.
|
||||
from django.http import HttpResponseBase
|
||||
if isinstance(result, HttpResponseBase):
|
||||
return result
|
||||
|
||||
# Return appropriate response
|
||||
if isinstance(result, FunctionError):
|
||||
status = {
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
@@ -798,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
}.get(result.code, 400)
|
||||
return result.to_response(status=status)
|
||||
|
||||
# RPC path — build response with server-driven invalidation
|
||||
view_class = get_function(fn_name)
|
||||
response_data = {"result": result.data}
|
||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||
@@ -825,18 +716,8 @@ def execute_context(
|
||||
params: dict[str, str],
|
||||
) -> FunctionResult | FunctionError:
|
||||
"""
|
||||
Execute all functions in a named context with merged params.
|
||||
|
||||
Each function receives only the params it declares in its Input schema.
|
||||
If any function fails (auth, validation, execution), the entire request fails.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest
|
||||
context_name: Name of the context (e.g., 'user', 'global')
|
||||
params: Query parameters (strings — Pydantic coerces types)
|
||||
|
||||
Returns:
|
||||
FunctionResult with bundled data, or FunctionError
|
||||
Run every function in a named context, handing each only the params it
|
||||
declares in its Input schema. The first failure aborts the whole bundle.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name)
|
||||
@@ -852,7 +733,6 @@ def execute_context(
|
||||
if view_class is None:
|
||||
continue
|
||||
|
||||
# Filter params to only those in this function's Input schema
|
||||
input_cls = getattr(view_class, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||
fn_params = {
|
||||
@@ -871,13 +751,9 @@ def execute_context(
|
||||
|
||||
|
||||
def _jwt_auth_only(view_func):
|
||||
"""
|
||||
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
|
||||
Checks MWT first, then legacy JWT.
|
||||
"""
|
||||
"""Token auth for GET views: MWT first, then JWT. GET needs no CSRF."""
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -886,7 +762,6 @@ def _jwt_auth_only(view_func):
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -895,7 +770,6 @@ def _jwt_auth_only(view_func):
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
|
||||
# No token — session auth (no CSRF needed for GET)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -904,18 +778,12 @@ def _jwt_auth_only(view_func):
|
||||
@_jwt_auth_only
|
||||
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
"""
|
||||
Fetch all functions in a named context in a single bundled GET request.
|
||||
GET endpoint answering every function in a named context as one bundle
|
||||
keyed by function name, with query params fanned out to each.
|
||||
|
||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
||||
|
||||
Response: raw bundled data, CDN-cacheable.
|
||||
{
|
||||
"user_profile": { ... },
|
||||
"user_orders": [ ... ]
|
||||
}
|
||||
|
||||
Headers:
|
||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
||||
The context's effective cache policy and revision are the strictest across
|
||||
its functions: any function declaring cache=False disables caching for the
|
||||
whole bundle, and the shortest declared TTL wins.
|
||||
"""
|
||||
if request.method != "GET":
|
||||
return FunctionError(
|
||||
@@ -925,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
|
||||
params = request.GET.dict()
|
||||
|
||||
# Resolve effective rev and cache policy across all functions in this context
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
@@ -947,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
else:
|
||||
effective_cache = min(effective_cache, fn_cache)
|
||||
|
||||
# Origin-side cache lookup (skip if cache=False)
|
||||
cache_backend = get_cache()
|
||||
cache_settings = get_settings()
|
||||
user_id = None
|
||||
@@ -988,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
error_response["Cache-Control"] = "no-store"
|
||||
return error_response
|
||||
|
||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||
# Sorted keys keep the serialized body byte-identical for a given result,
|
||||
# which is what makes it usable as a cache entry.
|
||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||
|
||||
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
||||
# The browser and non-Mizan intermediaries must not cache.
|
||||
# Caching happens in the origin cache below and at the edge, both of which
|
||||
# can be purged; a browser cache cannot, so it must not hold this.
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Store in origin-side cache (skip if cache=False)
|
||||
if use_cache:
|
||||
try:
|
||||
cache_put(
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
"""
|
||||
mizan.client.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
- JWT authentication utilities for validating tokens
|
||||
|
||||
Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
Token and settings names from `mizan.jwt`, re-exported under `mizan.client`
|
||||
for the executor and the WebSocket consumer. The Ninja auth class is
|
||||
deliberately absent here — reach for `mizan.jwt.security` for that.
|
||||
"""
|
||||
|
||||
# Token utilities (re-exports from django_jwt_session)
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
@@ -25,7 +15,6 @@ from mizan.jwt.tokens import (
|
||||
JWTUser,
|
||||
)
|
||||
|
||||
# Settings
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator.
|
||||
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
Builds the Edge manifest: a static JSON document mapping each context to its
|
||||
API endpoint, page routes, and parameter names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
@@ -30,20 +22,18 @@ def generate_edge_manifest(
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
Build the manifest dict.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
Each context entry carries the union of its functions' Input field names,
|
||||
its API endpoint under `base_url`, any page routes declared via
|
||||
`@client(route=...)`, and a render strategy derived from whether any
|
||||
parameter is user-scoped. Each mutation entry carries the contexts it
|
||||
affects and the parameter names shared with those contexts.
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
view_urls: Extra page routes per context name, merged with the ones
|
||||
read off `@client(route=...)`.
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
|
||||
@@ -1,153 +1,26 @@
|
||||
"""
|
||||
mizanFormMixin - Turn Django Forms into server functions.
|
||||
|
||||
This mixin transforms any Django Form into mizan server functions,
|
||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||
while exposing them through the unified server function API.
|
||||
|
||||
Usage:
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
)
|
||||
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
message = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
def on_submit_success(self, request):
|
||||
send_email(self.cleaned_data)
|
||||
return {"sent": True}
|
||||
|
||||
Auto-registers server functions:
|
||||
- contact.schema
|
||||
- contact.validate
|
||||
- contact.submit
|
||||
mizanFormMixin exposes a Django Form as the server functions
|
||||
`<name>.schema`, `<name>.validate`, and `<name>.submit`, registered from
|
||||
`__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .schemas import FormValidation
|
||||
|
||||
|
||||
def _django_field_to_python_type(field: forms.Field) -> type:
|
||||
"""
|
||||
Map a Django form field to a Python type for Pydantic schema generation.
|
||||
|
||||
This provides TypeScript with proper field types instead of generic `any`.
|
||||
"""
|
||||
# Handle common Django field types
|
||||
if isinstance(field, forms.BooleanField):
|
||||
return bool
|
||||
elif isinstance(field, forms.IntegerField):
|
||||
return int
|
||||
elif isinstance(field, forms.FloatField):
|
||||
return float
|
||||
elif isinstance(field, forms.DecimalField):
|
||||
return str # Decimals serialize as strings for precision
|
||||
elif isinstance(field, forms.DateTimeField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.DateField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.TimeField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.JSONField):
|
||||
return dict | list | str | int | float | bool | None
|
||||
elif isinstance(field, forms.MultipleChoiceField):
|
||||
return list[str]
|
||||
elif isinstance(field, forms.FileField):
|
||||
return str # File path/name as string
|
||||
elif isinstance(field, forms.ImageField):
|
||||
return str # File path/name as string
|
||||
else:
|
||||
# Default to string (covers CharField, EmailField, URLField, etc.)
|
||||
return str
|
||||
|
||||
|
||||
def _create_form_input_schema(
|
||||
form_class: type[forms.BaseForm],
|
||||
schema_name: str,
|
||||
) -> type[BaseModel]:
|
||||
"""
|
||||
Create a Pydantic model from Django Form fields.
|
||||
|
||||
This generates a typed schema for the form's input data, giving TypeScript
|
||||
full LSP support (autocomplete, type checking) for form fields.
|
||||
|
||||
Args:
|
||||
form_class: Django Form class to introspect
|
||||
schema_name: Name for the generated Pydantic model (e.g., "ContactFormData")
|
||||
|
||||
Returns:
|
||||
A Pydantic BaseModel subclass with fields matching the form
|
||||
"""
|
||||
# Instantiate form without data to get field definitions
|
||||
try:
|
||||
form = form_class()
|
||||
except TypeError:
|
||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||
fields_dict = getattr(form_class, "base_fields", {})
|
||||
else:
|
||||
fields_dict = form.fields
|
||||
|
||||
# Build Pydantic field definitions
|
||||
pydantic_fields: dict[str, Any] = {}
|
||||
|
||||
for field_name, field in fields_dict.items():
|
||||
python_type = _django_field_to_python_type(field)
|
||||
|
||||
# Optional fields (not required or has initial value)
|
||||
if not field.required:
|
||||
python_type = python_type | None
|
||||
default = None
|
||||
elif field.initial is not None:
|
||||
default = field.initial
|
||||
else:
|
||||
default = ... # Required field
|
||||
|
||||
pydantic_fields[field_name] = (python_type, default)
|
||||
|
||||
# Create the model with a unique name
|
||||
model = create_model(schema_name, **pydantic_fields)
|
||||
|
||||
return model
|
||||
logger = logging.getLogger("mizan.forms")
|
||||
|
||||
|
||||
class mizanFormMeta(BaseModel):
|
||||
"""
|
||||
Configuration for a mizan form.
|
||||
|
||||
This Pydantic model provides type-safe configuration with full LSP support,
|
||||
and serializes to JSON for the frontend schema.
|
||||
|
||||
Required:
|
||||
name: API identifier (e.g., "contact" → contact.schema, contact.validate, contact.submit)
|
||||
|
||||
Display options:
|
||||
title: Display title (default: derived from class name)
|
||||
subtitle: Display subtitle
|
||||
submit_label: Submit button text (default: "Submit")
|
||||
|
||||
Frontend behavior:
|
||||
live_validation: Enable live validation as user types (default: True)
|
||||
live_form_errors: Show form-level errors during live validation (default: False)
|
||||
refetch_schema_on_validate: Refetch schema on each validation - useful for
|
||||
dynamic choice fields (default: False)
|
||||
|
||||
Features:
|
||||
enable_formset: Generate formset endpoints (default: False)
|
||||
Per-form configuration. `name` is the API identifier the three registered
|
||||
function names are built from; the rest are carried into the emitted schema.
|
||||
"""
|
||||
|
||||
# Required
|
||||
@@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel):
|
||||
|
||||
class mizanFormMixin:
|
||||
"""
|
||||
Mixin that exposes a Django Form as mizan server functions.
|
||||
Mix into a Django Form alongside a `mizan = mizanFormMeta(...)` attribute to
|
||||
register `<name>.schema`, `<name>.validate`, and `<name>.submit`.
|
||||
|
||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
)
|
||||
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"sent": True}
|
||||
|
||||
This auto-registers:
|
||||
- contact.schema - Get form field definitions
|
||||
- contact.validate - Validate form data
|
||||
- contact.submit - Submit form
|
||||
|
||||
Overridable methods:
|
||||
get_init_kwargs(cls, request) -> dict: Extra kwargs for form instantiation
|
||||
on_submit_success(self, request) -> dict | None: Handle successful submission
|
||||
on_submit_failure(self, request, errors) -> None: Handle failed submission
|
||||
`get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three
|
||||
override points. Each is called unconditionally, so the definitions here are
|
||||
what a form that overrides none of them does.
|
||||
"""
|
||||
|
||||
# Configuration - subclasses must define this
|
||||
mizan: ClassVar[mizanFormMeta]
|
||||
|
||||
# Track registered forms to avoid duplicate registration
|
||||
# Set on registration so a re-import does not register the class twice
|
||||
_mizan_registered: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||
"""
|
||||
Override to provide extra kwargs for form instantiation.
|
||||
|
||||
Common use: pass request or user to forms that need them.
|
||||
|
||||
Example:
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request):
|
||||
return {"request": request, "user": request.user}
|
||||
Kwargs merged into every instantiation of this form. `request` is passed
|
||||
through to a form whose `__init__` names it; Django's own `BaseForm`
|
||||
signature does not, and rejects any keyword it did not declare, so a
|
||||
form that never asks for the request is constructed on data/files alone.
|
||||
"""
|
||||
accepted = inspect.signature(cls.__init__).parameters
|
||||
if "request" in accepted:
|
||||
return {"request": request}
|
||||
return {}
|
||||
|
||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||
"""
|
||||
Called after successful form validation and submission.
|
||||
|
||||
Override to handle the form submission logic.
|
||||
Return a dict to include data in the response.
|
||||
|
||||
Example:
|
||||
def on_submit_success(self, request):
|
||||
self.save()
|
||||
return {"id": self.instance.pk}
|
||||
Handle a validated submission. A returned dict is carried in the
|
||||
response payload; a ModelForm's `save()` returns a model instance, which
|
||||
is not payload, so only a dict result is forwarded.
|
||||
"""
|
||||
# Default: call save() if available
|
||||
if hasattr(self, "save"):
|
||||
result = self.save()
|
||||
# If save returns something serializable, include it
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return None
|
||||
|
||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||
def on_submit_failure(self, request: HttpRequest, errors: Any) -> None:
|
||||
"""
|
||||
Called after form validation fails.
|
||||
|
||||
Override to add custom error handling, logging, etc.
|
||||
Handle a rejected submission. The per-field errors already travel to the
|
||||
client in the response body, so the rejection is recorded server-side
|
||||
rather than re-raised.
|
||||
"""
|
||||
pass
|
||||
logger.info(
|
||||
"%s rejected a submission on %s: %s",
|
||||
type(self).__name__,
|
||||
getattr(request, "path", "<no path>"),
|
||||
errors,
|
||||
)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Auto-register when a concrete form class is defined."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
# Only register concrete forms with mizan config defined
|
||||
if _is_concrete_mizan_form(cls):
|
||||
_register_form_as_server_functions(cls)
|
||||
|
||||
|
||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
"""
|
||||
Check if a class is a concrete mizan form ready for registration.
|
||||
|
||||
A form is concrete if:
|
||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||
2. It inherits from Django's BaseForm
|
||||
3. It hasn't been registered yet (for this class definition)
|
||||
True when `cls` carries its own mizanFormMeta, is a Django form, and has
|
||||
not already been registered.
|
||||
"""
|
||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||
# Read cls.__dict__ so an inherited config does not re-register.
|
||||
mizan_config = cls.__dict__.get("mizan")
|
||||
if not isinstance(mizan_config, mizanFormMeta):
|
||||
return False
|
||||
|
||||
# Must be a Django form
|
||||
if not issubclass(cls, forms.BaseForm):
|
||||
return False
|
||||
|
||||
# Check if already registered (handle re-imports gracefully)
|
||||
if cls.__dict__.get("_mizan_registered", False):
|
||||
return False
|
||||
|
||||
@@ -280,50 +123,36 @@ def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
|
||||
def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"""
|
||||
Register a Django Form class as mizan server functions.
|
||||
|
||||
Creates and registers:
|
||||
- {name}.schema - Returns form field definitions
|
||||
- {name}.validate - Validates form data
|
||||
- {name}.submit - Validates and submits form
|
||||
|
||||
Each function gets a unique typed schema for better TypeScript LSP support.
|
||||
Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for
|
||||
`form_class`, plus the formset trio when `enable_formset` is set.
|
||||
"""
|
||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import validate_form_instance
|
||||
from mizan.forms.schemas import (
|
||||
FormSchema,
|
||||
FormSubmitFail,
|
||||
FormSubmitPass,
|
||||
FormValidation,
|
||||
)
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.validation_utils import validate_form_instance
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
config: mizanFormMeta = form_class.mizan
|
||||
form_name = config.name
|
||||
|
||||
# Mark as registered
|
||||
form_class._mizan_registered = True
|
||||
|
||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||
# "contact" -> "Contact", "reset_password" -> "ResetPassword"
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||
_form_data_schema_cache: dict[str, type[BaseModel]] = {}
|
||||
|
||||
def get_form_data_schema() -> type[BaseModel]:
|
||||
"""Lazily create the form data schema (form fields aren't available at registration time)."""
|
||||
if "schema" not in _form_data_schema_cache:
|
||||
_form_data_schema_cache["schema"] = _create_form_input_schema(
|
||||
form_class, f"{pascal_name}FormData"
|
||||
)
|
||||
return _form_data_schema_cache["schema"]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Schema Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Schema input wraps the form data for pre-populating dynamic fields
|
||||
# `data` pre-populates dynamic fields before the schema is read off the form.
|
||||
FormSchemaInput = create_model(
|
||||
f"{pascal_name}SchemaInput",
|
||||
data=(dict[str, Any], {}),
|
||||
@@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "schema",
|
||||
"form_class": form_class, # Store reference for schema generation
|
||||
"form_class": form_class,
|
||||
}
|
||||
|
||||
def call(self, input) -> FormSchema:
|
||||
@@ -347,13 +176,12 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
data=input.data if input else {},
|
||||
**init_kwargs,
|
||||
)
|
||||
# Override with mizanFormMeta values
|
||||
# mizanFormMeta wins over anything derived from the form class.
|
||||
if config.title is not None:
|
||||
schema.title = config.title
|
||||
if config.subtitle is not None:
|
||||
schema.subtitle = config.subtitle
|
||||
schema.submit_label = config.submit_label
|
||||
# Behavior settings are nested in schema.meta
|
||||
schema.meta.live_validation = config.live_validation
|
||||
schema.meta.live_form_errors = config.live_form_errors
|
||||
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
||||
@@ -367,7 +195,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
# Validate Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Use generic dict input - form fields aren't available during __init_subclass__
|
||||
# Generic dict input — form fields are unavailable during __init_subclass__.
|
||||
FormValidateInput = create_model(
|
||||
f"{pascal_name}ValidateInput",
|
||||
data=(dict[str, Any], ...),
|
||||
@@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
|
||||
def call(self, input) -> FormValidation:
|
||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||
# Input data is already a dict
|
||||
data = input.data
|
||||
_, validation = validate_form_instance(
|
||||
form_class,
|
||||
@@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
class SubmitFunction(ServerFunction):
|
||||
"""
|
||||
Submit function handles both JSON and multipart/form-data.
|
||||
|
||||
The executor detects form functions and parses the request appropriately.
|
||||
"""
|
||||
|
||||
# Use dict for input - form fields unknown at registration time
|
||||
Input = None # Signals executor to pass raw dict
|
||||
# Input=None signals the executor to pass the raw dict through, since
|
||||
# the Django form owns validation.
|
||||
Input = None
|
||||
|
||||
_meta: ClassVar[dict] = {
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "submit",
|
||||
"multipart": True, # Signal that this function accepts multipart
|
||||
"multipart": True,
|
||||
}
|
||||
|
||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||
"""Execute form submission."""
|
||||
request = self.request
|
||||
|
||||
# Check if we have multipart data from executor
|
||||
# Multipart bodies are parsed onto the request before dispatch.
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input is not None:
|
||||
# JSON input - already a dict
|
||||
data = input if isinstance(input, dict) else input.model_dump()
|
||||
files = None
|
||||
else:
|
||||
@@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
|
||||
init_kwargs = form_class.get_init_kwargs(request)
|
||||
|
||||
# Create and validate form
|
||||
form, validation = validate_form_instance(
|
||||
form_class,
|
||||
data=data,
|
||||
@@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
# Call the form's on_submit_success
|
||||
result_data = form.on_submit_success(request)
|
||||
return FormSubmitPass(success=True, data=result_data)
|
||||
|
||||
# Call the form's on_submit_failure
|
||||
form.on_submit_failure(request, validation)
|
||||
return FormSubmitFail(success=False, errors=validation)
|
||||
|
||||
@@ -472,36 +289,34 @@ def _register_formset_functions(
|
||||
form_class: type,
|
||||
form_name: str,
|
||||
) -> None:
|
||||
"""Register formset server functions for a form."""
|
||||
"""Register the `{name}.formset.*` server functions for a form."""
|
||||
from django.forms import formset_factory
|
||||
|
||||
from .schemas import (
|
||||
from mizan.forms.schemas import (
|
||||
FormsetSchema,
|
||||
FormsetSubmitFail,
|
||||
FormsetSubmitPass,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import build_formset_validation
|
||||
from .formset_utils import forms_to_formset_post_data
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.validation_utils import build_formset_validation
|
||||
from mizan.forms.formset_utils import forms_to_formset_post_data
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
formset_class = formset_factory(form_class)
|
||||
|
||||
# Generate PascalCase name for schemas
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Formset Schema Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict inputs throughout — form fields are unavailable during
|
||||
# __init_subclass__, so no typed schema can be built here.
|
||||
FormsetSchemaInput = create_model(
|
||||
f"{pascal_name}FormsetSchemaInput",
|
||||
forms=(list[dict[str, Any]], []),
|
||||
@@ -542,7 +357,6 @@ def _register_formset_functions(
|
||||
# Formset Validate Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict input - form fields aren't available during __init_subclass__
|
||||
FormsetValidateInput = create_model(
|
||||
f"{pascal_name}FormsetValidateInput",
|
||||
forms=(list[dict[str, Any]], ...),
|
||||
@@ -560,12 +374,12 @@ def _register_formset_functions(
|
||||
|
||||
def call(self, input) -> FormsetValidation:
|
||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
|
||||
formset_data = forms_to_formset_post_data(forms_data)
|
||||
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
||||
|
||||
# Every submitted row must validate; blank rows are not excused.
|
||||
for form in formset:
|
||||
form.empty_permitted = False
|
||||
|
||||
@@ -578,7 +392,6 @@ def _register_formset_functions(
|
||||
# Formset Submit Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict input - form fields aren't available during __init_subclass__
|
||||
FormsetSubmitInput = create_model(
|
||||
f"{pascal_name}FormsetSubmitInput",
|
||||
forms=(list[dict[str, Any]], ...),
|
||||
@@ -598,12 +411,10 @@ def _register_formset_functions(
|
||||
request = self.request
|
||||
init_kwargs = form_class.get_init_kwargs(request)
|
||||
|
||||
# Handle multipart vs JSON
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
post_data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input and hasattr(input, "forms"):
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
post_data = forms_to_formset_post_data(forms_data)
|
||||
files = None
|
||||
@@ -620,10 +431,8 @@ def _register_formset_functions(
|
||||
return FormsetSubmitPass(success=True)
|
||||
|
||||
validation = build_formset_validation(formset)
|
||||
# Call failure handler on each form
|
||||
for form in formset.forms:
|
||||
if hasattr(form, "on_submit_failure"):
|
||||
form.on_submit_failure(request, validation)
|
||||
form.on_submit_failure(request, validation)
|
||||
|
||||
return FormsetSubmitFail(success=False, errors=validation)
|
||||
|
||||
@@ -641,10 +450,8 @@ def register_form(
|
||||
submit_handler: Any = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a Django Form class as Mizan server functions.
|
||||
|
||||
Creates and registers `{name}.schema`, `{name}.validate`, and
|
||||
`{name}.submit` (if a submit_handler is provided).
|
||||
Register a plain Django Form as `{name}.schema`, `{name}.validate`, and —
|
||||
when `submit_handler` is given — `{name}.submit`.
|
||||
"""
|
||||
from mizan_core.client.function import create_form_functions
|
||||
from mizan_core.registry import register
|
||||
@@ -660,10 +467,8 @@ def register_form(
|
||||
|
||||
def get_forms() -> dict[str, list]:
|
||||
"""
|
||||
Group registered form-related functions by their form name.
|
||||
|
||||
Returns a mapping like:
|
||||
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
||||
Group registered form-related functions by their form name, e.g.
|
||||
`{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`.
|
||||
"""
|
||||
from mizan_core.registry import get_all_functions
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
from django import forms
|
||||
from django.forms import Field
|
||||
|
||||
from .schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||
from mizan.forms.schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||
|
||||
|
||||
def create_form_instance(
|
||||
@@ -14,65 +14,58 @@ def create_form_instance(
|
||||
**kwargs,
|
||||
) -> forms.BaseForm:
|
||||
"""
|
||||
Create a form instance, gracefully handling kwargs that the form doesn't accept.
|
||||
|
||||
Some Django forms (like allauth's) accept `request` in __init__, others don't.
|
||||
This function tries with all kwargs first, then progressively removes kwargs
|
||||
that cause TypeErrors until instantiation succeeds.
|
||||
Instantiate `form_class`, dropping kwargs its __init__ rejects.
|
||||
|
||||
Django form __init__ signatures vary — some accept `request`, others do
|
||||
not — so instantiation is retried with the offending kwarg removed until
|
||||
it succeeds or the TypeError is not about an unexpected keyword.
|
||||
"""
|
||||
# Common kwargs that forms may or may not accept
|
||||
optional_kwargs = ['request', 'user', 'instance']
|
||||
|
||||
# Build init kwargs
|
||||
|
||||
init_kwargs = dict(kwargs)
|
||||
if data is not None:
|
||||
init_kwargs['data'] = data
|
||||
if files is not None:
|
||||
init_kwargs['files'] = files
|
||||
|
||||
|
||||
while True:
|
||||
try:
|
||||
return form_class(**init_kwargs)
|
||||
except TypeError as e:
|
||||
error_msg = str(e)
|
||||
|
||||
# Check if it's an unexpected keyword argument error
|
||||
|
||||
if "unexpected keyword argument" not in error_msg:
|
||||
raise
|
||||
|
||||
# Find which kwarg caused the problem and remove it
|
||||
|
||||
removed = False
|
||||
for kwarg in optional_kwargs:
|
||||
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
||||
init_kwargs.pop(kwarg)
|
||||
removed = True
|
||||
break
|
||||
|
||||
# If we couldn't identify/remove the problematic kwarg, re-raise
|
||||
|
||||
if not removed:
|
||||
raise
|
||||
|
||||
|
||||
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
||||
"""
|
||||
Extract choices from a field, handling ModelChoiceField properly.
|
||||
ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable.
|
||||
Extract a field's choices as JSON-serializable pairs. ModelChoiceField
|
||||
yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`.
|
||||
"""
|
||||
if not hasattr(field, "choices"):
|
||||
return None
|
||||
|
||||
choices: list[FieldChoice] = []
|
||||
for raw_value, label in field.choices:
|
||||
value = getattr(
|
||||
raw_value, "value", raw_value
|
||||
) # ModelChoiceIteratorValue -> .value
|
||||
value = getattr(raw_value, "value", raw_value)
|
||||
choices.append(FieldChoice(value=str(value), label=str(label)))
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def _get_initial(value: Any) -> Any:
|
||||
"""Convert initial value to JSON-serializable format."""
|
||||
"""Convert an initial value to a JSON-serializable form."""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
@@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any:
|
||||
|
||||
|
||||
def _class_name_to_title(name: str) -> str:
|
||||
"""
|
||||
Convert a class name to a human-readable title.
|
||||
e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'
|
||||
"""
|
||||
# Remove 'Form' suffix
|
||||
"""'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'."""
|
||||
name = re.sub(r"Form$", "", name)
|
||||
# Insert spaces before capital letters
|
||||
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||
return name
|
||||
|
||||
|
||||
def _class_name_to_slug(name: str) -> str:
|
||||
"""
|
||||
Convert a class name to a slug.
|
||||
e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'
|
||||
"""
|
||||
# Remove 'Form' suffix
|
||||
"""'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'."""
|
||||
name = re.sub(r"Form$", "", name)
|
||||
# Insert underscores before capital letters and lowercase
|
||||
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
||||
return name.lower()
|
||||
|
||||
@@ -114,48 +97,31 @@ def build_form_schema(
|
||||
**kwargs,
|
||||
) -> FormSchema:
|
||||
"""
|
||||
Produce a FormSchema for the given Django form class and (optional) data.
|
||||
Produce a FormSchema for a Django form class and optional bound data.
|
||||
|
||||
The form class can define metadata via an inner Meta class:
|
||||
|
||||
class MyForm(forms.Form):
|
||||
class Meta:
|
||||
form_name = "my_form"
|
||||
title = "My Form Title"
|
||||
subtitle = "Optional description"
|
||||
submit_label = "Submit"
|
||||
|
||||
# Frontend behavior (optional)
|
||||
refetch_schema_on_validate = False # Set True for dynamic choice fields
|
||||
live_validation = True # Set False to disable live validation
|
||||
live_form_errors = False # Set True to show form errors live
|
||||
|
||||
If not provided, sensible defaults are derived from the class name.
|
||||
Attributes on the form's inner `Meta` class — `form_name`, `title`,
|
||||
`subtitle`, `submit_label`, `refetch_schema_on_validate`,
|
||||
`live_validation`, `live_form_errors` — override the values otherwise
|
||||
derived from the class name.
|
||||
"""
|
||||
form = create_form_instance(form_class, data=data, **kwargs)
|
||||
|
||||
# Extract metadata from form's Meta class
|
||||
form_meta = getattr(form_class, "Meta", None)
|
||||
|
||||
# Get form name (used as identifier)
|
||||
name = getattr(form_meta, "form_name", None)
|
||||
if name is None:
|
||||
name = _class_name_to_slug(form_class.__name__)
|
||||
|
||||
# Get title (human-readable heading)
|
||||
title = getattr(form_meta, "title", None)
|
||||
if title is None:
|
||||
title = _class_name_to_title(form_class.__name__)
|
||||
|
||||
# Get optional subtitle
|
||||
subtitle = getattr(form_meta, "subtitle", None)
|
||||
|
||||
# Get submit button label
|
||||
submit_label = getattr(form_meta, "submit_label", None)
|
||||
if submit_label is None:
|
||||
submit_label = "Submit"
|
||||
|
||||
# Build frontend behavior metadata
|
||||
frontend_meta = FormMeta(
|
||||
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
||||
live_validation=getattr(form_meta, "live_validation", True),
|
||||
|
||||
@@ -4,13 +4,13 @@ from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils.datastructures import MultiValueDict
|
||||
|
||||
from .schemas import (
|
||||
from mizan.forms.schemas import (
|
||||
FieldError,
|
||||
FieldErrorList,
|
||||
FormValidation,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import create_form_instance
|
||||
from mizan.forms.schema_utils import create_form_instance
|
||||
|
||||
|
||||
def validate_form_instance(
|
||||
@@ -19,12 +19,9 @@ def validate_form_instance(
|
||||
files: MultiValueDict[str, UploadedFile] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[forms.BaseForm, FormValidation]:
|
||||
"""
|
||||
Build a form instance and return (form, structured_validation_errors).
|
||||
"""
|
||||
"""Build a form instance and return it alongside its structured field errors."""
|
||||
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
||||
|
||||
# Run validation
|
||||
form.is_valid()
|
||||
|
||||
validation = FormValidation(
|
||||
@@ -46,9 +43,7 @@ def validate_form_instance(
|
||||
|
||||
|
||||
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
||||
"""
|
||||
Turn a Django formset into a FormsetValidation structure.
|
||||
"""
|
||||
"""Turn a Django formset's non-form and per-form errors into a FormsetValidation."""
|
||||
return FormsetValidation(
|
||||
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
||||
per_form=[
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
"""
|
||||
mizan.jwt - JWT authentication for server functions.
|
||||
JWT issuance and validation for mizan server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
- JWT authentication utilities for validating tokens
|
||||
|
||||
Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Usage in apps.py or urls.py (to register the functions):
|
||||
import mizan.jwt.functions # noqa: F401
|
||||
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
`jwt_obtain` / `jwt_refresh` are server functions; importing
|
||||
`mizan.jwt.functions` is what registers them. The Ninja auth class
|
||||
`JWTAuth` / `jwt_auth` resolves through `__getattr__` so that importing this
|
||||
package does not pull django-ninja's settings access in at module load time.
|
||||
"""
|
||||
|
||||
# Server functions (import to register with @client decorator)
|
||||
from .functions import jwt_obtain, jwt_refresh
|
||||
from mizan.jwt.functions import jwt_obtain, jwt_refresh
|
||||
|
||||
# Token utilities
|
||||
from .tokens import (
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -31,17 +20,12 @@ from .tokens import (
|
||||
JWTUser,
|
||||
)
|
||||
|
||||
# Settings
|
||||
from .settings import get_settings, JWTSettings
|
||||
|
||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||
# django-ninja's settings access at module load time.
|
||||
# Use: from mizan.jwt.security import jwt_auth
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("JWTAuth", "jwt_auth"):
|
||||
from .security import JWTAuth, jwt_auth
|
||||
from mizan.jwt.security import JWTAuth, jwt_auth
|
||||
|
||||
globals()["JWTAuth"] = JWTAuth
|
||||
globals()["jwt_auth"] = jwt_auth
|
||||
|
||||
@@ -1,64 +1,33 @@
|
||||
"""
|
||||
Django Ninja Security Classes for JWT Authentication
|
||||
|
||||
Provides authentication classes that can be used with Django Ninja's
|
||||
auth parameter to protect API endpoints.
|
||||
Django Ninja security class for JWT bearer authentication, usable as
|
||||
`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list.
|
||||
"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from ninja.security import HttpBearer
|
||||
|
||||
from .tokens import decode_token, JWTUser
|
||||
from mizan.jwt.tokens import decode_token, JWTUser
|
||||
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
"""
|
||||
JWT Bearer token authentication for Django Ninja.
|
||||
|
||||
Usage:
|
||||
from ninja_jwt_session import jwt_auth
|
||||
|
||||
@api.get("/protected/", auth=jwt_auth)
|
||||
def protected_endpoint(request):
|
||||
return {"user_id": request.user.id}
|
||||
|
||||
Or globally:
|
||||
api = NinjaExtraAPI(auth=[django_auth, jwt_auth])
|
||||
|
||||
The token must be passed in the Authorization header:
|
||||
Authorization: Bearer <access_token>
|
||||
|
||||
IMPORTANT: This is stateless - no database query is made.
|
||||
request.user is a JWTUser object with id, is_staff, is_superuser.
|
||||
If you need the full User object, query it explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
Reads `Authorization: Bearer <access_token>` and sets `request.user` to a
|
||||
JWTUser built from the token claims. No database query is made, so the
|
||||
resulting user carries only id, is_staff, and is_superuser.
|
||||
"""
|
||||
|
||||
def authenticate(self, request: HttpRequest, token: str):
|
||||
"""
|
||||
Validate the JWT and return a JWTUser if valid.
|
||||
|
||||
Returns None (authentication failed) if:
|
||||
- Token is invalid or expired
|
||||
- Token is not an access token
|
||||
|
||||
Note: No database query is made. The JWTUser is created from
|
||||
token claims. This is truly stateless authentication.
|
||||
"""
|
||||
# Decode and validate the token
|
||||
"""Return a JWTUser for a valid access token, or None to fail auth."""
|
||||
payload = decode_token(token, expected_type="access")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
jwt_user = JWTUser(payload)
|
||||
|
||||
# Set request.user for compatibility with code expecting it
|
||||
request.user = jwt_user
|
||||
|
||||
return jwt_user
|
||||
|
||||
|
||||
# Singleton instance for convenience
|
||||
jwt_auth = JWTAuth()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""
|
||||
JWT Token Creation and Validation
|
||||
JWT creation and validation over PyJWT.
|
||||
|
||||
Uses PyJWT directly - no allauth dependency.
|
||||
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||
Every token carries the Django session key in `sid`; `validate_session`
|
||||
re-checks that the session still exists, which is what makes logout revoke
|
||||
outstanding tokens immediately.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
import jwt
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
from .settings import get_settings
|
||||
from mizan.jwt.settings import get_settings
|
||||
|
||||
logger = logging.getLogger("mizan.jwt")
|
||||
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
@@ -34,13 +37,9 @@ class TokenPayload(NamedTuple):
|
||||
|
||||
class JWTUser:
|
||||
"""
|
||||
Minimal user object created from JWT claims.
|
||||
|
||||
Used as request.user for JWT-authenticated requests.
|
||||
No database query required - all data comes from the token.
|
||||
|
||||
If you need the full User object with all fields, query explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
Stand-in for `request.user` built entirely from JWT claims — no database
|
||||
row is loaded, so only id, is_staff, and is_superuser are real. Anything
|
||||
else about the user has to be queried explicitly by the caller.
|
||||
"""
|
||||
|
||||
def __init__(self, payload: TokenPayload):
|
||||
@@ -50,7 +49,7 @@ class JWTUser:
|
||||
self.is_superuser = payload.is_superuser
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True # Assumed active if they have a valid token
|
||||
self.is_active = True # A valid unexpired token stands in for the flag
|
||||
|
||||
def __str__(self):
|
||||
return f"JWTUser(id={self.id})"
|
||||
@@ -66,18 +65,7 @@ def create_access_token(
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "access"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
"""Create a short-lived access token."""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
@@ -105,18 +93,7 @@ def create_refresh_token(
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a longer-lived refresh token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "refresh"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
"""Create a longer-lived refresh token."""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
@@ -157,13 +134,10 @@ def create_token_pair(
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Returns None if:
|
||||
- Token is invalid or expired
|
||||
- Token type doesn't match expected_type (if specified)
|
||||
Decode and validate a JWT, returning None when it is malformed, expired,
|
||||
or not of `expected_type`.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -173,11 +147,18 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
settings.public_key,
|
||||
algorithms=[settings.algorithm],
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
except jwt.PyJWTError as exc:
|
||||
# Expired and forged tokens are routine on a public endpoint, so this
|
||||
# stays at debug rather than flooding the log on every bad request.
|
||||
logger.debug("JWT rejected: %s", exc)
|
||||
return None
|
||||
|
||||
# Validate token type if specified
|
||||
if expected_type and payload.get("type") != expected_type:
|
||||
logger.debug(
|
||||
"JWT rejected: expected type %r, got %r",
|
||||
expected_type,
|
||||
payload.get("type"),
|
||||
)
|
||||
return None
|
||||
|
||||
return TokenPayload(
|
||||
@@ -193,10 +174,8 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
|
||||
def validate_session(session_key: str) -> bool:
|
||||
"""
|
||||
Check if a session is still valid (exists and not expired).
|
||||
|
||||
This is the key to immediate logout revocation - if the session
|
||||
is destroyed, tokens tied to it become invalid.
|
||||
Report whether the Django session backing a token still exists. Returns
|
||||
True unconditionally when session validation is switched off in settings.
|
||||
"""
|
||||
from importlib import import_module
|
||||
|
||||
@@ -207,36 +186,30 @@ def validate_session(session_key: str) -> bool:
|
||||
if not jwt_settings.validate_session:
|
||||
return True
|
||||
|
||||
# Use the configured session engine
|
||||
engine = import_module(django_settings.SESSION_ENGINE)
|
||||
SessionStore = engine.SessionStore
|
||||
|
||||
# Try to load the session
|
||||
session = SessionStore(session_key=session_key)
|
||||
|
||||
# Check if session exists and is not empty
|
||||
# exists() is more reliable than checking load() result
|
||||
# exists() reads the backend directly; load() would silently hand back an
|
||||
# empty session for a missing key.
|
||||
return session.exists(session_key)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||
"""
|
||||
Use a refresh token to obtain new tokens.
|
||||
|
||||
Returns None if:
|
||||
- Refresh token is invalid or expired
|
||||
- Associated session no longer exists
|
||||
Exchange a refresh token for a fresh pair carrying the same claims.
|
||||
Returns None when the token is invalid or its session is gone.
|
||||
"""
|
||||
payload = decode_token(refresh_token, expected_type="refresh")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Validate the session still exists
|
||||
if not validate_session(payload.session_key):
|
||||
logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key)
|
||||
return None
|
||||
|
||||
# Issue new token pair with same claims
|
||||
return create_token_pair(
|
||||
payload.user_id,
|
||||
payload.session_key,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
"""
|
||||
Export channels schema as OpenAPI JSON for TypeScript generation.
|
||||
|
||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
||||
The schema is consumed by openapi-typescript for type generation.
|
||||
|
||||
Usage:
|
||||
python manage.py export_channels_schema
|
||||
Writes the channels schema to stdout as OpenAPI JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -29,6 +23,7 @@ class Command(BaseCommand):
|
||||
|
||||
schema = get_channels_openapi_schema()
|
||||
|
||||
# indent=0 is not compact in json.dumps; None is.
|
||||
indent = options["indent"] if options["indent"] > 0 else None
|
||||
output = json.dumps(schema, indent=indent)
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""
|
||||
Export Edge Manifest
|
||||
|
||||
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
||||
to configure CDN cache rules and invalidation routing.
|
||||
|
||||
Usage:
|
||||
python manage.py export_edge_manifest
|
||||
python manage.py export_edge_manifest --output mizan-manifest.json
|
||||
python manage.py export_edge_manifest --base-url /api/mizan
|
||||
"""
|
||||
"""Management command emitting the edge cache manifest as JSON."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"""
|
||||
Mizan IR (KDL) export — Django management command.
|
||||
|
||||
Usage:
|
||||
python manage.py export_mizan_ir
|
||||
|
||||
Triggers Mizan client discovery to populate the registry, then writes
|
||||
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
||||
consumes this directly.
|
||||
Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary
|
||||
consumes. Nothing else in this command may write to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
|
||||
@@ -20,9 +15,6 @@ class Command(BaseCommand):
|
||||
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
# Load every project-side @client function so the registry is
|
||||
# populated before we emit. Conventionally apps/*/clients.py.
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
|
||||
mizan_clients("apps")
|
||||
# Discovery populates the registry build_ir() reads.
|
||||
mizan_clients()
|
||||
self.stdout.write(build_ir(), ending="")
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""
|
||||
mizan.setup - Django integration helpers.
|
||||
|
||||
The function/composition registry now lives in `mizan_core.registry`.
|
||||
Channels register themselves through the channel-specific registry in
|
||||
`mizan.channels`. Forms register through `mizan.forms`. This module
|
||||
re-exports the helpers that Django mizan users typically reach for, so
|
||||
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
||||
working as a single curated surface.
|
||||
Curated Django-side surface: registration, lookup, discovery, and settings
|
||||
helpers, re-exported from `mizan_core.registry`, `mizan.channels`,
|
||||
`mizan.forms`, and this package's own modules.
|
||||
"""
|
||||
|
||||
from mizan_core.registry import (
|
||||
@@ -35,12 +30,13 @@ from mizan.forms import (
|
||||
get_forms,
|
||||
)
|
||||
|
||||
from .discovery import (
|
||||
from mizan.setup.discovery import (
|
||||
discover_apps_roots,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
)
|
||||
|
||||
from .settings import (
|
||||
from mizan.setup.settings import (
|
||||
mizanSettings,
|
||||
get_settings,
|
||||
clear_settings_cache,
|
||||
@@ -67,6 +63,7 @@ __all__ = [
|
||||
"validate_registry",
|
||||
"clear_registry",
|
||||
# Discovery
|
||||
"discover_apps_roots",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
# Settings
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
from django.apps import apps as django_apps
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from mizan._vendor.app_visitor import (
|
||||
DjangoAppVisitor,
|
||||
DjangoAppVisitorHandler,
|
||||
get_members,
|
||||
)
|
||||
|
||||
from mizan_core.registry import register, get_function
|
||||
from mizan_core.client.function import ServerFunction
|
||||
@@ -9,22 +17,20 @@ from mizan_core.client.function import ServerFunction
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _RegisterServerFunctions:
|
||||
class _RegisterServerFunctions(DjangoAppVisitorHandler):
|
||||
def on_module(
|
||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||
) -> None:
|
||||
for name, member in members:
|
||||
# Register ServerFunction subclasses
|
||||
if (
|
||||
isinstance(member, type)
|
||||
and issubclass(member, ServerFunction)
|
||||
and member is not ServerFunction
|
||||
and hasattr(member, "__name__")
|
||||
):
|
||||
# Use the function name as registration name
|
||||
fn_name = getattr(member, "name", None) or member.__name__
|
||||
|
||||
# Skip already registered (idempotent)
|
||||
# Idempotent: the same class under the same name is a re-visit.
|
||||
if get_function(fn_name) is member:
|
||||
continue
|
||||
|
||||
@@ -40,14 +46,48 @@ class _RegisterServerFunctions:
|
||||
)
|
||||
|
||||
|
||||
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root
|
||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||
visitor.visit(_RegisterServerFunctions())
|
||||
def discover_apps_roots() -> list[str]:
|
||||
"""
|
||||
Dotted package prefixes the project's own apps sit under, relative to
|
||||
BASE_DIR. `MIZAN_APPS_ROOT` in Django settings pins the answer.
|
||||
|
||||
An app counts as the project's own only when its directory is exactly
|
||||
BASE_DIR joined with its dotted name — the same resolution DjangoAppVisitor
|
||||
performs. That excludes installed packages even when the virtualenv holding
|
||||
them sits inside BASE_DIR.
|
||||
|
||||
An app declared as "apps.blog" yields "apps"; a top-level "blog" yields "".
|
||||
"""
|
||||
pinned = getattr(django_settings, "MIZAN_APPS_ROOT", None)
|
||||
if pinned is not None:
|
||||
return [pinned]
|
||||
|
||||
base_dir = Path(django_settings.BASE_DIR).resolve()
|
||||
|
||||
roots: list[str] = []
|
||||
for app_config in django_apps.get_app_configs():
|
||||
expected = base_dir.joinpath(*app_config.name.split("."))
|
||||
if Path(app_config.path).resolve() != expected:
|
||||
continue
|
||||
root = app_config.name.rpartition(".")[0]
|
||||
if root not in roots:
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def mizan_clients(apps_root: str | None = None, layer: str = "clients") -> None:
|
||||
"""
|
||||
Scan <app>/<layer>.py and <app>/<layer>/**/*.py and register every
|
||||
ServerFunction found. `apps_root` of None scans every discovered root.
|
||||
"""
|
||||
handler = _RegisterServerFunctions()
|
||||
roots = [apps_root] if apps_root is not None else discover_apps_roots()
|
||||
for root in roots:
|
||||
DjangoAppVisitor(layer=layer, apps_root=root).visit(handler)
|
||||
|
||||
|
||||
# Registers server functions from one module path, e.g. 'mizan.jwt.functions'
|
||||
def mizan_module(module_path: str) -> None:
|
||||
"""Register the server functions defined in one module, e.g. 'mizan.jwt.functions'."""
|
||||
members = get_members(module_path)
|
||||
handler = _RegisterServerFunctions()
|
||||
handler.on_module("", [], members)
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
"""
|
||||
mizan.ssr — Server-side rendering via Bun subprocess.
|
||||
|
||||
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'OPTIONS': {
|
||||
'worker_path': 'frontend/ssr-worker.tsx',
|
||||
'timeout': 5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then use Django's standard render():
|
||||
|
||||
return render(request, 'ProfilePage', {'user_id': 5})
|
||||
|
||||
The component name is the template name. The context dict becomes props.
|
||||
Server-side rendering as a Django template backend: the template name is the
|
||||
React component's file path and the context dict becomes its props.
|
||||
"""
|
||||
|
||||
from .backend import MizanTemplates
|
||||
from mizan.ssr.backend import MizanTemplates
|
||||
|
||||
__all__ = ["MizanTemplates"]
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
"""
|
||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'DIRS': [BASE_DIR / 'frontend'],
|
||||
'OPTIONS': {
|
||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
||||
Django template backend that resolves a template name to a .tsx/.jsx file
|
||||
under DIRS and renders it through a Bun subprocess. `OPTIONS['worker']` names
|
||||
the worker script; `OPTIONS['timeout']` bounds a single render.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .bridge import SSRBridge
|
||||
from mizan.ssr.bridge import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
@@ -38,12 +28,12 @@ class MizanTemplate:
|
||||
import json as _json
|
||||
|
||||
props = dict(context) if context else {}
|
||||
# Neither is JSON-serializable, and neither belongs in client hydration.
|
||||
props.pop("request", None)
|
||||
props.pop("csrf_token", None)
|
||||
|
||||
result = self._bridge.render(self.file_path, props)
|
||||
|
||||
# Serialize props as hydration data for client-side React
|
||||
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||
|
||||
return mark_safe(
|
||||
@@ -54,10 +44,12 @@ class MizanTemplate:
|
||||
|
||||
class MizanTemplates(BaseEngine):
|
||||
"""
|
||||
Django template backend that renders React components via Bun.
|
||||
Template backend whose template names are file paths resolved against
|
||||
DIRS. The bridge subprocess is created on first template lookup.
|
||||
|
||||
Template names are file paths resolved against DIRS.
|
||||
Same model as Django's built-in template engines.
|
||||
A template is a module the Bun worker imports by path, so a source string
|
||||
names nothing this engine can render — `from_string` is left to BaseEngine,
|
||||
which rejects it.
|
||||
"""
|
||||
|
||||
def __init__(self, params: dict[str, Any]) -> None:
|
||||
@@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine):
|
||||
self.get_bridge(),
|
||||
)
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
def from_string(self, template_code: str) -> MizanTemplate:
|
||||
raise TemplateDoesNotExist(
|
||||
"MizanTemplates renders .tsx files, not template strings."
|
||||
)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
Persistent Bun subprocess speaking newline-delimited JSON-RPC over
|
||||
stdin/stdout.
|
||||
|
||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||
Response: {"id": 1, "html": "<div>...</div>"}
|
||||
|
||||
The subprocess stays alive across requests. It is started on first use
|
||||
and restarted automatically if it crashes.
|
||||
Message id 0 is reserved for the worker's unsolicited ready signal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,10 +29,9 @@ class RenderResult:
|
||||
|
||||
class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
Owns the Bun subprocess. Thread-safe: concurrent render() callers are
|
||||
matched to their response by message id, and stdin writes are serialized
|
||||
so requests never interleave mid-line.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||
@@ -42,18 +39,17 @@ class SSRBridge:
|
||||
self._timeout = timeout
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||
self._write_lock = threading.Lock()
|
||||
self._counter = 0
|
||||
self._pending: dict[int, threading.Event] = {}
|
||||
self._results: dict[int, dict] = {}
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure cleanup on process exit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
"""Start the Bun subprocess if it's not running."""
|
||||
"""Start the Bun subprocess if it is not already running."""
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
|
||||
@@ -73,7 +69,6 @@ class SSRBridge:
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Wait for the "ready" signal from the worker
|
||||
if not self._ready.wait(timeout=self._timeout):
|
||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||
self.shutdown()
|
||||
@@ -82,7 +77,7 @@ class SSRBridge:
|
||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||
|
||||
def _read_responses(self) -> None:
|
||||
"""Background thread that reads JSON responses from stdout."""
|
||||
"""Background thread that reads JSON responses from the worker's stdout."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if isinstance(line, bytes):
|
||||
@@ -99,7 +94,6 @@ class SSRBridge:
|
||||
|
||||
msg_id = msg.get("id")
|
||||
|
||||
# Ready signal (id=0)
|
||||
if msg_id == 0 and msg.get("ready"):
|
||||
self._ready.set()
|
||||
continue
|
||||
@@ -112,18 +106,10 @@ class SSRBridge:
|
||||
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
Render the component at absolute path `file` with `props` to HTML.
|
||||
|
||||
Args:
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
RenderResult with the HTML string.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails.
|
||||
Raises TimeoutError past the configured timeout and RuntimeError when
|
||||
the worker reports a render error or its pipe is broken.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
@@ -139,7 +125,6 @@ class SSRBridge:
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||
with self._write_lock:
|
||||
try:
|
||||
self._proc.stdin.write(request.encode("utf-8"))
|
||||
@@ -163,19 +148,24 @@ class SSRBridge:
|
||||
return RenderResult(html=result["html"])
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the Bun subprocess."""
|
||||
if self._proc is not None:
|
||||
"""Stop the Bun subprocess, escalating to kill if terminate does not land."""
|
||||
if self._proc is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except OSError:
|
||||
logger.warning("Closing SSR worker stdin failed", exc_info=True)
|
||||
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.warning("SSR worker did not terminate; killing it", exc_info=True)
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
self._proc.kill()
|
||||
except OSError:
|
||||
logger.warning("Killing SSR worker failed", exc_info=True)
|
||||
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Hello({ name }: { name?: string }) {
|
||||
return <div data-mizan-component="Hello">Hello, {name}!</div>
|
||||
}
|
||||
@@ -1,29 +1,21 @@
|
||||
"""
|
||||
Protocol Benchmark: HTTP vs WebSocket Server Functions
|
||||
Latency and throughput measurements for server-function calls, comparing the
|
||||
direct executor path against the full HTTP view path.
|
||||
|
||||
Compares performance of HTTP POST vs WebSocket RPC for server function calls.
|
||||
Includes realistic scenarios with ORM queries.
|
||||
|
||||
Usage:
|
||||
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
||||
|
||||
Note:
|
||||
These are not unit tests - they measure performance. Results are printed
|
||||
to stdout and should be run in isolation for accurate measurements.
|
||||
These measure rather than assert on timing; each one still checks that the
|
||||
function under measurement returned the right answer. Timings printed here are
|
||||
only meaningful when the module is run in isolation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import connection
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
||||
from django.test import RequestFactory, TransactionTestCase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||
@@ -141,9 +133,10 @@ def setup_benchmark_functions():
|
||||
|
||||
class ProtocolBenchmark(TransactionTestCase):
|
||||
"""
|
||||
Benchmark comparing HTTP vs WebSocket (simulated) performance.
|
||||
Per-call latency for the executor path versus the HTTP view path.
|
||||
|
||||
Uses TransactionTestCase to ensure database state is realistic.
|
||||
TransactionTestCase rather than TestCase: the timings must include real
|
||||
commits instead of running inside one rolled-back transaction.
|
||||
"""
|
||||
|
||||
# Number of iterations for each benchmark
|
||||
@@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
# Create test users for ORM benchmarks
|
||||
self._create_test_users()
|
||||
|
||||
def _create_test_users(self):
|
||||
"""Create test users for benchmarks."""
|
||||
# Create 100 test users
|
||||
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(
|
||||
User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0, # 90% active
|
||||
is_staff=i < 5, # 5 staff
|
||||
is_active=i % 10 != 0,
|
||||
is_staff=i < 5,
|
||||
)
|
||||
)
|
||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||
@@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
return request
|
||||
|
||||
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
||||
"""
|
||||
Benchmark direct executor calls (simulates WebSocket RPC).
|
||||
|
||||
Returns timing statistics.
|
||||
"""
|
||||
"""Time direct executor calls and return timing statistics."""
|
||||
request = self._make_request()
|
||||
times = []
|
||||
|
||||
@@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
return self._compute_stats(times, f"Executor ({label})")
|
||||
|
||||
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
||||
"""
|
||||
Benchmark HTTP view calls.
|
||||
|
||||
Returns timing statistics.
|
||||
"""
|
||||
"""Time HTTP view calls and return timing statistics."""
|
||||
times = []
|
||||
|
||||
# Warmup
|
||||
@@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
self.assertIn("bench", user["email"].lower())
|
||||
|
||||
def test_summary(self):
|
||||
"""Print summary of all benchmarks."""
|
||||
"""Print the legend for the preceding benchmark tables."""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("BENCHMARK SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
||||
print(f"Warmup iterations: {self.WARMUP}")
|
||||
print("\nKey findings:")
|
||||
print("- 'Executor' simulates WebSocket RPC (direct function call)")
|
||||
print("- 'HTTP' measures full request/response cycle")
|
||||
print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch")
|
||||
print("- For I/O-bound operations, protocol overhead is negligible")
|
||||
print("\nColumns:")
|
||||
print("- 'Executor' calls execute_function directly")
|
||||
print("- 'HTTP' calls function_call_view, so it includes JSON parsing,")
|
||||
print(" CSRF handling, and view dispatch")
|
||||
print("=" * 80)
|
||||
|
||||
# Verify bench_simple still produces correct output after all benchmarks
|
||||
@@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
|
||||
|
||||
class ThroughputBenchmark(TransactionTestCase):
|
||||
"""
|
||||
Measure requests per second (throughput) for server functions.
|
||||
|
||||
Tests both sequential and concurrent scenarios.
|
||||
"""
|
||||
"""Requests per second for the executor path versus the HTTP view path."""
|
||||
|
||||
DURATION_SECONDS = 2 # How long to run each throughput test
|
||||
|
||||
@@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
self._create_test_users()
|
||||
|
||||
def _create_test_users(self):
|
||||
"""Create test users for benchmarks."""
|
||||
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(
|
||||
@@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
self.assertGreaterEqual(result.data["total_users"], 0)
|
||||
|
||||
def test_throughput_summary(self):
|
||||
"""Print throughput summary."""
|
||||
"""Print the measurement conditions for the preceding throughput tests."""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("THROUGHPUT SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
||||
print("\nNotes:")
|
||||
print("- These are single-threaded sequential measurements")
|
||||
print("- Real throughput scales with worker processes (gunicorn -w N)")
|
||||
print("- Database queries are the bottleneck, not protocol overhead")
|
||||
print("- Async workers (uvicorn) can handle more concurrent connections")
|
||||
print("\nConditions:")
|
||||
print("- Single-threaded and sequential")
|
||||
print("- SQLite in-memory database")
|
||||
print("=" * 80)
|
||||
|
||||
# Verify bench_simple still produces correct output after all throughput tests
|
||||
|
||||
@@ -5,11 +5,10 @@ Tests for mizan.channels module.
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.channels import (
|
||||
ReactChannel,
|
||||
Channel,
|
||||
register,
|
||||
get_channel,
|
||||
get_registered_channels,
|
||||
@@ -18,9 +17,6 @@ from mizan.channels import (
|
||||
)
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
@@ -42,52 +38,47 @@ class MockAnonymousUser:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ReactChannel Base Class Tests
|
||||
# Channel Base Class Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannelBaseTests(TestCase):
|
||||
"""Tests for ReactChannel base class."""
|
||||
class ChannelBaseTests(TestCase):
|
||||
"""Tests for Channel base class."""
|
||||
|
||||
def test_react_channel_default_class_vars(self):
|
||||
"""ReactChannel should have None defaults for nested classes."""
|
||||
self.assertIsNone(ReactChannel.Params)
|
||||
self.assertIsNone(ReactChannel.ReactMessage)
|
||||
self.assertIsNone(ReactChannel.DjangoMessage)
|
||||
def test_channel_default_class_vars(self):
|
||||
"""Channel should have None defaults for nested classes."""
|
||||
self.assertIsNone(Channel.Params)
|
||||
self.assertIsNone(Channel.ClientMessage)
|
||||
self.assertIsNone(Channel.ServerMessage)
|
||||
|
||||
def test_react_channel_requires_authorize_override(self):
|
||||
"""ReactChannel subclass must override authorize()."""
|
||||
def test_channel_requires_authorize_override(self):
|
||||
"""A subclass without authorize() cannot be instantiated."""
|
||||
|
||||
class IncompleteChannel(ReactChannel):
|
||||
pass
|
||||
class NoAuthorizeChannel(Channel):
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
channel = IncompleteChannel()
|
||||
channel.user = MockUser()
|
||||
with self.assertRaises(TypeError) as ctx:
|
||||
NoAuthorizeChannel()
|
||||
|
||||
with self.assertRaises(NotImplementedError) as ctx:
|
||||
channel.authorize()
|
||||
self.assertIn("authorize", str(ctx.exception))
|
||||
|
||||
self.assertIn("must implement authorize()", str(ctx.exception))
|
||||
def test_channel_requires_group_override(self):
|
||||
"""A subclass without group() cannot be instantiated."""
|
||||
|
||||
def test_react_channel_requires_group_override(self):
|
||||
"""ReactChannel subclass must override group()."""
|
||||
|
||||
class IncompleteChannel(ReactChannel):
|
||||
class NoGroupChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
channel = IncompleteChannel()
|
||||
channel.user = MockUser()
|
||||
with self.assertRaises(TypeError) as ctx:
|
||||
NoGroupChannel()
|
||||
|
||||
with self.assertRaises(NotImplementedError) as ctx:
|
||||
channel.group()
|
||||
self.assertIn("group", str(ctx.exception))
|
||||
|
||||
self.assertIn("must implement group()", str(ctx.exception))
|
||||
def test_channel_receive_default(self):
|
||||
"""Channel.receive() should return None by default."""
|
||||
|
||||
def test_react_channel_receive_default(self):
|
||||
"""ReactChannel.receive() should return None by default."""
|
||||
|
||||
class BasicChannel(ReactChannel):
|
||||
class BasicChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase):
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_react_channel_init_creates_empty_groups(self):
|
||||
"""ReactChannel.__init__() should create empty _groups set."""
|
||||
def test_channel_init_creates_empty_groups(self):
|
||||
"""Channel.__init__() should create empty _groups set."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase):
|
||||
def test_channel_with_params(self):
|
||||
"""Channel should accept Params Pydantic model."""
|
||||
|
||||
class ParamsChannel(ReactChannel):
|
||||
class ParamsChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
limit: int = 10
|
||||
@@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase):
|
||||
|
||||
self.assertIsNotNone(ParamsChannel.Params)
|
||||
|
||||
# Test params model
|
||||
params = ParamsChannel.Params(room="general")
|
||||
self.assertEqual(params.room, "general")
|
||||
self.assertEqual(params.limit, 10)
|
||||
|
||||
def test_channel_with_react_message(self):
|
||||
"""Channel should accept ReactMessage Pydantic model."""
|
||||
def test_channel_with_client_message(self):
|
||||
"""Channel should accept ClientMessage Pydantic model."""
|
||||
|
||||
class MessageChannel(ReactChannel):
|
||||
class ReactMessage(BaseModel):
|
||||
class MessageChannel(Channel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
timestamp: int
|
||||
|
||||
@@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "messages"
|
||||
|
||||
self.assertIsNotNone(MessageChannel.ReactMessage)
|
||||
self.assertIsNotNone(MessageChannel.ClientMessage)
|
||||
|
||||
# Test message model
|
||||
msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345)
|
||||
msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345)
|
||||
self.assertEqual(msg.text, "Hello")
|
||||
self.assertEqual(msg.timestamp, 12345)
|
||||
|
||||
def test_channel_with_django_message(self):
|
||||
"""Channel should accept DjangoMessage Pydantic model."""
|
||||
def test_channel_with_server_message(self):
|
||||
"""Channel should accept ServerMessage Pydantic model."""
|
||||
|
||||
class BroadcastChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class BroadcastChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
created_at: str
|
||||
@@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "broadcast"
|
||||
|
||||
self.assertIsNotNone(BroadcastChannel.DjangoMessage)
|
||||
self.assertIsNotNone(BroadcastChannel.ServerMessage)
|
||||
|
||||
# Test message model
|
||||
msg = BroadcastChannel.DjangoMessage(
|
||||
msg = BroadcastChannel.ServerMessage(
|
||||
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||
)
|
||||
self.assertEqual(msg.user, "john")
|
||||
@@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase):
|
||||
def test_channel_receive_with_typed_messages(self):
|
||||
"""Channel.receive() should work with typed messages."""
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
|
||||
@@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
||||
return self.ServerMessage(user=self.user.email, text=msg.text)
|
||||
|
||||
channel = ChatChannel()
|
||||
channel.user = MockUser(email="test@example.com")
|
||||
|
||||
params = ChatChannel.Params(room="general")
|
||||
incoming = ChatChannel.ReactMessage(text="Hello!")
|
||||
incoming = ChatChannel.ClientMessage(text="Hello!")
|
||||
|
||||
result = channel.receive(params, incoming)
|
||||
|
||||
self.assertIsInstance(result, ChatChannel.DjangoMessage)
|
||||
self.assertIsInstance(result, ChatChannel.ServerMessage)
|
||||
self.assertEqual(result.user, "test@example.com")
|
||||
self.assertEqual(result.text, "Hello!")
|
||||
|
||||
@@ -243,7 +231,7 @@ class RegistrationTests(TestCase):
|
||||
def test_register_adds_to_registry(self):
|
||||
"""register() should add channel to registry."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -255,17 +243,31 @@ class RegistrationTests(TestCase):
|
||||
self.assertIn("test-channel", _registry)
|
||||
self.assertEqual(_registry["test-channel"], TestChannel)
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
"""register() should raise on duplicate name."""
|
||||
def test_register_sets_registered_name(self):
|
||||
"""register() should stamp the wire name onto the class."""
|
||||
|
||||
class Channel1(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
class Channel2(ReactChannel):
|
||||
register(TestChannel, "named-channel")
|
||||
|
||||
self.assertEqual(TestChannel._registered_name, "named-channel")
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
"""register() should raise on duplicate name."""
|
||||
|
||||
class Channel1(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
class Channel2(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -279,21 +281,10 @@ class RegistrationTests(TestCase):
|
||||
|
||||
self.assertIn("already registered", str(ctx.exception))
|
||||
|
||||
def test_register_validates_authorize(self):
|
||||
"""register() should validate that authorize method exists."""
|
||||
|
||||
class NoAuthorizeChannel(ReactChannel):
|
||||
pass
|
||||
|
||||
# Should still pass because ReactChannel has authorize
|
||||
# (just raises NotImplementedError when called)
|
||||
register(NoAuthorizeChannel, "no-authorize-test")
|
||||
self.assertIn("no-authorize-test", _registry)
|
||||
|
||||
def test_get_channel_returns_registered(self):
|
||||
"""get_channel() should return registered channel."""
|
||||
|
||||
class MyChannel(ReactChannel):
|
||||
class MyChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -315,7 +306,7 @@ class RegistrationTests(TestCase):
|
||||
def test_get_registered_channels_returns_copy(self):
|
||||
"""get_registered_channels() should return a copy of registry."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -326,7 +317,6 @@ class RegistrationTests(TestCase):
|
||||
|
||||
result = get_registered_channels()
|
||||
|
||||
# Modifying result shouldn't affect original
|
||||
result["modified"] = "test"
|
||||
|
||||
self.assertIn("copy-test", _registry)
|
||||
@@ -360,7 +350,7 @@ class SchemaExportTests(TestCase):
|
||||
def test_get_channels_schema_with_basic_channel(self):
|
||||
"""get_channels_schema() should include basic channel info."""
|
||||
|
||||
class BasicChannel(ReactChannel):
|
||||
class BasicChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -376,13 +366,13 @@ class SchemaExportTests(TestCase):
|
||||
|
||||
self.assertEqual(channel_schema["name"], "basic")
|
||||
self.assertIsNone(channel_schema["params"])
|
||||
self.assertIsNone(channel_schema["reactMessage"])
|
||||
self.assertIsNone(channel_schema["djangoMessage"])
|
||||
self.assertIsNone(channel_schema["clientMessage"])
|
||||
self.assertIsNone(channel_schema["serverMessage"])
|
||||
|
||||
def test_get_channels_schema_with_params(self):
|
||||
"""get_channels_schema() should include params schema."""
|
||||
|
||||
class ParamsChannel(ReactChannel):
|
||||
class ParamsChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
limit: int = 50
|
||||
@@ -407,14 +397,14 @@ class SchemaExportTests(TestCase):
|
||||
def test_get_channels_schema_with_messages(self):
|
||||
"""get_channels_schema() should include message schemas."""
|
||||
|
||||
class FullChannel(ReactChannel):
|
||||
class FullChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
channel_id: int
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
timestamp: str
|
||||
@@ -431,24 +421,21 @@ class SchemaExportTests(TestCase):
|
||||
|
||||
channel_schema = schema["channels"]["full-channel"]
|
||||
|
||||
# Check params
|
||||
self.assertIsNotNone(channel_schema["params"])
|
||||
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
||||
|
||||
# Check ReactMessage
|
||||
self.assertIsNotNone(channel_schema["reactMessage"])
|
||||
self.assertIn("text", channel_schema["reactMessage"]["properties"])
|
||||
self.assertIsNotNone(channel_schema["clientMessage"])
|
||||
self.assertIn("text", channel_schema["clientMessage"]["properties"])
|
||||
|
||||
# Check DjangoMessage
|
||||
self.assertIsNotNone(channel_schema["djangoMessage"])
|
||||
self.assertIn("user", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIn("text", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIsNotNone(channel_schema["serverMessage"])
|
||||
self.assertIn("user", channel_schema["serverMessage"]["properties"])
|
||||
self.assertIn("text", channel_schema["serverMessage"]["properties"])
|
||||
self.assertIn("timestamp", channel_schema["serverMessage"]["properties"])
|
||||
|
||||
def test_get_channels_schema_multiple_channels(self):
|
||||
"""get_channels_schema() should include all registered channels."""
|
||||
|
||||
class Channel1(ReactChannel):
|
||||
class Channel1(Channel):
|
||||
class Params(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -458,7 +445,7 @@ class SchemaExportTests(TestCase):
|
||||
def group(self, params):
|
||||
return f"c1_{params.id}"
|
||||
|
||||
class Channel2(ReactChannel):
|
||||
class Channel2(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -473,13 +460,117 @@ class SchemaExportTests(TestCase):
|
||||
self.assertIn("channel-one", schema["channels"])
|
||||
self.assertIn("channel-two", schema["channels"])
|
||||
|
||||
# Channel 1 has params
|
||||
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
||||
|
||||
# Channel 2 has no params
|
||||
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry Extension Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ChannelsExtensionTests(TestCase):
|
||||
"""Tests for the channels extension plugged into mizan_core.registry."""
|
||||
|
||||
def setUp(self):
|
||||
self._original_registry = dict(_registry)
|
||||
|
||||
def tearDown(self):
|
||||
_registry.clear()
|
||||
_registry.update(self._original_registry)
|
||||
|
||||
def _extension(self):
|
||||
from mizan_core.registry import _extensions
|
||||
|
||||
return _extensions["channels"]
|
||||
|
||||
def test_extension_all_returns_registry_copy(self):
|
||||
"""all() should return name -> channel class, decoupled from the registry."""
|
||||
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "ext"
|
||||
|
||||
register(TestChannel, "ext-all")
|
||||
|
||||
result = self._extension().all()
|
||||
|
||||
self.assertEqual(result["ext-all"], TestChannel)
|
||||
|
||||
result["modified"] = TestChannel
|
||||
self.assertNotIn("modified", _registry)
|
||||
|
||||
def test_extension_schema_names_both_directions(self):
|
||||
"""schema() should carry client_message and server_message slots."""
|
||||
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
return True
|
||||
|
||||
def group(self, params):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
register(ChatChannel, "ext-chat")
|
||||
|
||||
entry = self._extension().schema()["ext-chat"]
|
||||
|
||||
self.assertEqual(entry["type"], "channel")
|
||||
self.assertTrue(entry["bidirectional"])
|
||||
self.assertIn("params", entry)
|
||||
self.assertIn("client_message", entry)
|
||||
self.assertIn("server_message", entry)
|
||||
|
||||
def test_extension_schema_omits_absent_client_message(self):
|
||||
"""A server-push-only channel is not bidirectional."""
|
||||
|
||||
class NotificationsChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notifications"
|
||||
|
||||
register(NotificationsChannel, "ext-notifications")
|
||||
|
||||
entry = self._extension().schema()["ext-notifications"]
|
||||
|
||||
self.assertNotIn("client_message", entry)
|
||||
self.assertIn("server_message", entry)
|
||||
self.assertFalse(entry["bidirectional"])
|
||||
|
||||
def test_extension_clear_empties_registry(self):
|
||||
"""clear() should drop every registration."""
|
||||
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "ext"
|
||||
|
||||
register(TestChannel, "ext-clear")
|
||||
|
||||
self._extension().clear()
|
||||
|
||||
self.assertEqual(_registry, {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authorization Tests
|
||||
# =============================================================================
|
||||
@@ -491,7 +582,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_authenticated_user(self):
|
||||
"""authorize() should work with authenticated users."""
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class AuthChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return self.user.is_authenticated
|
||||
|
||||
@@ -506,7 +597,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_anonymous_user(self):
|
||||
"""authorize() should work with anonymous users."""
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class AuthChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return self.user.is_authenticated
|
||||
|
||||
@@ -521,7 +612,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_params(self):
|
||||
"""authorize() should have access to params."""
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
@@ -553,7 +644,7 @@ class GroupTests(TestCase):
|
||||
def test_group_returns_string(self):
|
||||
"""group() should return a string group name."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -567,7 +658,7 @@ class GroupTests(TestCase):
|
||||
def test_group_with_params(self):
|
||||
"""group() should use params for dynamic group names."""
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room_id: int
|
||||
|
||||
@@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_join_group() should add group to _groups set."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_leave_group() should remove group from _groups set."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_leave_group() should ignore groups not in _groups."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase):
|
||||
await channel._leave_group("unknown-group")
|
||||
return channel._groups
|
||||
|
||||
groups = asyncio.get_event_loop().run_until_complete(test())
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
# Should not have called group_discard
|
||||
channel._channel_layer.group_discard.assert_not_called()
|
||||
|
||||
def test_leave_all_groups(self):
|
||||
"""_leave_all_groups() should leave all joined groups."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_broadcast() should send message to channel layer."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase):
|
||||
channel = TestChannel()
|
||||
channel._channel_layer = AsyncMock()
|
||||
|
||||
message = TestChannel.DjangoMessage(text="Hello")
|
||||
message = TestChannel.ServerMessage(text="Hello")
|
||||
|
||||
async def test():
|
||||
await channel._broadcast("my-group", message)
|
||||
@@ -747,8 +837,8 @@ class ServerPushTests(TestCase):
|
||||
"""push() should work for channels without params."""
|
||||
import asyncio
|
||||
|
||||
class NotificationChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class NotificationChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
|
||||
@@ -762,7 +852,7 @@ class ServerPushTests(TestCase):
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
message = NotificationChannel.DjangoMessage(
|
||||
message = NotificationChannel.ServerMessage(
|
||||
title="Alert", body="Something happened"
|
||||
)
|
||||
|
||||
@@ -781,11 +871,11 @@ class ServerPushTests(TestCase):
|
||||
"""push() should work for channels with params."""
|
||||
import asyncio
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
@@ -798,7 +888,7 @@ class ServerPushTests(TestCase):
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
message = RoomChannel.DjangoMessage(text="Hello room!")
|
||||
message = RoomChannel.ServerMessage(text="Hello room!")
|
||||
|
||||
async def test():
|
||||
await RoomChannel.push(room="general", message=message)
|
||||
@@ -814,10 +904,9 @@ class ServerPushTests(TestCase):
|
||||
def test_push_without_channel_layer_warns(self):
|
||||
"""push() should warn when no channel layer is configured."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -829,7 +918,7 @@ class ServerPushTests(TestCase):
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||
mock_get_layer.return_value = None
|
||||
|
||||
message = TestChannel.DjangoMessage(text="test")
|
||||
message = TestChannel.ServerMessage(text="test")
|
||||
|
||||
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||
|
||||
@@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase):
|
||||
|
||||
output = out.getvalue()
|
||||
|
||||
# Should be valid JSON with OpenAPI structure
|
||||
schema = json.loads(output)
|
||||
|
||||
self.assertIn("openapi", schema)
|
||||
@@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase):
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase):
|
||||
output = out.getvalue()
|
||||
schema = json.loads(output)
|
||||
|
||||
# Check that channel is in x-mizan-channels metadata
|
||||
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
||||
self.assertIn("export-test", channel_names)
|
||||
|
||||
def test_export_command_names_message_slots(self):
|
||||
"""The x-mizan-channels table should name the client and server slots."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
class SlotChannel(Channel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "slots"
|
||||
|
||||
register(SlotChannel, "slot_channel")
|
||||
|
||||
out = StringIO()
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
schema = json.loads(out.getvalue())
|
||||
entry = next(
|
||||
c for c in schema["x-mizan-channels"] if c["name"] == "slot_channel"
|
||||
)
|
||||
|
||||
self.assertEqual(entry["pascalName"], "SlotChannel")
|
||||
self.assertTrue(entry["hasClientMessage"])
|
||||
self.assertTrue(entry["hasServerMessage"])
|
||||
self.assertEqual(entry["clientMessageType"], "SlotChannelClientMessage")
|
||||
self.assertEqual(entry["serverMessageType"], "SlotChannelServerMessage")
|
||||
|
||||
def test_export_command_type_names_match_the_ir(self):
|
||||
"""A dotted-and-hyphenated wire name yields the same type names the IR
|
||||
emits — the OpenAPI document and the IR describe one set of types."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
from mizan_core.ir import build_ir, wire_to_pascal
|
||||
|
||||
class ActivityFeedChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class ClientMessage(BaseModel):
|
||||
ack: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
event: str
|
||||
|
||||
def authorize(self, params):
|
||||
return True
|
||||
|
||||
def group(self, params):
|
||||
return f"activity_{params.user_id}"
|
||||
|
||||
register(ActivityFeedChannel, "activity.live-feed")
|
||||
|
||||
out = StringIO()
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
schema = json.loads(out.getvalue())
|
||||
entry = next(
|
||||
c for c in schema["x-mizan-channels"] if c["name"] == "activity.live-feed"
|
||||
)
|
||||
|
||||
pascal = wire_to_pascal("activity.live-feed")
|
||||
self.assertEqual(pascal, "ActivityLiveFeed")
|
||||
self.assertEqual(entry["pascalName"], pascal)
|
||||
self.assertEqual(entry["paramsType"], f"{pascal}Params")
|
||||
self.assertEqual(entry["clientMessageType"], f"{pascal}ClientMessage")
|
||||
self.assertEqual(entry["serverMessageType"], f"{pascal}ServerMessage")
|
||||
|
||||
components = schema["components"]["schemas"]
|
||||
ir = build_ir()
|
||||
for slot in ("Params", "ClientMessage", "ServerMessage"):
|
||||
self.assertIn(f"{pascal}{slot}", components)
|
||||
self.assertIn(f'type "{pascal}{slot}"', ir)
|
||||
|
||||
def test_export_command_respects_indent(self):
|
||||
"""export_channels_schema should respect --indent option."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
# With indent
|
||||
out_indent = StringIO()
|
||||
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
||||
|
||||
# Without indent (compact)
|
||||
out_compact = StringIO()
|
||||
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
||||
|
||||
# Indented should be longer (has whitespace)
|
||||
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
||||
|
||||
|
||||
@@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase):
|
||||
"""Tests for WebSocket RPC functionality."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear mizan registry
|
||||
from mizan_core.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
# Register test functions
|
||||
from mizan.client import client
|
||||
from mizan_core.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -27,7 +27,7 @@ from mizan_core.registry import (
|
||||
)
|
||||
from mizan.forms import register_form
|
||||
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.channels import Channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -61,10 +61,9 @@ class ErrorOutput(BaseModel):
|
||||
|
||||
|
||||
def setup_function_style_tests():
|
||||
"""Register function-style test functions.
|
||||
|
||||
Note: Since @client no longer auto-registers (registration happens via
|
||||
mizan_clients() discovery), we explicitly register each function here.
|
||||
"""
|
||||
Register the function-style test functions. Applying @client does not put
|
||||
a function in the registry, so each one is passed to register() here.
|
||||
"""
|
||||
|
||||
@client
|
||||
@@ -514,8 +513,8 @@ class ContextTests(TestCase):
|
||||
fn = get_function("global_context")
|
||||
self.assertEqual(fn._meta.get("context"), "global")
|
||||
|
||||
def test_context_local(self):
|
||||
"""Test @client(context='local') still works with deprecation warning."""
|
||||
def test_context_arbitrary_name_is_verbatim_and_silent(self):
|
||||
"""Any non-empty context string becomes the name verbatim, with no warning."""
|
||||
import warnings
|
||||
|
||||
class CtxOutput(BaseModel):
|
||||
@@ -528,8 +527,7 @@ class ContextTests(TestCase):
|
||||
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
||||
return CtxOutput(data=f"user_{user_id}")
|
||||
|
||||
self.assertEqual(len(w), 1)
|
||||
self.assertIn("deprecated", str(w[0].message).lower())
|
||||
self.assertEqual([str(entry.message) for entry in w], [])
|
||||
|
||||
register(local_context, "local_context")
|
||||
|
||||
@@ -1019,7 +1017,7 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertIn("team_info", data)
|
||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||
|
||||
# Mizan handles caching via its protocol; origin emits no-store
|
||||
# Origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_error_not_cached(self):
|
||||
@@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase):
|
||||
|
||||
|
||||
class ChannelTests(TestCase):
|
||||
"""Tests for ReactChannel."""
|
||||
"""Tests for Channel."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -1187,8 +1185,8 @@ class ChannelTests(TestCase):
|
||||
"""Test channel registration."""
|
||||
from mizan.channels import register as register_channel, get_channel
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -1201,14 +1199,14 @@ class ChannelTests(TestCase):
|
||||
"""Test channel schema export."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: int
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
|
||||
@@ -1225,16 +1223,16 @@ class ChannelTests(TestCase):
|
||||
chat_schema = schema["channels"]["chat"]
|
||||
self.assertEqual(chat_schema["type"], "channel")
|
||||
self.assertIn("params", chat_schema)
|
||||
self.assertIn("react_message", chat_schema)
|
||||
self.assertIn("django_message", chat_schema)
|
||||
self.assertIn("client_message", chat_schema)
|
||||
self.assertIn("server_message", chat_schema)
|
||||
self.assertTrue(chat_schema["bidirectional"])
|
||||
|
||||
def test_server_push_only_channel(self):
|
||||
"""Test channel without ReactMessage (server-push only)."""
|
||||
"""Test channel without ClientMessage (server-push only)."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class NotificationsChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class NotificationsChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -1244,7 +1242,7 @@ class ChannelTests(TestCase):
|
||||
schema = get_schema()
|
||||
notif_schema = schema["channels"]["notifications"]
|
||||
|
||||
self.assertNotIn("react_message", notif_schema)
|
||||
self.assertNotIn("client_message", notif_schema)
|
||||
self.assertFalse(notif_schema["bidirectional"])
|
||||
|
||||
|
||||
@@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase):
|
||||
"""
|
||||
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
||||
|
||||
This is a regression test for the bug where `UserOutput | None` was
|
||||
incorrectly treated as a primitive type (because Union types aren't
|
||||
recognized by `isinstance(t, type)`), causing the output to be wrapped
|
||||
in a 'result' field.
|
||||
Union types are not recognized by `isinstance(t, type)`, so
|
||||
`UserOutput | None` can be mistaken for a primitive and wrapped in a
|
||||
'result' field. This pins that it is not.
|
||||
"""
|
||||
import types
|
||||
|
||||
@@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase):
|
||||
self.assertFalse(result.data["success"])
|
||||
self.assertIn("errors", result.data)
|
||||
|
||||
def test_form_submit_failure_calls_hook(self):
|
||||
"""A rejected submission calls on_submit_failure with the validation errors."""
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
seen = []
|
||||
|
||||
class HookForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="failure_hook_test")
|
||||
required_field = forms.CharField()
|
||||
|
||||
def on_submit_failure(self, request, errors):
|
||||
seen.append(errors)
|
||||
|
||||
request = self._make_request()
|
||||
result = execute_function(request, "failure_hook_test.submit", {})
|
||||
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertFalse(result.data["success"])
|
||||
self.assertEqual(len(seen), 1)
|
||||
self.assertEqual([entry.field for entry in seen[0].errors], ["required_field"])
|
||||
|
||||
def test_form_meta_serialization(self):
|
||||
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
||||
from mizan.forms import mizanFormMeta
|
||||
@@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase):
|
||||
self.assertEqual(len(result.data["fields"]), 1)
|
||||
self.assertEqual(result.data["fields"][0]["type"], "text")
|
||||
|
||||
def test_default_init_kwargs_forwards_request_only_when_declared(self):
|
||||
"""The base get_init_kwargs passes `request` to a form whose __init__ names it."""
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class PlainInitForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="plain_init_test")
|
||||
field = forms.CharField()
|
||||
|
||||
class RequestInitForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="request_init_test")
|
||||
field = forms.CharField()
|
||||
|
||||
def __init__(self, *args, request=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.seen_request = request
|
||||
|
||||
request = self._make_request()
|
||||
|
||||
self.assertEqual(PlainInitForm.get_init_kwargs(request), {})
|
||||
self.assertEqual(RequestInitForm.get_init_kwargs(request), {"request": request})
|
||||
|
||||
# And the form actually constructs with the forwarded kwarg.
|
||||
form = RequestInitForm(**RequestInitForm.get_init_kwargs(request))
|
||||
self.assertIs(form.seen_request, request)
|
||||
|
||||
def test_formset_functions_not_registered_by_default(self):
|
||||
"""Test that formset functions are not registered by default."""
|
||||
from django import forms
|
||||
@@ -1847,7 +1892,7 @@ class HTTPIntegrationTests(TestCase):
|
||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||
self.assertEqual(data["user_orders"]["count"], 50)
|
||||
|
||||
# Mizan handles caching; origin emits no-store
|
||||
# Origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_fetch_string_to_int_coercion(self):
|
||||
@@ -2133,14 +2178,15 @@ class ReturnTypeBranchingTests(TestCase):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Edge Compatibility Tests — Prove CDN caching works before Edge exists
|
||||
# Edge Compatibility Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class EdgeCompatibilityTests(TestCase):
|
||||
"""
|
||||
Tests that prove Edge caching is possible. Every failure mode that
|
||||
would break a CDN layer is tested here without building the CDN.
|
||||
Response properties a CDN layer reads: byte-identical bodies for identical
|
||||
requests, sorted JSON keys, no-store on mutations and errors, and an
|
||||
X-Mizan-Invalidate header that parses back to the JSON body's targets.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Deterministic JSON ──────────────────────────────────────────────────
|
||||
|
||||
def test_deterministic_json_output(self):
|
||||
"""Same request produces byte-identical response body. Cache keys depend on this."""
|
||||
"""Same request produces a byte-identical response body."""
|
||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
|
||||
@@ -2204,12 +2250,12 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||
|
||||
def test_context_get_no_store(self):
|
||||
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
||||
"""Context GET emits no-store."""
|
||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_mutation_post_not_cacheable(self):
|
||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
||||
"""Mutation POST emits no-store."""
|
||||
response = self.client.post(
|
||||
"/api/mizan/call/",
|
||||
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
||||
@@ -2219,14 +2265,14 @@ class EdgeCompatibilityTests(TestCase):
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_error_response_not_cacheable(self):
|
||||
"""Error responses have no-store. CDN must not cache errors."""
|
||||
"""Error responses emit no-store."""
|
||||
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_different_params_different_response(self):
|
||||
"""Different query params produce different response bodies (different cache entries)."""
|
||||
"""Different query params produce different response bodies."""
|
||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
||||
|
||||
@@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
|
||||
header = response["X-Mizan-Invalidate"]
|
||||
|
||||
# Parse the header (this is what Edge would do)
|
||||
# Parse the header back into structured entries
|
||||
entries = []
|
||||
for part in header.split(", "):
|
||||
segments = part.split(";")
|
||||
@@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Query param ordering doesn't affect content ─────────────────────────
|
||||
|
||||
def test_param_order_irrelevant(self):
|
||||
"""Different query param ordering produces same content (cache key normalization)."""
|
||||
"""Different query param ordering produces the same content."""
|
||||
@client(context=ReactContext("multi"))
|
||||
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
@@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
]
|
||||
header = _format_invalidate_header(original)
|
||||
|
||||
# Parse (what Edge would do)
|
||||
# Parse back
|
||||
segments = header.split(";")
|
||||
ctx = segments[0]
|
||||
params = {}
|
||||
@@ -3339,7 +3385,8 @@ def _redis_available() -> bool:
|
||||
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
||||
client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Tests for app-root discovery, which decides where mizan_clients() scans.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from mizan.setup.discovery import discover_apps_roots
|
||||
|
||||
# The installed mizan-django package root: `tests` sits directly beneath it and
|
||||
# the virtualenv holding django.contrib.* does too.
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DiscoverAppsRootsTests(TestCase):
|
||||
@override_settings(MIZAN_APPS_ROOT="somewhere_else")
|
||||
def test_pinned_root_is_used_verbatim(self):
|
||||
self.assertEqual(discover_apps_roots(), ["somewhere_else"])
|
||||
|
||||
@override_settings(MIZAN_APPS_ROOT="")
|
||||
def test_pinned_empty_root_means_apps_sit_at_base_dir(self):
|
||||
self.assertEqual(discover_apps_roots(), [""])
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||
def test_top_level_project_app_yields_the_empty_root(self):
|
||||
self.assertIn("", discover_apps_roots())
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||
def test_installed_packages_contribute_no_root(self):
|
||||
# django.contrib.* resolve inside the virtualenv, which lives under
|
||||
# BASE_DIR here — a containment check alone would wrongly admit them.
|
||||
self.assertNotIn("django.contrib", discover_apps_roots())
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT / "no_such_directory")
|
||||
def test_no_matching_app_yields_no_roots(self):
|
||||
self.assertEqual(discover_apps_roots(), [])
|
||||
@@ -1,22 +1,10 @@
|
||||
"""
|
||||
Advanced Penetration Tests for mizan Server Functions
|
||||
Attack-shaped tests over execute_function and the WebSocket consumer.
|
||||
|
||||
These tests simulate a professional security researcher attempting to break
|
||||
the protocol. Focus areas:
|
||||
|
||||
1. Race conditions and TOCTOU vulnerabilities
|
||||
2. Memory exhaustion and resource depletion
|
||||
3. Type confusion at serialization boundaries
|
||||
4. Session/authentication state manipulation
|
||||
5. Pydantic validation bypass attempts
|
||||
6. WebSocket protocol-level attacks
|
||||
7. Timing side-channel attacks
|
||||
8. Concurrent state corruption
|
||||
9. Deserialization attacks
|
||||
10. Unicode normalization exploits
|
||||
|
||||
SAFE TO RUN: These tests don't execute actual exploits - they verify
|
||||
that the defenses hold against attack patterns.
|
||||
Grouped by the surface each one drives: memory exhaustion, type confusion at
|
||||
the serialization boundary, concurrent execution, Pydantic validation bypass,
|
||||
WebSocket protocol framing, timing measurement, Unicode normalization, JSON
|
||||
parsing limits, authorization boundaries, and registration collisions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -485,9 +473,6 @@ class RaceConditionTests(TestCase):
|
||||
|
||||
result = execute_function(request, "timed_auth_func", None)
|
||||
|
||||
# The result reflects the state at time of check
|
||||
# This test documents the behavior - the user's is_authenticated
|
||||
# is read during execution, and if it changes, that's reflected
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
# On first access is_authenticated returns True, on second it returns False
|
||||
# The function reads is_authenticated once, so it sees the first value (True)
|
||||
@@ -503,9 +488,8 @@ class PydanticBypassTests(TestCase):
|
||||
"""
|
||||
Attempt to bypass Pydantic validation.
|
||||
|
||||
Note: The @client decorator creates dynamic Pydantic models from function
|
||||
parameters. Custom validators must be on the parameter types themselves,
|
||||
not separate classes. This tests the actual validation behavior.
|
||||
The @client decorator builds the Input model from the function's parameter
|
||||
annotations, so a custom validator has to live on the parameter type itself.
|
||||
|
||||
Attack vectors:
|
||||
- Type coercion bypass
|
||||
@@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase):
|
||||
|
||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||
"""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel, Channel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
@@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase):
|
||||
"""
|
||||
Test attempting to subscribe to the same channel twice.
|
||||
"""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel, Channel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase):
|
||||
# Large differences could leak function existence
|
||||
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
||||
|
||||
# Document the ratio but don't fail - this is informational
|
||||
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
||||
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
||||
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
||||
@@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase):
|
||||
"""
|
||||
request = self._make_request()
|
||||
|
||||
# These look like "admin" but use different Unicode characters
|
||||
# Built from chr(): each of these renders identically to its ASCII
|
||||
# counterpart, so a literal would be unreadable in source.
|
||||
lookalikes = [
|
||||
"\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a'
|
||||
"adm\u0131n", # Turkish dotless i (U+0131)
|
||||
"\u00e1dmin", # Latin a with acute
|
||||
"\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters
|
||||
"\u0251dmin", # Latin alpha
|
||||
"\u0430\u0501m\u0456n", # Mix of Cyrillic characters
|
||||
chr(0x0430) + "dmin", # Cyrillic small a
|
||||
"adm" + chr(0x0131) + "n", # Turkish dotless i
|
||||
chr(0x00E1) + "dmin", # Latin a with acute
|
||||
"".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)),
|
||||
chr(0x0251) + "dmin", # Latin alpha
|
||||
chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix
|
||||
]
|
||||
|
||||
for lookalike in lookalikes:
|
||||
@@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase):
|
||||
|
||||
request = self._make_request()
|
||||
|
||||
# é can be represented as:
|
||||
# 1. U+00E9 (precomposed)
|
||||
# 2. U+0065 U+0301 (decomposed: e + combining acute)
|
||||
precomposed = "caf\u00e9" # café with precomposed é
|
||||
decomposed = "cafe\u0301" # café with combining acute
|
||||
# Built from chr() so the two spellings stay distinguishable in source:
|
||||
# U+00E9 precomposed vs. "e" + U+0301 combining acute.
|
||||
precomposed = "caf" + chr(0x00E9)
|
||||
decomposed = "cafe" + chr(0x0301)
|
||||
|
||||
# These look identical but are different byte sequences
|
||||
self.assertNotEqual(precomposed, decomposed)
|
||||
@@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase):
|
||||
"""
|
||||
request = self._make_request()
|
||||
|
||||
# Built from chr(): every one of these renders as nothing at all.
|
||||
zero_width_chars = [
|
||||
"\u200b", # Zero-width space
|
||||
"\u200c", # Zero-width non-joiner
|
||||
"\u200d", # Zero-width joiner
|
||||
"\u2060", # Word joiner
|
||||
"\ufeff", # Zero-width no-break space (BOM)
|
||||
chr(0x200B), # Zero-width space
|
||||
chr(0x200C), # Zero-width non-joiner
|
||||
chr(0x200D), # Zero-width joiner
|
||||
chr(0x2060), # Word joiner
|
||||
chr(0xFEFF), # Zero-width no-break space (BOM)
|
||||
]
|
||||
|
||||
for zwc in zero_width_chars:
|
||||
@@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase):
|
||||
|
||||
try:
|
||||
result = execute_function(request, "json_func", {"data": nested})
|
||||
# Should either succeed or fail gracefully
|
||||
self.assertIn(type(result), [FunctionResult, FunctionError])
|
||||
except RecursionError:
|
||||
# This is acceptable - Python's recursion limit hit
|
||||
pass
|
||||
except RecursionError as exc:
|
||||
print(f"\nCPython recursion limit reached at 500 levels: {exc}")
|
||||
|
||||
def test_json_number_precision(self):
|
||||
"""
|
||||
@@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase):
|
||||
"""
|
||||
Test that a different function cannot override an existing one.
|
||||
|
||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
||||
But a DIFFERENT function cannot take over an existing name.
|
||||
Re-registering the same object under its own name is allowed; a
|
||||
different object claiming a taken name raises.
|
||||
"""
|
||||
from mizan.client import ServerFunction
|
||||
from mizan_core.registry import register
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
"""
|
||||
Security-focused E2E tests for mizan server functions.
|
||||
|
||||
These tests probe for potential vulnerabilities without running any
|
||||
malicious code - they simply verify that defenses work correctly.
|
||||
|
||||
Security areas covered:
|
||||
1. Input Validation - Large inputs, nested objects, type confusion
|
||||
2. Authorization - Bypass attempts, permission checks
|
||||
3. HTTP Endpoint - CSRF, method restrictions, JSON parsing
|
||||
4. WebSocket RPC - Malformed messages, unauthorized calls
|
||||
5. Information Disclosure - Error enumeration, internal detail leakage
|
||||
6. Injection Prevention - Special characters, unicode edge cases
|
||||
Adversarial-input tests: hostile payloads driven through execute_function,
|
||||
function_call_view, and the WebSocket consumer.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, Client, override_settings
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
@@ -29,9 +19,9 @@ from mizan.client.executor import (
|
||||
execute_function,
|
||||
function_call_view,
|
||||
)
|
||||
from mizan_core.registry import clear_registry, register, register_as, get_function
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan.client import client
|
||||
from mizan.channels import Channel
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -46,10 +36,6 @@ class SimpleOutput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class NestedInput(BaseModel):
|
||||
level1: dict
|
||||
|
||||
|
||||
class DeeplyNestedOutput(BaseModel):
|
||||
depth: int
|
||||
|
||||
@@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel):
|
||||
|
||||
|
||||
class InputValidationSecurityTests(TestCase):
|
||||
"""
|
||||
Test input validation for security edge cases.
|
||||
|
||||
Verifies that Pydantic validation catches malicious or malformed input
|
||||
BEFORE any function code executes.
|
||||
"""
|
||||
"""Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase):
|
||||
"""Test that null bytes in strings are handled safely."""
|
||||
request = self._make_request()
|
||||
|
||||
# Null byte injection attempt
|
||||
payload = "normal\x00injected"
|
||||
result = execute_function(request, "echo_any", {"message": payload})
|
||||
|
||||
@@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase):
|
||||
"""Test various unicode edge cases."""
|
||||
request = self._make_request()
|
||||
|
||||
# Escapes, not literals: these codepoints are invisible in an editor, and
|
||||
# a literal NUL cannot appear in Python source at all.
|
||||
test_cases = [
|
||||
# Zero-width characters
|
||||
"normal\u200btext",
|
||||
"normaltext",
|
||||
# Right-to-left override (potential display issues)
|
||||
"test\u202eevil",
|
||||
"testevil",
|
||||
# Emoji sequences
|
||||
"👨👩👧👦",
|
||||
"\U0001f468\U0001f469\U0001f467\U0001f466",
|
||||
# Combining characters
|
||||
"a\u0300\u0301\u0302",
|
||||
"à́̂",
|
||||
# Null character
|
||||
"test\u0000null",
|
||||
"test\x00null",
|
||||
# Replacement character
|
||||
"test\ufffdreplace",
|
||||
"test<EFBFBD>replace",
|
||||
]
|
||||
|
||||
for payload in test_cases:
|
||||
@@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase):
|
||||
|
||||
|
||||
class AuthorizationSecurityTests(TestCase):
|
||||
"""
|
||||
Test authorization bypass attempts.
|
||||
|
||||
Verifies that authentication/authorization checks can't be bypassed
|
||||
through various attack vectors.
|
||||
"""
|
||||
"""execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
@client
|
||||
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
||||
# Bad pattern: returns different errors for auth vs not found
|
||||
# This is intentionally bad to test we detect it
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("User not logged in")
|
||||
return SimpleOutput(value="ok")
|
||||
@@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase):
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
|
||||
def test_spoofed_is_authenticated_attribute(self):
|
||||
"""Test that spoofing is_authenticated doesn't work."""
|
||||
"""Test that a duck-typed user carrying is_authenticated is accepted."""
|
||||
|
||||
# Create object that claims to be authenticated but isn't a real user
|
||||
class FakeUser:
|
||||
is_authenticated = True
|
||||
id = 999
|
||||
@@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
request = self._make_request(user=FakeUser())
|
||||
result = execute_function(request, "requires_auth", None)
|
||||
|
||||
# This actually works because we only check is_authenticated
|
||||
# This test documents the behavior - real Django handles this
|
||||
# execute_function only reads is_authenticated, so this duck-type passes
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
|
||||
def test_user_id_manipulation_blocked(self):
|
||||
@@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
@client
|
||||
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
||||
# Properly checking: can only access own data
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
if request.user.id != target_user_id:
|
||||
@@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
|
||||
class HTTPEndpointSecurityTests(TestCase):
|
||||
"""
|
||||
Test HTTP endpoint security.
|
||||
|
||||
Verifies CSRF protection, method restrictions, and JSON parsing security.
|
||||
"""
|
||||
"""Method restrictions, JSON body parsing, and function-name lookup on the HTTP view."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
# Bypass CSRF for this test
|
||||
request._dont_enforce_csrf_checks = True
|
||||
|
||||
response = function_call_view(request)
|
||||
@@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
def test_function_identifier_traversal(self):
|
||||
"""Test that path traversal-style function identifiers are handled."""
|
||||
# Try various path traversal attempts as function identifiers
|
||||
malicious_names = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\windows\\system32",
|
||||
@@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
|
||||
class WebSocketRPCSecurityTests(TestCase):
|
||||
"""
|
||||
Test WebSocket RPC security.
|
||||
|
||||
Verifies that malformed messages and unauthorized calls are handled safely.
|
||||
"""
|
||||
"""Malformed and unresolvable RPC frames over the WebSocket consumer."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
consumer.channel_layer = MagicMock()
|
||||
consumer.channel_name = "test"
|
||||
|
||||
# Track sent messages
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call without id
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||
)
|
||||
@@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call without fn
|
||||
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
||||
|
||||
# Should return error
|
||||
self.assertEqual(len(sent_messages), 1)
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
||||
@@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call with wrong input type
|
||||
# Pydantic coerces an int to str, so an omitted required field is what
|
||||
# actually produces a validation error here.
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{
|
||||
"id": "123",
|
||||
"fn": "ws_echo",
|
||||
"args": {"message": 12345}, # Should be string
|
||||
"args": {"message": 12345},
|
||||
}
|
||||
)
|
||||
|
||||
# Pydantic coerces int to string, so this actually succeeds
|
||||
# Let's test with missing required field instead
|
||||
sent_messages.clear()
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
||||
{"id": "124", "fn": "ws_echo", "args": {}}
|
||||
)
|
||||
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
@@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
|
||||
|
||||
class InformationDisclosureTests(TestCase):
|
||||
"""
|
||||
Test information disclosure vulnerabilities.
|
||||
|
||||
Verifies that error messages don't leak sensitive information.
|
||||
"""
|
||||
"""Contents of FunctionError responses with DEBUG=False."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase):
|
||||
|
||||
@client
|
||||
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
||||
# Simulate accessing sensitive config that might leak in error
|
||||
secret_key = "super_secret_key_12345"
|
||||
raise RuntimeError(f"Database error with key: {secret_key}")
|
||||
|
||||
@@ -703,7 +655,6 @@ class InformationDisclosureTests(TestCase):
|
||||
"""Test that error messages don't help enumerate functions in production."""
|
||||
request = self._make_request()
|
||||
|
||||
# Try various function names/UUIDs
|
||||
test_names = [
|
||||
"admin_panel",
|
||||
"get_all_users",
|
||||
@@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase):
|
||||
result = execute_function(request, name, None)
|
||||
self.assertIsInstance(result, FunctionError)
|
||||
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
||||
# In production (DEBUG=False), error message is generic
|
||||
# - doesn't reveal function name or UUID existence
|
||||
# With DEBUG=False the message is identical for every name
|
||||
self.assertEqual(result.message, "Function not found")
|
||||
|
||||
def test_validation_errors_dont_leak_internals(self):
|
||||
@@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase):
|
||||
request = self._make_request()
|
||||
result = execute_function(request, "validated_func", {"secret_field": 123})
|
||||
|
||||
# Pydantic coerces to string, so let's try with wrong structure
|
||||
# Pydantic coerces to string, so an unknown field is what fails here.
|
||||
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
||||
|
||||
self.assertIsInstance(result, FunctionError)
|
||||
@@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase):
|
||||
|
||||
|
||||
class InjectionPreventionTests(TestCase):
|
||||
"""
|
||||
Test injection attack prevention.
|
||||
|
||||
Verifies that input validation prevents various injection attacks.
|
||||
Note: These tests verify the framework's security, not actual injection
|
||||
attempts - they just ensure malicious input is handled safely.
|
||||
"""
|
||||
"""SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
@client
|
||||
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
||||
# This function just echoes - the test is about validation
|
||||
return SimpleOutput(value=user_input)
|
||||
|
||||
register(echo_safe, "echo_safe")
|
||||
@@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
for payload in sql_payloads:
|
||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||
# Should succeed - it's just a string, not executed as SQL
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
# The payload is returned as-is (no SQL execution)
|
||||
self.assertEqual(result.data["value"], payload)
|
||||
|
||||
def test_command_injection_in_string_field(self):
|
||||
@@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
for payload in cmd_payloads:
|
||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||
# Should succeed - it's just a string
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["value"], payload)
|
||||
|
||||
@@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase):
|
||||
"""Test that special JSON values are handled safely."""
|
||||
request = self._make_request()
|
||||
|
||||
# Various JSON edge cases
|
||||
test_cases = [
|
||||
{"__proto__": {"polluted": True}},
|
||||
{"constructor": {"prototype": {}}},
|
||||
{"key": None},
|
||||
{"key": float("inf")}, # This will fail JSON serialization
|
||||
{"key": float("inf")},
|
||||
]
|
||||
|
||||
for data in test_cases:
|
||||
@@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
|
||||
class ChannelAuthorizationTests(TestCase):
|
||||
"""
|
||||
Test WebSocket channel authorization.
|
||||
|
||||
Verifies that channel subscriptions properly check permissions.
|
||||
"""
|
||||
"""Subscription outcomes when authorize() returns False, raises, or gets bad params."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
# Also clear the channels registry
|
||||
from mizan.channels import _registry as channels_registry
|
||||
|
||||
channels_registry.clear()
|
||||
@@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def _register_test_channels(self):
|
||||
"""Register test channels using the channels module's register."""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class PublicChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class PublicChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "public"
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class AuthChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "auth"
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room_id: int
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
# Only allow access to room 1 and 2
|
||||
return params.room_id in [1, 2]
|
||||
|
||||
def group(self, params):
|
||||
@@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def test_authorize_exception_handling(self):
|
||||
"""Test that exceptions in authorize() are handled safely."""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
class ErrorChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class ErrorChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase):
|
||||
{"channel": "auth-channel", "params": {}}
|
||||
)
|
||||
|
||||
# Should be rejected
|
||||
self.assertIn("error", sent_messages[0])
|
||||
self.assertIn("Not authorized", sent_messages[0]["error"])
|
||||
|
||||
@@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase):
|
||||
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||
)
|
||||
|
||||
# Should fail validation
|
||||
self.assertIn("error", sent_messages[0])
|
||||
|
||||
def test_room_authorization_enforced(self):
|
||||
@@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
|
||||
class AbusePreventionTests(TestCase):
|
||||
"""
|
||||
Test abuse prevention capabilities.
|
||||
|
||||
Note: The current implementation doesn't have built-in rate limiting,
|
||||
so these tests document the expected behavior and identify areas
|
||||
where rate limiting should be added.
|
||||
"""
|
||||
"""Repeated and batched execute_function calls."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase):
|
||||
"""Test that rapid function calls don't cause issues."""
|
||||
request = self._make_request()
|
||||
|
||||
# Make 100 rapid calls
|
||||
results = []
|
||||
for _ in range(100):
|
||||
result = execute_function(request, "simple_func", None)
|
||||
results.append(result)
|
||||
|
||||
# All should succeed (no rate limiting currently) and return expected data
|
||||
for result in results:
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["value"], "ok")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Tests for the Mizan SSR bridge and template backend.
|
||||
Tests for the SSR bridge and the MizanTemplates Django template backend.
|
||||
|
||||
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
||||
Tests skip gracefully if Bun is not available.
|
||||
The bridge shells out to Bun, so every test here skips unless `bun` is on PATH
|
||||
and the worker script is present.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -11,13 +11,12 @@ import threading
|
||||
|
||||
from django.test import SimpleTestCase, RequestFactory
|
||||
|
||||
# Path to the test worker
|
||||
_SSR_WORKER = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..", "..", "..", "..", "..", # up to repo root
|
||||
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
||||
_REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
|
||||
)
|
||||
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
||||
_SSR_WORKER = os.path.join(_REPO_ROOT, "workers", "mizan-ssr", "src", "worker.tsx")
|
||||
_COMPONENT_DIR = os.path.join(os.path.dirname(__file__), "ssr_components")
|
||||
_HELLO = os.path.join(_COMPONENT_DIR, "Hello.tsx")
|
||||
|
||||
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||
_SKIP_MSG = "Bun not available"
|
||||
@@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.bridge import SSRBridge
|
||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=10.0)
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "bridge"):
|
||||
self.bridge.shutdown()
|
||||
|
||||
def test_ping(self):
|
||||
"""Worker starts and responds to ping."""
|
||||
self.assertTrue(self.bridge.ping())
|
||||
|
||||
def test_render_simple(self):
|
||||
"""Renders a simple component to HTML."""
|
||||
result = self.bridge.render("Hello", {"name": "World"})
|
||||
def test_render_starts_worker_and_returns_html(self):
|
||||
"""The first render boots the worker and returns rendered markup."""
|
||||
result = self.bridge.render(_HELLO, {"name": "World"})
|
||||
self.assertIn("Hello,", result.html)
|
||||
self.assertIn("World", result.html)
|
||||
|
||||
def test_render_with_props(self):
|
||||
"""Renders a component with multiple props."""
|
||||
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
||||
def test_render_passes_props_through(self):
|
||||
"""Props reach the component."""
|
||||
result = self.bridge.render(_HELLO, {"name": "Alice"})
|
||||
self.assertIn("Alice", result.html)
|
||||
self.assertIn("42", result.html)
|
||||
self.assertIn('data-mizan-component="Hello"', result.html)
|
||||
|
||||
def test_render_missing_component(self):
|
||||
"""Rendering an unregistered component raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("NonExistent", {})
|
||||
self.assertIn("not registered", str(ctx.exception))
|
||||
|
||||
def test_render_error(self):
|
||||
"""Component that throws during render raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("Broken", {})
|
||||
self.assertIn("Render error", str(ctx.exception))
|
||||
def test_render_missing_file_raises(self):
|
||||
"""Rendering a path with no module raises RuntimeError naming the failure."""
|
||||
missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.bridge.render(missing, {})
|
||||
|
||||
def test_crash_recovery(self):
|
||||
"""Bridge restarts the worker if it dies."""
|
||||
# First render works
|
||||
result = self.bridge.render("Hello", {"name": "Before"})
|
||||
"""The bridge restarts the worker if it dies."""
|
||||
result = self.bridge.render(_HELLO, {"name": "Before"})
|
||||
self.assertIn("Before", result.html)
|
||||
|
||||
# Kill the subprocess
|
||||
self.bridge._proc.kill()
|
||||
self.bridge._proc.wait()
|
||||
|
||||
# Next render should restart and work
|
||||
result = self.bridge.render("Hello", {"name": "After"})
|
||||
result = self.bridge.render(_HELLO, {"name": "After"})
|
||||
self.assertIn("After", result.html)
|
||||
|
||||
def test_concurrent_renders(self):
|
||||
"""Multiple threads can render simultaneously."""
|
||||
"""Concurrent callers each get their own response matched by message id."""
|
||||
results = {}
|
||||
errors = {}
|
||||
|
||||
def render_in_thread(name: str, idx: int):
|
||||
try:
|
||||
result = self.bridge.render("Hello", {"name": name})
|
||||
results[idx] = result.html
|
||||
results[idx] = self.bridge.render(_HELLO, {"name": name}).html
|
||||
except Exception as e:
|
||||
errors[idx] = e
|
||||
|
||||
@@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase):
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
t.join(timeout=20)
|
||||
|
||||
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
||||
self.assertEqual(errors, {})
|
||||
self.assertEqual(len(results), 5)
|
||||
for i in range(5):
|
||||
self.assertIn(f"User{i}", results[i])
|
||||
@@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.backend import MizanTemplates
|
||||
self.engine = MizanTemplates({
|
||||
"NAME": "mizan-test",
|
||||
"DIRS": [],
|
||||
"DIRS": [_COMPONENT_DIR],
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"worker_path": _SSR_WORKER,
|
||||
"timeout": 5,
|
||||
"worker": _SSR_WORKER,
|
||||
"timeout": 10,
|
||||
},
|
||||
})
|
||||
self.factory = RequestFactory()
|
||||
@@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
||||
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||
self.engine._bridge.shutdown()
|
||||
|
||||
def test_get_template(self):
|
||||
"""get_template returns a MizanTemplate."""
|
||||
def test_get_template_resolves_name_to_file(self):
|
||||
"""get_template resolves the name against DIRS to an absolute file path."""
|
||||
from mizan.ssr.backend import MizanTemplate
|
||||
template = self.engine.get_template("Hello")
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
self.assertIsInstance(template, MizanTemplate)
|
||||
self.assertEqual(template.component_name, "Hello")
|
||||
self.assertEqual(template.file_path, os.path.abspath(_HELLO))
|
||||
|
||||
def test_template_render(self):
|
||||
"""MizanTemplate.render() produces HTML."""
|
||||
template = self.engine.get_template("Hello")
|
||||
def test_missing_template_raises(self):
|
||||
"""A name that resolves to no file under DIRS raises TemplateDoesNotExist."""
|
||||
from django.template import TemplateDoesNotExist
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
self.engine.get_template("NoSuchComponent.tsx")
|
||||
|
||||
def test_template_render_emits_html_and_hydration_data(self):
|
||||
"""render() wraps the markup and serializes the props for hydration."""
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
html = template.render({"name": "Django"})
|
||||
self.assertIn("Hello,", html)
|
||||
self.assertIn("Django", html)
|
||||
self.assertIn('data-mizan-component="Hello"', html)
|
||||
self.assertIn('id="mizan-root"', html)
|
||||
self.assertIn('window.__MIZAN_SSR_DATA__={"name": "Django"}', html)
|
||||
|
||||
def test_template_render_strips_django_internals(self):
|
||||
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
||||
template = self.engine.get_template("Hello")
|
||||
"""request and csrf_token are dropped from props and from hydration data."""
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
request = self.factory.get("/")
|
||||
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
||||
html = template.render(
|
||||
{"name": "Test", "request": request, "csrf_token": "abc"}, request
|
||||
)
|
||||
self.assertIn("Test", html)
|
||||
self.assertNotIn("csrf_token", html)
|
||||
self.assertNotIn("abc", html)
|
||||
|
||||
def test_from_string_raises(self):
|
||||
"""from_string is not supported."""
|
||||
from django.template import TemplateDoesNotExist
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
def test_from_string_is_unsupported(self):
|
||||
"""This engine renders modules by path, so it has no source-string form."""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.engine.from_string("<div>Not supported</div>")
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
"""
|
||||
mizan URL Configuration
|
||||
|
||||
HTTP endpoints:
|
||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
||||
- POST /call/ - Server function calls (HTTP transport)
|
||||
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
|
||||
|
||||
Security:
|
||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
||||
- Use the management command instead: python manage.py export_mizan_ir
|
||||
mizan's HTTP endpoints: session bootstrap, the server-function call endpoint,
|
||||
and the bundled per-context fetch. Schema export is reachable only through the
|
||||
`export_mizan_ir` management command, never over HTTP.
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
@@ -16,7 +9,7 @@ from django.middleware.csrf import get_token
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
||||
from .client.executor import function_call_view, context_fetch_view
|
||||
from mizan.client.executor import function_call_view, context_fetch_view
|
||||
|
||||
app_name = "mizan"
|
||||
|
||||
@@ -24,13 +17,8 @@ app_name = "mizan"
|
||||
@ensure_csrf_cookie
|
||||
def session_init_view(request):
|
||||
"""
|
||||
Initialize a Django session and return the CSRF token.
|
||||
|
||||
Used by SSR to establish a session before making authenticated requests.
|
||||
The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set.
|
||||
|
||||
Returns:
|
||||
{ "csrfToken": "..." }
|
||||
Start a Django session and return `{"csrfToken": ...}`. The decorator is
|
||||
what puts the csrftoken cookie on the response.
|
||||
"""
|
||||
return JsonResponse({"csrfToken": get_token(request)})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
BaseUserManager,
|
||||
@@ -7,8 +9,6 @@ from django.db import models
|
||||
|
||||
|
||||
class EmailUserManager(BaseUserManager):
|
||||
"""Custom user manager using email as the unique identifier."""
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError("Email is required")
|
||||
@@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager):
|
||||
|
||||
|
||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
"""Minimal user model with email as USERNAME_FIELD.
|
||||
|
||||
Matches the calling convention used in mizan's test suite:
|
||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
||||
"""
|
||||
|
||||
email = models.EmailField(unique=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
@@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
app_label = "tests"
|
||||
|
||||
|
||||
# ─── Shape test models ──────────────────────────────────────────────────────
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class TimestampMixin(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
"""
|
||||
Django settings for running mizan's test suite standalone.
|
||||
|
||||
Usage:
|
||||
cd django/
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
"""
|
||||
|
||||
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
||||
|
||||
DEBUG = True
|
||||
@@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# JWT settings for test_auth.py (can be overridden per-class with @override_settings)
|
||||
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# Session engine (for test_auth.py SessionStore usage)
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
"""
|
||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||
|
||||
HTTP RPC dispatch, context bundling, and a WebSocket carrying channel
|
||||
subscriptions and RPC on mizan-django's envelope. Forms, Shapes and SSR are
|
||||
out of scope — FastAPI projects use native equivalents.
|
||||
|
||||
Usage:
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.include_router(ws_router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
|
||||
# Register your @client-decorated functions
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import register
|
||||
from .my_functions import echo
|
||||
register(echo, "echo")
|
||||
Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket),
|
||||
the error hierarchy with its exception handlers, and the channel base class with
|
||||
its registry.
|
||||
"""
|
||||
|
||||
from .executor import (
|
||||
from mizan_fastapi.executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
@@ -34,14 +19,19 @@ from .executor import (
|
||||
compute_invalidation,
|
||||
execute_function,
|
||||
)
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from .websocket import ws_router
|
||||
from .channels import ReactChannel, broadcast, get_channel, register as register_channel
|
||||
from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from mizan_fastapi.websocket import ws_router
|
||||
from mizan_fastapi.channels import (
|
||||
Channel,
|
||||
broadcast,
|
||||
get_channel,
|
||||
register as register_channel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ws_router",
|
||||
"ReactChannel",
|
||||
"Channel",
|
||||
"register_channel",
|
||||
"get_channel",
|
||||
"broadcast",
|
||||
|
||||
@@ -5,10 +5,8 @@ A channel names a group of subscribers and decides who may join it. `group(param
|
||||
is the fan-out key, so two subscribers with the same params share a group and a push
|
||||
addressed to those params reaches both.
|
||||
|
||||
Membership is held in this process. Django's channel layer carries groups across
|
||||
workers through Redis; nothing here does, so a push reaches only the subscribers
|
||||
connected to the process that sent it. One process — a desktop shell, a single
|
||||
uvicorn worker — is the shape this serves.
|
||||
Membership is held in this process. A push therefore reaches only the subscribers
|
||||
whose socket is attached to the process that sent it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,20 +18,27 @@ from typing import Any, ClassVar
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.registry import RegistryExtension, register_extension
|
||||
|
||||
# group name -> the live sockets subscribed to it
|
||||
_groups: dict[str, set[Any]] = defaultdict(set)
|
||||
_registry: dict[str, type["ReactChannel"]] = {}
|
||||
_registry: dict[str, type["Channel"]] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
class Channel:
|
||||
"""A named fan-out. Subclass, override what the channel decides, and register it.
|
||||
|
||||
A subclass may define `on_connect(params)` / `on_disconnect()`; the socket handler
|
||||
calls them when they exist.
|
||||
The three nested models are the channel's payload types, named from the client's
|
||||
side: `Params` keys the fan-out, `ClientMessage` travels up, `ServerMessage`
|
||||
travels down. A subclass may also define `on_connect(params)` / `on_disconnect()`;
|
||||
the socket handler calls them when they exist.
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = ""
|
||||
Params: ClassVar[type[BaseModel] | None] = None
|
||||
ClientMessage: ClassVar[type[BaseModel] | None] = None
|
||||
ServerMessage: ClassVar[type[BaseModel] | None] = None
|
||||
|
||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||
"""Whether this subscriber may join. Default: anyone may."""
|
||||
@@ -68,16 +73,16 @@ class _Params:
|
||||
return self._values
|
||||
|
||||
|
||||
def register(channel_class: type[ReactChannel], name: str) -> None:
|
||||
def register(channel_class: type[Channel], name: str) -> None:
|
||||
channel_class.name = name
|
||||
_registry[name] = channel_class
|
||||
|
||||
|
||||
def get_channel(name: str) -> type[ReactChannel] | None:
|
||||
def get_channel(name: str) -> type[Channel] | None:
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def registered() -> dict[str, type[ReactChannel]]:
|
||||
def registered() -> dict[str, type[Channel]]:
|
||||
return dict(_registry)
|
||||
|
||||
|
||||
@@ -122,3 +127,36 @@ async def broadcast(
|
||||
except Exception as e:
|
||||
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
|
||||
await leave(group, socket)
|
||||
|
||||
|
||||
class _ChannelsExtension(RegistryExtension):
|
||||
"""The `channels` slot of the core registry — one entry per registered channel,
|
||||
each carrying the JSON schema of whichever payload models the channel declares.
|
||||
"""
|
||||
|
||||
def all(self) -> dict[str, type[Channel]]:
|
||||
return dict(_registry)
|
||||
|
||||
def schema(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for name, channel_class in _registry.items():
|
||||
entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": "channel",
|
||||
"bidirectional": False,
|
||||
}
|
||||
if channel_class.Params is not None:
|
||||
entry["params"] = channel_class.Params.model_json_schema()
|
||||
if channel_class.ClientMessage is not None:
|
||||
entry["client_message"] = channel_class.ClientMessage.model_json_schema()
|
||||
entry["bidirectional"] = True
|
||||
if channel_class.ServerMessage is not None:
|
||||
entry["server_message"] = channel_class.ServerMessage.model_json_schema()
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
register_extension("channels", _ChannelsExtension())
|
||||
|
||||
@@ -6,8 +6,7 @@ Usage:
|
||||
|
||||
Imports the named module (whose import side effects must register every
|
||||
@client function with `mizan_core.registry`), then writes the canonical
|
||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||
directly.
|
||||
Mizan IR as KDL to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"""
|
||||
FastAPI router exposing Mizan's HTTP endpoints:
|
||||
|
||||
GET /session/ — session-init probe
|
||||
POST /call/ — RPC dispatch
|
||||
GET /ctx/{context_name}/ — bundled context fetch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
from .executor import (
|
||||
from mizan_fastapi.executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
@@ -45,12 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
||||
|
||||
@router.get("/session/")
|
||||
async def session_init() -> JSONResponse:
|
||||
"""Session-init probe. Parity with mizan-django's session endpoint.
|
||||
|
||||
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||
null token so the response shape stays uniform across backends. The
|
||||
wire-parity harness uses this endpoint as its readiness probe.
|
||||
"""
|
||||
"""Session-init probe. The CSRF slot is null — nothing on this backend issues a token."""
|
||||
return _no_store({"csrfToken": None})
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,8 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
|
||||
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||
{"error": "..."}
|
||||
|
||||
The envelope is mizan-django's, so a client speaks to either backend unchanged. RPC
|
||||
dispatches through the same `execute_function` the HTTP route calls — one dispatch path,
|
||||
so the two transports cannot disagree about what a function does.
|
||||
|
||||
`data` is the `{result, invalidate, merge}` envelope every non-HTTP transport hands the
|
||||
kernel, so `mizanCall` applies a socket mutation's invalidation exactly as it applies an
|
||||
HTTP one. mizan-django's socket sends a bare result and drops it.
|
||||
An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the
|
||||
HTTP route builds, and both are produced by `execute_function`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,9 +42,9 @@ ws_router = APIRouter()
|
||||
class _SocketRequest:
|
||||
"""What a server function receives when the call arrived over the socket.
|
||||
|
||||
The HTTP path hands functions a Starlette `Request`; there is none here, so this
|
||||
carries the surface a function actually reads — `state`, headers, and a method, since
|
||||
a socket RPC sends data and expects an answer.
|
||||
There is no Starlette `Request` on a socket, so this carries the surface a function
|
||||
actually reads — `state`, headers, and a method, since a socket RPC sends data and
|
||||
expects an answer.
|
||||
"""
|
||||
|
||||
method = "POST"
|
||||
@@ -64,7 +59,7 @@ class _SocketRequest:
|
||||
|
||||
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any:
|
||||
"""Params as the channel's declared model, or a bare holder when it declares none."""
|
||||
model = getattr(channel_cls, "Params", None)
|
||||
model = channel_cls.Params
|
||||
if model is not None and raw:
|
||||
return model(**raw)
|
||||
return channels._Params(raw) if raw else None
|
||||
@@ -203,10 +198,10 @@ _ACTIONS = {
|
||||
|
||||
@ws_router.websocket("/ws/")
|
||||
async def mizan_socket(socket: WebSocket) -> None:
|
||||
"""One connection, every action. A disconnect ends the loop and clears the membership.
|
||||
"""One connection, every action.
|
||||
|
||||
A close is how a socket ends, so the disconnect is reported as the departure it is
|
||||
rather than raised as a fault; `finally` clears the membership either way.
|
||||
A close is how a socket ends, so the disconnect is logged rather than raised;
|
||||
`finally` clears the membership either way.
|
||||
"""
|
||||
await socket.accept()
|
||||
try:
|
||||
|
||||
@@ -88,7 +88,7 @@ def app():
|
||||
|
||||
@client
|
||||
async def async_echo(request, text: str) -> EchoOutput:
|
||||
# await something on the loop to prove we're really running async
|
||||
# Yielding to the loop fails outright if the handler is not awaited.
|
||||
await asyncio.sleep(0)
|
||||
return EchoOutput(message=f"async: {text}")
|
||||
|
||||
@@ -183,18 +183,19 @@ class ContextFetchTests:
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
# ─── Auth gating ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuthTests:
|
||||
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
|
||||
|
||||
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||
assert r.status_code == 401
|
||||
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InvalidationTests:
|
||||
def test_mutation_emits_invalidate_list(self, http):
|
||||
r = http.post(
|
||||
@@ -211,8 +212,6 @@ class InvalidationTests:
|
||||
|
||||
|
||||
class StructuredOutputTests:
|
||||
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
||||
|
||||
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||
assert r.status_code == 200
|
||||
@@ -232,21 +231,20 @@ class StructuredOutputTests:
|
||||
assert r_missing.json()["result"] is None
|
||||
|
||||
|
||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
# ─── Async handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AsyncHandlerTests:
|
||||
"""`async def` handlers dispatch on the loop via view.acall."""
|
||||
|
||||
def test_async_handler_returns_awaited_result(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"] == {"message": "async: hello"}
|
||||
|
||||
|
||||
class MergeTests:
|
||||
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MergeTests:
|
||||
def test_merge_target_emits_merge_entry(self, http):
|
||||
r = http.post(
|
||||
"/api/mizan/call/",
|
||||
@@ -254,9 +252,8 @@ class MergeTests:
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
||||
# items_list returns list[ItemOutput], so the slot resolves to items_list.
|
||||
assert body["merge"] == [
|
||||
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||
]
|
||||
# invalidate stays empty when only merge is declared
|
||||
assert body["invalidate"] == []
|
||||
|
||||
17
backends/mizan-rust-axum/Cargo.lock
generated
17
backends/mizan-rust-axum/Cargo.lock
generated
@@ -264,12 +264,28 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -300,6 +316,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
||||
//! with a Cache-Control: no-store header.
|
||||
//! Render a `MizanError` as an axum `Response`: the JSON envelope
|
||||
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
|
||||
//! `Cache-Control: no-store` header.
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each variant's status spelled as an axum constant. Naming the constant
|
||||
/// rather than round-tripping a `u16` leaves no numeric value axum could
|
||||
/// reject, so the mapping is total.
|
||||
fn status_of(err: &MizanError) -> StatusCode {
|
||||
match err {
|
||||
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
|
||||
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = StatusCode::from_u16(self.0.http_status())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut resp = (status, Json(self.0.to_json())).into_response();
|
||||
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
//! HTTP handlers for the Mizan endpoints.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
|
||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||
/// Body for POST /call/.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallBody {
|
||||
pub fn_: Option<String>,
|
||||
/// `fn` is a Rust keyword, hence the serde rename.
|
||||
#[serde(rename = "fn")]
|
||||
pub function_name: Option<String>,
|
||||
pub function_name: String,
|
||||
#[serde(default)]
|
||||
pub args: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl CallBody {
|
||||
fn resolved_name(&self) -> Option<&str> {
|
||||
self.function_name
|
||||
.as_deref()
|
||||
.or(self.fn_.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CallResponse {
|
||||
pub result: Value,
|
||||
@@ -47,28 +39,37 @@ pub struct CallResponse {
|
||||
pub merge: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn no_store(json: Value) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
||||
fn no_store<T: Serialize>(body: T) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(body)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch.
|
||||
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
|
||||
/// handler selects the registrations that string names and matches over the
|
||||
/// two shapes that selection has; `[]` is the selection a string nothing
|
||||
/// registered under makes, and it is answered with the NOT_FOUND envelope.
|
||||
pub async fn function_call(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Json(body): Json<CallBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let fn_name = body
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
|
||||
let fn_spec = lookup_function(&fn_name)
|
||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||
let registered = function_named(&body.function_name);
|
||||
let fn_spec = match registered.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"function {:?} not registered",
|
||||
body.function_name
|
||||
))))
|
||||
}
|
||||
[fn_spec, ..] => *fn_spec,
|
||||
};
|
||||
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||
let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
};
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
@@ -81,82 +82,86 @@ pub async fn function_call(
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let payload = CallResponse {
|
||||
Ok(no_store(CallResponse {
|
||||
result,
|
||||
invalidate,
|
||||
merge: merge_payload,
|
||||
};
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
|
||||
/// path segment, so `[]` is the selection a segment no registered function
|
||||
/// declares membership in makes, answered with the NOT_FOUND envelope.
|
||||
pub async fn context_fetch(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Path(context_name): Path<String>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if lookup_context(&context_name).is_none() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
let members = context_members(&context_name);
|
||||
let selected = match members.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} names no registered functions"
|
||||
))))
|
||||
}
|
||||
selected => selected,
|
||||
};
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(&context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||
// params get parsed via the per-function input_params primitive table.
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
for fn_spec in selected {
|
||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||
Ok(result) => {
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(no_store(Value::Object(bundled)))
|
||||
}
|
||||
|
||||
/// Coerce string-valued query params into typed JSON values using the
|
||||
/// function's declared input_params. Strings that don't parse stay as
|
||||
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
||||
/// A query string carries every value as text, so each declared input param
|
||||
/// reads its raw text as the primitive it declares. Text spelling something
|
||||
/// else stays the text it already is: `dispatch` validates every arg against
|
||||
/// the declared shape and is the one step that words the VALIDATION_FAILED
|
||||
/// answer, so re-wording it here would give one request two spellings of the
|
||||
/// same complaint.
|
||||
fn coerce_query_args(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
params: &BTreeMap<String, String>,
|
||||
) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(raw) = params.get(ip.name) {
|
||||
let parsed = match ip.primitive {
|
||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||
serde_json::Number::from_f64(v).map(Value::Number)
|
||||
}),
|
||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
|
||||
let as_text = Value::from(raw.clone());
|
||||
let coerced = match ip.primitive {
|
||||
Primitive::String => as_text,
|
||||
Primitive::Boolean => match raw.as_str() {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
_spells_neither => as_text,
|
||||
},
|
||||
Primitive::Integer => match raw.parse::<i64>() {
|
||||
Ok(integer) => Value::from(integer),
|
||||
Err(_spells_no_integer) => as_text,
|
||||
},
|
||||
Primitive::Number => match raw.parse::<f64>() {
|
||||
Ok(float) => match Number::from_f64(float) {
|
||||
Some(number) => Value::Number(number),
|
||||
None => as_text,
|
||||
},
|
||||
Err(_spells_no_number) => as_text,
|
||||
},
|
||||
};
|
||||
if let Some(v) = parsed {
|
||||
out.insert(ip.name.into(), v);
|
||||
} else {
|
||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
||||
}
|
||||
out.insert(ip.name.into(), coerced);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||
/// readiness-probe consumers see a well-formed response.
|
||||
/// GET /session/ — emits `{"csrfToken": null}`.
|
||||
pub async fn session_init() -> Response {
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
no_store(serde_json::json!({ "csrfToken": null }))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! Exposed endpoints:
|
||||
//! * `GET /session/` — session-init probe
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
@@ -51,8 +51,7 @@ where
|
||||
}
|
||||
|
||||
/// Router variant for callers that have no app state to thread — the
|
||||
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
||||
/// and other stateless test apps.
|
||||
/// dispatch path receives a unit-typed handle.
|
||||
pub fn router_stateless() -> Router {
|
||||
router(())
|
||||
}
|
||||
|
||||
17
backends/mizan-tauri/Cargo.lock
generated
17
backends/mizan-tauri/Cargo.lock
generated
@@ -1747,6 +1747,12 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -1762,6 +1768,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1789,6 +1805,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//! Ships as a Tauri plugin:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tauri::Builder::default()
|
||||
//! .plugin(mizan_tauri::init())
|
||||
//! .run(tauri::generate_context!())
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||
//! consumes. There is no per-function tauri::command; the registry IS
|
||||
//! the dispatch table.
|
||||
//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
|
||||
//! FUNCTIONS / CONTEXTS registries. There is no per-function
|
||||
//! `tauri::command`; the registry IS the dispatch table.
|
||||
//!
|
||||
//! Wire envelope:
|
||||
//!
|
||||
@@ -24,23 +20,20 @@
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//! Response shapes:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||
//! see one error surface regardless of transport.
|
||||
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||
//! Tauri serializes into the JS-side `Promise.reject`.
|
||||
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
@@ -79,9 +72,8 @@ pub enum Envelope {
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
/// Error payload returned to the frontend. The JS-side transport reads
|
||||
/// `code` / `message` / `details` and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
@@ -114,6 +106,11 @@ impl From<MizanError> for ErrorPayload {
|
||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
///
|
||||
/// Each arm selects the registrations its envelope names and matches over
|
||||
/// the two shapes that selection has. Both shapes are ordinary: the JS side
|
||||
/// picks the string, so `[]` is the selection a string nothing registered
|
||||
/// under makes, and it is answered with the NOT_FOUND envelope.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
@@ -123,98 +120,79 @@ async fn mizan_invoke<R: Runtime>(
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
} => {
|
||||
let registered = function_named(&function_name);
|
||||
let fn_spec = match registered.as_slice() {
|
||||
[] => {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {function_name:?} not registered"
|
||||
))))
|
||||
}
|
||||
[fn_spec, ..] => *fn_spec,
|
||||
};
|
||||
|
||||
let req = RequestHandle::new(&app);
|
||||
match fn_spec.dispatch(req, Value::Object(args.clone())).await {
|
||||
Ok(result) => Ok(call_payload(fn_spec, &args, result)),
|
||||
Err(e) => Err(ErrorPayload::from(e)),
|
||||
}
|
||||
}
|
||||
Envelope::Fetch { context, params } => {
|
||||
let members = context_members(&context);
|
||||
let selected = match members.as_slice() {
|
||||
[] => {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context:?} names no registered functions"
|
||||
))))
|
||||
}
|
||||
selected => selected,
|
||||
};
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in selected {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(&app);
|
||||
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||
Ok(result) => {
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Err(e) => return Err(ErrorPayload::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
fn_name: &str,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||
ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
/// The `call` response body — the handler's result alongside the
|
||||
/// invalidation targets and merge entries the registry derives from the
|
||||
/// arguments and that result.
|
||||
fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
let merges = compute_merges(fn_spec, args, &result);
|
||||
|
||||
let mut payload = json!({
|
||||
"result": result,
|
||||
"invalidate": invalidate,
|
||||
});
|
||||
if let Some(merge) = merge_payload {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.expect("payload is a JSON object")
|
||||
.insert("merge".into(), Value::Array(merge));
|
||||
let mut payload = Map::new();
|
||||
payload.insert("result".into(), result);
|
||||
payload.insert("invalidate".into(), Value::Array(invalidate));
|
||||
if !merges.is_empty() {
|
||||
let entries: Vec<Value> = merges.iter().map(MergeEntry::to_json).collect();
|
||||
payload.insert("merge".into(), Value::Array(entries));
|
||||
}
|
||||
Ok(payload)
|
||||
Value::Object(payload)
|
||||
}
|
||||
|
||||
async fn handle_fetch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as
|
||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||
/// carries typed JSON, so the filter is sufficient on its own.
|
||||
/// The envelope's params narrowed to the keys this function declares as
|
||||
/// input. The Tauri arg channel already carries typed JSON, so no
|
||||
/// string-to-primitive coercion is needed here.
|
||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
out.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
let declared = fn_spec.input_params();
|
||||
params
|
||||
.iter()
|
||||
.filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str()))
|
||||
.map(|(name, value)| (name.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
7
backends/mizan-ts/src/cache/backend.ts
vendored
7
backends/mizan-ts/src/cache/backend.ts
vendored
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Cache backends — MemoryCache for testing.
|
||||
* A cache backend is a flat string-to-string store.
|
||||
*
|
||||
* Simple key-value store. No reverse indexes.
|
||||
* There is no reverse index from a context to its keys, so `deleteByPrefix` is
|
||||
* what a broad purge relies on and every backend owes it.
|
||||
*/
|
||||
|
||||
export interface CacheBackend {
|
||||
get(key: string): string | null
|
||||
set(key: string, value: string): void
|
||||
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
|
||||
|
||||
deleteByPrefix(prefix: string): number {
|
||||
let count = 0
|
||||
// Snapshot the keys — deleting while iterating the live view is UB.
|
||||
for (const key of [...this._store.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._store.delete(key)
|
||||
|
||||
17
backends/mizan-ts/src/cache/index.ts
vendored
17
backends/mizan-ts/src/cache/index.ts
vendored
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* mizan cache — TypeScript adapter.
|
||||
*
|
||||
* Same protocol as Python's mizan.cache. Cross-language conformance
|
||||
* verified by pin tests. No reverse indexes — scoped purge recomputes
|
||||
* the key directly, broad purge uses prefix scan.
|
||||
*/
|
||||
|
||||
export { MemoryCache } from './backend'
|
||||
export type { CacheBackend } from './backend'
|
||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
@@ -52,6 +44,13 @@ export function cachePut(
|
||||
backend.set(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached entries for a context. Returns the number removed.
|
||||
*
|
||||
* With params and a secret the exact key is recomputed and dropped; without
|
||||
* them every key carrying the context prefix is scanned and dropped. There is
|
||||
* no reverse index from a context to its keys, so those are the only two forms.
|
||||
*/
|
||||
export function cachePurge(
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
@@ -61,11 +60,9 @@ export function cachePurge(
|
||||
rev: number = 0,
|
||||
): number {
|
||||
if (params && secret) {
|
||||
// Scoped purge — recompute key and delete directly
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
return backend.delete(key) ? 1 : 0
|
||||
} else {
|
||||
// Broad purge — prefix scan
|
||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||
return backend.deleteByPrefix(prefix)
|
||||
}
|
||||
|
||||
14
backends/mizan-ts/src/cache/keys.ts
vendored
14
backends/mizan-ts/src/cache/keys.ts
vendored
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
* Cache key derivation — HMAC-SHA256 over a JSON-canonical form.
|
||||
*
|
||||
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
||||
* Cross-language conformance verified by pin tests.
|
||||
*
|
||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
||||
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
|
||||
* broad purge run as a prefix scan over the backend's keyspace.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto'
|
||||
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
|
||||
|
||||
/**
|
||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
||||
*
|
||||
* Hand-rolled rather than JSON.stringify because the bytes must match
|
||||
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
|
||||
* a key derived here is looked up by the Python side under the same secret,
|
||||
* so any serialization drift silently splits the keyspace in two.
|
||||
*/
|
||||
function stableStringify(obj: any): string {
|
||||
if (obj === null || obj === undefined) return 'null'
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
/**
|
||||
* Mizan @client decorator and function wrapper.
|
||||
*
|
||||
* Two registration styles:
|
||||
*
|
||||
* 1. Function wrapper (standalone functions):
|
||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
||||
*
|
||||
* 2. Class decorator (methods):
|
||||
* class Handlers {
|
||||
* @client({ context: UserCtx })
|
||||
* async userProfile(userId: number) { ... }
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||
import { register } from './registry'
|
||||
|
||||
@@ -35,8 +20,14 @@ function normalizeAffects(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover parameter names by parsing the function's own source text.
|
||||
*
|
||||
* Names are the wire contract — the dispatcher matches request params to
|
||||
* positional arguments by name — and JS erases them at runtime, so the
|
||||
* source string is the only place they survive.
|
||||
*/
|
||||
function extractParams(fn: Function): ParamDef[] {
|
||||
// Extract parameter names from function.toString()
|
||||
const source = fn.toString()
|
||||
const match = source.match(/\(([^)]*)\)/)
|
||||
if (!match || !match[1].trim()) return []
|
||||
@@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] {
|
||||
.map(p => p.trim())
|
||||
.filter(p => p && !p.startsWith('...'))
|
||||
.map(p => {
|
||||
// Handle destructured defaults: name = default, name: type
|
||||
// Strips a default value or a type annotation off the name
|
||||
const name = p.split(/[=:]/)[0].trim()
|
||||
return { name, type: 'any', required: !p.includes('=') }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function wrapper — registers a standalone function.
|
||||
*
|
||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
||||
*/
|
||||
/** Wrap and register a standalone function. */
|
||||
export function client<T extends (...args: any[]) => Promise<any>>(
|
||||
options: ClientOptions,
|
||||
fn: T,
|
||||
): T
|
||||
|
||||
/**
|
||||
* Class method decorator.
|
||||
*
|
||||
* class Handlers {
|
||||
* @client({ context: UserCtx })
|
||||
* async userProfile(userId: number) { ... }
|
||||
* }
|
||||
*/
|
||||
/** Register a class method. */
|
||||
export function client(options: ClientOptions): MethodDecorator
|
||||
|
||||
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
|
||||
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
||||
// Function wrapper form: client(options, fn)
|
||||
if (fn && typeof fn === 'function') {
|
||||
const options = optionsOrFn as ClientOptions
|
||||
@@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
|
||||
const name = fn.name || 'anonymous'
|
||||
const params = extractParams(fn)
|
||||
const isView = false // Determined at call time for function wrappers
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
name,
|
||||
fn: fn as any,
|
||||
fn: fn as RegistryEntry['fn'],
|
||||
context,
|
||||
affects,
|
||||
params,
|
||||
private: options.private ?? false,
|
||||
viewPath: isView,
|
||||
// A wrapped function's return is only known once called, so the
|
||||
// view-vs-RPC split is decided by the dispatcher, not here.
|
||||
viewPath: false,
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Request dispatch — context GET and mutation POST handlers.
|
||||
* Context GET and mutation POST handlers.
|
||||
*
|
||||
* Framework-agnostic. Returns plain objects. The router adapter
|
||||
* (Express, Hono, etc.) converts to framework-specific responses.
|
||||
* Handlers return plain MizanResponse objects; turning one into a
|
||||
* framework's own response type is the router adapter's job.
|
||||
*/
|
||||
|
||||
import { getFunction, getContextGroups } from './registry'
|
||||
@@ -62,7 +62,10 @@ export async function handleContextFetch(
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss on error */ }
|
||||
} catch (e: any) {
|
||||
// A failed lookup degrades to a miss, so recompute below rather than fail the request.
|
||||
console.error(`mizan: cache lookup failed for context '${contextName}'`, e)
|
||||
}
|
||||
}
|
||||
|
||||
const results: Record<string, any> = {}
|
||||
@@ -86,6 +89,7 @@ export async function handleContextFetch(
|
||||
|
||||
results[fnName] = result
|
||||
} catch (e: any) {
|
||||
console.error(`mizan: context function '${fnName}' raised`, e)
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||
@@ -111,7 +115,10 @@ export async function handleContextFetch(
|
||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||
try {
|
||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||
} catch { /* cache store failure is non-fatal */ }
|
||||
} catch (e: any) {
|
||||
// The results are already computed, so a store failure costs a future hit, not this response.
|
||||
console.error(`mizan: cache store failed for context '${contextName}'`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -186,20 +193,25 @@ export async function handleMutationCall(
|
||||
// Purge origin-side cache
|
||||
const cb = getCache()
|
||||
if (cb) {
|
||||
try {
|
||||
for (const entry of invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
cachePurge(cb, entry)
|
||||
for (const target of invalidate) {
|
||||
try {
|
||||
if (typeof target === 'string') {
|
||||
cachePurge(cb, target)
|
||||
} else {
|
||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
||||
cachePurge(cb, target.context, target.params, _cacheSecret)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// The client still gets X-Mizan-Invalidate, so a stale origin entry
|
||||
// is recoverable; one bad target must not skip the remaining ones.
|
||||
console.error(`mizan: cache purge failed for`, target, e)
|
||||
}
|
||||
} catch { /* purge failure is non-fatal */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, body: responseData, headers }
|
||||
} catch (e: any) {
|
||||
console.error(`mizan: mutation '${fnName}' raised`, e)
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
/**
|
||||
* Invalidation protocol — header formatting, auto-scoping.
|
||||
*
|
||||
* Matches Django's implementation exactly. Same format. Same rules.
|
||||
*/
|
||||
|
||||
import type { RegistryEntry } from './types'
|
||||
import { getContextGroups, getContextParamNames, getFunction } from './registry'
|
||||
import { getContextGroups, getContextParamNames } from './registry'
|
||||
|
||||
type InvalidateEntry = string | { context: string; params: Record<string, any> }
|
||||
|
||||
/**
|
||||
* Resolve invalidation targets with three-tier auto-scoping.
|
||||
* Resolve what a mutation's `affects` targets invalidate.
|
||||
*
|
||||
* Tier 1: Argument name matching
|
||||
* Tier 2: Auth inference (Edge-side, not handled here)
|
||||
* Tier 3: Broad fallback
|
||||
* Each target narrows to the call arguments whose names the target's context
|
||||
* also declares as params — that intersection is the scoped purge. A target
|
||||
* with no such overlap emits as a bare context name, meaning purge every
|
||||
* entry under that context.
|
||||
*/
|
||||
export function resolveInvalidation(
|
||||
entry: RegistryEntry,
|
||||
@@ -34,7 +29,6 @@ export function resolveInvalidation(
|
||||
const resolved = resolveAffectsTarget(targetName)
|
||||
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
|
||||
|
||||
// Tier 1: argument name matching
|
||||
if (callArgs && ctxForParams) {
|
||||
const contextParams = getContextParamNames(ctxForParams)
|
||||
const matched: Record<string, any> = {}
|
||||
@@ -47,7 +41,6 @@ export function resolveInvalidation(
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: broad fallback
|
||||
result.push(targetName)
|
||||
}
|
||||
|
||||
@@ -55,7 +48,10 @@ export function resolveInvalidation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an affects target is a context name or function name.
|
||||
* Determine whether an affects target names a context or a function.
|
||||
*
|
||||
* An unrecognized name resolves as a context, so a target registered later
|
||||
* still purges by name rather than being dropped here.
|
||||
*/
|
||||
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
|
||||
const groups = getContextGroups()
|
||||
@@ -74,9 +70,10 @@ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; nam
|
||||
}
|
||||
|
||||
/**
|
||||
* Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
* Format invalidation targets as an X-Mizan-Invalidate header value.
|
||||
*
|
||||
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
|
||||
* Comma-separated targets; within a target, semicolon-separated URL-encoded
|
||||
* params follow the context name.
|
||||
*/
|
||||
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Edge Manifest Generator
|
||||
*
|
||||
* Produces the same JSON format as mizan-django. One Edge Worker.
|
||||
* Two backend languages. Same manifest.
|
||||
*/
|
||||
|
||||
import type { EdgeManifest } from './types'
|
||||
import type { EdgeManifest, ManifestFunction } from './types'
|
||||
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
|
||||
|
||||
// Both camelCase and snake_case forms included for cross-language matching.
|
||||
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
|
||||
// Wire format is snake_case; camelCase is the TS-local convention. Both forms
|
||||
// are listed because a param name arrives here as whichever the author wrote.
|
||||
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
|
||||
|
||||
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
@@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
// Contexts
|
||||
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
||||
const paramNames = new Set<string>()
|
||||
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
|
||||
const functions: ManifestFunction[] = []
|
||||
const pageRoutes: string[] = []
|
||||
|
||||
for (const fnName of fnNames) {
|
||||
@@ -29,14 +22,14 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
|
||||
for (const p of entry.params) paramNames.add(p.name)
|
||||
|
||||
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||
const fnEntry: ManifestFunction = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||
if (entry.route) {
|
||||
fnEntry.route = entry.route
|
||||
fnEntry.methods = entry.methods || ['GET']
|
||||
pageRoutes.push(entry.route)
|
||||
}
|
||||
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
||||
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
|
||||
if (entry.cache !== undefined) fnEntry.cache = entry.cache
|
||||
functions.push(fnEntry)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Mizan Registry — Central registration for server functions.
|
||||
*/
|
||||
|
||||
import type { RegistryEntry } from './types'
|
||||
|
||||
const _functions: Map<string, RegistryEntry> = new Map()
|
||||
|
||||
export function register(entry: RegistryEntry): void {
|
||||
// Re-registering the same function object is a module re-evaluation, not a
|
||||
// name collision, so only a different fn under a taken name is an error.
|
||||
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
|
||||
throw new Error(`Function '${entry.name}' already registered`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Mizan TypeScript Adapter — Shared Types
|
||||
*/
|
||||
|
||||
export class ReactContext {
|
||||
constructor(public readonly name: string) {
|
||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||
@@ -42,8 +38,17 @@ export interface RegistryEntry {
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ManifestFunction {
|
||||
name: string
|
||||
path: 'rpc' | 'view'
|
||||
route?: string
|
||||
methods?: string[]
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ManifestContext {
|
||||
functions: Array<{ name: string; path: 'rpc' | 'view' }>
|
||||
functions: ManifestFunction[]
|
||||
endpoints: string[]
|
||||
params: string[]
|
||||
user_scoped: boolean
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
/**
|
||||
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
|
||||
*
|
||||
* These prove that a Cloudflare Worker (Edge) can sit in front of a
|
||||
* TypeScript backend and behave identically to sitting in front of Django.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
function setupUserContext() {
|
||||
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||
client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
||||
})
|
||||
|
||||
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||
client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||
return { count: userId * 10 }
|
||||
})
|
||||
|
||||
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||
client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||
return { name, email: `user${userId}@test.com` }
|
||||
})
|
||||
|
||||
@@ -122,7 +115,7 @@ describe('Edge Compatibility', () => {
|
||||
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
||||
])
|
||||
|
||||
// Parse (what Edge does)
|
||||
// Parse the header the way the Edge worker does
|
||||
const segments = header.split(';')
|
||||
const ctx = segments[0]
|
||||
const params: Record<string, string> = {}
|
||||
@@ -139,12 +132,6 @@ describe('Edge Compatibility', () => {
|
||||
// ── Empty invalidation ─────────────────────────────────────────────
|
||||
|
||||
test('no affects = no header, no body key', async () => {
|
||||
client({ context: new ReactContext('plain') }, async function plainFn() {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// A context function called via mutation dispatch (shouldn't have invalidation)
|
||||
// Actually test a function without affects
|
||||
clearRegistry()
|
||||
client({}, async function noAffects() { return { ok: true } })
|
||||
const r = await handleMutationCall('noAffects', {})
|
||||
@@ -251,6 +238,18 @@ describe('Manifest', () => {
|
||||
expect(fn.cache).toBe(60)
|
||||
})
|
||||
|
||||
test('cache=false appears in manifest', () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('nocache')
|
||||
client({ context: Ctx, cache: false }, async function uncachedFn() {
|
||||
return { value: 1 }
|
||||
})
|
||||
|
||||
const m = generateManifest()
|
||||
const fn = m.contexts.nocache.functions[0]
|
||||
expect(fn.cache).toBe(false)
|
||||
})
|
||||
|
||||
test('cache=60 still emits no-store on HTTP', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('live')
|
||||
@@ -294,8 +293,9 @@ describe('Cache Conformance', () => {
|
||||
})
|
||||
|
||||
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
||||
// These exact values are pinned from Python's derive_cache_key output.
|
||||
// If this test fails, cross-language cache key compatibility is broken.
|
||||
// Literals captured from Python's derive_cache_key under the same
|
||||
// secret. A key derived here is looked up by the Python side, so a
|
||||
// mismatch means the two runtimes address different keyspaces.
|
||||
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||
|
||||
@@ -362,6 +362,27 @@ describe('Cache Conformance', () => {
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('cache=false context is never stored', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('volatile')
|
||||
client({ context: Ctx, cache: false }, async function volatileFn(itemId: number) {
|
||||
return { value: itemId }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
const r1 = await handleContextFetch('volatile', { itemId: '1' })
|
||||
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
const r2 = await handleContextFetch('volatile', { itemId: '1' })
|
||||
expect(r2.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('handleMutationCall purges cache', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('product')
|
||||
|
||||
@@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
"jinja2>=3.1",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ckdl>=1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
"""Cache backends — a key/value store keyed by the derived HMAC cache key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
import abc
|
||||
|
||||
|
||||
class CacheBackend(Protocol):
|
||||
"""Interface that all Mizan cache backends implement."""
|
||||
class CacheBackend(abc.ABC):
|
||||
"""A key/value store holding serialized context payloads."""
|
||||
|
||||
def get(self, key: str) -> bytes | None: ...
|
||||
def set(self, key: str, value: bytes) -> None: ...
|
||||
def delete(self, key: str) -> bool: ...
|
||||
def delete_by_prefix(self, prefix: str) -> int: ...
|
||||
def clear(self) -> None: ...
|
||||
@abc.abstractmethod
|
||||
def get(self, key: str) -> bytes | None:
|
||||
"""The stored value for `key`, or None when absent."""
|
||||
|
||||
@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:
|
||||
"""
|
||||
In-memory cache backend for testing.
|
||||
|
||||
Uses a Python dict. No persistence, no cross-process sharing.
|
||||
"""
|
||||
class MemoryCache(CacheBackend):
|
||||
"""A process-local dict. No persistence, no cross-process sharing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, bytes] = {}
|
||||
@@ -53,13 +57,8 @@ class MemoryCache:
|
||||
self._store.clear()
|
||||
|
||||
|
||||
class RedisCache:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
class RedisCache(CacheBackend):
|
||||
"""Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops."""
|
||||
|
||||
DEFAULT_TTL = 86400 # 24h safety-net
|
||||
|
||||
@@ -71,11 +70,11 @@ class RedisCache:
|
||||
) -> None:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
except ImportError:
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Redis is required for Mizan's cache backend. "
|
||||
"Install it with: pip install mizan[cache]"
|
||||
)
|
||||
) from exc
|
||||
self._client = redis_lib.from_url(
|
||||
redis_url,
|
||||
socket_connect_timeout=5,
|
||||
|
||||
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
@@ -1,12 +1,4 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
"""Cache key derivation — HMAC-SHA256 over a canonical JSON form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,7 +7,6 @@ import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Context prefix for broad purge (SCAN pattern)
|
||||
CONTEXT_KEY_PREFIX = "ctx:"
|
||||
|
||||
|
||||
@@ -33,8 +24,9 @@ def derive_cache_key(
|
||||
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||
"""
|
||||
def _normalize(v: Any) -> str:
|
||||
"""Normalize values for cross-language HMAC consistency.
|
||||
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
||||
"""Render a param value in its JSON-native spelling.
|
||||
Python str(True) is "True" but JS String(true) is "true", and the two
|
||||
must hash identically."""
|
||||
if v is True:
|
||||
return "true"
|
||||
if v is False:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Two styles supported:
|
||||
|
||||
1. Function-based (recommended, Django Ninja style):
|
||||
@client("update-profile")
|
||||
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
|
||||
Function form:
|
||||
@client
|
||||
def update_profile(request, name: str) -> UpdateProfileOutput:
|
||||
return UpdateProfileOutput(success=True)
|
||||
|
||||
2. Class-based (for complex cases):
|
||||
Class form:
|
||||
class UpdateProfile(ServerFunction):
|
||||
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
||||
return UpdateProfileOutput(success=True)
|
||||
@@ -21,7 +19,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -30,24 +27,16 @@ from typing import (
|
||||
Generic,
|
||||
Literal,
|
||||
TypeVar,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ─── Framework-response-base hook ───────────────────────────────────────────
|
||||
#
|
||||
# View-path detection — distinguishing functions that return data (RPC path)
|
||||
# from functions that return a framework-native response object (view path) —
|
||||
# requires knowing the framework's response base class. Each backend adapter
|
||||
# registers its base class here at import time.
|
||||
#
|
||||
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
|
||||
# starlette.responses.Response. If unset, all functions are treated as RPC.
|
||||
# needs the framework's response base class, which only the backend adapter
|
||||
# knows. While it is unset, every function is treated as RPC.
|
||||
|
||||
_framework_response_base: type | None = None
|
||||
|
||||
@@ -101,7 +90,7 @@ class ReactContext:
|
||||
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")
|
||||
|
||||
|
||||
@@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
||||
class _FunctionWrapper(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]
|
||||
_input_cls: ClassVar[type[BaseModel] | None]
|
||||
_output_cls: ClassVar[type[BaseModel]]
|
||||
@@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
||||
if isinstance(context, str):
|
||||
if not context.strip():
|
||||
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
|
||||
raise ValueError(
|
||||
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.
|
||||
- False (default): Not a context, just a callable function.
|
||||
- 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.
|
||||
Mutually exclusive with context=.
|
||||
@@ -526,8 +508,8 @@ def _create_server_function(
|
||||
is_view_path = is_framework_response(output_type)
|
||||
|
||||
if is_view_path:
|
||||
# View path — no Pydantic output wrapping needed
|
||||
output_cls = BaseModel # placeholder, never used for serialization
|
||||
# A view path serializes nothing, so Output is never read off this class.
|
||||
output_cls = BaseModel
|
||||
is_primitive_output = False
|
||||
else:
|
||||
# RPC path — resolve output type
|
||||
@@ -555,7 +537,7 @@ def _create_server_function(
|
||||
FunctionWrapper._output_cls = output_cls
|
||||
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:
|
||||
FunctionWrapper.Input = input_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
|
||||
FunctionWrapper._meta = {**meta}
|
||||
|
||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||
# This allows the decorator to be used without import-time side effects.
|
||||
# Registration happens via discovery (mizan_clients), so the decorator has
|
||||
# no import-time side effects.
|
||||
|
||||
return FunctionWrapper
|
||||
|
||||
@@ -657,15 +639,14 @@ class ComposedContext:
|
||||
"leaves": [leaf.name for leaf in leaves],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_schema_export(cls) -> dict[str, Any]:
|
||||
def get_schema_export(self) -> dict[str, Any]:
|
||||
"""Export schema for TypeScript generation."""
|
||||
return {
|
||||
"name": cls.name,
|
||||
"name": self.name,
|
||||
"type": "compose",
|
||||
"meta": cls._meta,
|
||||
"children": cls._meta.get("children", []),
|
||||
"leaves": cls._meta.get("leaves", []),
|
||||
"meta": self._meta,
|
||||
"children": self._meta["children"],
|
||||
"leaves": self._meta["leaves"],
|
||||
}
|
||||
|
||||
|
||||
@@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
elif isinstance(item, ComposedContext):
|
||||
return item._leaves.copy()
|
||||
elif hasattr(item, "_leaves"):
|
||||
# Duck typing for composed contexts
|
||||
return item._leaves.copy()
|
||||
else:
|
||||
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.
|
||||
|
||||
Usage:
|
||||
@client(context='local')
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(context='local')
|
||||
@client(context=UserContext)
|
||||
def user_posts(request, user_id: int) -> PostsOutput: ...
|
||||
|
||||
@compose(user_profile, user_posts)
|
||||
def user_page():
|
||||
pass
|
||||
|
||||
# Frontend generates:
|
||||
# <UserPageProvider user_id={123}>
|
||||
# <App />
|
||||
# </UserPageProvider>
|
||||
|
||||
Nesting:
|
||||
@compose(ctx_a, ctx_b)
|
||||
def ab(): pass
|
||||
@@ -767,7 +744,7 @@ def compose(
|
||||
if id(leaf) in seen:
|
||||
raise ValueError(
|
||||
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))
|
||||
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
"""
|
||||
Mizan IR — KDL emission from the live `mizan_core.registry`.
|
||||
|
||||
`build_ir()` walks every registered function class, introspects its
|
||||
Pydantic Input/Output models directly (not via JSON-Schema), and emits
|
||||
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
|
||||
this via a backend-specific entry point (Django management command,
|
||||
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
|
||||
`build_ir()` walks every registered function class, introspects its Pydantic
|
||||
Input/Output models directly (not via JSON-Schema), computes a plain-data
|
||||
document, and renders it through `templates/ir/document.kdl.j2`.
|
||||
|
||||
KDL grammar — locked contract:
|
||||
KDL grammar:
|
||||
|
||||
type "<Name>" {
|
||||
struct {
|
||||
field "<name>" required=#true|#false default=<lit> {
|
||||
primitive "integer|number|boolean|string"
|
||||
| ref "<TypeName>"
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
field "<name>" required=#false default=<lit> {
|
||||
<type-child>
|
||||
}
|
||||
...
|
||||
}
|
||||
| list { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| 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>" {
|
||||
camel "<camelCase>"
|
||||
has-input #true|#false
|
||||
input "<TypeName>" // omitted if has-input=#false
|
||||
output "<TypeName>"
|
||||
output-nullable #true|#false // omitted when #false (default)
|
||||
transport "http"|"websocket"|"both"
|
||||
output-nullable #true // omitted when #false (default)
|
||||
transport "http"|"websocket"
|
||||
context "<ctx_name>" // omitted unless context-grouped
|
||||
affects "<ctx_name>" // 0..N occurrences
|
||||
merge "<ctx_name>" // 0..N occurrences
|
||||
@@ -51,303 +51,174 @@ KDL grammar — locked contract:
|
||||
}
|
||||
}
|
||||
|
||||
channel "<name>" {
|
||||
channel "<wire_name>" {
|
||||
pascal-name "<PascalCase>"
|
||||
params "<TypeName>" // omitted if no params
|
||||
react-message "<TypeName>" // omitted if no react message
|
||||
django-message "<TypeName>" // omitted if no django message
|
||||
params "<TypeName>" // omitted when the channel takes no params
|
||||
client-message "<TypeName>" // client -> server; omitted if none
|
||||
server-message "<TypeName>" // server -> client; omitted if none
|
||||
}
|
||||
|
||||
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
|
||||
the Pydantic→json-schema converter — all gone.
|
||||
Channel slots are named from the client's point of view — `client-message`
|
||||
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
|
||||
|
||||
import re
|
||||
import types
|
||||
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 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
|
||||
|
||||
|
||||
__all__ = ["build_ir"]
|
||||
__all__ = ["build_ir", "wire_to_pascal"]
|
||||
|
||||
|
||||
# Common user-identity param names; mirrors the equivalent in mizan-django /
|
||||
# mizan-fastapi schema-export logic.
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
# ─── Wire-name derivations ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─── 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-escape a string and wrap in quotes."""
|
||||
# ─── KDL value encoding ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _kdl(value: str) -> str:
|
||||
"""KDL-escape a string and wrap it in quotes."""
|
||||
escaped = (
|
||||
s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _kdl_bool(b: bool) -> str:
|
||||
return "#true" if b else "#false"
|
||||
def _kdlbool(value: bool) -> str:
|
||||
return "#true" if value else "#false"
|
||||
|
||||
|
||||
def _kdl_value(v: Any) -> str:
|
||||
"""Render a JSON-shape Python value as a KDL literal."""
|
||||
if v is None:
|
||||
return "#null"
|
||||
if v is True or v is False:
|
||||
return _kdl_bool(v)
|
||||
if isinstance(v, (int, float)):
|
||||
return repr(v)
|
||||
if isinstance(v, str):
|
||||
return _kdl_string(v)
|
||||
# Fallback for compound values — defaults aren't typed in our IR.
|
||||
import json
|
||||
return _kdl_string(json.dumps(v))
|
||||
_ENV = Environment(
|
||||
loader=PackageLoader("mizan_core", "templates"),
|
||||
undefined=StrictUndefined,
|
||||
keep_trailing_newline=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
_ENV.filters["kdl"] = _kdl
|
||||
_ENV.filters["kdlbool"] = _kdlbool
|
||||
|
||||
|
||||
# ─── 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:
|
||||
"""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)}")
|
||||
# ─── Type shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _OpenNode:
|
||||
"""A KDL node whose children are being built."""
|
||||
def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]:
|
||||
"""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)
|
||||
if origin is Union or isinstance(annotation, types.UnionType):
|
||||
branches = [a for a in get_args(annotation) if a is not type(None)]
|
||||
if len(branches) > 1:
|
||||
with block.node("union") as inner_block:
|
||||
for branch in branches:
|
||||
_emit_type_child(inner_block, branch, named_types)
|
||||
return
|
||||
return {
|
||||
"kind": "union",
|
||||
"branches": [_shape(branch, refs) for branch in branches],
|
||||
}
|
||||
|
||||
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
|
||||
elem = extract_list_element(annotation)
|
||||
if elem is not None:
|
||||
with block.node("list") as inner_block:
|
||||
_emit_type_child(inner_block, elem, named_types)
|
||||
return
|
||||
element = extract_list_element(annotation)
|
||||
if element is not None:
|
||||
return {"kind": "list", "of": _shape(element, refs)}
|
||||
|
||||
# Literal[a, b, c] → enum
|
||||
if origin is Literal:
|
||||
args = get_args(annotation)
|
||||
if all(isinstance(a, str) for a in args):
|
||||
quoted = " ".join(_kdl_string(a) for a in args)
|
||||
block.lines.append(f"{block._prefix()}enum {quoted}")
|
||||
return
|
||||
values = get_args(annotation)
|
||||
if all(isinstance(v, str) for v in values):
|
||||
return {"kind": "enum", "values": list(values)}
|
||||
|
||||
# Pydantic model → reference by name.
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
type_name = annotation.__name__
|
||||
named_types.setdefault(type_name, _StructShape(annotation))
|
||||
block.leaf("ref", _kdl_string(type_name))
|
||||
return
|
||||
refs.append(annotation)
|
||||
return {"kind": "ref", "name": annotation.__name__}
|
||||
|
||||
# Primitives
|
||||
if annotation is int:
|
||||
block.leaf("primitive", _kdl_string("integer"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "integer"}
|
||||
if annotation is float:
|
||||
block.leaf("primitive", _kdl_string("number"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "number"}
|
||||
if annotation is bool:
|
||||
block.leaf("primitive", _kdl_string("boolean"))
|
||||
return
|
||||
if annotation is str:
|
||||
block.leaf("primitive", _kdl_string("string"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "boolean"}
|
||||
|
||||
# Open-shape fallback (dict / Any / etc).
|
||||
block.leaf("primitive", _kdl_string("string"))
|
||||
# str, dict, Any and every other open shape collapse to string.
|
||||
return {"kind": "primitive", "name": "string"}
|
||||
|
||||
|
||||
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
||||
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
|
||||
with block.node("alias") as alias_block:
|
||||
_emit_type_child(alias_block, annotation, named_types)
|
||||
def _struct_fields(
|
||||
model: type[BaseModel], refs: list[type[BaseModel]]
|
||||
) -> list[dict[str, Any]]:
|
||||
fields: list[dict[str, Any]] = []
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
# `is_required()` covers both the explicit Required marker and the
|
||||
# presence of a default.
|
||||
required = field_info.is_required()
|
||||
default = field_info.default
|
||||
has_default = (
|
||||
not required
|
||||
and default is not None
|
||||
and default is not PydanticUndefined
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
props: dict[str, str] = {}
|
||||
# `field_info.is_required()` checks both the explicit Required
|
||||
# marker and the presence of a default.
|
||||
required = field_info.is_required()
|
||||
if not required:
|
||||
props["required"] = _kdl_bool(False)
|
||||
default = field_info.default
|
||||
if default is not None and default is not PydanticUndefined and default is not ...:
|
||||
props["default"] = _kdl_value(default)
|
||||
|
||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
# ─── Named types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _StructShape:
|
||||
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
|
||||
__slots__ = ("model",)
|
||||
def __init__(self, model: type[BaseModel]):
|
||||
self.model = model
|
||||
|
||||
|
||||
class _AliasShape:
|
||||
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
|
||||
__slots__ = ("annotation",)
|
||||
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 _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 _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]:
|
||||
"""Return a copy of the function's Input model named `<CamelName>Input`."""
|
||||
from pydantic import create_model
|
||||
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
canonical = f"{camel}Input"
|
||||
"""The function's Input model under the canonical `<CamelName>Input` name."""
|
||||
canonical = f"{_snake_to_camel(fn_class.name)}Input"
|
||||
src = fn_class.Input
|
||||
if src.__name__ == canonical:
|
||||
return src
|
||||
# Re-derive under the canonical name so codegen consumers see a stable name.
|
||||
return create_model(canonical, __base__=src)
|
||||
|
||||
|
||||
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
|
||||
"""Return a copy of the model named `<CamelName>Output`."""
|
||||
from pydantic import create_model
|
||||
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
canonical = f"{camel}Output"
|
||||
"""`base` under the canonical `<CamelName>Output` name."""
|
||||
canonical = f"{_snake_to_camel(fn_class.name)}Output"
|
||||
if base.__name__ == canonical:
|
||||
return 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]:
|
||||
"""Collect every value that goes inside a `function` block."""
|
||||
def _seed_named_types(
|
||||
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", {})
|
||||
name = fn_class.name
|
||||
camel = _snake_to_camel(name)
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
has_input = _has_input(input_cls)
|
||||
is_context = meta.get("context")
|
||||
is_form = meta.get("form", False)
|
||||
return not (meta.get("private") or meta.get("view_path"))
|
||||
|
||||
|
||||
def _function_entry(fn_class: Any) -> dict[str, Any]:
|
||||
meta = getattr(fn_class, "_meta", {})
|
||||
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 {
|
||||
"name": name,
|
||||
"name": fn_class.name,
|
||||
"camel": camel,
|
||||
"has_input": has_input,
|
||||
"input_type": f"{camel}Input" if has_input else None,
|
||||
"output_type": output_type_name,
|
||||
"output_type": f"{camel}Output",
|
||||
"output_nullable": output_nullable,
|
||||
"transport": "websocket" if meta.get("websocket") else "http",
|
||||
"context": is_context if isinstance(is_context, str) else None,
|
||||
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
|
||||
"context": context if isinstance(context, str) else None,
|
||||
"affects": [
|
||||
a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"
|
||||
],
|
||||
"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_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:
|
||||
inner, _ = extract_optional(annotation)
|
||||
if inner is int:
|
||||
@@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str:
|
||||
return "string"
|
||||
|
||||
|
||||
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
|
||||
name = channel["name"]
|
||||
with root.node("channel", _kdl_string(name)) as block:
|
||||
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
|
||||
if channel.get("hasParams") and channel.get("paramsType"):
|
||||
block.leaf("params", _kdl_string(channel["paramsType"]))
|
||||
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
|
||||
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
|
||||
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
|
||||
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))
|
||||
def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]:
|
||||
param_info: dict[str, dict[str, Any]] = {}
|
||||
for fn_name in fn_names:
|
||||
input_cls = getattr(get_function(fn_name), "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, {"shared_by": []})
|
||||
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
"""
|
||||
Mizan core registry — function and composition registration with an
|
||||
extension hook for backend-specific registries (channels, forms, etc.)
|
||||
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.
|
||||
Mizan core registry — function and composition registration, plus an
|
||||
extension hook backend-specific registries (channels, forms, …) plug into.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Protocol
|
||||
import abc
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
# ─── Core registries ────────────────────────────────────────────────────────
|
||||
@@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {}
|
||||
|
||||
# ─── Extension hook ─────────────────────────────────────────────────────────
|
||||
|
||||
class RegistryExtension(Protocol):
|
||||
class RegistryExtension(abc.ABC):
|
||||
"""
|
||||
Backend-specific registries plug into core via this Protocol.
|
||||
|
||||
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.
|
||||
A backend registry of its own registrations (channels, forms, websocket
|
||||
consumers, …) contributing one subdict to the unified schema export.
|
||||
"""
|
||||
|
||||
def schema(self) -> dict[str, Any]: ...
|
||||
def clear(self) -> None: ...
|
||||
@abc.abstractmethod
|
||||
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] = {}
|
||||
@@ -146,10 +147,7 @@ def get_registry() -> dict[str, Any]:
|
||||
"compositions": _compositions.copy(),
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
|
||||
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal file
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal 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 %}
|
||||
@@ -1,10 +1,4 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
"""Annotation-introspection helpers used when walking @client function signatures."""
|
||||
|
||||
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
|
||||
and `None` itself. For anything else, returns `(annotation, False)`.
|
||||
|
||||
Multi-arm unions like `A | B | None` are returned as-is — protocol-level
|
||||
discriminated unions aren't supported yet, and silently picking one arm
|
||||
would hide that.
|
||||
A multi-arm union like `A | B | None` is returned as-is — picking one arm
|
||||
would silently discard the others.
|
||||
"""
|
||||
origin = get_origin(annotation)
|
||||
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:
|
||||
"""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
|
||||
function-name slot inside the context bundle. Three shapes match:
|
||||
Three shapes match:
|
||||
|
||||
- direct: slot is `T`, value is `T` → replace
|
||||
- upsert: slot is `list[T]`, value is `T` → upsert by id
|
||||
|
||||
107
cores/mizan-python/tests/test_ir.py
Normal file
107
cores/mizan-python/tests/test_ir.py
Normal 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)
|
||||
111
cores/mizan-rust-macros/src/channel.rs
Normal file
111
cores/mizan-rust-macros/src/channel.rs
Normal 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),*
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,33 +6,33 @@ use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
|
||||
|
||||
/// Attribute args: either nothing, or one string literal that overrides the
|
||||
/// derived snake_case context name.
|
||||
pub struct ContextArgs {
|
||||
pub explicit_name: Option<String>,
|
||||
/// Where the context's wire name comes from: the attribute, or the struct's
|
||||
/// own identifier when the attribute names none.
|
||||
pub enum ContextName {
|
||||
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> {
|
||||
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()) {
|
||||
return Ok(Self {
|
||||
explicit_name: Some(lit.value()),
|
||||
});
|
||||
return Ok(ContextName::Explicit(lit.value()));
|
||||
}
|
||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||
let metas = parser.parse2(attr_tokens)?;
|
||||
for meta in metas {
|
||||
if let Meta::NameValue(nv) = meta {
|
||||
if nv.path.is_ident("name") {
|
||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
||||
return Ok(Self {
|
||||
explicit_name: Some(s.value()),
|
||||
});
|
||||
if let syn::Expr::Lit(syn::ExprLit {
|
||||
lit: Lit::Str(s), ..
|
||||
}) = 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>\")]`",
|
||||
))
|
||||
}
|
||||
|
||||
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() {
|
||||
return syn::Error::new_spanned(
|
||||
&item.fields,
|
||||
@@ -54,9 +61,7 @@ pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
|
||||
}
|
||||
|
||||
let ident = item.ident.clone();
|
||||
let name = args
|
||||
.explicit_name
|
||||
.unwrap_or_else(|| ident.to_string().to_snake_case());
|
||||
let name = name.resolve(&ident);
|
||||
|
||||
let register_static =
|
||||
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
||||
|
||||
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use proc_macro2::{TokenStream, TokenTree};
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{
|
||||
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit,
|
||||
Meta, Token,
|
||||
parse::{Parse, ParseStream},
|
||||
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
|
||||
/// variant identifier so the IR's enum variant matches what serde emits
|
||||
/// on the wire. Supported casings mirror serde's set.
|
||||
/// variant identifier so the IR's enum variant matches what serde emits on
|
||||
/// 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 {
|
||||
match rule {
|
||||
"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 = "...")]`.
|
||||
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
|
||||
/// The string a `#[serde(<key> = "...")]` entry in `attrs` carries, or
|
||||
/// `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 {
|
||||
if !attr.path().is_ident("serde") {
|
||||
continue;
|
||||
}
|
||||
let list = match &attr.meta {
|
||||
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") {
|
||||
let Meta::List(list) = &attr.meta else {
|
||||
continue;
|
||||
}
|
||||
let list = match &attr.meta {
|
||||
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") {
|
||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
||||
return Some(s.value());
|
||||
let mut on_key = false;
|
||||
let mut on_value = false;
|
||||
for tree in list.tokens.clone() {
|
||||
match tree {
|
||||
TokenTree::Ident(ident) => {
|
||||
on_key = ident == key;
|
||||
on_value = false;
|
||||
}
|
||||
TokenTree::Punct(punct) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme
|
||||
/// TypeEntry registration. Every Mizan-shaped type lands in the IR;
|
||||
/// the emitter's inline-substitution pass collapses primitive-aliases
|
||||
/// and enums at use sites so the IR stays tight.
|
||||
pub fn expand(input: DeriveInput) -> TokenStream {
|
||||
let ident = input.ident.clone();
|
||||
/// A braced struct field paired with the identifier it carries. `all` is the
|
||||
/// only constructor and it reads a `FieldsNamed` group, so `ident` is a total
|
||||
/// accessor rather than an Option the caller has to open.
|
||||
struct NamedField<'a> {
|
||||
ident: &'a Ident,
|
||||
field: &'a Field,
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// The wire name serde emits: a `#[serde(rename)]` override, else the
|
||||
/// identifier with serde's `r#` raw-prefix stripping applied.
|
||||
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(_) => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&input,
|
||||
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
ident: input.ident,
|
||||
shape,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `NamedType` expression the generated `shape()` returns.
|
||||
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 rename_all = serde_rename_all(&input.attrs);
|
||||
|
||||
let named_type_body = match &input.data {
|
||||
Data::Struct(s) => emit_struct(s),
|
||||
Data::Enum(e) => emit_enum(e, rename_all.as_deref()),
|
||||
Data::Union(_) => {
|
||||
return syn::Error::new_spanned(
|
||||
&input,
|
||||
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
|
||||
)
|
||||
.to_compile_error();
|
||||
}
|
||||
};
|
||||
|
||||
let register_static =
|
||||
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case());
|
||||
let register_static = format_ident!(
|
||||
"__MIZAN_TYPE_REGISTER_{}",
|
||||
type_name.to_shouty_snake_case()
|
||||
);
|
||||
|
||||
quote! {
|
||||
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),*
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
|
||||
//! * `MizanType` impl on the Input struct
|
||||
//! * 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>`
|
||||
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
|
||||
|
||||
@@ -10,13 +10,25 @@ use heck::{ToLowerCamelCase, ToShoutySnakeCase};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{
|
||||
parse::Parser,
|
||||
parenthesized,
|
||||
parse::{Parse, ParseStream},
|
||||
punctuated::Punctuated,
|
||||
spanned::Spanned,
|
||||
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
|
||||
token::Paren,
|
||||
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(...)]`.
|
||||
#[derive(Default)]
|
||||
@@ -28,125 +40,149 @@ pub struct FunctionArgs {
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
impl FunctionArgs {
|
||||
pub fn parse(attr_tokens: TokenStream) -> 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)?;
|
||||
impl Parse for FunctionArgs {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
let mut out = Self::default();
|
||||
for meta in metas {
|
||||
match meta {
|
||||
Meta::NameValue(nv) => {
|
||||
if nv.path.is_ident("context") {
|
||||
out.context = Some(expect_path(&nv.value)?);
|
||||
} else if nv.path.is_ident("affects") {
|
||||
out.affects = collect_paths(&nv.value)?;
|
||||
} else if nv.path.is_ident("merge") {
|
||||
out.merge = collect_paths(&nv.value)?;
|
||||
} else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
nv.path,
|
||||
"unknown attribute key; expected one of: context, affects, merge",
|
||||
));
|
||||
}
|
||||
}
|
||||
Meta::Path(p) => {
|
||||
if p.is_ident("websocket") {
|
||||
out.websocket = true;
|
||||
} else if p.is_ident("private") {
|
||||
out.private = true;
|
||||
} else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
p,
|
||||
"unknown flag; expected `websocket` or `private`",
|
||||
));
|
||||
}
|
||||
}
|
||||
Meta::List(l) => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
l,
|
||||
"list-shaped attribute args not supported here",
|
||||
));
|
||||
}
|
||||
while !input.is_empty() {
|
||||
if input.peek(kw::context) {
|
||||
input.parse::<kw::context>()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
out.context = Some(input.parse()?);
|
||||
} else if input.peek(kw::affects) {
|
||||
input.parse::<kw::affects>()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
out.affects = parse_path_group(input)?;
|
||||
} else if input.peek(kw::merge) {
|
||||
input.parse::<kw::merge>()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
out.merge = parse_path_group(input)?;
|
||||
} else if input.peek(kw::websocket) {
|
||||
input.parse::<kw::websocket>()?;
|
||||
out.websocket = true;
|
||||
} else if input.peek(kw::private) {
|
||||
input.parse::<kw::private>()?;
|
||||
out.private = true;
|
||||
} else {
|
||||
return Err(input.error(
|
||||
"expected one of: `context = T`, `affects = T`, `merge = T`, `websocket`, `private`",
|
||||
));
|
||||
}
|
||||
if input.is_empty() {
|
||||
break;
|
||||
}
|
||||
input.parse::<Token![,]>()?;
|
||||
}
|
||||
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.",
|
||||
));
|
||||
}
|
||||
if out.context.is_some() && !out.merge.is_empty() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
out.context.as_ref().unwrap(),
|
||||
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
|
||||
));
|
||||
if let Some(ctx) = &out.context {
|
||||
if !out.affects.is_empty() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ctx,
|
||||
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
|
||||
));
|
||||
}
|
||||
if !out.merge.is_empty() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ctx,
|
||||
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
||||
if let Expr::Path(ExprPath { path, .. }) = expr {
|
||||
Ok(path.clone())
|
||||
/// One context type (`affects = Ctx`) or a parenthesized group of them
|
||||
/// (`affects = (CtxA, CtxB)`).
|
||||
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 {
|
||||
Err(syn::Error::new_spanned(
|
||||
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)`)",
|
||||
)),
|
||||
Ok(vec![input.parse()?])
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about one input parameter, extracted from the fn signature.
|
||||
struct InputArg {
|
||||
ident: syn::Ident,
|
||||
ident: Ident,
|
||||
ty: Type,
|
||||
}
|
||||
|
||||
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
if item.sig.asyncness.is_none() {
|
||||
return syn::Error::new_spanned(
|
||||
&item.sig.fn_token,
|
||||
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
|
||||
)
|
||||
.to_compile_error();
|
||||
/// The handler grammar `#[mizan::client]` accepts: an `async fn` taking a
|
||||
/// request handle followed by plain-identifier params, with an explicit return
|
||||
/// type. The token stream is parsed straight into this shape, so `expand`
|
||||
/// reads three settled fields and has nothing left to reject.
|
||||
///
|
||||
/// A missing `async` or a missing request handle needs no rejection here: the
|
||||
/// 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 camel = fn_name.to_lower_camel_case();
|
||||
let input_type_name = format!("{camel}Input");
|
||||
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 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);
|
||||
|
||||
// ─── Synthetic Input struct ────────────────────────────────────────────
|
||||
@@ -156,12 +192,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
for arg in &input_args {
|
||||
let ident = &arg.ident;
|
||||
let ty = &arg.ty;
|
||||
// Strip a leading underscore from the wire-level field name —
|
||||
// Rust convention uses `_foo` to silence unused-arg warnings,
|
||||
// but the wire schema and the Python fixture name the param
|
||||
// `foo`. The struct field keeps its source ident (so the
|
||||
// dispatch wrapper's `validated.#ident` compiles), and a serde
|
||||
// `rename` bridges the wire-level JSON name.
|
||||
// Rust convention writes `_foo` to silence an unused-arg warning,
|
||||
// but the wire schema names the param `foo`. The struct field
|
||||
// keeps its source ident so the dispatch wrapper's
|
||||
// `validated.#ident` compiles, and a serde `rename` bridges the
|
||||
// JSON name.
|
||||
let name_str = ident.to_string();
|
||||
let wire_name = name_str.trim_start_matches('_').to_string();
|
||||
let serde_rename = if wire_name != name_str {
|
||||
@@ -170,8 +205,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
TokenStream::new()
|
||||
};
|
||||
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
|
||||
let is_optional = unwrap_option(ty).is_some();
|
||||
let required = !is_optional;
|
||||
let required = !is_optional(ty);
|
||||
let shape = type_shape_expr(ty);
|
||||
field_shapes.push(quote! {
|
||||
::mizan_core::StructField {
|
||||
@@ -202,11 +236,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
};
|
||||
|
||||
// ─── 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();
|
||||
if has_input {
|
||||
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());
|
||||
if analysis.is_vec {
|
||||
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
|
||||
// userOrdersOutput → alias { list { ref "OrderOutput" } }
|
||||
// The Ref name is resolved via `<T as MizanType>::type_name()`.
|
||||
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: || ::mizan_core::NamedType::Alias(
|
||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(
|
||||
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
|
||||
))
|
||||
),
|
||||
let output_shape_expr = match &analysis.form {
|
||||
ReturnForm::Sequence { element } => {
|
||||
let element_ref = ref_shape_expr(element);
|
||||
let alias = quote! {
|
||||
::mizan_core::NamedType::Alias(
|
||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#element_ref))
|
||||
)
|
||||
};
|
||||
});
|
||||
// Also register the element type itself by its own name. `TYPE_NAME`
|
||||
// is an associated const, so this is usable in a static initializer.
|
||||
// The static ident scopes by the function name so two handlers
|
||||
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap
|
||||
// dedupes by the entry's `name` at emit time.
|
||||
let elem_static =
|
||||
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
|
||||
type_registrations.push(quote! {
|
||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
|
||||
shape_fn: <#elem 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
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: || #alias,
|
||||
};
|
||||
});
|
||||
// The element type also registers under its own name. The static
|
||||
// ident is scoped by the function name so two handlers returning
|
||||
// `Vec<Same>` don't collide; the emitter dedupes by entry name.
|
||||
let element_static =
|
||||
element_type_static_ident_scoped(element, &fn_name.to_shouty_snake_case());
|
||||
type_registrations.push(quote! {
|
||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||
static #element_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||
name: <#element as ::mizan_core::MizanType>::TYPE_NAME,
|
||||
shape_fn: <#element 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) ────
|
||||
// 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();
|
||||
for arg in &input_args {
|
||||
// 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 = name_str.trim_start_matches('_').to_string();
|
||||
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
|
||||
// Non-primitive params don't surface in the context's `param`
|
||||
// block; they participate as opaque payloads. Using `String` as
|
||||
// the placeholder primitive matches Python's fallback in
|
||||
// `_annotation_to_primitive`.
|
||||
quote! { ::mizan_core::Primitive::String }
|
||||
});
|
||||
let is_optional = unwrap_option(&arg.ty).is_some();
|
||||
let required = !is_optional;
|
||||
let primitive = match classify(&arg.ty) {
|
||||
TypeForm::Primitive(p) => p,
|
||||
TypeForm::Optional(_) => opaque_primitive(),
|
||||
TypeForm::Sequence(_) => opaque_primitive(),
|
||||
TypeForm::Named(_) => opaque_primitive(),
|
||||
};
|
||||
let required = !is_optional(&arg.ty);
|
||||
input_params.push(quote! {
|
||||
::mizan_core::InputParam {
|
||||
name: #name_str,
|
||||
@@ -354,7 +384,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
let private = args.private;
|
||||
|
||||
let dispatch_body = build_dispatch(
|
||||
&item,
|
||||
&inner_fn_ident,
|
||||
&input_args,
|
||||
has_input,
|
||||
&input_type_ident,
|
||||
@@ -362,8 +392,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
);
|
||||
|
||||
quote! {
|
||||
// Keep the user's original fn intact — the macro never rewrites the
|
||||
// body, only wraps it for dispatch.
|
||||
#item
|
||||
|
||||
#input_struct
|
||||
@@ -383,6 +411,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
fn has_input(&self) -> bool { #has_input }
|
||||
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
|
||||
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 context(&self) -> ::std::option::Option<&'static str> { #context_value }
|
||||
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(
|
||||
item: &ItemFn,
|
||||
inner: &Ident,
|
||||
input_args: &[InputArg],
|
||||
has_input: bool,
|
||||
input_type_ident: &syn::Ident,
|
||||
input_type_ident: &Ident,
|
||||
returns_result: bool,
|
||||
) -> TokenStream {
|
||||
let inner = &item.sig.ident;
|
||||
// When the user returns `Result<T, MizanError>`, lift Err out into the
|
||||
// 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.
|
||||
// `?` lifts a user `Result<T, MizanError>`'s Err into the wrapper's outer
|
||||
// Result; a plain `T` serializes directly.
|
||||
let unwrap_user_result = if returns_result {
|
||||
quote! { ? }
|
||||
} else {
|
||||
@@ -501,16 +488,17 @@ fn build_dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
|
||||
// Derive a unique static-name for the type's registration entry,
|
||||
// scoped by the surrounding function so siblings returning the same
|
||||
// `Vec<T>` don't collide at the static-name layer. The IR-side
|
||||
// BTreeMap dedupes by TypeEntry.name at emission time.
|
||||
let last = match ty {
|
||||
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
|
||||
_ => None,
|
||||
/// A static-name for the element type's registration entry, scoped by the
|
||||
/// surrounding function so siblings returning the same `Vec<T>` don't collide
|
||||
/// at the static-name layer.
|
||||
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> Ident {
|
||||
let stem = match path_head(ty) {
|
||||
Head::Path { name, .. } => name,
|
||||
Head::Unnamed => "ANON".to_string(),
|
||||
};
|
||||
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
|
||||
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope)
|
||||
format_ident!(
|
||||
"__MIZAN_TYPE_ELEM_{}_FOR_{}",
|
||||
stem.to_shouty_snake_case(),
|
||||
fn_scope
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
//! ```ignore
|
||||
//! use mizan_core::prelude::*;
|
||||
//! 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).
|
||||
//! The function macro is named `client` so `mizan::` stays purely a module
|
||||
//! path — a module path can't simultaneously be a callable macro in Rust, so
|
||||
//! `#[mizan(...)]` would collide with `mizan::context`.
|
||||
|
||||
mod channel;
|
||||
mod context;
|
||||
mod derive;
|
||||
mod function;
|
||||
mod shape;
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
|
||||
use syn::{parse_macro_input, ItemStruct};
|
||||
|
||||
#[proc_macro_derive(Mizan)]
|
||||
pub fn derive_mizan(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
derive::expand(input).into()
|
||||
let derived = parse_macro_input!(input as derive::MizanDerive);
|
||||
derive::expand(derived).into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let args = match context::ContextArgs::parse(attr.into()) {
|
||||
Ok(a) => a,
|
||||
let name = match context::ContextName::parse(attr.into()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return e.to_compile_error().into(),
|
||||
};
|
||||
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]`
|
||||
@@ -49,10 +36,17 @@ pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
/// websocket, private)]`.
|
||||
#[proc_macro_attribute]
|
||||
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let args = match function::FunctionArgs::parse(attr.into()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return e.to_compile_error().into(),
|
||||
};
|
||||
let item = parse_macro_input!(item as ItemFn);
|
||||
function::expand(args, item).into()
|
||||
let args = parse_macro_input!(attr as function::FunctionArgs);
|
||||
let handler = parse_macro_input!(item as function::Handler);
|
||||
function::expand(args, handler).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()
|
||||
}
|
||||
|
||||
@@ -6,203 +6,189 @@ use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
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 {
|
||||
/// Inner type once `Option<...>` is unwrapped.
|
||||
pub inner: Type,
|
||||
/// True if the outermost wrapper is `Option<...>`.
|
||||
pub form: ReturnForm,
|
||||
/// True if the outermost wrapper (after `Result`) is `Option<...>`.
|
||||
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
|
||||
/// dispatch wrapper emits `?` so user-side errors bubble out as
|
||||
/// `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 fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
||||
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
|
||||
(ok, true)
|
||||
} else {
|
||||
(ty.clone(), false)
|
||||
let (effective, returns_result) = strip_result(ty);
|
||||
let (unwrapped, nullable) = match classify(&effective) {
|
||||
TypeForm::Optional(inner) => (inner, true),
|
||||
TypeForm::Sequence(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => (effective, false),
|
||||
};
|
||||
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
|
||||
(t, true)
|
||||
} else {
|
||||
(effective, false)
|
||||
let form = match classify(&unwrapped) {
|
||||
TypeForm::Sequence(element) => ReturnForm::Sequence { element },
|
||||
TypeForm::Optional(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => {
|
||||
ReturnForm::Scalar { inner: unwrapped }
|
||||
}
|
||||
};
|
||||
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,
|
||||
}
|
||||
ReturnAnalysis {
|
||||
form,
|
||||
nullable,
|
||||
returns_result,
|
||||
}
|
||||
}
|
||||
|
||||
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate
|
||||
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify
|
||||
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if
|
||||
/// 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;
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
extract_single_generic(&last.arguments)
|
||||
(ty.clone(), false)
|
||||
}
|
||||
|
||||
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
|
||||
/// when constructing the struct field shapes.
|
||||
pub fn classify(ty: &Type) -> TypeForm {
|
||||
if let Type::Array(array) = ty {
|
||||
return TypeForm::Sequence((*array.elem).clone());
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn is_optional(ty: &Type) -> bool {
|
||||
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 {
|
||||
if let Some(inner) = unwrap_option(ty) {
|
||||
let inner_shape = type_shape_expr(&inner);
|
||||
return quote! {
|
||||
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
|
||||
};
|
||||
}
|
||||
if let Some(elem) = unwrap_vec(ty) {
|
||||
let inner_shape = type_shape_expr(&elem);
|
||||
return quote! {
|
||||
::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) {
|
||||
// `BTreeMap<K, V>` on the wire is a JSON object keyed by `K`'s
|
||||
// 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) {
|
||||
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).
|
||||
/// String-keyed maps land on the wire as JSON objects; the IR carries the
|
||||
/// value shape as a list element since KDL doesn't model dynamic-keyed maps
|
||||
/// distinctly yet.
|
||||
pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
||||
let path = match ty {
|
||||
Type::Path(TypePath { qself: None, path }) => path,
|
||||
_ => 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 {
|
||||
PathArguments::AngleBracketed(a) => a,
|
||||
_ => return None,
|
||||
};
|
||||
// BTreeMap<K, V> — second type argument is V.
|
||||
let mut type_args = args.args.iter().filter_map(|a| {
|
||||
if let GenericArgument::Type(t) = a {
|
||||
Some(t.clone())
|
||||
} else {
|
||||
None
|
||||
match classify(ty) {
|
||||
TypeForm::Optional(inner) => {
|
||||
let inner_shape = type_shape_expr(&inner);
|
||||
quote! {
|
||||
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
|
||||
}
|
||||
}
|
||||
});
|
||||
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,
|
||||
TypeForm::Sequence(element) => {
|
||||
let inner_shape = type_shape_expr(&element);
|
||||
quote! {
|
||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
||||
}
|
||||
}
|
||||
TypeForm::Primitive(primitive) => {
|
||||
quote! { ::mizan_core::TypeShape::Primitive(#primitive) }
|
||||
}
|
||||
TypeForm::Named(named) => ref_shape_expr(&named),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// A `TypeShape::Ref` carrying both the referent's IR name and its shape
|
||||
/// constructor, so resolving the reference needs no registry lookup.
|
||||
pub fn ref_shape_expr(ty: &Type) -> TokenStream {
|
||||
quote! {
|
||||
::mizan_core::TypeShape::Ref {
|
||||
name: <#ty as ::mizan_core::MizanType>::TYPE_NAME,
|
||||
shape: <#ty as ::mizan_core::MizanType>::shape,
|
||||
}
|
||||
}
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
extract_single_generic(&last.arguments)
|
||||
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())
|
||||
}
|
||||
|
||||
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
|
||||
let args = match args {
|
||||
/// 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,
|
||||
_ => return None,
|
||||
PathArguments::None => return Vec::new(),
|
||||
PathArguments::Parenthesized(_) => return Vec::new(),
|
||||
};
|
||||
for arg in &args.args {
|
||||
let mut out = Vec::new();
|
||||
for arg in &angled.args {
|
||||
if let GenericArgument::Type(t) = arg {
|
||||
return Some(t.clone());
|
||||
out.push(t.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
|
||||
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
|
||||
//! 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.
|
||||
//! `deno_web` supplies the web-platform globals a bare isolate lacks —
|
||||
//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
|
||||
//! implementations rather than partial shims.
|
||||
//!
|
||||
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
|
||||
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
|
||||
//! web-platform implementations, not shims (a partial polyfill is
|
||||
//! 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.
|
||||
//! Only the bundle is ever `eval`'d. Per-render props enter through
|
||||
//! `v8::json::parse` and are handed in as a call argument, so a prop string has
|
||||
//! no surrounding source to break out of.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
|
||||
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
|
||||
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
||||
/// 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 {
|
||||
runtime: JsRuntime,
|
||||
render_fn: v8::Global<v8::Function>,
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
@@ -64,50 +75,64 @@ impl SsrEngine {
|
||||
runtime
|
||||
.execute_script("[mizan:bundle]", 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`
|
||||
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
||||
/// argument — never spliced into evaluated source.
|
||||
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
||||
let render_fn = self.render_fn.clone();
|
||||
deno_core::scope!(scope, &mut self.runtime);
|
||||
let context = scope.get_current_context();
|
||||
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 func = v8::Local::new(scope, &render_fn);
|
||||
|
||||
let props = parse_props(scope, props_json)?;
|
||||
let recv = v8::undefined(scope).into();
|
||||
let result = func
|
||||
let html = func
|
||||
.call(scope, recv, &[props])
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn renders_react_bundle_in_embedded_v8() {
|
||||
let bundle = std::fs::read_to_string(concat!(
|
||||
fn fixture_bundle() -> String {
|
||||
std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/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");
|
||||
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(...)`
|
||||
// call. Through the value-call path it is inert data: it reaches the
|
||||
// component as a string, never as source.
|
||||
let bundle = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixture/bundle.js"
|
||||
))
|
||||
.expect("fixture bundle");
|
||||
|
||||
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
|
||||
let html = engine
|
||||
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
||||
.expect("render");
|
||||
// The payload rendered as text; it did not execute.
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createElement } from "react"
|
||||
|
||||
// A trivial component: props in, element out. The keystone only needs to prove
|
||||
// a real React tree renders to HTML inside a bare JS context.
|
||||
// Props in, element out. The `id` is the handle the render assertions match on.
|
||||
export function Hello({ name }) {
|
||||
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser"
|
||||
import { createElement } from "react"
|
||||
import { Hello } from "./Hello.js"
|
||||
|
||||
// The bundle exposes one global the embedded engine calls. No module system at
|
||||
// runtime — the engine receives a bare script that defines `renderApp`. This is
|
||||
// the production shape in miniature: build-time bundle, runtime eval.
|
||||
// There is no module system in the embedded engine — it receives a bare
|
||||
// script, so the entry point has to land on `globalThis` for the Rust side to
|
||||
// reach it.
|
||||
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
// Proxy for the embedded-V8 runtime: a bare global context with no Node
|
||||
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
|
||||
// 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.
|
||||
// Runs bundle.js inside a `vm` context holding only the globals listed below,
|
||||
// so the bundle sees the same bare environment the embedded V8 engine gives it.
|
||||
const fs = require("fs")
|
||||
const vm = require("vm")
|
||||
|
||||
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
|
||||
|
||||
// The minimal host globals React's bundle touches at init / sync render. The
|
||||
// rusty_v8 engine must provide the same set — this list is the spec for it.
|
||||
// The host globals React's bundle touches at init and during a sync render.
|
||||
const sandbox = {
|
||||
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
|
||||
TextEncoder, TextDecoder,
|
||||
@@ -19,11 +16,12 @@ vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
|
||||
const html = sandbox.renderApp({ name: "World" })
|
||||
console.log("RENDERED:", html)
|
||||
|
||||
const expected = '<div id="greeting">Hello, World!</div>'
|
||||
if (html !== expected) {
|
||||
console.error("MISMATCH — expected:", expected)
|
||||
console.error(`expected ${expected}, got ${html}`)
|
||||
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)
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
|
||||
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
|
||||
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
|
||||
//!
|
||||
//! React Server Components and the Flight serialization protocol carry
|
||||
//! 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.
|
||||
//! Scans the SSR fixture's authored JS for tokens that only appear when React
|
||||
//! Server Components, the Flight protocol, or a meta-framework SSR runtime is
|
||||
//! in play. The scan goes red the moment one of them enters the source.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
|
||||
// React Server Components / Flight
|
||||
"react-server-dom",
|
||||
"renderToReadableStream",
|
||||
"renderToPipeableStream",
|
||||
"createFromReadableStream",
|
||||
"createFromFetch",
|
||||
"use server",
|
||||
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
|
||||
// SSR runtimes / meta-frameworks
|
||||
"next/",
|
||||
"nuxt",
|
||||
"@sveltejs/kit",
|
||||
@@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[
|
||||
#[test]
|
||||
fn ssr_has_no_rsc_or_flight_surface() {
|
||||
for path in SCANNED {
|
||||
let Ok(src) = std::fs::read_to_string(path) else {
|
||||
continue; // a generated/optional file absent is fine; authored source is the point
|
||||
};
|
||||
assert!(
|
||||
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 {
|
||||
assert!(
|
||||
!src.contains(needle),
|
||||
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
|
||||
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
|
||||
classic renderToString-family only, rendered synchronously from props.",
|
||||
"{needle:?} found in {path}; this scan forbids it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
139
cores/mizan-rust/Cargo.lock
generated
139
cores/mizan-rust/Cargo.lock
generated
@@ -13,6 +13,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -34,6 +46,17 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "linkme"
|
||||
version = "0.3.36"
|
||||
@@ -60,13 +83,41 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "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]]
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"indoc",
|
||||
"kdl",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -82,6 +133,79 @@ dependencies = [
|
||||
"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]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -166,6 +290,21 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
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"
|
||||
|
||||
[dependencies]
|
||||
linkme = "0.3"
|
||||
minijinja = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
async-trait = "0.1"
|
||||
@@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" }
|
||||
|
||||
[dev-dependencies]
|
||||
indoc = "2"
|
||||
kdl = "6"
|
||||
|
||||
@@ -1,200 +1,313 @@
|
||||
//! Cross-function invariant verification — fails at `build_ir()` time, which
|
||||
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
|
||||
//! graph-level inconsistencies surface before any client artifact is emitted.
|
||||
//! Cross-function invariant checks over the registered graph.
|
||||
|
||||
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
|
||||
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::ir::{NamedType, Primitive, TypeShape};
|
||||
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
|
||||
/// graph-check and runtime merge resolution.
|
||||
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
|
||||
for entry in TYPES {
|
||||
if entry.name == name {
|
||||
return Some((entry.shape_fn)());
|
||||
}
|
||||
}
|
||||
None
|
||||
/// A structural fingerprint of a type, with every reference resolved through
|
||||
/// to the shape it names. Two types are interchangeable exactly when their
|
||||
/// fingerprints are equal, so comparison is one derived `==` instead of a
|
||||
/// pairwise walk over both shape enums.
|
||||
#[derive(PartialEq)]
|
||||
enum Canonical {
|
||||
Record(Vec<CanonicalField>),
|
||||
Aliased(Box<Canonical>),
|
||||
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
|
||||
/// splice into a context slot `slot` when any of three shapes hold —
|
||||
/// matches Python's `types_match_for_merge`:
|
||||
/// * direct: `slot` shape equals `value` shape → replace
|
||||
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id
|
||||
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
|
||||
#[derive(PartialEq)]
|
||||
struct CanonicalField {
|
||||
name: &'static str,
|
||||
required: bool,
|
||||
shape: Canonical,
|
||||
}
|
||||
|
||||
fn canonical_named(named: &NamedType) -> Canonical {
|
||||
match named {
|
||||
NamedType::Struct(fields) => Canonical::Record(
|
||||
fields
|
||||
.iter()
|
||||
.map(|f| CanonicalField {
|
||||
name: f.name,
|
||||
required: f.required,
|
||||
shape: canonical_shape(&f.shape),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))),
|
||||
NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_shape(shape: &TypeShape) -> Canonical {
|
||||
match shape {
|
||||
TypeShape::Primitive(p) => Canonical::Primitive(p.name()),
|
||||
TypeShape::Ref { shape, .. } => canonical_named(&shape()),
|
||||
TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))),
|
||||
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).
|
||||
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
|
||||
if named_shapes_equal(slot, value) {
|
||||
/// 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;
|
||||
}
|
||||
// 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 {
|
||||
match (a, b) {
|
||||
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
|
||||
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
|
||||
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
|
||||
_ => false,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
_ => 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)
|
||||
})
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
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."
|
||||
),
|
||||
}
|
||||
(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();
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_affects_targets() {
|
||||
/// 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 {
|
||||
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,
|
||||
);
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
let mutation_output = fn_spec.output_type();
|
||||
let mutation_shape = match resolve_type_shape(mutation_output) {
|
||||
Some(s) => s,
|
||||
None => panic!(
|
||||
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
|
||||
fn_spec.name(), mutation_output,
|
||||
),
|
||||
};
|
||||
let mut matches: Vec<&'static str> = Vec::new();
|
||||
for candidate in FUNCTIONS {
|
||||
if candidate.context() != Some(ctx_entry.name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
|
||||
if types_match(&candidate_shape, &mutation_shape) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
/// 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 {
|
||||
if candidate.context() != Some(context_name) {
|
||||
continue;
|
||||
}
|
||||
if types_match(&candidate.output_shape(), mutation_shape) {
|
||||
matched = matched.with(candidate.name());
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
|
||||
= std::collections::HashMap::new();
|
||||
let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new();
|
||||
for fn_spec in FUNCTIONS {
|
||||
if fn_spec.context() != Some(ctx.name) {
|
||||
continue;
|
||||
}
|
||||
for p in fn_spec.input_params() {
|
||||
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
|
||||
if std::mem::discriminant(prev_primitive)
|
||||
!= std::mem::discriminant(&p.primitive)
|
||||
{
|
||||
panic!(
|
||||
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
|
||||
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
|
||||
Shared params must have one type across the whole context.",
|
||||
ctx.name, p.name,
|
||||
prev_fn, prev_primitive.name(),
|
||||
fn_spec.name(), p.primitive.name(),
|
||||
);
|
||||
match by_name.entry(p.name) {
|
||||
Entry::Occupied(seen) => {
|
||||
let (first_primitive, first_fn) = *seen.get();
|
||||
if first_primitive != p.primitive {
|
||||
defects.push(GraphDefect::DivergentParamType {
|
||||
context: ctx.name,
|
||||
param: p.name,
|
||||
first_fn,
|
||||
first_type: first_primitive.name(),
|
||||
second_fn: fn_spec.name(),
|
||||
second_type: p.primitive.name(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert((p.primitive, fn_spec.name()));
|
||||
}
|
||||
} else {
|
||||
by_name.insert(p.name, (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)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
|
||||
//!
|
||||
//! 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.
|
||||
//! The IR data model the KDL emitter walks: named types, inline type shapes,
|
||||
//! and the descriptors a registered function or channel carries.
|
||||
|
||||
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NamedType {
|
||||
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
|
||||
/// `type "X" { struct { field ... } }` — a record.
|
||||
Struct(Vec<StructField>),
|
||||
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
|
||||
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
|
||||
@@ -21,14 +18,20 @@ pub enum NamedType {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypeShape {
|
||||
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>),
|
||||
Optional(Box<TypeShape>),
|
||||
Enum(Vec<&'static str>),
|
||||
Union(Vec<TypeShape>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Primitive {
|
||||
Integer,
|
||||
Number,
|
||||
@@ -64,8 +67,8 @@ pub enum DefaultValue {
|
||||
Null,
|
||||
}
|
||||
|
||||
/// One descriptor of what a mutation `affects`. Mirrors Python's
|
||||
/// `_normalize_affects` shape — either a named context or a named function.
|
||||
/// One descriptor of what a mutation `affects` — either a named context or a
|
||||
/// named function.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AffectTarget {
|
||||
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)]
|
||||
pub enum Transport {
|
||||
Http,
|
||||
|
||||
@@ -1,60 +1,141 @@
|
||||
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
||||
//!
|
||||
//! The Python emitter is the spec; this is the second implementation under
|
||||
//! the same contract. Any divergence is a bug here, not a contract change.
|
||||
//! KDL emitter — collects the registries (named types, functions, contexts,
|
||||
//! channels) into a KDL node tree and renders it through
|
||||
//! `templates/ir.kdl.jinja`.
|
||||
|
||||
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
|
||||
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::ir::{
|
||||
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape,
|
||||
};
|
||||
use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES};
|
||||
use crate::traits::FunctionSpec;
|
||||
use minijinja::value::ViaDeserialize;
|
||||
use minijinja::{context, Environment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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.
|
||||
fn kdl_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
/// A KDL scalar, carried structurally so the template's `kdl` filter — not
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn kdl_bool(b: bool) -> &'static str {
|
||||
if b {
|
||||
"#true"
|
||||
} else {
|
||||
"#false"
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
fn kdl_default(v: &DefaultValue) -> String {
|
||||
match v {
|
||||
DefaultValue::Null => "#null".into(),
|
||||
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
|
||||
DefaultValue::Integer(i) => i.to_string(),
|
||||
DefaultValue::Number(f) => {
|
||||
// Match Python's `repr(float)` for whole-number-equal-but-float
|
||||
// values: e.g. 1.0 → "1.0", not "1".
|
||||
/// 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);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
KdlValue::Bool(b) => {
|
||||
if *b {
|
||||
"#true".to_string()
|
||||
} else {
|
||||
"#false".to_string()
|
||||
}
|
||||
}
|
||||
KdlValue::Integer(i) => i.to_string(),
|
||||
KdlValue::Number(f) => {
|
||||
// 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() {
|
||||
format!("{f:.1}")
|
||||
} else {
|
||||
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 {
|
||||
let normalized = name.replace('.', "_").replace('-', "_");
|
||||
let mut parts = normalized.split('_');
|
||||
@@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
struct Emitter<'a> {
|
||||
lines: Vec<String>,
|
||||
/// Types whose references should be substituted with their inline
|
||||
/// shape at the use site (and which don't emit as their own
|
||||
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
|
||||
/// The PascalCase stem every emitted type name for `wire_name` is built on:
|
||||
/// split on `[._-]`, then title-case each part, where a character is
|
||||
/// uppercased only when the character before it is not a letter.
|
||||
pub fn wire_to_pascal(wire_name: &str) -> String {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl<'a> Emitter<'a> {
|
||||
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
|
||||
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) {
|
||||
impl NodeBuilder<'_> {
|
||||
fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode {
|
||||
match shape {
|
||||
TypeShape::Primitive(p) => {
|
||||
let name = kdl_string(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]);
|
||||
KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name()))
|
||||
}
|
||||
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) => {
|
||||
self.open(indent, &["list"]);
|
||||
self.emit_type_child(indent + 1, inner);
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)])
|
||||
}
|
||||
TypeShape::Optional(inner) => {
|
||||
self.open(indent, &["optional"]);
|
||||
self.emit_type_child(indent + 1, inner);
|
||||
self.close(indent);
|
||||
KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)])
|
||||
}
|
||||
TypeShape::Enum(variants) => {
|
||||
let mut parts: Vec<String> = vec!["enum".into()];
|
||||
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);
|
||||
KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||
}
|
||||
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) {
|
||||
let name_lit = kdl_string(name);
|
||||
self.open(indent, &["type", &name_lit]);
|
||||
match body {
|
||||
NamedType::Struct(fields) => {
|
||||
self.open(indent + 1, &["struct"]);
|
||||
for field in fields {
|
||||
self.emit_struct_field(indent + 2, field);
|
||||
}
|
||||
self.close(indent + 1);
|
||||
}
|
||||
NamedType::Alias(inner) => {
|
||||
self.open(indent + 1, &["alias"]);
|
||||
self.emit_type_child(indent + 2, inner);
|
||||
self.close(indent + 1);
|
||||
fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode {
|
||||
let inner = match body {
|
||||
NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block(
|
||||
fields
|
||||
.iter()
|
||||
.map(|field| self.struct_field(depth + 2, field))
|
||||
.collect(),
|
||||
),
|
||||
NamedType::Alias(shape) => {
|
||||
KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)])
|
||||
}
|
||||
NamedType::Enum(variants) => {
|
||||
let mut parts: Vec<String> = vec!["enum".into()];
|
||||
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 + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||
}
|
||||
}
|
||||
self.close(indent);
|
||||
};
|
||||
KdlNode::new(depth, "type")
|
||||
.arg(KdlValue::str(name))
|
||||
.block(vec![inner])
|
||||
}
|
||||
|
||||
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
|
||||
let name = kdl_string(field.name);
|
||||
let mut header: Vec<String> = vec!["field".into(), name];
|
||||
fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode {
|
||||
let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name));
|
||||
if !field.required {
|
||||
header.push(format!("required={}", kdl_bool(false)));
|
||||
node = node.prop("required", KdlValue::Bool(false));
|
||||
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();
|
||||
self.open(indent, &line_parts);
|
||||
self.emit_type_child(indent + 1, &field.shape);
|
||||
self.close(indent);
|
||||
node.block(vec![self.type_child(depth + 1, &field.shape)])
|
||||
}
|
||||
|
||||
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
|
||||
let name = kdl_string(fn_spec.name());
|
||||
self.open(indent, &["function", &name]);
|
||||
|
||||
let camel = kdl_string(fn_spec.camel_name());
|
||||
self.leaf(indent + 1, &["camel", &camel]);
|
||||
|
||||
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
|
||||
fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode {
|
||||
let inner = depth + 1;
|
||||
let mut children = vec![
|
||||
KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())),
|
||||
KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())),
|
||||
];
|
||||
|
||||
if let Some(input_type) = fn_spec.input_type() {
|
||||
let lit = kdl_string(input_type);
|
||||
self.leaf(indent + 1, &["input", &lit]);
|
||||
children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type)));
|
||||
}
|
||||
|
||||
let output_lit = kdl_string(fn_spec.output_type());
|
||||
self.leaf(indent + 1, &["output", &output_lit]);
|
||||
children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type())));
|
||||
|
||||
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());
|
||||
self.leaf(indent + 1, &["transport", &transport_lit]);
|
||||
children
|
||||
.push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name())));
|
||||
|
||||
if let Some(ctx) = fn_spec.context() {
|
||||
let lit = kdl_string(ctx);
|
||||
self.leaf(indent + 1, &["context", &lit]);
|
||||
children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx)));
|
||||
}
|
||||
|
||||
for affect in fn_spec.affects() {
|
||||
// Mirror Python's behavior: only context-typed affects make it
|
||||
// into the KDL `affects` leaf. Function-typed affects are
|
||||
// reserved for a future IR extension.
|
||||
if let crate::ir::AffectTarget::Context(name) = affect {
|
||||
let lit = kdl_string(name);
|
||||
self.leaf(indent + 1, &["affects", &lit]);
|
||||
match affect {
|
||||
// The `affects` leaf names a context; a function-typed target
|
||||
// has no leaf in the document.
|
||||
AffectTarget::Context(name) => {
|
||||
children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name)));
|
||||
}
|
||||
AffectTarget::Function { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
for merge in fn_spec.merge() {
|
||||
let lit = kdl_string(merge);
|
||||
self.leaf(indent + 1, &["merge", &lit]);
|
||||
children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge)));
|
||||
}
|
||||
|
||||
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() {
|
||||
let lit = kdl_string(form_name);
|
||||
self.leaf(indent + 1, &["form-name", &lit]);
|
||||
children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name)));
|
||||
}
|
||||
if let Some(form_role) = fn_spec.form_role() {
|
||||
let lit = kdl_string(form_role);
|
||||
self.leaf(indent + 1, &["form-role", &lit]);
|
||||
children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role)));
|
||||
}
|
||||
}
|
||||
|
||||
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]) {
|
||||
let name_lit = kdl_string(ctx_name);
|
||||
self.open(indent, &["context", &name_lit]);
|
||||
fn context(
|
||||
&self,
|
||||
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.
|
||||
for fn_spec in members {
|
||||
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.
|
||||
// Params collected across every member, keyed so they emit
|
||||
// alphabetized by param name.
|
||||
struct ParamSlot {
|
||||
primitive: Primitive,
|
||||
shared_by: Vec<&'static str>,
|
||||
@@ -295,120 +327,163 @@ impl<'a> Emitter<'a> {
|
||||
|
||||
let member_count = members.len();
|
||||
for (param_name, slot) in params.iter() {
|
||||
let name_lit = kdl_string(param_name);
|
||||
self.open(indent + 1, &["param", &name_lit]);
|
||||
let type_lit = kdl_string(slot.primitive.name());
|
||||
self.leaf(indent + 2, &["type", &type_lit]);
|
||||
let required = slot.shared_by.len() == member_count;
|
||||
self.leaf(indent + 2, &["required", kdl_bool(required)]);
|
||||
let mut param_children = vec![
|
||||
KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())),
|
||||
KdlNode::new(inner + 1, "required")
|
||||
.arg(KdlValue::Bool(slot.shared_by.len() == member_count)),
|
||||
];
|
||||
for sharer in &slot.shared_by {
|
||||
let lit = kdl_string(sharer);
|
||||
self.leaf(indent + 2, &["shared-by", &lit]);
|
||||
param_children
|
||||
.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 {
|
||||
// Trim trailing blanks, then add a single terminating newline.
|
||||
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
|
||||
self.lines.pop();
|
||||
fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode {
|
||||
let inner = depth + 1;
|
||||
let mut children =
|
||||
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");
|
||||
out.push('\n');
|
||||
out
|
||||
KdlNode::new(depth, "channel")
|
||||
.arg(KdlValue::str(channel.name))
|
||||
.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`.
|
||||
pub(crate) struct IrSnapshot {
|
||||
pub types: BTreeMap<&'static str, NamedType>,
|
||||
pub types: BTreeMap<String, NamedType>,
|
||||
pub functions: Vec<&'static dyn FunctionSpec>,
|
||||
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
||||
/// Types that inline to a `TypeShape` at every reference site rather
|
||||
/// than emitting as their own `type "X" { ... }` entry. Populated from
|
||||
/// `Alias(Primitive(_))` and `Enum` named types — both are
|
||||
/// information-zero indirections that the codegen consumer doesn't
|
||||
/// gain anything from naming. Matches the Python emitter's behavior
|
||||
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
|
||||
/// materialize as named types).
|
||||
pub channels: Vec<ChannelRecord>,
|
||||
/// Types that inline to a `TypeShape` at every reference site rather than
|
||||
/// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`,
|
||||
/// both of which carry no structure a named entry would add.
|
||||
pub inlines: BTreeMap<&'static str, TypeShape>,
|
||||
}
|
||||
|
||||
impl IrSnapshot {
|
||||
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();
|
||||
for entry in TYPES {
|
||||
all_types.insert(entry.name, (entry.shape_fn)());
|
||||
}
|
||||
|
||||
// Partition into emit-candidate types vs inlines. An inline is a
|
||||
// named type whose shape collapses to a single `TypeShape` at the
|
||||
// field site — primitive aliases and string enums.
|
||||
// Partition into emit-candidate types vs inlines.
|
||||
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
|
||||
for (name, body) in all_types {
|
||||
match &body {
|
||||
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||
inlines.insert(name, TypeShape::Primitive(*p));
|
||||
}
|
||||
match body {
|
||||
NamedType::Enum(variants) => {
|
||||
inlines.insert(name, TypeShape::Enum(variants.clone()));
|
||||
inlines.insert(name, TypeShape::Enum(variants));
|
||||
}
|
||||
_ => {
|
||||
candidates.insert(name, body);
|
||||
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||
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
|
||||
// input/output. The function macro registers canonical-named
|
||||
// entries (e.g. `userPrefsOutput`); derive registers original-named
|
||||
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
|
||||
// via Ref-walk from a function's input/output names belong in the
|
||||
// emitted IR. Mirrors Python's `_collect_named_types`.
|
||||
// Channels: alphabetical by wire name, each declared slot's type
|
||||
// named `<Pascal><Slot>`. The slot shapes enter the type section
|
||||
// directly, so they are emitted whether or not a function reaches
|
||||
// them.
|
||||
let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> =
|
||||
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> =
|
||||
std::collections::HashSet::new();
|
||||
let mut frontier: Vec<&'static str> = Vec::new();
|
||||
for fn_spec in FUNCTIONS {
|
||||
if fn_spec.private() {
|
||||
continue;
|
||||
}
|
||||
if let Some(input_name) = fn_spec.input_type() {
|
||||
if reachable.insert(input_name) {
|
||||
frontier.push(input_name);
|
||||
}
|
||||
}
|
||||
let output_name = fn_spec.output_type();
|
||||
if reachable.insert(output_name) {
|
||||
frontier.push(output_name);
|
||||
reachable.insert(input_name);
|
||||
}
|
||||
reachable.insert(fn_spec.output_type());
|
||||
}
|
||||
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);
|
||||
}
|
||||
for (_, body) in &channel_types {
|
||||
collect_refs(body, &mut |r| {
|
||||
reachable.insert(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()
|
||||
.filter(|(name, _)| reachable.contains(name))
|
||||
.map(|(name, body)| (name.to_string(), body))
|
||||
.collect();
|
||||
types.extend(channel_types);
|
||||
|
||||
// Functions: alphabetical by wire name (canonical IR ordering,
|
||||
// matches the Python emitter's `sorted(functions)`). Skip `private`.
|
||||
// Functions: alphabetical by wire name. Skip `private`.
|
||||
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -416,8 +491,8 @@ impl IrSnapshot {
|
||||
.collect();
|
||||
functions.sort_by_key(|f| f.name());
|
||||
|
||||
// Contexts: alphabetical by name (canonical IR ordering), each with
|
||||
// its members sorted alphabetically too.
|
||||
// Contexts: alphabetical by name, each with its members sorted
|
||||
// alphabetically too.
|
||||
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
||||
context_names.sort();
|
||||
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
|
||||
@@ -437,6 +512,7 @@ impl IrSnapshot {
|
||||
types,
|
||||
functions,
|
||||
contexts,
|
||||
channels,
|
||||
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) {
|
||||
match shape {
|
||||
TypeShape::Ref(name) => visit(name),
|
||||
TypeShape::Ref { name, .. } => visit(name),
|
||||
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
|
||||
TypeShape::Union(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 {
|
||||
crate::graph_check::verify_invariants();
|
||||
let snap = IrSnapshot::collect();
|
||||
let mut em = Emitter::new(&snap.inlines);
|
||||
let builder = NodeBuilder {
|
||||
inlines: &snap.inlines,
|
||||
};
|
||||
|
||||
// Type definitions
|
||||
let types_emitted = !snap.types.is_empty();
|
||||
for (name, body) in &snap.types {
|
||||
em.emit_named_type(0, name, body);
|
||||
}
|
||||
if types_emitted {
|
||||
em.blank();
|
||||
}
|
||||
let sections: Vec<Vec<KdlNode>> = [
|
||||
snap.types
|
||||
.iter()
|
||||
.map(|(name, body)| builder.named_type(0, name, body))
|
||||
.collect::<Vec<_>>(),
|
||||
snap.functions
|
||||
.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 fns_emitted = !snap.functions.is_empty();
|
||||
for fn_spec in &snap.functions {
|
||||
em.emit_function(0, *fn_spec);
|
||||
}
|
||||
if fns_emitted {
|
||||
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()
|
||||
let mut env = Environment::new();
|
||||
env.add_filter("kdl", render_kdl_value);
|
||||
env.template_from_named_str("ir.kdl", IR_TEMPLATE)
|
||||
.expect("compile templates/ir.kdl.jinja")
|
||||
.render(context! { sections })
|
||||
.expect("render templates/ir.kdl.jinja")
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
//!
|
||||
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
|
||||
//! KDL to the Python emitter. Both backends emit the same contract.
|
||||
//! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as
|
||||
//! one Mizan IR document.
|
||||
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
|
||||
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
|
||||
//! consumer crate's expansion sites.
|
||||
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`)
|
||||
//! at the consumer crate's expansion sites.
|
||||
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
|
||||
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
|
||||
//! adapter calls these per request.
|
||||
//! `function_named` / `context_members`, which the adapters call per request.
|
||||
//!
|
||||
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
|
||||
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
|
||||
@@ -22,12 +21,13 @@ pub mod runtime;
|
||||
pub mod traits;
|
||||
|
||||
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::{
|
||||
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
||||
FUNCTIONS, TYPES,
|
||||
context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS,
|
||||
CONTEXTS, FUNCTIONS, TYPES,
|
||||
};
|
||||
pub use runtime::{
|
||||
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
||||
@@ -35,21 +35,20 @@ pub use runtime::{
|
||||
};
|
||||
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||
|
||||
// Re-export proc macros so consumers depend on one crate.
|
||||
pub use mizan_macros::{client, context, Mizan};
|
||||
pub use mizan_macros::{channel, client, context, Mizan};
|
||||
|
||||
pub mod prelude {
|
||||
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::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||
pub use mizan_macros::Mizan;
|
||||
}
|
||||
|
||||
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
|
||||
/// the public API — consumers must not depend on names under `__priv`.
|
||||
/// The crates `mizan-macros` expansions name by absolute path.
|
||||
#[doc(hidden)]
|
||||
pub mod __priv {
|
||||
pub use linkme;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
|
||||
//! statics that land here at link time.
|
||||
|
||||
use crate::ir::NamedType;
|
||||
use crate::ir::{ChannelSlotKind, NamedType};
|
||||
use crate::traits::FunctionSpec;
|
||||
use linkme::distributed_slice;
|
||||
|
||||
@@ -17,6 +17,21 @@ pub struct ContextEntry {
|
||||
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]
|
||||
pub static TYPES: [TypeEntry] = [..];
|
||||
|
||||
@@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..];
|
||||
#[distributed_slice]
|
||||
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
|
||||
|
||||
/// Find a registered function by wire name. Used by the HTTP adapter.
|
||||
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS.iter().copied().find(|f| f.name() == name)
|
||||
#[distributed_slice]
|
||||
pub static CHANNELS: [ChannelEntry] = [..];
|
||||
|
||||
/// 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.
|
||||
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
|
||||
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.
|
||||
/// The functions that declare `ctx_name` as their `context` membership. Order
|
||||
/// matches `FUNCTIONS` iteration order — i.e., registration order.
|
||||
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
//! Runtime helpers — error envelope, request handle, invalidation/merge
|
||||
//! resolution. Ports `compute_invalidation` / `compute_merges` /
|
||||
//! `_resolve_merge_slot` / `_scoped_params` from
|
||||
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
|
||||
//! Runtime helpers — error envelope, request handle, and the per-response
|
||||
//! invalidation / merge resolution the adapters call after a dispatch.
|
||||
|
||||
use crate::registry::context_members;
|
||||
use crate::traits::FunctionSpec;
|
||||
use serde_json::Value;
|
||||
use std::any::Any;
|
||||
|
||||
/// Type-erased handle to the framework's request object. The HTTP adapter
|
||||
/// stuffs its native `Request` here; user code casts back via the adapter's
|
||||
/// helper types.
|
||||
/// A borrow of the request object a hosting framework owns.
|
||||
///
|
||||
/// `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)]
|
||||
pub struct RequestHandle<'a> {
|
||||
pub inner: &'a (dyn Any + Send + Sync),
|
||||
inner: &'a (dyn Any + Send + Sync),
|
||||
}
|
||||
|
||||
impl<'a> RequestHandle<'a> {
|
||||
/// Wrap a typed reference. The most common path — handlers downcast back
|
||||
/// to `T` via `downcast::<T>()`.
|
||||
/// Wrap a typed reference.
|
||||
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
||||
Self { inner: req }
|
||||
}
|
||||
|
||||
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
|
||||
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
|
||||
/// Wrap a reference the caller has already erased.
|
||||
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
|
||||
Self { inner: req }
|
||||
}
|
||||
|
||||
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
|
||||
self.inner.downcast_ref::<T>()
|
||||
/// The reference the adapter installed.
|
||||
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)]
|
||||
pub enum MizanError {
|
||||
NotFound(String),
|
||||
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `merge` list from a function's `merge` metadata. Each entry
|
||||
/// names the slot inside the context bundle the return value lands in.
|
||||
/// Build the `merge` list from the function's already-resolved merge entries.
|
||||
/// Each names the slot inside the context bundle the return value lands in.
|
||||
pub fn compute_merges(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
result: &Value,
|
||||
) -> Vec<MergeEntry> {
|
||||
let targets = fn_spec.merge();
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mutation_output = fn_spec.output_type();
|
||||
let mut out = Vec::new();
|
||||
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(),
|
||||
params: if scoped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
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());
|
||||
crate::graph_check::merges_for(fn_spec.name())
|
||||
.map(|resolved| {
|
||||
let scoped = scoped_params(resolved.context, args);
|
||||
MergeEntry {
|
||||
context: resolved.context.into(),
|
||||
slot: resolved.slot.into(),
|
||||
value: result.clone(),
|
||||
params: if scoped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(scoped)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches.len() == 1 {
|
||||
Some(matches[0].into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Match input args against the context's declared Input field names.
|
||||
@@ -258,3 +228,36 @@ fn scoped_params(
|
||||
.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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::runtime::{MizanError, RequestHandle};
|
||||
@@ -6,11 +6,10 @@ use serde_json::Value;
|
||||
use std::future::Future;
|
||||
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`
|
||||
/// initializers — TypeEntry's `name` field reads it directly without an
|
||||
/// init-time function call.
|
||||
/// `TYPE_NAME` is a `const` rather than a function so it can be named from a
|
||||
/// `static` initializer.
|
||||
pub trait MizanType {
|
||||
const TYPE_NAME: &'static str;
|
||||
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 {
|
||||
const NAME: &'static str;
|
||||
}
|
||||
|
||||
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
|
||||
///
|
||||
/// Everything here is plain data except `dispatch`, which is the type-erased
|
||||
/// runtime entry point used by the HTTP adapter.
|
||||
/// One Mizan-registered function: plain data throughout except `dispatch`.
|
||||
pub trait FunctionSpec: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn camel_name(&self) -> &'static str;
|
||||
fn has_input(&self) -> bool;
|
||||
fn input_type(&self) -> Option<&'static str>;
|
||||
fn output_type(&self) -> &'static str;
|
||||
|
||||
/// The shape registered under `output_type()`.
|
||||
fn output_shape(&self) -> NamedType;
|
||||
|
||||
fn output_nullable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Field-shape description of this function's Input parameters, used by
|
||||
/// the context builder to compute shared-param elevation. Empty when
|
||||
/// `has_input()` is false.
|
||||
/// This function's Input parameters. Empty when `has_input()` is false.
|
||||
fn input_params(&self) -> &'static [InputParam] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
|
||||
/// JSON arguments; the macro-generated impl deserializes into the
|
||||
/// function's typed input, awaits the body, and serializes the result.
|
||||
/// Deserializes `args` into this function's typed input, awaits the body,
|
||||
/// and serializes the result — the whole call with its types erased behind
|
||||
/// JSON.
|
||||
fn dispatch<'a>(
|
||||
&'a self,
|
||||
req: RequestHandle<'a>,
|
||||
@@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// One parameter of a function's synthesized Input. The macro emits a static
|
||||
/// 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.
|
||||
/// One parameter of a function's synthesized Input.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct InputParam {
|
||||
pub name: &'static str,
|
||||
|
||||
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal file
6
cores/mizan-rust/templates/ir.kdl.jinja
Normal 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 %}
|
||||
@@ -1,11 +1,8 @@
|
||||
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros)
|
||||
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
|
||||
//! Python-emitted reference).
|
||||
//!
|
||||
//! 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.
|
||||
//! `build_ir()` renders the proc-macro-populated registries; the emitted KDL
|
||||
//! is parsed by the `kdl` crate and then compared byte for byte with
|
||||
//! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`.
|
||||
|
||||
use kdl::{KdlDocument, KdlNode};
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::RequestHandle;
|
||||
@@ -46,7 +43,17 @@ pub struct StatusOutput {
|
||||
#[mizan::context("user")]
|
||||
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]
|
||||
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
|
||||
@@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
|
||||
ProfileOutput {
|
||||
user_id,
|
||||
name: "placeholder".into(),
|
||||
}
|
||||
profile_of(user_id)
|
||||
}
|
||||
|
||||
#[mizan::client(context = UserCtx)]
|
||||
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
|
||||
vec![]
|
||||
pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec<OrderOutput> {
|
||||
ORDERS
|
||||
.iter()
|
||||
.filter(|(_, owner, _)| *owner == user_id)
|
||||
.map(|(id, owner, total)| OrderOutput {
|
||||
id: *id,
|
||||
user_id: *owner,
|
||||
total: *total,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[mizan::client(affects = UserCtx)]
|
||||
pub async fn update_profile(
|
||||
_req: &RequestHandle<'_>,
|
||||
_user_id: i64,
|
||||
_name: String,
|
||||
user_id: i64,
|
||||
name: String,
|
||||
) -> StatusOutput {
|
||||
StatusOutput { ok: true }
|
||||
StatusOutput {
|
||||
ok: user_id > 0 && !name.trim().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
|
||||
None
|
||||
pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option<ProfileOutput> {
|
||||
ORDERS
|
||||
.iter()
|
||||
.any(|(_, owner, _)| *owner == user_id)
|
||||
.then(|| profile_of(user_id))
|
||||
}
|
||||
|
||||
#[mizan::client(merge = UserCtx)]
|
||||
@@ -99,20 +116,96 @@ pub async fn rename_user(
|
||||
ProfileOutput { user_id, name }
|
||||
}
|
||||
|
||||
// ─── The byte-equivalence test ──────────────────────────────────────────────
|
||||
// ─── Reading the parsed document ────────────────────────────────────────────
|
||||
|
||||
fn canonical_kdl_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.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]
|
||||
fn build_ir_matches_canonical_afi_kdl() {
|
||||
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
|
||||
let actual = mizan_core::build_ir();
|
||||
let emitted = mizan_core::build_ir();
|
||||
|
||||
if actual != expected {
|
||||
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
|
||||
// Parsing before comparing means a malformed emission fails here rather
|
||||
// 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 {
|
||||
panic!(
|
||||
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
|
||||
@@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() {
|
||||
}
|
||||
panic!(
|
||||
"KDL diverges in length: actual_len={} expected_len={}",
|
||||
actual.len(),
|
||||
emitted.len(),
|
||||
expected.len(),
|
||||
);
|
||||
}
|
||||
|
||||
68
cores/mizan-rust/tests/merge_defects.rs
Normal file
68
cores/mizan-rust/tests/merge_defects.rs
Normal 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();
|
||||
}
|
||||
113
cores/mizan-rust/tests/merge_resolution.rs
Normal file
113
cores/mizan-rust/tests/merge_resolution.rs
Normal 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"]);
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
"""Launcher: runs the Django ASGI server locally and opens it in a native window."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -13,19 +8,18 @@ import time
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def start_server(host: str, port: int):
|
||||
"""Start the Django ASGI server in a background thread."""
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
# Run migrations on first launch
|
||||
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)
|
||||
|
||||
import uvicorn
|
||||
@@ -38,51 +32,49 @@ def start_server(host: str, port: int):
|
||||
)
|
||||
|
||||
|
||||
def wait_for_server(url: str, timeout: float = 10.0):
|
||||
"""Poll until the server responds."""
|
||||
def wait_for_server(url: str, timeout: float = 10.0) -> None:
|
||||
"""Poll until the server answers, or raise carrying the last refusal."""
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
deadline = time.time() + timeout
|
||||
last_error: OSError | None = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
urlopen(url, timeout=1)
|
||||
return True
|
||||
except (URLError, OSError):
|
||||
return
|
||||
except OSError as e:
|
||||
last_error = e
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
raise TimeoutError(
|
||||
f"Django server did not answer {url} within {timeout}s"
|
||||
) from last_error
|
||||
|
||||
|
||||
def main():
|
||||
host = "127.0.0.1"
|
||||
port = 8765
|
||||
|
||||
# Start Django in a daemon thread
|
||||
server = threading.Thread(target=start_server, args=(host, port), daemon=True)
|
||||
server.start()
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
wait_for_server(f"{base_url}/api/mizan/session/")
|
||||
|
||||
print(f"Backend running at {base_url}")
|
||||
|
||||
# Check if --headless flag is passed (for testing)
|
||||
if "--headless" in sys.argv:
|
||||
print("Headless mode — server running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
print("Interrupted — stopping the server.", file=sys.stderr)
|
||||
return
|
||||
|
||||
# Open native window
|
||||
import webview
|
||||
|
||||
window = webview.create_window(
|
||||
webview.create_window(
|
||||
title="mizan Desktop",
|
||||
url=base_url,
|
||||
width=1024,
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""
|
||||
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 logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -21,11 +11,14 @@ from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client import client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register
|
||||
from mizan.channels import Channel
|
||||
from mizan.setup import register
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Info
|
||||
# =============================================================================
|
||||
@@ -119,17 +112,21 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
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(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
except PermissionError:
|
||||
raise PermissionError(f"Cannot read directory: {dir_path}")
|
||||
|
||||
@@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
||||
|
||||
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:
|
||||
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:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Safety: only allow writing within home directory
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only write files within home directory: {home}")
|
||||
@@ -339,11 +335,14 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
note.delete()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
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()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
|
||||
|
||||
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.)."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
uptime_seconds: float
|
||||
memory_mb: float
|
||||
note_count: int
|
||||
timestamp: str
|
||||
|
||||
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):
|
||||
return "app_status"
|
||||
@@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel):
|
||||
register_channel(AppStatusChannel, "app_status")
|
||||
|
||||
|
||||
class NotesChannel(ReactChannel):
|
||||
class NotesChannel(Channel):
|
||||
"""Push notifications when notes are modified."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
action: str # "created", "updated", "deleted"
|
||||
note_id: int
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
# One local user owns the whole process; there is no identity to check.
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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() {
|
||||
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(() => {
|
||||
call('system_info').then(setInfo).catch(() => {})
|
||||
call<undefined, SystemFacts>('system_info').then(setInfo).catch(setError)
|
||||
}, [call])
|
||||
|
||||
if (!info) return <div style={styles.card}>Loading system info...</div>
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>System</h2>
|
||||
<table style={styles.table}>
|
||||
<tbody>
|
||||
{Object.entries(info).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={styles.label}>{k}</td>
|
||||
<td style={styles.value}>{String(v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="panel p-5 mb-4">
|
||||
<h2 className="panel-heading mb-3">System</h2>
|
||||
{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>
|
||||
{Object.entries(info).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td className="info-key py-1 pr-3 whitespace-nowrap">{k}</td>
|
||||
<td className="py-1 break-all">{String(v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const status = useMizanStatus()
|
||||
return (
|
||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const tone = status === 'connected' ? 'status-bar--online' : 'status-bar--offline'
|
||||
|
||||
// ─── Notes ──────────────────────────────────────────────────────────────────
|
||||
return <div className={`status-bar ${tone}`}>{status}</div>
|
||||
}
|
||||
|
||||
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 [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const create = async () => {
|
||||
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 })
|
||||
const clearDraft = () => {
|
||||
setSelected(null)
|
||||
setTitle('')
|
||||
setContent('')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
await call('delete_note', { id })
|
||||
if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') }
|
||||
refresh()
|
||||
const create = () => {
|
||||
if (!title.trim()) return
|
||||
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()
|
||||
})
|
||||
.catch(setError)
|
||||
}
|
||||
|
||||
const select = (n: Note) => {
|
||||
@@ -88,44 +94,50 @@ function Notes() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Notes ({notes.length})</h2>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="panel p-5 mb-4">
|
||||
<h2 className="panel-heading mb-3">Notes ({notes.length})</h2>
|
||||
{error && <div className="load-error mb-2">{error.message}</div>}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
{notes.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => select(n)}
|
||||
style={{
|
||||
...styles.noteItem,
|
||||
borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent',
|
||||
}}
|
||||
className={
|
||||
'note-item flex items-center justify-between py-2 px-3 mb-0.5 ' +
|
||||
(selected?.id === n.id ? 'note-item--selected' : '')
|
||||
}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{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 style={{ flex: 2 }}>
|
||||
<div className="flex-[2]">
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
style={styles.input}
|
||||
className="field w-full py-2 px-3 mb-2"
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="Content"
|
||||
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'}
|
||||
</button>
|
||||
{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
|
||||
</button>
|
||||
)}
|
||||
@@ -135,8 +147,6 @@ function Notes() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── File Browser ───────────────────────────────────────────────────────────
|
||||
|
||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||
|
||||
function FileBrowser() {
|
||||
@@ -144,25 +154,27 @@ function FileBrowser() {
|
||||
const [dir, setDir] = useState('~')
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [parent, setParent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
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 => {
|
||||
setDir(data.directory)
|
||||
setEntries(data.entries.slice(0, 50))
|
||||
setParent(data.parent)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(setError)
|
||||
}, [call])
|
||||
|
||||
useEffect(() => { browse('~') }, [browse])
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Files</h2>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>{dir}</div>
|
||||
<div className="panel p-5 mb-4">
|
||||
<h2 className="panel-heading mb-3">Files</h2>
|
||||
{error && <div className="load-error mb-2">{error.message}</div>}
|
||||
<div className="path-line mb-2">{dir}</div>
|
||||
{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)
|
||||
</div>
|
||||
)}
|
||||
@@ -170,24 +182,22 @@ function FileBrowser() {
|
||||
<div
|
||||
key={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 && <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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
||||
<div className="max-w-[960px] mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="app-title">mizan Desktop</h1>
|
||||
<StatusBar />
|
||||
</div>
|
||||
<SystemInfo />
|
||||
@@ -197,19 +207,3 @@ export function App() {
|
||||
</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' },
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
|
||||
115
examples/django-react-desktop-app/frontend/src/styles.css
Normal file
115
examples/django-react-desktop-app/frontend/src/styles.css
Normal 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;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
mizan = { path = "../django", editable = true }
|
||||
mizan = { path = "../../backends/mizan-django", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user