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/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
examples/django-react-site/harness/test-results/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
examples/django-react-desktop-app/frontend/dist/
|
protocol/mizan-generate/bin/mizan-generate-*
|
||||||
examples/django-react-site/harness/src/api/generated.*
|
|
||||||
examples/django-react-site/harness/test-results/
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,98 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
mizan - Django + React unified framework
|
The mizan package surface: the `client` decorator with its context types, the
|
||||||
|
`Channel` base and its registry, the form/shape/export submodules, and
|
||||||
|
`wrap_asgi`, which mounts the WebSocket consumer alongside an HTTP application.
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
`urls` and `Shape` resolve through `__getattr__` rather than at import time.
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. urls.py - HTTP endpoint
|
|
||||||
```python
|
|
||||||
from mizan import urls as mizan_urls
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('api/mizan/', include(mizan_urls)),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. asgi.py - WebSocket support (optional)
|
|
||||||
```python
|
|
||||||
from mizan import wrap_asgi
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Define server functions
|
|
||||||
```python
|
|
||||||
# apps/myapp/clients.py
|
|
||||||
from mizan import client
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
# HTTP-only function (default)
|
|
||||||
@client
|
|
||||||
def echo(request, text: str) -> EchoOutput:
|
|
||||||
return EchoOutput(message=f"Echo: {text}")
|
|
||||||
|
|
||||||
# Global context (singleton, SSR-hydrated)
|
|
||||||
@client(context='global')
|
|
||||||
def current_user(request) -> UserOutput:
|
|
||||||
return UserOutput(email=request.user.email)
|
|
||||||
|
|
||||||
# WebSocket-enabled for real-time
|
|
||||||
@client(websocket=True)
|
|
||||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
|
||||||
return MessageOutput(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Auto-discover in apps.py
|
|
||||||
```python
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
from mizan.setup import mizan_clients
|
|
||||||
mizan_clients('apps')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Frontend - generate types and use
|
|
||||||
```bash
|
|
||||||
npm run schemas
|
|
||||||
```
|
|
||||||
```tsx
|
|
||||||
import { useEcho, useCurrentUser } from '@/api'
|
|
||||||
|
|
||||||
const user = useCurrentUser()
|
|
||||||
const echo = useEcho()
|
|
||||||
await echo({ text: 'hello' })
|
|
||||||
```
|
|
||||||
|
|
||||||
## What You Get
|
|
||||||
|
|
||||||
| Backend | Frontend | Transport |
|
|
||||||
|------------------------------------|-----------------------|------------|
|
|
||||||
| `@client` | `useXxx()` hook | HTTP |
|
|
||||||
| `@client(context='global')` | `useXxx()` + SSR | HTTP |
|
|
||||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
|
||||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
|
||||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
|
||||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
|
||||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# All imports at module level (sorted)
|
# All imports at module level (sorted)
|
||||||
from . import channels
|
from mizan import channels
|
||||||
from . import client as client_module
|
from mizan import client as client_module
|
||||||
from . import export
|
from mizan import export
|
||||||
from . import forms
|
from mizan import forms
|
||||||
from . import setup
|
from mizan import setup
|
||||||
from .channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
from .channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
from mizan.client import (
|
||||||
|
ComposedContext,
|
||||||
|
GlobalContext,
|
||||||
|
ReactContext,
|
||||||
|
ServerFunction,
|
||||||
|
client,
|
||||||
|
compose,
|
||||||
|
)
|
||||||
|
|
||||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
from .setup import (
|
from mizan.setup import (
|
||||||
mizan_clients,
|
mizan_clients,
|
||||||
mizan_module,
|
mizan_module,
|
||||||
get_channel,
|
get_channel,
|
||||||
@@ -105,11 +38,11 @@ from .setup import (
|
|||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
"""Lazy loading for modules that can't be imported at app load time."""
|
"""Lazy loading for modules that can't be imported at app load time."""
|
||||||
if name == "urls":
|
if name == "urls":
|
||||||
from .urls import urlpatterns as mizan_patterns
|
from mizan.urls import urlpatterns as mizan_patterns
|
||||||
|
|
||||||
return mizan_patterns
|
return mizan_patterns
|
||||||
if name == "Shape":
|
if name == "Shape":
|
||||||
from .shapes import Shape
|
from mizan.shapes import Shape
|
||||||
|
|
||||||
return Shape
|
return Shape
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
@@ -117,17 +50,8 @@ def __getattr__(name):
|
|||||||
|
|
||||||
def wrap_asgi(http_application):
|
def wrap_asgi(http_application):
|
||||||
"""
|
"""
|
||||||
Wrap an ASGI application with mizan WebSocket support.
|
Route HTTP to `http_application` and /ws/ to the mizan consumer, with the
|
||||||
|
channels auth middleware supplying `scope["user"]` on the socket branch.
|
||||||
Usage in asgi.py:
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
from mizan import wrap_asgi
|
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
|
||||||
|
|
||||||
This adds:
|
|
||||||
- WebSocket routing at /ws/ for RPC and channels
|
|
||||||
- Authentication middleware for WebSocket connections
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
@@ -140,7 +64,7 @@ def wrap_asgi(http_application):
|
|||||||
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
||||||
)
|
)
|
||||||
|
|
||||||
from .channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
return ProtocolTypeRouter(
|
return ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
@@ -174,7 +98,7 @@ __all__ = [
|
|||||||
# ASGI
|
# ASGI
|
||||||
"wrap_asgi",
|
"wrap_asgi",
|
||||||
# Channels
|
# Channels
|
||||||
"ReactChannel",
|
"Channel",
|
||||||
"register_channel",
|
"register_channel",
|
||||||
# Shapes
|
# Shapes
|
||||||
"Shape",
|
"Shape",
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import sys
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
from typing import Protocol, Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def get_members(path):
|
def get_members(path):
|
||||||
try:
|
try:
|
||||||
module = import_module(path)
|
module = import_module(path)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError as exc:
|
||||||
print('Could not import module "{}"'.format(path))
|
# Callers of this module write machine-read output to stdout, so the
|
||||||
|
# diagnostic must not share that stream.
|
||||||
|
print(f'Could not import module "{path}": {exc}', file=sys.stderr)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
members = [
|
members = [
|
||||||
@@ -21,7 +26,8 @@ def get_members(path):
|
|||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
class DjangoAppVisitorHandler(Protocol):
|
class DjangoAppVisitorHandler(ABC):
|
||||||
|
@abstractmethod
|
||||||
def on_module(
|
def on_module(
|
||||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
@@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol):
|
|||||||
|
|
||||||
class DjangoAppVisitor:
|
class DjangoAppVisitor:
|
||||||
"""
|
"""
|
||||||
Discovers Python modules under each Django app following conventions:
|
Walks each installed app for modules named after `layer`:
|
||||||
- <app>/<module>.py -> url_prefix "<renamed>/"
|
<app>/<layer>.py -> path_parts []
|
||||||
- <app>/<module>/**/*.py -> url_prefix "<renamed>/<subdirs...>/<module>/"
|
<app>/<layer>/**/*.py -> path_parts [<subdirs...>, <stem>]
|
||||||
|
|
||||||
Example:
|
`apps_root` is the dotted package the apps live under, relative to
|
||||||
<app>/<module>/forms/nksn.py -> url_prefix "<renamed>/forms/nksn/"
|
BASE_DIR; "" means the apps sit directly at BASE_DIR.
|
||||||
module_path "<app>.module.forms.nksn"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -66,7 +71,6 @@ class DjangoAppVisitor:
|
|||||||
|
|
||||||
app_module = f"{module_prefix}{app_name}"
|
app_module = f"{module_prefix}{app_name}"
|
||||||
|
|
||||||
# 1) Visit package: <app>/<module>/**/*.py
|
|
||||||
layer_dir = app_dir / self.layer
|
layer_dir = app_dir / self.layer
|
||||||
if layer_dir.is_dir():
|
if layer_dir.is_dir():
|
||||||
for py_file in layer_dir.rglob("*.py"):
|
for py_file in layer_dir.rglob("*.py"):
|
||||||
@@ -83,7 +87,6 @@ class DjangoAppVisitor:
|
|||||||
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2) Visit module module file: <app>/module.py
|
|
||||||
layer_file = app_dir / f"{self.layer}.py"
|
layer_file = app_dir / f"{self.layer}.py"
|
||||||
if layer_file.is_file():
|
if layer_file.is_file():
|
||||||
handler.on_module(
|
handler.on_module(
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
Origin-side cache keyed by HMAC digests of (context, params, user, rev).
|
||||||
|
|
||||||
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
There are no reverse indexes: a scoped purge recomputes the one key it needs
|
||||||
Scoped purge recomputes the key and deletes directly.
|
and deletes it, and a purge with no params falls back to a key-prefix scan.
|
||||||
Broad purge uses key-prefix scan (rare operation).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,7 +24,7 @@ _init_lock = threading.Lock()
|
|||||||
def get_cache() -> CacheBackend | None:
|
def get_cache() -> CacheBackend | None:
|
||||||
"""
|
"""
|
||||||
Get the configured cache backend, or None if caching is disabled.
|
Get the configured cache backend, or None if caching is disabled.
|
||||||
Thread-safe.
|
Thread-safe; the backend is built once on first call.
|
||||||
"""
|
"""
|
||||||
global _cache_instance, _initialized
|
global _cache_instance, _initialized
|
||||||
if _initialized:
|
if _initialized:
|
||||||
@@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None:
|
|||||||
from mizan.setup.settings import get_settings
|
from mizan.setup.settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Both settings are required; one without the other is a
|
||||||
|
# misconfiguration worth naming rather than silently ignoring.
|
||||||
if settings.cache_secret and settings.cache_redis_url:
|
if settings.cache_secret and settings.cache_redis_url:
|
||||||
_cache_instance = RedisCache(settings.cache_redis_url)
|
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||||
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||||
@@ -113,13 +111,11 @@ def cache_purge(
|
|||||||
rev: int = 0,
|
rev: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Purge cached entries for a context.
|
Purge cached entries for a context and return how many were removed.
|
||||||
|
|
||||||
Scoped purge (params provided): recomputes the HMAC key and deletes
|
With params and a secret, the exact key is recomputed and deleted — one
|
||||||
it directly. One DELETE, no index needed.
|
DELETE. Without them, every key under the prefix "ctx:{context}:" is
|
||||||
|
scanned and removed.
|
||||||
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
|
||||||
This is a rare operation (Tier 3 fallback in invalidation).
|
|
||||||
"""
|
"""
|
||||||
if params is not None and len(params) > 0 and secret:
|
if params is not None and len(params) > 0 and secret:
|
||||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
|
|||||||
@@ -1,81 +1,15 @@
|
|||||||
"""
|
"""WebSocket channels: the Channel base class, the channel registry, and
|
||||||
mizan.channels - Real-time WebSocket communication.
|
the schema exports built from it."""
|
||||||
|
|
||||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
|
||||||
Hooks are auto-generated with full TypeScript types.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```python
|
|
||||||
# channels.py
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
class ChatChannel(channels.ReactChannel):
|
|
||||||
|
|
||||||
class Params(BaseModel):
|
|
||||||
room: str
|
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
|
||||||
text: str
|
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
|
||||||
user: str
|
|
||||||
text: str
|
|
||||||
timestamp: datetime
|
|
||||||
|
|
||||||
def authorize(self, params: Params) -> bool:
|
|
||||||
return self.user.is_authenticated
|
|
||||||
|
|
||||||
def group(self, params: Params) -> str:
|
|
||||||
return f'chat_{params.room}'
|
|
||||||
|
|
||||||
def receive(self, params: Params, msg: ReactMessage) -> DjangoMessage | None:
|
|
||||||
return self.DjangoMessage(
|
|
||||||
user=self.user.email,
|
|
||||||
text=msg.text,
|
|
||||||
timestamp=now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
channels.register(ChatChannel, 'chat')
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
# asgi.py
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
|
||||||
"http": get_asgi_application(),
|
|
||||||
"websocket": channels.get_websocket_application(),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Usage (auto-generated)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useChatChannel } from '@/api/generated.channels'
|
|
||||||
|
|
||||||
function Chat({ room }) {
|
|
||||||
const chat = useChatChannel({ room })
|
|
||||||
|
|
||||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
|
||||||
chat.messages // DjangoMessage[]
|
|
||||||
chat.send({ text: 'Hello' }) // ReactMessage
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Push
|
|
||||||
|
|
||||||
```python
|
|
||||||
await ChatChannel.push(room='general', message=ChatChannel.DjangoMessage(...))
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import abc
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.ir import wire_to_pascal
|
||||||
|
from mizan_core.registry import RegistryExtension, register_extension
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
@@ -84,36 +18,25 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
class Channel(abc.ABC):
|
||||||
# Base Classes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ReactChannel:
|
|
||||||
"""
|
"""
|
||||||
Base class for WebSocket channels.
|
A WebSocket channel.
|
||||||
|
|
||||||
Define nested Pydantic classes for typed messaging:
|
Subclasses declare the wire types as nested Pydantic models:
|
||||||
- Params: Query parameters for subscribing (optional)
|
Params (subscription query parameters), ClientMessage (travels
|
||||||
- ReactMessage: Messages from browser to server (optional)
|
client -> server), ServerMessage (travels server -> client). Any
|
||||||
- DjangoMessage: Messages from server to browser (optional)
|
slot left undeclared stays None and that direction is unavailable.
|
||||||
|
|
||||||
Implement required methods:
|
authorize() and group() are abstract. receive(), on_connect() and
|
||||||
- authorize(): Permission check for connection
|
on_disconnect() are the override points; each definition here records
|
||||||
- group(): Which group to broadcast to
|
what happened and a subclass replaces or extends it.
|
||||||
|
|
||||||
Optionally implement:
|
|
||||||
- receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast
|
|
||||||
- on_connect(): Called after successful connection
|
|
||||||
- on_disconnect(): Called when connection closes
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Nested classes (optional, defined by subclasses)
|
|
||||||
Params: ClassVar[Type[BaseModel] | None] = None
|
Params: ClassVar[Type[BaseModel] | None] = None
|
||||||
ReactMessage: ClassVar[Type[BaseModel] | None] = None
|
ClientMessage: ClassVar[Type[BaseModel] | None] = None
|
||||||
DjangoMessage: ClassVar[Type[BaseModel] | None] = None
|
ServerMessage: ClassVar[Type[BaseModel] | None] = None
|
||||||
|
|
||||||
# Set by the framework when handling a connection
|
# Set by the consumer when it builds an instance for a subscription.
|
||||||
user: "AbstractBaseUser | AnonymousUser"
|
user: "AbstractBaseUser | AnonymousUser"
|
||||||
_channel_layer: Any = None
|
_channel_layer: Any = None
|
||||||
_channel_name: str = ""
|
_channel_name: str = ""
|
||||||
@@ -125,64 +48,58 @@ class ReactChannel:
|
|||||||
self._groups = set()
|
self._groups = set()
|
||||||
self._params_dict = {}
|
self._params_dict = {}
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||||
"""
|
"""Return True to allow the connection, False to reject it."""
|
||||||
Permission check. Return True to allow connection, False to reject.
|
|
||||||
|
|
||||||
Override this to implement custom authorization logic.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
f"{self.__class__.__name__} must implement authorize()"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
def group(self, params: BaseModel | None = None) -> str:
|
def group(self, params: BaseModel | None = None) -> str:
|
||||||
"""
|
"""Return the channel-layer group name this subscription broadcasts to."""
|
||||||
Return the group name for broadcasting.
|
|
||||||
|
|
||||||
Messages returned from receive() are broadcast to this group.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
|
||||||
|
|
||||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||||
"""
|
"""
|
||||||
Handle incoming ReactMessage.
|
Handle one ClientMessage; a returned ServerMessage is broadcast to the
|
||||||
|
group. A channel that accepts inbound frames overrides this — reaching
|
||||||
Return a DjangoMessage to broadcast to the group, or None to skip.
|
the definition here means the frame has nowhere to go.
|
||||||
Override this to implement message handling.
|
|
||||||
"""
|
"""
|
||||||
|
logger.warning(
|
||||||
|
"%s does not handle inbound %s; the frame is dropped",
|
||||||
|
type(self).__name__,
|
||||||
|
type(msg).__name__,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def on_connect(self, params: BaseModel | None = None) -> None:
|
async def on_connect(self, params: BaseModel | None = None) -> None:
|
||||||
"""Called after successful connection and group join."""
|
"""Runs after the group join; a subclass extends it via super()."""
|
||||||
pass
|
logger.debug(
|
||||||
|
"%s subscription opened on %s",
|
||||||
|
type(self).__name__,
|
||||||
|
self._channel_name or "<no channel name>",
|
||||||
|
)
|
||||||
|
|
||||||
async def on_disconnect(self) -> None:
|
async def on_disconnect(self) -> None:
|
||||||
"""Called when the connection closes."""
|
"""Runs as the subscription closes; a subclass extends it via super()."""
|
||||||
pass
|
logger.debug(
|
||||||
|
"%s subscription closed, leaving %d group(s)",
|
||||||
# -------------------------------------------------------------------------
|
type(self).__name__,
|
||||||
# Internal Methods (used by the consumer)
|
len(self._groups),
|
||||||
# -------------------------------------------------------------------------
|
)
|
||||||
|
|
||||||
async def _join_group(self, group_name: str) -> None:
|
async def _join_group(self, group_name: str) -> None:
|
||||||
"""Join a channel layer group."""
|
|
||||||
if self._channel_layer:
|
if self._channel_layer:
|
||||||
await self._channel_layer.group_add(group_name, self._channel_name)
|
await self._channel_layer.group_add(group_name, self._channel_name)
|
||||||
self._groups.add(group_name)
|
self._groups.add(group_name)
|
||||||
|
|
||||||
async def _leave_group(self, group_name: str) -> None:
|
async def _leave_group(self, group_name: str) -> None:
|
||||||
"""Leave a channel layer group."""
|
|
||||||
if self._channel_layer and group_name in self._groups:
|
if self._channel_layer and group_name in self._groups:
|
||||||
await self._channel_layer.group_discard(group_name, self._channel_name)
|
await self._channel_layer.group_discard(group_name, self._channel_name)
|
||||||
self._groups.discard(group_name)
|
self._groups.discard(group_name)
|
||||||
|
|
||||||
async def _leave_all_groups(self) -> None:
|
async def _leave_all_groups(self) -> None:
|
||||||
"""Leave all joined groups."""
|
|
||||||
for group_name in list(self._groups):
|
for group_name in list(self._groups):
|
||||||
await self._leave_group(group_name)
|
await self._leave_group(group_name)
|
||||||
|
|
||||||
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
||||||
"""Broadcast a message to a group."""
|
|
||||||
if self._channel_layer:
|
if self._channel_layer:
|
||||||
await self._channel_layer.group_send(
|
await self._channel_layer.group_send(
|
||||||
group_name,
|
group_name,
|
||||||
@@ -195,20 +112,11 @@ class ReactChannel:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Class Methods for Server Push
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def push(cls, message: BaseModel, **params) -> None:
|
async def push(cls, message: BaseModel, **params) -> None:
|
||||||
"""
|
"""
|
||||||
Push a message from server code (views, tasks, signals).
|
Send a ServerMessage to every subscriber of the group named by the
|
||||||
|
given params, from outside a subscription (views, tasks, signals).
|
||||||
Usage:
|
|
||||||
await ChatChannel.push(
|
|
||||||
room='general',
|
|
||||||
message=ChatChannel.DjangoMessage(user='system', text='Hello')
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
@@ -219,16 +127,13 @@ class ReactChannel:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build params model if defined
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if cls.Params:
|
if cls.Params:
|
||||||
params_obj = cls.Params(**params)
|
params_obj = cls.Params(**params)
|
||||||
|
|
||||||
# Get group name
|
|
||||||
instance = cls()
|
instance = cls()
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
|
|
||||||
# Send to group
|
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
group_name,
|
group_name,
|
||||||
{
|
{
|
||||||
@@ -241,63 +146,31 @@ class ReactChannel:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
_registry: dict[str, Type[Channel]] = {}
|
||||||
# Registry
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_registry: dict[str, Type[ReactChannel]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register(channel_class: Type[ReactChannel], name: str) -> None:
|
def register(channel_class: Type[Channel], name: str) -> None:
|
||||||
"""
|
"""Register a channel class under a URL-friendly wire name."""
|
||||||
Register a channel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
channel_class: The ReactChannel subclass to register
|
|
||||||
name: URL-friendly name (used in subscriptions)
|
|
||||||
"""
|
|
||||||
if name in _registry:
|
if name in _registry:
|
||||||
raise ValueError(f"Channel '{name}' is already registered")
|
raise ValueError(f"Channel '{name}' is already registered")
|
||||||
|
|
||||||
channel_class._registered_name = name
|
channel_class._registered_name = name
|
||||||
|
|
||||||
# Validate the channel class
|
|
||||||
if not hasattr(channel_class, "authorize"):
|
|
||||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
|
||||||
if not hasattr(channel_class, "group"):
|
|
||||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
|
||||||
|
|
||||||
_registry[name] = channel_class
|
_registry[name] = channel_class
|
||||||
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
||||||
|
|
||||||
|
|
||||||
def get_channel(name: str) -> Type[ReactChannel] | None:
|
def get_channel(name: str) -> Type[Channel] | None:
|
||||||
"""Get a registered channel class by name."""
|
"""Get a registered channel class by name."""
|
||||||
return _registry.get(name)
|
return _registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
def get_registered_channels() -> dict[str, Type[Channel]]:
|
||||||
"""Get all registered channel classes."""
|
"""Get a copy of the name -> channel-class registry."""
|
||||||
return dict(_registry)
|
return dict(_registry)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# WebSocket Consumer
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_websocket_application():
|
def get_websocket_application():
|
||||||
"""
|
"""Build the ASGI application that serves every registered channel."""
|
||||||
Get the WebSocket application for ASGI.
|
|
||||||
|
|
||||||
Usage in asgi.py:
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
|
||||||
"http": get_asgi_application(),
|
|
||||||
"websocket": channels.get_websocket_application(),
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
from channels.routing import URLRouter
|
from channels.routing import URLRouter
|
||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
@@ -308,7 +181,7 @@ def get_websocket_application():
|
|||||||
"Install it with: pip install channels channels-redis"
|
"Install it with: pip install channels channels-redis"
|
||||||
)
|
)
|
||||||
|
|
||||||
from .connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
return AuthMiddlewareStack(
|
return AuthMiddlewareStack(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
@@ -319,42 +192,30 @@ def get_websocket_application():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Schema Export (for TypeScript generation)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_channels_schema() -> dict:
|
def get_channels_schema() -> dict:
|
||||||
"""
|
"""JSON-schema per registered channel, keyed by wire name."""
|
||||||
Get schema for all registered channels (for TypeScript generation).
|
|
||||||
|
|
||||||
Returns a dict suitable for the frontend code generator.
|
|
||||||
"""
|
|
||||||
schema = {"channels": {}}
|
schema = {"channels": {}}
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
channel_schema = {
|
channel_schema = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"params": None,
|
"params": None,
|
||||||
"reactMessage": None,
|
"clientMessage": None,
|
||||||
"djangoMessage": None,
|
"serverMessage": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract Params schema
|
if channel_class.Params:
|
||||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
|
|
||||||
# Extract ReactMessage schema
|
if channel_class.ClientMessage:
|
||||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
|
||||||
channel_schema[
|
channel_schema[
|
||||||
"reactMessage"
|
"clientMessage"
|
||||||
] = channel_class.ReactMessage.model_json_schema()
|
] = channel_class.ClientMessage.model_json_schema()
|
||||||
|
|
||||||
# Extract DjangoMessage schema
|
if channel_class.ServerMessage:
|
||||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
|
||||||
channel_schema[
|
channel_schema[
|
||||||
"djangoMessage"
|
"serverMessage"
|
||||||
] = channel_class.DjangoMessage.model_json_schema()
|
] = channel_class.ServerMessage.model_json_schema()
|
||||||
|
|
||||||
schema["channels"][name] = channel_schema
|
schema["channels"][name] = channel_schema
|
||||||
|
|
||||||
@@ -369,34 +230,37 @@ def _register_channel_schema_endpoint(
|
|||||||
input_cls: type | None,
|
input_cls: type | None,
|
||||||
output_cls: type,
|
output_cls: type,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
"""
|
||||||
|
Attach one operation to `api` whose annotations name `input_cls` and
|
||||||
|
`output_cls`, so Ninja emits both into `components.schemas`.
|
||||||
|
"""
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
|
|
||||||
def endpoint(request, data):
|
def schema_carrier(request, data):
|
||||||
pass
|
return output_cls.model_json_schema()
|
||||||
|
|
||||||
endpoint.__annotations__ = {"data": input_cls}
|
schema_carrier.__annotations__ = {"data": input_cls}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def endpoint(request):
|
def schema_carrier(request):
|
||||||
pass
|
return output_cls.model_json_schema()
|
||||||
|
|
||||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||||
endpoint
|
schema_carrier
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_channels_openapi_schema() -> dict:
|
def get_channels_openapi_schema() -> dict:
|
||||||
"""
|
"""
|
||||||
Get OpenAPI schema for all registered channels.
|
OpenAPI document covering every registered channel's wire types, with the
|
||||||
|
per-channel slot table under the `x-mizan-channels` extension key.
|
||||||
|
|
||||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation
|
||||||
This schema is consumed by openapi-typescript for type generation.
|
the Mizan IR emits, so the two documents name one type identically.
|
||||||
"""
|
"""
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Create temporary Ninja API for schema generation only
|
|
||||||
schema_api = NinjaAPI(
|
schema_api = NinjaAPI(
|
||||||
title="mizan Channels",
|
title="mizan Channels",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store dynamically created classes
|
|
||||||
schema_classes: dict[str, type] = {}
|
schema_classes: dict[str, type] = {}
|
||||||
channel_metadata: list[dict] = []
|
channel_metadata: list[dict] = []
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
pascal_name = name.replace("_", " ").title().replace(" ", "")
|
pascal_name = wire_to_pascal(name)
|
||||||
|
|
||||||
channel_meta = {
|
channel_meta = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"pascalName": pascal_name,
|
"pascalName": pascal_name,
|
||||||
"hasParams": False,
|
"hasParams": False,
|
||||||
"hasReactMessage": False,
|
"hasClientMessage": False,
|
||||||
"hasDjangoMessage": False,
|
"hasServerMessage": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register Params type
|
if channel_class.Params:
|
||||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
|
||||||
params_name = f"{pascal_name}Params"
|
params_name = f"{pascal_name}Params"
|
||||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||||
channel_meta["hasParams"] = True
|
channel_meta["hasParams"] = True
|
||||||
channel_meta["paramsType"] = params_name
|
channel_meta["paramsType"] = params_name
|
||||||
|
|
||||||
# Create dummy endpoint to include in schema
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/params",
|
path=f"/channels/{name}/params",
|
||||||
@@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
output_cls=BaseModel,
|
output_cls=BaseModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register ReactMessage type
|
if channel_class.ClientMessage:
|
||||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
client_name = f"{pascal_name}ClientMessage"
|
||||||
react_name = f"{pascal_name}ReactMessage"
|
schema_classes[client_name] = type(
|
||||||
schema_classes[react_name] = type(
|
client_name, (channel_class.ClientMessage,), {}
|
||||||
react_name, (channel_class.ReactMessage,), {}
|
|
||||||
)
|
)
|
||||||
channel_meta["hasReactMessage"] = True
|
channel_meta["hasClientMessage"] = True
|
||||||
channel_meta["reactMessageType"] = react_name
|
channel_meta["clientMessageType"] = client_name
|
||||||
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/react",
|
path=f"/channels/{name}/client",
|
||||||
operation_id=f"{name}ReactMessage",
|
operation_id=f"{name}ClientMessage",
|
||||||
summary=f"{pascal_name} React→Django message",
|
summary=f"{pascal_name} client→server message",
|
||||||
input_cls=schema_classes[react_name],
|
input_cls=schema_classes[client_name],
|
||||||
output_cls=BaseModel,
|
output_cls=BaseModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register DjangoMessage type
|
if channel_class.ServerMessage:
|
||||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
server_name = f"{pascal_name}ServerMessage"
|
||||||
django_name = f"{pascal_name}DjangoMessage"
|
schema_classes[server_name] = type(
|
||||||
schema_classes[django_name] = type(
|
server_name, (channel_class.ServerMessage,), {}
|
||||||
django_name, (channel_class.DjangoMessage,), {}
|
|
||||||
)
|
)
|
||||||
channel_meta["hasDjangoMessage"] = True
|
channel_meta["hasServerMessage"] = True
|
||||||
channel_meta["djangoMessageType"] = django_name
|
channel_meta["serverMessageType"] = server_name
|
||||||
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/django",
|
path=f"/channels/{name}/server",
|
||||||
operation_id=f"{name}DjangoMessage",
|
operation_id=f"{name}ServerMessage",
|
||||||
summary=f"{pascal_name} Django→React message",
|
summary=f"{pascal_name} server→client message",
|
||||||
input_cls=None,
|
input_cls=None,
|
||||||
output_cls=schema_classes[django_name],
|
output_cls=schema_classes[server_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
channel_metadata.append(channel_meta)
|
channel_metadata.append(channel_meta)
|
||||||
|
|
||||||
# Get OpenAPI schema from Ninja
|
|
||||||
# path_prefix="" avoids URL reverse() — this API is never mounted
|
# path_prefix="" avoids URL reverse() — this API is never mounted
|
||||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||||
|
|
||||||
# Add channel metadata extension
|
|
||||||
schema["x-mizan-channels"] = channel_metadata
|
schema["x-mizan-channels"] = channel_metadata
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Schema Endpoint (for TypeScript generation)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_schema_router = None
|
_schema_router = None
|
||||||
|
|
||||||
|
|
||||||
def _get_schema_router():
|
def _get_schema_router():
|
||||||
"""Get the Ninja router for the channels schema endpoint."""
|
|
||||||
global _schema_router
|
global _schema_router
|
||||||
if _schema_router is None:
|
if _schema_router is None:
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
@@ -502,17 +354,16 @@ def _get_schema_router():
|
|||||||
|
|
||||||
@_schema_router.get("/schema/")
|
@_schema_router.get("/schema/")
|
||||||
def channels_schema(request):
|
def channels_schema(request):
|
||||||
"""Get schema for all registered channels (for TypeScript generation)."""
|
|
||||||
return get_channels_schema()
|
return get_channels_schema()
|
||||||
|
|
||||||
return _schema_router
|
return _schema_router
|
||||||
|
|
||||||
|
|
||||||
def get_urls():
|
def get_urls():
|
||||||
"""Get URL patterns for channels schema endpoint."""
|
"""URL patterns serving the channels schema endpoint."""
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
api = NinjaAPI(urls_namespace="django_react_channels")
|
api = NinjaAPI(urls_namespace="mizan_channels")
|
||||||
api.add_router("/", _get_schema_router())
|
api.add_router("/", _get_schema_router())
|
||||||
return api.urls
|
return api.urls
|
||||||
|
|
||||||
@@ -523,17 +374,8 @@ def __getattr__(name):
|
|||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
class _ChannelsExtension(RegistryExtension):
|
||||||
# Core Registry Extension
|
"""Exposes the channel registry to mizan_core under the 'channels' key."""
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class _ChannelsExtension:
|
|
||||||
"""
|
|
||||||
Plugs the channel registry into mizan_core.registry as the 'channels'
|
|
||||||
extension. Schema output goes under schema['channels'] in the unified
|
|
||||||
registry export consumed by codegen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def all(self) -> dict:
|
def all(self) -> dict:
|
||||||
return dict(_registry)
|
return dict(_registry)
|
||||||
@@ -546,13 +388,17 @@ class _ChannelsExtension:
|
|||||||
"type": "channel",
|
"type": "channel",
|
||||||
"bidirectional": False,
|
"bidirectional": False,
|
||||||
}
|
}
|
||||||
if getattr(channel_class, "Params", None):
|
if channel_class.Params:
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
if getattr(channel_class, "ReactMessage", None):
|
if channel_class.ClientMessage:
|
||||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"client_message"
|
||||||
|
] = channel_class.ClientMessage.model_json_schema()
|
||||||
channel_schema["bidirectional"] = True
|
channel_schema["bidirectional"] = True
|
||||||
if getattr(channel_class, "DjangoMessage", None):
|
if channel_class.ServerMessage:
|
||||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"server_message"
|
||||||
|
] = channel_class.ServerMessage.model_json_schema()
|
||||||
out[name] = channel_schema
|
out[name] = channel_schema
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -560,25 +406,15 @@ class _ChannelsExtension:
|
|||||||
_registry.clear()
|
_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
from mizan_core.registry import register_extension as _register_extension
|
register_extension("channels", _ChannelsExtension())
|
||||||
_register_extension("channels", _ChannelsExtension())
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Exports
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# URLs
|
|
||||||
"urls",
|
"urls",
|
||||||
# Base class
|
"Channel",
|
||||||
"ReactChannel",
|
|
||||||
# Registration
|
|
||||||
"register",
|
"register",
|
||||||
"get_channel",
|
"get_channel",
|
||||||
"get_registered_channels",
|
"get_registered_channels",
|
||||||
# ASGI application
|
|
||||||
"get_websocket_application",
|
"get_websocket_application",
|
||||||
# Schema export
|
|
||||||
"get_channels_schema",
|
"get_channels_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket consumer for mizan.channels.
|
WebSocket consumer multiplexing channel subscriptions and RPC calls over one
|
||||||
|
socket.
|
||||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
Browser sends:
|
Browser sends:
|
||||||
@@ -16,7 +15,7 @@ Protocol:
|
|||||||
|
|
||||||
Server sends:
|
Server sends:
|
||||||
# Channel messages
|
# Channel messages
|
||||||
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
|
{"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}}
|
||||||
|
|
||||||
# RPC responses
|
# RPC responses
|
||||||
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
||||||
@@ -25,15 +24,8 @@ Protocol:
|
|||||||
{"error": "..."}
|
{"error": "..."}
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
Supports both session (cookie) and JWT authentication:
|
Session cookies arrive through AuthMiddlewareStack during the handshake;
|
||||||
- Session: Handled automatically via AuthMiddlewareStack (cookies in handshake)
|
a JWT arrives as a query parameter: ws://localhost/ws/?token=<access_token>
|
||||||
- JWT: Pass token as query parameter: ws://...?token=<jwt>
|
|
||||||
|
|
||||||
The WebSocket URL for JWT auth would be: ws://localhost/ws/?token=<access_token>
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Functions must be explicitly registered (no arbitrary code execution)
|
|
||||||
- Pydantic validation runs BEFORE any function code
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -43,7 +35,8 @@ from urllib.parse import parse_qs
|
|||||||
|
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from . import get_channel
|
|
||||||
|
from mizan.channels import get_channel
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,27 +44,23 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class WebSocketRequest:
|
class WebSocketRequest:
|
||||||
"""
|
"""
|
||||||
Minimal request adapter for WebSocket context.
|
The request surface ServerFunction reads, backed by a WebSocket scope
|
||||||
|
instead of an HttpRequest.
|
||||||
Provides the interface expected by ServerFunction without full HttpRequest.
|
|
||||||
This is intentionally minimal - only expose what's needed.
|
|
||||||
|
|
||||||
Note: Some Django libraries (e.g., allauth rate limiting) check request.method.
|
|
||||||
We set method="POST" since WebSocket RPC calls are semantically similar to POST.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# WebSocket RPC is semantically similar to POST (sends data, expects response)
|
# Some Django libraries (allauth rate limiting) branch on request.method;
|
||||||
|
# an RPC call carries data and expects a response, so POST is the match.
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
def __init__(self, scope: dict, channel_name: str = None):
|
def __init__(self, scope: dict, channel_name: str = None):
|
||||||
self.user = scope.get("user")
|
self.user = scope.get("user")
|
||||||
self.session = scope.get("session", {})
|
self.session = scope.get("session", {})
|
||||||
self.channel_name = channel_name # For push subscriptions
|
self.channel_name = channel_name
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def META(self) -> dict:
|
def META(self) -> dict:
|
||||||
"""HTTP headers from WebSocket handshake."""
|
"""HTTP headers from the WebSocket handshake, in WSGI key form."""
|
||||||
headers = dict(self._scope.get("headers", []))
|
headers = dict(self._scope.get("headers", []))
|
||||||
return {
|
return {
|
||||||
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
||||||
@@ -80,24 +69,15 @@ class WebSocketRequest:
|
|||||||
|
|
||||||
|
|
||||||
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||||
"""
|
"""Holds every channel subscription opened over one WebSocket connection."""
|
||||||
Multiplexed WebSocket consumer for django_react channels.
|
|
||||||
|
|
||||||
Manages multiple channel subscriptions over a single WebSocket connection.
|
|
||||||
|
|
||||||
Authentication:
|
|
||||||
- Session auth via cookies (handled by AuthMiddlewareStack)
|
|
||||||
- JWT auth via query parameter: ws://...?token=<jwt>
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Track subscriptions: {(channel_name, params_json): channel_instance}
|
# {(channel_name, params_json): channel_instance}
|
||||||
self._subscriptions: dict[tuple[str, str], Any] = {}
|
self._subscriptions: dict[tuple[str, str], Any] = {}
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
||||||
# Check for JWT token in query parameters
|
|
||||||
await self._try_jwt_auth()
|
await self._try_jwt_auth()
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
@@ -107,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def _try_jwt_auth(self):
|
async def _try_jwt_auth(self):
|
||||||
"""
|
"""
|
||||||
Attempt JWT authentication from query parameter.
|
Authenticate from a ?token=<jwt> query parameter, building a JWTUser
|
||||||
|
from the token claims with no database query.
|
||||||
|
|
||||||
If a valid JWT token is provided via ?token=<jwt>, authenticate the user
|
An invalid token leaves the scope untouched so session auth still
|
||||||
using JWTUser (no database query).
|
applies; a valid one overwrites whatever session auth resolved.
|
||||||
|
|
||||||
Security: If JWT is provided but invalid, we log it but don't reject
|
|
||||||
the connection - the session auth may still be valid. However, if JWT
|
|
||||||
IS valid, it takes precedence over session auth.
|
|
||||||
"""
|
"""
|
||||||
# Parse query string for token
|
|
||||||
query_string = self.scope.get("query_string", b"").decode()
|
query_string = self.scope.get("query_string", b"").decode()
|
||||||
params = parse_qs(query_string)
|
params = parse_qs(query_string)
|
||||||
token_list = params.get("token", [])
|
token_list = params.get("token", [])
|
||||||
|
|
||||||
if not token_list:
|
if not token_list:
|
||||||
return # No JWT provided, use session auth
|
return
|
||||||
|
|
||||||
token = token_list[0]
|
token = token_list[0]
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate JWT and create JWTUser (no DB query)
|
|
||||||
try:
|
try:
|
||||||
from mizan.client.jwt import decode_token
|
from mizan.client.jwt import decode_token
|
||||||
from mizan.jwt.tokens import JWTUser
|
from mizan.jwt.tokens import JWTUser
|
||||||
@@ -136,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||||
if payload is None:
|
if payload is None:
|
||||||
logger.debug("JWT token invalid or expired")
|
logger.debug("JWT token invalid or expired")
|
||||||
return # Fall back to session auth
|
return
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
self.scope["user"] = JWTUser(payload)
|
self.scope["user"] = JWTUser(payload)
|
||||||
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -157,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
||||||
|
|
||||||
async def receive_json(self, content: dict):
|
async def receive_json(self, content: dict):
|
||||||
"""Handle incoming JSON messages."""
|
"""Route one incoming frame by its "action" field."""
|
||||||
action = content.get("action")
|
action = content.get("action")
|
||||||
|
|
||||||
if action == "subscribe":
|
if action == "subscribe":
|
||||||
@@ -178,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_subscribe(self, content: dict):
|
async def _handle_subscribe(self, content: dict):
|
||||||
"""Handle subscription request."""
|
"""Authorize, join the group, and record the subscription."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
|
|
||||||
# Get channel class
|
|
||||||
channel_class = get_channel(channel_name)
|
channel_class = get_channel(channel_name)
|
||||||
if not channel_class:
|
if not channel_class:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -192,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create subscription key
|
|
||||||
params_json = json.dumps(params_dict, sort_keys=True)
|
params_json = json.dumps(params_dict, sort_keys=True)
|
||||||
sub_key = (channel_name, params_json)
|
sub_key = (channel_name, params_json)
|
||||||
|
|
||||||
# Check if already subscribed
|
|
||||||
if sub_key in self._subscriptions:
|
if sub_key in self._subscriptions:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -207,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create channel instance
|
|
||||||
instance = channel_class()
|
instance = channel_class()
|
||||||
instance.user = self.scope.get("user")
|
instance.user = self.scope.get("user")
|
||||||
instance._channel_layer = self.channel_layer
|
instance._channel_layer = self.channel_layer
|
||||||
@@ -215,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
instance._registered_name = channel_name
|
instance._registered_name = channel_name
|
||||||
instance._params_dict = params_dict
|
instance._params_dict = params_dict
|
||||||
|
|
||||||
# Parse params
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if channel_class.Params:
|
if channel_class.Params:
|
||||||
try:
|
try:
|
||||||
@@ -229,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check authorization
|
|
||||||
try:
|
try:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
authorized = instance.authorize(params_obj)
|
authorized = instance.authorize(params_obj)
|
||||||
@@ -254,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get group and join
|
|
||||||
try:
|
try:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
@@ -271,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store subscription
|
|
||||||
self._subscriptions[sub_key] = instance
|
self._subscriptions[sub_key] = instance
|
||||||
|
|
||||||
# Call on_connect hook
|
|
||||||
try:
|
try:
|
||||||
await instance.on_connect(params_obj)
|
await instance.on_connect(params_obj)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||||
|
|
||||||
# Confirm subscription
|
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"subscribed": True,
|
"subscribed": True,
|
||||||
@@ -292,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||||
|
|
||||||
async def _handle_unsubscribe(self, content: dict):
|
async def _handle_unsubscribe(self, content: dict):
|
||||||
"""Handle unsubscription request."""
|
"""Drop the subscription and leave its groups."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
|
|
||||||
@@ -318,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"Unsubscribed from {channel_name}")
|
logger.debug(f"Unsubscribed from {channel_name}")
|
||||||
|
|
||||||
async def _handle_message(self, content: dict):
|
async def _handle_message(self, content: dict):
|
||||||
"""Handle incoming message from browser."""
|
"""Validate a ClientMessage, hand it to receive(), broadcast what comes back."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
data = content.get("data", {})
|
data = content.get("data", {})
|
||||||
@@ -338,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
channel_class = instance.__class__
|
channel_class = instance.__class__
|
||||||
|
|
||||||
# Check if channel accepts messages
|
if not channel_class.ClientMessage:
|
||||||
if not channel_class.ReactMessage:
|
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"error": f"Channel {channel_name} does not accept messages",
|
"error": f"Channel {channel_name} does not accept messages",
|
||||||
@@ -348,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse message
|
|
||||||
try:
|
try:
|
||||||
msg = channel_class.ReactMessage(**data)
|
msg = channel_class.ClientMessage(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -360,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse params
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if channel_class.Params:
|
if channel_class.Params:
|
||||||
params_obj = channel_class.Params(**params_dict)
|
params_obj = channel_class.Params(**params_dict)
|
||||||
|
|
||||||
# Handle message
|
|
||||||
try:
|
try:
|
||||||
response = instance.receive(params_obj, msg)
|
response = instance.receive(params_obj, msg)
|
||||||
|
|
||||||
# If handler returned a message, broadcast it
|
|
||||||
if response is not None:
|
if response is not None:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
@@ -389,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def _handle_rpc(self, content: dict):
|
async def _handle_rpc(self, content: dict):
|
||||||
"""
|
"""
|
||||||
Handle RPC (server function) call.
|
Run a registered server function.
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||||
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
||||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||||
|
|
||||||
Security:
|
Only functions registered with @client(websocket=True) are reachable,
|
||||||
- Only functions with @client(websocket=True) are allowed
|
and execute_function validates args against the function's Input model
|
||||||
- Pydantic validation happens BEFORE any function code runs
|
before any function body runs.
|
||||||
- Function must be explicitly registered (no arbitrary code execution)
|
|
||||||
- User context from WebSocket session is passed to function
|
|
||||||
"""
|
"""
|
||||||
from mizan.client.executor import execute_function, FunctionError
|
from mizan.client.executor import execute_function, FunctionError
|
||||||
from mizan_core.registry import get_function
|
from mizan_core.registry import get_function
|
||||||
@@ -409,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
fn_name = content.get("fn")
|
fn_name = content.get("fn")
|
||||||
args = content.get("args", {})
|
args = content.get("args", {})
|
||||||
|
|
||||||
# Validate request structure
|
|
||||||
if not request_id:
|
if not request_id:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -431,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if function exists and has websocket=True
|
|
||||||
fn_class = get_function(fn_name)
|
fn_class = get_function(fn_name)
|
||||||
if fn_class is None:
|
if fn_class is None:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -446,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only allow functions explicitly marked with websocket=True
|
|
||||||
fn_meta = getattr(fn_class, "_meta", {})
|
fn_meta = getattr(fn_class, "_meta", {})
|
||||||
if not fn_meta.get("websocket"):
|
if not fn_meta.get("websocket"):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -461,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create request adapter from WebSocket scope
|
|
||||||
ws_request = WebSocketRequest(
|
ws_request = WebSocketRequest(
|
||||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute function (Pydantic validation happens inside execute_function)
|
# execute_function is sync, so it runs in a thread pool
|
||||||
# This is sync, so we need to run it in a thread pool
|
|
||||||
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
||||||
ws_request,
|
ws_request,
|
||||||
fn_name,
|
fn_name,
|
||||||
args,
|
args,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send response
|
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -488,8 +439,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a
|
# the {result, invalidate, merge} envelope the HTTP RPC path builds
|
||||||
# mutation sent over the socket invalidates exactly as one sent over HTTP
|
|
||||||
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
||||||
|
|
||||||
data = {"result": result.data}
|
data = {"result": result.data}
|
||||||
@@ -508,16 +458,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def _handle_ctx(self, content: dict):
|
async def _handle_ctx(self, content: dict):
|
||||||
"""
|
"""
|
||||||
Handle a context-bundle fetch.
|
Fetch a context bundle through execute_context.
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||||
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
|
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
|
||||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||||
|
|
||||||
Dispatches through the same execute_context the HTTP view calls. That view's
|
|
||||||
origin-side cache is a CDN concern with nothing in front of a socket, so it is not
|
|
||||||
part of this path.
|
|
||||||
"""
|
"""
|
||||||
from mizan.client.executor import execute_context, FunctionError
|
from mizan.client.executor import execute_context, FunctionError
|
||||||
|
|
||||||
@@ -563,10 +509,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def channel_message(self, event: dict):
|
async def channel_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
Handle messages broadcast to a group.
|
Forward a group broadcast down the socket, carrying the channel name
|
||||||
|
and params the client routes on.
|
||||||
Called when channel_layer.group_send() is used.
|
|
||||||
Includes channel name and params so the client can route the message.
|
|
||||||
"""
|
"""
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -579,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def push_message(self, event: dict):
|
async def push_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
Handle push messages from server functions.
|
Forward a topic push down the socket.
|
||||||
|
|
||||||
Called when push("topic", data) is used from a server function.
|
Wire shape: {"type": "push", "topic": "room:42", "data": {...}}
|
||||||
The client receives this to update its local state.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
|
||||||
"""
|
"""
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,68 +1,47 @@
|
|||||||
"""
|
"""
|
||||||
mizan Push - Server-initiated messages to clients.
|
Topic-based server-initiated messages.
|
||||||
|
|
||||||
Simple API for pushing data to subscribed WebSocket connections.
|
A topic string ("room:42", "user:123:notifications") maps onto one channel
|
||||||
|
layer group; subscribing a connection adds its channel name to that group,
|
||||||
Usage:
|
and pushing sends a "push.message" event to every member.
|
||||||
# In a server function - push to all subscribers
|
|
||||||
from mizan.push import push
|
|
||||||
|
|
||||||
push("room:42", {"type": "new_message", "data": {...}})
|
|
||||||
|
|
||||||
# Subscribe a connection to a topic (call during context fetch)
|
|
||||||
from mizan.push import subscribe
|
|
||||||
|
|
||||||
subscribe(request, "room:42")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
import logging
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Lazy import to avoid import errors when channels is not installed
|
logger = logging.getLogger(__name__)
|
||||||
# (e.g., during schema generation)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from channels.layers import BaseChannelLayer
|
|
||||||
|
|
||||||
|
|
||||||
def _get_channel_layer() -> "BaseChannelLayer | None":
|
def _get_channel_layer():
|
||||||
"""Get channel layer, returning None if channels is not installed."""
|
"""The configured channel layer, or None when django-channels is absent."""
|
||||||
try:
|
try:
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
except ImportError as e:
|
||||||
return get_channel_layer()
|
logger.warning("django-channels is not installed, push is inert: %s", e)
|
||||||
except ImportError:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return get_channel_layer()
|
||||||
def _async_to_sync(coro):
|
|
||||||
"""Wrapper for async_to_sync that handles missing channels."""
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
|
|
||||||
return async_to_sync(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def get_topic_group_name(topic: str) -> str:
|
def get_topic_group_name(topic: str) -> str:
|
||||||
"""Convert a topic string to a valid channel layer group name."""
|
"""
|
||||||
# Channel layer group names must be valid ASCII alphanumeric + hyphens/underscores/periods
|
Convert a topic to a channel layer group name. Group names allow ASCII
|
||||||
# Replace colons with underscores
|
alphanumerics plus hyphens, underscores and periods, so the topic
|
||||||
|
separator becomes an underscore.
|
||||||
|
"""
|
||||||
return topic.replace(":", "_")
|
return topic.replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
def subscribe(request, topic: str) -> None:
|
def subscribe(request, topic: str) -> None:
|
||||||
"""
|
"""
|
||||||
Subscribe this WebSocket connection to a topic.
|
Add this WebSocket connection to a topic's group.
|
||||||
|
|
||||||
Call this in a context or server function to register the connection
|
An HTTP request carries no channel_name, so there is nothing to add.
|
||||||
for push notifications on the given topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django request (must have channel_name attribute from WebSocket)
|
|
||||||
topic: Topic string, e.g., "room:42", "user:123:notifications"
|
|
||||||
"""
|
"""
|
||||||
channel_name = getattr(request, "channel_name", None)
|
channel_name = getattr(request, "channel_name", None)
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
# HTTP request, not WebSocket - can't subscribe
|
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
@@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
_async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe(request, topic: str) -> None:
|
def unsubscribe(request, topic: str) -> None:
|
||||||
"""
|
"""Remove this WebSocket connection from a topic's group."""
|
||||||
Unsubscribe this WebSocket connection from a topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django request (must have channel_name attribute from WebSocket)
|
|
||||||
topic: Topic string to unsubscribe from
|
|
||||||
"""
|
|
||||||
channel_name = getattr(request, "channel_name", None)
|
channel_name = getattr(request, "channel_name", None)
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
return
|
return
|
||||||
@@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
_async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||||
|
|
||||||
|
|
||||||
def push(topic: str, data: dict | BaseModel) -> None:
|
def push(topic: str, data: dict | BaseModel) -> None:
|
||||||
"""
|
"""Send data to every connection subscribed to a topic."""
|
||||||
Push data to all connections subscribed to a topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic: Topic string, e.g., "room:42"
|
|
||||||
data: Data to send (dict or Pydantic model)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
push("room:42", {
|
|
||||||
"type": "new_message",
|
|
||||||
"message": {"id": 1, "text": "Hello", "user": "alice@example.com"}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
import logging
|
logger.warning(
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"No channel layer configured, cannot push to topic '%s'", topic
|
"No channel layer configured, cannot push to topic '%s'", topic
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert Pydantic model to dict if needed
|
|
||||||
if isinstance(data, BaseModel):
|
if isinstance(data, BaseModel):
|
||||||
data = data.model_dump()
|
data = data.model_dump()
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
|
|
||||||
_async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
group_name,
|
group_name,
|
||||||
{
|
{
|
||||||
"type": "push.message", # Maps to push_message handler in consumer
|
# The event's "type" selects the consumer method of the same name,
|
||||||
|
# with dots translated to underscores.
|
||||||
|
"type": "push.message",
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
},
|
},
|
||||||
@@ -133,9 +93,12 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||||
"""Async version of push for use in async contexts."""
|
"""Send data to every connection subscribed to a topic, from the event loop."""
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
|
logger.warning(
|
||||||
|
"No channel layer configured, cannot push to topic '%s'", topic
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(data, BaseModel):
|
if isinstance(data, BaseModel):
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
mizan.client - Server function implementation.
|
The server-function surface: the `client` decorator and `ServerFunction` base
|
||||||
|
come from `mizan_core`; execution and dispatch are Django-specific and live in
|
||||||
This subpackage contains everything needed to make server functions work:
|
`mizan.client.executor`.
|
||||||
- The @client decorator (lives in mizan_core.client.function)
|
|
||||||
- ServerFunction base class (mizan_core.client.function)
|
|
||||||
- Function execution logic (.executor — Django-specific dispatch)
|
|
||||||
- JWT authentication (.jwt — Django-specific session integration)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan.client import client, ServerFunction, compose
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Register the Django framework response base so view-path detection works
|
# Registering the Django response base has to happen before any
|
||||||
# in mizan_core.client.function. Has to happen before any @client-decorated
|
# @client-decorated code is evaluated, or view-path detection in
|
||||||
# code is evaluated.
|
# mizan_core.client.function cannot recognize a returned HttpResponse.
|
||||||
from django.http import HttpResponseBase as _HttpResponseBase
|
from django.http import HttpResponseBase as _HttpResponseBase
|
||||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||||
_set_response_base(_HttpResponseBase)
|
_set_response_base(_HttpResponseBase)
|
||||||
@@ -39,7 +32,7 @@ from mizan_core.client.function import (
|
|||||||
create_form_functions,
|
create_form_functions,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .executor import (
|
from mizan.client.executor import (
|
||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
function_call_view,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
mizan Function Executor
|
Dispatch for registered server functions over HTTP.
|
||||||
|
|
||||||
Handles execution of server functions.
|
Input is validated against the function's Pydantic Input before the body ever
|
||||||
This is the core of the "Server Functions" feature - callable from React
|
runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or
|
||||||
without REST boilerplate.
|
an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF;
|
||||||
|
anything else falls through to session auth with CSRF enforced.
|
||||||
Security model:
|
|
||||||
- All input validated against Pydantic schema BEFORE execution
|
|
||||||
- Authentication: JWT (stateless) or Session (stateful) - auto-detected
|
|
||||||
- JWT: Authorization header with Bearer token (no CSRF needed)
|
|
||||||
- Session: Cookie-based with CSRF token (via X-CSRFToken header)
|
|
||||||
- WebSocket RPC uses Origin header checking instead
|
|
||||||
- No implicit function exposure - must be explicitly registered
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,7 +14,7 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
||||||
from django.views.decorators.csrf import csrf_protect
|
from django.views.decorators.csrf import csrf_protect
|
||||||
@@ -31,9 +24,6 @@ from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
|||||||
from mizan_core.registry import get_function, get_context_groups
|
from mizan_core.registry import get_function, get_context_groups
|
||||||
from mizan.setup.settings import get_settings
|
from mizan.setup.settings import get_settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,50 +89,40 @@ def _check_auth_requirement(
|
|||||||
auth_requirement: str | Callable | None,
|
auth_requirement: str | Callable | None,
|
||||||
) -> FunctionError | None:
|
) -> FunctionError | None:
|
||||||
"""
|
"""
|
||||||
Check if the request meets the auth requirement.
|
Test `request` against an auth requirement of 'required', 'staff',
|
||||||
|
'superuser', a callable, or None. Returns a FunctionError on failure.
|
||||||
|
|
||||||
Args:
|
The built-in checks read only flags already on request.user — a JWTUser or
|
||||||
request: The Django HttpRequest (with user set)
|
a session User — so none of them hit the database. A callable may.
|
||||||
auth_requirement: 'required', 'staff', 'superuser', callable, or None
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionError if auth check fails, None if it passes.
|
|
||||||
|
|
||||||
Note: This uses request.user which may be a JWTUser (stateless) or
|
|
||||||
Django User (from session). Either way, no additional DB query is made
|
|
||||||
for the built-in checks. Custom callables may query DB if they choose.
|
|
||||||
"""
|
"""
|
||||||
if auth_requirement is None:
|
if auth_requirement is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Handle callable auth
|
|
||||||
if callable(auth_requirement):
|
if callable(auth_requirement):
|
||||||
try:
|
try:
|
||||||
result = auth_requirement(request)
|
result = auth_requirement(request)
|
||||||
if result:
|
if result:
|
||||||
return None # Authorized
|
return None
|
||||||
else:
|
else:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message="Access denied",
|
message="Access denied",
|
||||||
)
|
)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
# Custom error message from the callable
|
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message=str(e) or "Access denied",
|
message=str(e) or "Access denied",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check authentication (required for all string-based auth)
|
# Every string-based requirement implies authentication.
|
||||||
if not getattr(user, "is_authenticated", False):
|
if not getattr(user, "is_authenticated", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.UNAUTHORIZED,
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
message="Authentication required",
|
message="Authentication required",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check staff requirement
|
|
||||||
if auth_requirement == "staff":
|
if auth_requirement == "staff":
|
||||||
if not getattr(user, "is_staff", False):
|
if not getattr(user, "is_staff", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -150,7 +130,6 @@ def _check_auth_requirement(
|
|||||||
message="Staff access required",
|
message="Staff access required",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check superuser requirement
|
|
||||||
elif auth_requirement == "superuser":
|
elif auth_requirement == "superuser":
|
||||||
if not getattr(user, "is_superuser", False):
|
if not getattr(user, "is_superuser", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -168,7 +147,7 @@ def _purge_cache_for_invalidation(
|
|||||||
invalidate: list,
|
invalidate: list,
|
||||||
request: HttpRequest | None = None,
|
request: HttpRequest | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
|
"""Purge origin-side cache entries for invalidation targets, scoped by user when known."""
|
||||||
cache = get_cache()
|
cache = get_cache()
|
||||||
if cache is None:
|
if cache is None:
|
||||||
return
|
return
|
||||||
@@ -199,34 +178,27 @@ def _purge_cache_for_invalidation(
|
|||||||
|
|
||||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
"""
|
"""
|
||||||
Determine whether an affects target is a context name or function name.
|
Classify an affects target as a context or a function inside one.
|
||||||
|
|
||||||
Returns:
|
|
||||||
("context", "user", None) — full context invalidation
|
("context", "user", None) — full context invalidation
|
||||||
("function", "user_profile", "user") — function within context
|
("function", "user_profile", "user") — function within context
|
||||||
"""
|
"""
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
|
|
||||||
# Check if it's a context name directly
|
|
||||||
if target_name in groups:
|
if target_name in groups:
|
||||||
return ("context", target_name, None)
|
return ("context", target_name, None)
|
||||||
|
|
||||||
# Check if it's a function name within a context
|
|
||||||
for ctx_name, fn_names in groups.items():
|
for ctx_name, fn_names in groups.items():
|
||||||
if target_name in fn_names:
|
if target_name in fn_names:
|
||||||
return ("function", target_name, ctx_name)
|
return ("function", target_name, ctx_name)
|
||||||
|
|
||||||
# Not a context or context function — treat as context name anyway
|
# An unregistered name is treated as a context so invalidation still
|
||||||
# (it might be a non-context function or an as-yet-unregistered context)
|
# propagates rather than being silently dropped.
|
||||||
return ("context", target_name, None)
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
|
||||||
def _get_context_param_names(context_name: str) -> set[str]:
|
def _get_context_param_names(context_name: str) -> set[str]:
|
||||||
"""
|
"""Union of the Input field names across every function in a context."""
|
||||||
Get the set of parameter names used by functions in a context.
|
|
||||||
|
|
||||||
Returns the union of all Input field names across context functions.
|
|
||||||
"""
|
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
fn_names = groups.get(context_name, [])
|
fn_names = groups.get(context_name, [])
|
||||||
param_names: set[str] = set()
|
param_names: set[str] = set()
|
||||||
@@ -247,18 +219,14 @@ def _resolve_invalidation(
|
|||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> list[str | dict[str, Any]] | None:
|
) -> list[str | dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
Resolve invalidation targets with three-tier auto-scoping.
|
Turn a mutation's `affects` metadata into invalidation targets, returning
|
||||||
|
None when there is nothing to invalidate.
|
||||||
|
|
||||||
Tier 1: Argument name matching — if the mutation's input args overlap
|
A target is scoped to specific params when the mutation's input argument
|
||||||
with the context's params by name, auto-scope.
|
names overlap the context's param names; otherwise the whole context is
|
||||||
Tier 2: Auth inference — Edge-side concern, not handled here.
|
invalidated. A function-level target is keyed by the function name.
|
||||||
Tier 3: Broad fallback — invalidate all instances.
|
|
||||||
|
|
||||||
Also handles function-level targeting: affects='user_profile' resolves
|
The returned list serializes into both the JSON body and the header.
|
||||||
to the function name (v1: runtime refetches the whole context anyway).
|
|
||||||
|
|
||||||
Returns a list suitable for both JSON body and header serialization.
|
|
||||||
Returns None if no invalidation needed.
|
|
||||||
"""
|
"""
|
||||||
if view_class is None:
|
if view_class is None:
|
||||||
return None
|
return None
|
||||||
@@ -275,7 +243,6 @@ def _resolve_invalidation(
|
|||||||
if target["type"] == "context":
|
if target["type"] == "context":
|
||||||
target_name = target["name"]
|
target_name = target["name"]
|
||||||
elif target["type"] == "function" and target.get("context"):
|
elif target["type"] == "function" and target.get("context"):
|
||||||
# Function-level: use the function name as the invalidation key
|
|
||||||
target_name = target["name"]
|
target_name = target["name"]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
@@ -284,11 +251,9 @@ def _resolve_invalidation(
|
|||||||
continue
|
continue
|
||||||
seen.add(target_name)
|
seen.add(target_name)
|
||||||
|
|
||||||
# Resolve the context this target belongs to (for param lookup)
|
|
||||||
resolved = _resolve_affects_target(target_name)
|
resolved = _resolve_affects_target(target_name)
|
||||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||||
|
|
||||||
# Tier 1: argument name matching
|
|
||||||
if input_data and ctx_for_params:
|
if input_data and ctx_for_params:
|
||||||
context_params = _get_context_param_names(ctx_for_params)
|
context_params = _get_context_param_names(ctx_for_params)
|
||||||
matched = {
|
matched = {
|
||||||
@@ -299,7 +264,6 @@ def _resolve_invalidation(
|
|||||||
result.append({"context": target_name, "params": matched})
|
result.append({"context": target_name, "params": matched})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Tier 3: broad fallback
|
|
||||||
result.append(target_name)
|
result.append(target_name)
|
||||||
|
|
||||||
return result if result else None
|
return result if result else None
|
||||||
@@ -311,15 +275,12 @@ def _resolve_merges(
|
|||||||
result_data: Any,
|
result_data: Any,
|
||||||
) -> list[dict[str, Any]] | None:
|
) -> list[dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
Resolve merge targets from @client(merge=...).
|
Turn a mutation's `merge` metadata into `{context, slot, value, params?}`
|
||||||
|
entries. `slot` is the function-name inside the context bundle the value
|
||||||
Each entry is `{context, slot, value, params?}` — `slot` is the
|
lands in, resolved here by matching the mutation's declared Output against
|
||||||
function-name inside the context bundle the value lands in, resolved
|
each context-function's Output. Entries whose slot is ambiguous are
|
||||||
server-side by matching the mutation's return type against each
|
dropped, and params are scoped the same way `_resolve_invalidation` scopes
|
||||||
context-function's return type. Kernel does no shape inference.
|
them.
|
||||||
|
|
||||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
|
||||||
Entries whose slot can't be uniquely resolved are dropped.
|
|
||||||
"""
|
"""
|
||||||
if view_class is None:
|
if view_class is None:
|
||||||
return None
|
return None
|
||||||
@@ -358,7 +319,7 @@ def _resolve_merges(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
"""Find the one function in `context_name` whose Output matches the mutation's, if unique."""
|
||||||
if mutation_output is None:
|
if mutation_output is None:
|
||||||
return None
|
return None
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
@@ -378,12 +339,11 @@ def _format_invalidate_header(
|
|||||||
invalidate: list[str | dict[str, Any]],
|
invalidate: list[str | dict[str, Any]],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
Format invalidation targets as the X-Mizan-Invalidate header value:
|
||||||
|
comma-separated contexts, each optionally followed by semicolon-separated
|
||||||
|
`key=value` params. Keys and values are URL-encoded so a param can never
|
||||||
|
contain a delimiter.
|
||||||
|
|
||||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
|
||||||
Param values are URL-encoded to prevent delimiter collisions.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
["user"] → "user"
|
["user"] → "user"
|
||||||
["user", "notifications"] → "user, notifications"
|
["user", "notifications"] → "user, notifications"
|
||||||
[{"context": "user", "params": {"user_id": 5}}]
|
[{"context": "user", "params": {"user_id": 5}}]
|
||||||
@@ -417,22 +377,17 @@ def execute_function(
|
|||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||||
"""
|
"""
|
||||||
Execute a registered server function.
|
Look up, authorize, validate, and run a registered server function.
|
||||||
|
|
||||||
Args:
|
Returns the function's HttpResponse untouched when it returned one,
|
||||||
request: The Django HttpRequest
|
otherwise a FunctionResult or FunctionError.
|
||||||
fn_name: Name of the registered function
|
|
||||||
input_data: Input data to pass to the function
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionResult on success, FunctionError on failure
|
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
# Look up the function by name
|
|
||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
if view_class is None:
|
if view_class is None:
|
||||||
# In DEBUG mode, include the name for easier debugging
|
# Naming the missing function is a debugging aid, not something to
|
||||||
|
# hand an unauthenticated caller in production.
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
message = f"Function '{fn_name}' not found"
|
message = f"Function '{fn_name}' not found"
|
||||||
else:
|
else:
|
||||||
@@ -442,7 +397,6 @@ def execute_function(
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reject private functions from RPC dispatch
|
|
||||||
meta = getattr(view_class, "_meta", {})
|
meta = getattr(view_class, "_meta", {})
|
||||||
if meta.get("private"):
|
if meta.get("private"):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -450,36 +404,28 @@ def execute_function(
|
|||||||
message="Function is not client-callable",
|
message="Function is not client-callable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check auth requirement BEFORE executing
|
# Auth is checked before the function body ever runs.
|
||||||
auth_requirement = meta.get("auth")
|
auth_requirement = meta.get("auth")
|
||||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||||
if auth_error is not None:
|
if auth_error is not None:
|
||||||
return auth_error
|
return auth_error
|
||||||
|
|
||||||
# Instantiate the view with the request
|
|
||||||
view = view_class(request)
|
view = view_class(request)
|
||||||
|
|
||||||
# Check if this is a form function that handles input specially
|
|
||||||
meta = getattr(view_class, "_meta", {})
|
|
||||||
is_form_multipart = meta.get("multipart", False)
|
is_form_multipart = meta.get("multipart", False)
|
||||||
|
|
||||||
# For form functions with Input=None, skip Pydantic validation
|
|
||||||
# The form itself handles validation
|
|
||||||
input_cls = view.Input
|
input_cls = view.Input
|
||||||
if input_cls is None and is_form_multipart:
|
if input_cls is None and is_form_multipart:
|
||||||
# Form function - pass input_data directly (already parsed by view or will be)
|
# Form functions carry Input=None; the Django form owns validation.
|
||||||
validated_input = input_data
|
validated_input = input_data
|
||||||
elif input_cls is BaseModel:
|
elif input_cls is BaseModel:
|
||||||
has_input = False
|
has_input = False
|
||||||
validated_input = None
|
validated_input = None
|
||||||
else:
|
else:
|
||||||
# Check if it has any fields defined
|
|
||||||
has_input = bool(input_cls.model_fields) if input_cls else False
|
has_input = bool(input_cls.model_fields) if input_cls else False
|
||||||
|
|
||||||
# Validate input against Pydantic schema
|
|
||||||
try:
|
try:
|
||||||
if input_data:
|
if input_data:
|
||||||
# Ensure input_data is a dict (not array or other type)
|
|
||||||
if not isinstance(input_data, dict):
|
if not isinstance(input_data, dict):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.BAD_REQUEST,
|
code=ErrorCode.BAD_REQUEST,
|
||||||
@@ -488,11 +434,11 @@ def execute_function(
|
|||||||
)
|
)
|
||||||
validated_input = input_cls(**input_data)
|
validated_input = input_cls(**input_data)
|
||||||
elif has_input:
|
elif has_input:
|
||||||
# Check if function requires input fields
|
|
||||||
input_schema = input_cls.model_json_schema()
|
input_schema = input_cls.model_json_schema()
|
||||||
required_fields = input_schema.get("required", [])
|
required_fields = input_schema.get("required", [])
|
||||||
if required_fields:
|
if required_fields:
|
||||||
# Format as field errors for consistency
|
# Shaped like Pydantic's own field errors so the client
|
||||||
|
# has one error format to handle.
|
||||||
errors = {field: ["Field required"] for field in required_fields}
|
errors = {field: ["Field required"] for field in required_fields}
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.VALIDATION_ERROR,
|
code=ErrorCode.VALIDATION_ERROR,
|
||||||
@@ -501,10 +447,8 @@ def execute_function(
|
|||||||
)
|
)
|
||||||
validated_input = input_cls()
|
validated_input = input_cls()
|
||||||
else:
|
else:
|
||||||
# No input expected, create empty model
|
|
||||||
validated_input = None
|
validated_input = None
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
# Convert Pydantic errors to our format
|
|
||||||
errors = {}
|
errors = {}
|
||||||
for error in e.errors():
|
for error in e.errors():
|
||||||
field = ".".join(str(loc) for loc in error["loc"])
|
field = ".".join(str(loc) for loc in error["loc"])
|
||||||
@@ -518,7 +462,6 @@ def execute_function(
|
|||||||
details={"fields": errors},
|
details={"fields": errors},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute the function
|
|
||||||
try:
|
try:
|
||||||
output = view.call(validated_input)
|
output = view.call(validated_input)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
@@ -528,28 +471,24 @@ def execute_function(
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
# Functions can raise PermissionError for auth issues
|
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message=str(e) or "Permission denied",
|
message=str(e) or "Permission denied",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the full exception for debugging
|
|
||||||
logger.exception(f"Error executing function {fn_name}")
|
logger.exception(f"Error executing function {fn_name}")
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.INTERNAL_ERROR,
|
code=ErrorCode.INTERNAL_ERROR,
|
||||||
message="An internal error occurred",
|
message="An internal error occurred",
|
||||||
# Don't expose internal details in production
|
# Internals are only named when debug logging is already on.
|
||||||
details={"type": type(e).__name__}
|
details={"type": type(e).__name__}
|
||||||
if logger.isEnabledFor(logging.DEBUG)
|
if logger.isEnabledFor(logging.DEBUG)
|
||||||
else None,
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
|
||||||
from django.http import HttpResponseBase
|
from django.http import HttpResponseBase
|
||||||
|
|
||||||
if isinstance(output, HttpResponseBase):
|
if isinstance(output, HttpResponseBase):
|
||||||
# View path — add invalidation header + purge origin cache
|
|
||||||
invalidate = _resolve_invalidation(view_class, input_data)
|
invalidate = _resolve_invalidation(view_class, input_data)
|
||||||
if invalidate:
|
if invalidate:
|
||||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||||
@@ -557,9 +496,8 @@ def execute_function(
|
|||||||
output["Cache-Control"] = "no-store"
|
output["Cache-Control"] = "no-store"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
# to_jsonable_python walks BaseModel / list / dict recursively, so nested
|
||||||
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
# shapes need no per-shape branch here.
|
||||||
# out wire-ready without a per-shape branch.
|
|
||||||
from pydantic_core import to_jsonable_python
|
from pydantic_core import to_jsonable_python
|
||||||
|
|
||||||
return FunctionResult(data=to_jsonable_python(output))
|
return FunctionResult(data=to_jsonable_python(output))
|
||||||
@@ -567,10 +505,9 @@ def execute_function(
|
|||||||
|
|
||||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempt to authenticate the request using MWT (Mizan Web Token).
|
Authenticate from the X-Mizan-Token header, setting request.user to an
|
||||||
|
MWTUser on success. False means no header, no configured secret, or a
|
||||||
Checks the X-Mizan-Token header. If present and valid, sets request.user
|
token that did not verify.
|
||||||
to an MWTUser. Returns True on success, False if no MWT header or invalid.
|
|
||||||
"""
|
"""
|
||||||
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
@@ -607,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool:
|
|||||||
|
|
||||||
def _try_jwt_auth(request: HttpRequest) -> bool:
|
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempt to authenticate the request using JWT.
|
Authenticate from an Authorization Bearer token, setting request.user to a
|
||||||
|
JWTUser built from the claims — no database query. False means no bearer
|
||||||
If Authorization header contains a valid Bearer token, authenticates
|
header or a token that did not verify; the caller must then reject rather
|
||||||
the request and sets request.user to a JWTUser. Returns True if JWT
|
than fall back to session auth.
|
||||||
auth succeeded.
|
|
||||||
|
|
||||||
IMPORTANT: This is stateless - no database query is made. The JWTUser
|
|
||||||
object is created from the token claims. If you need the full User
|
|
||||||
object, query it explicitly in your function.
|
|
||||||
|
|
||||||
Security: If JWT is provided but invalid, we return False and do NOT
|
|
||||||
fall back to session auth. The caller should reject the request.
|
|
||||||
"""
|
"""
|
||||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
if not auth_header.startswith("Bearer "):
|
if not auth_header.startswith("Bearer "):
|
||||||
@@ -636,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
|||||||
if payload is None:
|
if payload is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
request.user = JWTUser(payload)
|
request.user = JWTUser(payload)
|
||||||
request._mizan_jwt_authenticated = True
|
request._mizan_jwt_authenticated = True
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logging.getLogger("mizan.jwt").warning(
|
||||||
|
"JWT authentication failed unexpectedly", exc_info=True
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -652,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
|||||||
|
|
||||||
def _csrf_protect_unless_token(view_func):
|
def _csrf_protect_unless_token(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator that applies CSRF protection unless token auth is used.
|
Wrap a view so CSRF applies only on the session path. MWT is checked
|
||||||
|
first, then JWT; both are self-authenticating. A token that is present but
|
||||||
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
invalid rejects the request outright rather than falling back to session
|
||||||
Both are self-authenticating, so CSRF protection is not needed.
|
auth.
|
||||||
|
|
||||||
Security: If a token is provided but invalid, reject the request - do NOT
|
|
||||||
fall back to session auth.
|
|
||||||
"""
|
"""
|
||||||
csrf_protected_view = csrf_protect(view_func)
|
csrf_protected_view = csrf_protect(view_func)
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
# MWT takes priority
|
|
||||||
if _has_mwt_header(request):
|
if _has_mwt_header(request):
|
||||||
if _try_mwt_auth(request):
|
if _try_mwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -673,7 +600,6 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
message="Invalid or expired MWT",
|
message="Invalid or expired MWT",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# Legacy JWT fallback
|
|
||||||
if _has_jwt_header(request):
|
if _has_jwt_header(request):
|
||||||
if _try_jwt_auth(request):
|
if _try_jwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -682,7 +608,6 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
message="Invalid or expired JWT token",
|
message="Invalid or expired JWT token",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# No token — session auth with CSRF
|
|
||||||
return csrf_protected_view(request, *args, **kwargs)
|
return csrf_protected_view(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -691,52 +616,25 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
@_csrf_protect_unless_token
|
@_csrf_protect_unless_token
|
||||||
def function_call_view(request: HttpRequest) -> JsonResponse:
|
def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||||
"""
|
"""
|
||||||
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
POST endpoint for server-function calls.
|
||||||
|
|
||||||
Authentication (auto-detected):
|
A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries
|
||||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
`fn` as a form field alongside the form's own fields, and its parsed data
|
||||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
and files are attached to the request for the form function to pick up.
|
||||||
|
|
||||||
Endpoint: POST /api/mizan/call/
|
Success answers `{"result": ...}`, plus `invalidate` / `merge` when the
|
||||||
|
function declared them; failure answers the FunctionError shape.
|
||||||
Request body (JSON):
|
|
||||||
{
|
|
||||||
"fn": "function_name", // Function name
|
|
||||||
"args": { ... } // Optional, depending on function
|
|
||||||
}
|
|
||||||
|
|
||||||
Request body (multipart/form-data for form submit functions):
|
|
||||||
fn: function_name
|
|
||||||
<field>: <value>
|
|
||||||
...
|
|
||||||
|
|
||||||
Response on success:
|
|
||||||
{
|
|
||||||
"error": false,
|
|
||||||
"data": { ... } // Function output
|
|
||||||
}
|
|
||||||
|
|
||||||
Response on error:
|
|
||||||
{
|
|
||||||
"error": true,
|
|
||||||
"code": "VALIDATION_ERROR",
|
|
||||||
"message": "Input validation failed",
|
|
||||||
"details": { ... }
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
# Only allow POST
|
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.BAD_REQUEST,
|
code=ErrorCode.BAD_REQUEST,
|
||||||
message="Only POST method allowed",
|
message="Only POST method allowed",
|
||||||
).to_response(status=405)
|
).to_response(status=405)
|
||||||
|
|
||||||
# Check content type to determine parsing method
|
|
||||||
content_type = request.content_type or ""
|
content_type = request.content_type or ""
|
||||||
is_multipart = content_type.startswith("multipart/form-data")
|
is_multipart = content_type.startswith("multipart/form-data")
|
||||||
|
|
||||||
if is_multipart:
|
if is_multipart:
|
||||||
# Multipart form data - used by form submit functions
|
|
||||||
fn_name = request.POST.get("fn")
|
fn_name = request.POST.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -744,15 +642,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Missing 'fn' field",
|
message="Missing 'fn' field",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
# Get form data (excluding 'fn')
|
|
||||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||||
|
|
||||||
# Attach parsed form data and files to request for form functions
|
|
||||||
request._mizan_form_data = input_data
|
request._mizan_form_data = input_data
|
||||||
request._mizan_form_files = request.FILES
|
request._mizan_form_files = request.FILES
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON body - standard RPC
|
|
||||||
try:
|
try:
|
||||||
if request.body:
|
if request.body:
|
||||||
body = json.loads(request.body)
|
body = json.loads(request.body)
|
||||||
@@ -767,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Invalid JSON in request body",
|
message="Invalid JSON in request body",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
# Extract function name and args
|
|
||||||
fn_name = body.get("fn")
|
fn_name = body.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -777,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
input_data = body.get("args")
|
input_data = body.get("args")
|
||||||
|
|
||||||
# Execute the function
|
|
||||||
result = execute_function(request, fn_name, input_data)
|
result = execute_function(request, fn_name, input_data)
|
||||||
|
|
||||||
# View path — function returned an HttpResponse directly
|
# The function returned an HttpResponse directly.
|
||||||
from django.http import HttpResponseBase
|
from django.http import HttpResponseBase
|
||||||
if isinstance(result, HttpResponseBase):
|
if isinstance(result, HttpResponseBase):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Return appropriate response
|
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
status = {
|
status = {
|
||||||
ErrorCode.NOT_FOUND: 404,
|
ErrorCode.NOT_FOUND: 404,
|
||||||
@@ -798,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
}.get(result.code, 400)
|
}.get(result.code, 400)
|
||||||
return result.to_response(status=status)
|
return result.to_response(status=status)
|
||||||
|
|
||||||
# RPC path — build response with server-driven invalidation
|
|
||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
response_data = {"result": result.data}
|
response_data = {"result": result.data}
|
||||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||||
@@ -825,18 +716,8 @@ def execute_context(
|
|||||||
params: dict[str, str],
|
params: dict[str, str],
|
||||||
) -> FunctionResult | FunctionError:
|
) -> FunctionResult | FunctionError:
|
||||||
"""
|
"""
|
||||||
Execute all functions in a named context with merged params.
|
Run every function in a named context, handing each only the params it
|
||||||
|
declares in its Input schema. The first failure aborts the whole bundle.
|
||||||
Each function receives only the params it declares in its Input schema.
|
|
||||||
If any function fails (auth, validation, execution), the entire request fails.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django HttpRequest
|
|
||||||
context_name: Name of the context (e.g., 'user', 'global')
|
|
||||||
params: Query parameters (strings — Pydantic coerces types)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionResult with bundled data, or FunctionError
|
|
||||||
"""
|
"""
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
fn_names = groups.get(context_name)
|
fn_names = groups.get(context_name)
|
||||||
@@ -852,7 +733,6 @@ def execute_context(
|
|||||||
if view_class is None:
|
if view_class is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter params to only those in this function's Input schema
|
|
||||||
input_cls = getattr(view_class, "Input", None)
|
input_cls = getattr(view_class, "Input", None)
|
||||||
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||||
fn_params = {
|
fn_params = {
|
||||||
@@ -871,13 +751,9 @@ def execute_context(
|
|||||||
|
|
||||||
|
|
||||||
def _jwt_auth_only(view_func):
|
def _jwt_auth_only(view_func):
|
||||||
"""
|
"""Token auth for GET views: MWT first, then JWT. GET needs no CSRF."""
|
||||||
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
|
|
||||||
Checks MWT first, then legacy JWT.
|
|
||||||
"""
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
# MWT takes priority
|
|
||||||
if _has_mwt_header(request):
|
if _has_mwt_header(request):
|
||||||
if _try_mwt_auth(request):
|
if _try_mwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -886,7 +762,6 @@ def _jwt_auth_only(view_func):
|
|||||||
message="Invalid or expired MWT",
|
message="Invalid or expired MWT",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# Legacy JWT fallback
|
|
||||||
if _has_jwt_header(request):
|
if _has_jwt_header(request):
|
||||||
if _try_jwt_auth(request):
|
if _try_jwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -895,7 +770,6 @@ def _jwt_auth_only(view_func):
|
|||||||
message="Invalid or expired JWT token",
|
message="Invalid or expired JWT token",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# No token — session auth (no CSRF needed for GET)
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -904,18 +778,12 @@ def _jwt_auth_only(view_func):
|
|||||||
@_jwt_auth_only
|
@_jwt_auth_only
|
||||||
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||||
"""
|
"""
|
||||||
Fetch all functions in a named context in a single bundled GET request.
|
GET endpoint answering every function in a named context as one bundle
|
||||||
|
keyed by function name, with query params fanned out to each.
|
||||||
|
|
||||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
The context's effective cache policy and revision are the strictest across
|
||||||
|
its functions: any function declaring cache=False disables caching for the
|
||||||
Response: raw bundled data, CDN-cacheable.
|
whole bundle, and the shortest declared TTL wins.
|
||||||
{
|
|
||||||
"user_profile": { ... },
|
|
||||||
"user_orders": [ ... ]
|
|
||||||
}
|
|
||||||
|
|
||||||
Headers:
|
|
||||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
|
||||||
"""
|
"""
|
||||||
if request.method != "GET":
|
if request.method != "GET":
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -925,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
|
|
||||||
params = request.GET.dict()
|
params = request.GET.dict()
|
||||||
|
|
||||||
# Resolve effective rev and cache policy across all functions in this context
|
|
||||||
_cache_log = logging.getLogger("mizan.cache")
|
_cache_log = logging.getLogger("mizan.cache")
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
fn_names = groups.get(context_name, [])
|
fn_names = groups.get(context_name, [])
|
||||||
@@ -947,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
else:
|
else:
|
||||||
effective_cache = min(effective_cache, fn_cache)
|
effective_cache = min(effective_cache, fn_cache)
|
||||||
|
|
||||||
# Origin-side cache lookup (skip if cache=False)
|
|
||||||
cache_backend = get_cache()
|
cache_backend = get_cache()
|
||||||
cache_settings = get_settings()
|
cache_settings = get_settings()
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -988,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
error_response["Cache-Control"] = "no-store"
|
error_response["Cache-Control"] = "no-store"
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
# Sorted keys keep the serialized body byte-identical for a given result,
|
||||||
|
# which is what makes it usable as a cache entry.
|
||||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||||
|
|
||||||
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
# Caching happens in the origin cache below and at the edge, both of which
|
||||||
# The browser and non-Mizan intermediaries must not cache.
|
# can be purged; a browser cache cannot, so it must not hold this.
|
||||||
response["Cache-Control"] = "no-store"
|
response["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
# Store in origin-side cache (skip if cache=False)
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
try:
|
try:
|
||||||
cache_put(
|
cache_put(
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
mizan.client.jwt - JWT authentication for server functions.
|
Token and settings names from `mizan.jwt`, re-exported under `mizan.client`
|
||||||
|
for the executor and the WebSocket consumer. The Ninja auth class is
|
||||||
Provides:
|
deliberately absent here — reach for `mizan.jwt.security` for that.
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
|
||||||
- JWT authentication utilities for validating tokens
|
|
||||||
|
|
||||||
Server Functions:
|
|
||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
|
||||||
|
|
||||||
Note: This module is purpose-built for mizan server functions.
|
|
||||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Token utilities (re-exports from django_jwt_session)
|
|
||||||
from mizan.jwt.tokens import (
|
from mizan.jwt.tokens import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
@@ -25,7 +15,6 @@ from mizan.jwt.tokens import (
|
|||||||
JWTUser,
|
JWTUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
|
||||||
from mizan.jwt.settings import get_settings, JWTSettings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Mizan Edge Manifest Generator.
|
Builds the Edge manifest: a static JSON document mapping each context to its
|
||||||
|
API endpoint, page routes, and parameter names.
|
||||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
|
||||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
|
||||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
|
||||||
codegen, the manifest drives CDN purging.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan_core.registry import get_context_groups, get_registry
|
from mizan_core.registry import get_context_groups, get_registry
|
||||||
@@ -30,20 +22,18 @@ def generate_edge_manifest(
|
|||||||
view_urls: dict[str, list[str]] | None = None,
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
Build the manifest dict.
|
||||||
patterns and params for CDN cache purging.
|
|
||||||
|
|
||||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
Each context entry carries the union of its functions' Input field names,
|
||||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
its API endpoint under `base_url`, any page routes declared via
|
||||||
1. Looks up 'user' in the manifest
|
`@client(route=...)`, and a render strategy derived from whether any
|
||||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
parameter is user-scoped. Each mutation entry carries the contexts it
|
||||||
3. Purges the resolved URLs + the context API endpoint
|
affects and the parameter names shared with those contexts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_url: The Mizan API mount point (default: /api/mizan)
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
view_urls: Optional mapping of context names to URL patterns for
|
view_urls: Extra page routes per context name, merged with the ones
|
||||||
view-path functions. These are URLs that Edge should
|
read off `@client(route=...)`.
|
||||||
also purge when a context is invalidated.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Manifest dict suitable for JSON serialization.
|
Manifest dict suitable for JSON serialization.
|
||||||
|
|||||||
@@ -1,153 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
mizanFormMixin - Turn Django Forms into server functions.
|
mizanFormMixin exposes a Django Form as the server functions
|
||||||
|
`<name>.schema`, `<name>.validate`, and `<name>.submit`, registered from
|
||||||
This mixin transforms any Django Form into mizan server functions,
|
`__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute.
|
||||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
|
||||||
while exposing them through the unified server function API.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from django import forms
|
|
||||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
|
||||||
|
|
||||||
class ContactForm(mizanFormMixin, forms.Form):
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="contact",
|
|
||||||
title="Contact Us",
|
|
||||||
submit_label="Send",
|
|
||||||
)
|
|
||||||
|
|
||||||
name = forms.CharField()
|
|
||||||
email = forms.EmailField()
|
|
||||||
message = forms.CharField(widget=forms.Textarea)
|
|
||||||
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
send_email(self.cleaned_data)
|
|
||||||
return {"sent": True}
|
|
||||||
|
|
||||||
Auto-registers server functions:
|
|
||||||
- contact.schema
|
|
||||||
- contact.validate
|
|
||||||
- contact.submit
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
import inspect
|
||||||
|
import logging
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel, create_model
|
from pydantic import BaseModel, create_model
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
logger = logging.getLogger("mizan.forms")
|
||||||
from .schemas import FormValidation
|
|
||||||
|
|
||||||
|
|
||||||
def _django_field_to_python_type(field: forms.Field) -> type:
|
|
||||||
"""
|
|
||||||
Map a Django form field to a Python type for Pydantic schema generation.
|
|
||||||
|
|
||||||
This provides TypeScript with proper field types instead of generic `any`.
|
|
||||||
"""
|
|
||||||
# Handle common Django field types
|
|
||||||
if isinstance(field, forms.BooleanField):
|
|
||||||
return bool
|
|
||||||
elif isinstance(field, forms.IntegerField):
|
|
||||||
return int
|
|
||||||
elif isinstance(field, forms.FloatField):
|
|
||||||
return float
|
|
||||||
elif isinstance(field, forms.DecimalField):
|
|
||||||
return str # Decimals serialize as strings for precision
|
|
||||||
elif isinstance(field, forms.DateTimeField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.DateField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.TimeField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.JSONField):
|
|
||||||
return dict | list | str | int | float | bool | None
|
|
||||||
elif isinstance(field, forms.MultipleChoiceField):
|
|
||||||
return list[str]
|
|
||||||
elif isinstance(field, forms.FileField):
|
|
||||||
return str # File path/name as string
|
|
||||||
elif isinstance(field, forms.ImageField):
|
|
||||||
return str # File path/name as string
|
|
||||||
else:
|
|
||||||
# Default to string (covers CharField, EmailField, URLField, etc.)
|
|
||||||
return str
|
|
||||||
|
|
||||||
|
|
||||||
def _create_form_input_schema(
|
|
||||||
form_class: type[forms.BaseForm],
|
|
||||||
schema_name: str,
|
|
||||||
) -> type[BaseModel]:
|
|
||||||
"""
|
|
||||||
Create a Pydantic model from Django Form fields.
|
|
||||||
|
|
||||||
This generates a typed schema for the form's input data, giving TypeScript
|
|
||||||
full LSP support (autocomplete, type checking) for form fields.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
form_class: Django Form class to introspect
|
|
||||||
schema_name: Name for the generated Pydantic model (e.g., "ContactFormData")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A Pydantic BaseModel subclass with fields matching the form
|
|
||||||
"""
|
|
||||||
# Instantiate form without data to get field definitions
|
|
||||||
try:
|
|
||||||
form = form_class()
|
|
||||||
except TypeError:
|
|
||||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
|
||||||
fields_dict = getattr(form_class, "base_fields", {})
|
|
||||||
else:
|
|
||||||
fields_dict = form.fields
|
|
||||||
|
|
||||||
# Build Pydantic field definitions
|
|
||||||
pydantic_fields: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for field_name, field in fields_dict.items():
|
|
||||||
python_type = _django_field_to_python_type(field)
|
|
||||||
|
|
||||||
# Optional fields (not required or has initial value)
|
|
||||||
if not field.required:
|
|
||||||
python_type = python_type | None
|
|
||||||
default = None
|
|
||||||
elif field.initial is not None:
|
|
||||||
default = field.initial
|
|
||||||
else:
|
|
||||||
default = ... # Required field
|
|
||||||
|
|
||||||
pydantic_fields[field_name] = (python_type, default)
|
|
||||||
|
|
||||||
# Create the model with a unique name
|
|
||||||
model = create_model(schema_name, **pydantic_fields)
|
|
||||||
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
class mizanFormMeta(BaseModel):
|
class mizanFormMeta(BaseModel):
|
||||||
"""
|
"""
|
||||||
Configuration for a mizan form.
|
Per-form configuration. `name` is the API identifier the three registered
|
||||||
|
function names are built from; the rest are carried into the emitted schema.
|
||||||
This Pydantic model provides type-safe configuration with full LSP support,
|
|
||||||
and serializes to JSON for the frontend schema.
|
|
||||||
|
|
||||||
Required:
|
|
||||||
name: API identifier (e.g., "contact" → contact.schema, contact.validate, contact.submit)
|
|
||||||
|
|
||||||
Display options:
|
|
||||||
title: Display title (default: derived from class name)
|
|
||||||
subtitle: Display subtitle
|
|
||||||
submit_label: Submit button text (default: "Submit")
|
|
||||||
|
|
||||||
Frontend behavior:
|
|
||||||
live_validation: Enable live validation as user types (default: True)
|
|
||||||
live_form_errors: Show form-level errors during live validation (default: False)
|
|
||||||
refetch_schema_on_validate: Refetch schema on each validation - useful for
|
|
||||||
dynamic choice fields (default: False)
|
|
||||||
|
|
||||||
Features:
|
|
||||||
enable_formset: Generate formset endpoints (default: False)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Required
|
# Required
|
||||||
@@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel):
|
|||||||
|
|
||||||
class mizanFormMixin:
|
class mizanFormMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that exposes a Django Form as mizan server functions.
|
Mix into a Django Form alongside a `mizan = mizanFormMeta(...)` attribute to
|
||||||
|
register `<name>.schema`, `<name>.validate`, and `<name>.submit`.
|
||||||
|
|
||||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
`get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three
|
||||||
|
override points. Each is called unconditionally, so the definitions here are
|
||||||
class ContactForm(mizanFormMixin, forms.Form):
|
what a form that overrides none of them does.
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="contact",
|
|
||||||
title="Contact Us",
|
|
||||||
)
|
|
||||||
|
|
||||||
name = forms.CharField()
|
|
||||||
email = forms.EmailField()
|
|
||||||
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
return {"sent": True}
|
|
||||||
|
|
||||||
This auto-registers:
|
|
||||||
- contact.schema - Get form field definitions
|
|
||||||
- contact.validate - Validate form data
|
|
||||||
- contact.submit - Submit form
|
|
||||||
|
|
||||||
Overridable methods:
|
|
||||||
get_init_kwargs(cls, request) -> dict: Extra kwargs for form instantiation
|
|
||||||
on_submit_success(self, request) -> dict | None: Handle successful submission
|
|
||||||
on_submit_failure(self, request, errors) -> None: Handle failed submission
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Configuration - subclasses must define this
|
# Configuration - subclasses must define this
|
||||||
mizan: ClassVar[mizanFormMeta]
|
mizan: ClassVar[mizanFormMeta]
|
||||||
|
|
||||||
# Track registered forms to avoid duplicate registration
|
# Set on registration so a re-import does not register the class twice
|
||||||
_mizan_registered: ClassVar[bool] = False
|
_mizan_registered: ClassVar[bool] = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Override to provide extra kwargs for form instantiation.
|
Kwargs merged into every instantiation of this form. `request` is passed
|
||||||
|
through to a form whose `__init__` names it; Django's own `BaseForm`
|
||||||
Common use: pass request or user to forms that need them.
|
signature does not, and rejects any keyword it did not declare, so a
|
||||||
|
form that never asks for the request is constructed on data/files alone.
|
||||||
Example:
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request):
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
"""
|
"""
|
||||||
|
accepted = inspect.signature(cls.__init__).parameters
|
||||||
|
if "request" in accepted:
|
||||||
|
return {"request": request}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Called after successful form validation and submission.
|
Handle a validated submission. A returned dict is carried in the
|
||||||
|
response payload; a ModelForm's `save()` returns a model instance, which
|
||||||
Override to handle the form submission logic.
|
is not payload, so only a dict result is forwarded.
|
||||||
Return a dict to include data in the response.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
self.save()
|
|
||||||
return {"id": self.instance.pk}
|
|
||||||
"""
|
"""
|
||||||
# Default: call save() if available
|
|
||||||
if hasattr(self, "save"):
|
if hasattr(self, "save"):
|
||||||
result = self.save()
|
result = self.save()
|
||||||
# If save returns something serializable, include it
|
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
return result
|
return result
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
def on_submit_failure(self, request: HttpRequest, errors: Any) -> None:
|
||||||
"""
|
"""
|
||||||
Called after form validation fails.
|
Handle a rejected submission. The per-field errors already travel to the
|
||||||
|
client in the response body, so the rejection is recorded server-side
|
||||||
Override to add custom error handling, logging, etc.
|
rather than re-raised.
|
||||||
"""
|
"""
|
||||||
pass
|
logger.info(
|
||||||
|
"%s rejected a submission on %s: %s",
|
||||||
|
type(self).__name__,
|
||||||
|
getattr(request, "path", "<no path>"),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
def __init_subclass__(cls, **kwargs):
|
||||||
"""Auto-register when a concrete form class is defined."""
|
"""Auto-register when a concrete form class is defined."""
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
# Only register concrete forms with mizan config defined
|
|
||||||
if _is_concrete_mizan_form(cls):
|
if _is_concrete_mizan_form(cls):
|
||||||
_register_form_as_server_functions(cls)
|
_register_form_as_server_functions(cls)
|
||||||
|
|
||||||
|
|
||||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a class is a concrete mizan form ready for registration.
|
True when `cls` carries its own mizanFormMeta, is a Django form, and has
|
||||||
|
not already been registered.
|
||||||
A form is concrete if:
|
|
||||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
|
||||||
2. It inherits from Django's BaseForm
|
|
||||||
3. It hasn't been registered yet (for this class definition)
|
|
||||||
"""
|
"""
|
||||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
# Read cls.__dict__ so an inherited config does not re-register.
|
||||||
mizan_config = cls.__dict__.get("mizan")
|
mizan_config = cls.__dict__.get("mizan")
|
||||||
if not isinstance(mizan_config, mizanFormMeta):
|
if not isinstance(mizan_config, mizanFormMeta):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Must be a Django form
|
|
||||||
if not issubclass(cls, forms.BaseForm):
|
if not issubclass(cls, forms.BaseForm):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if already registered (handle re-imports gracefully)
|
|
||||||
if cls.__dict__.get("_mizan_registered", False):
|
if cls.__dict__.get("_mizan_registered", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -280,50 +123,36 @@ def _is_concrete_mizan_form(cls: type) -> bool:
|
|||||||
|
|
||||||
def _register_form_as_server_functions(form_class: type) -> None:
|
def _register_form_as_server_functions(form_class: type) -> None:
|
||||||
"""
|
"""
|
||||||
Register a Django Form class as mizan server functions.
|
Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for
|
||||||
|
`form_class`, plus the formset trio when `enable_formset` is set.
|
||||||
Creates and registers:
|
|
||||||
- {name}.schema - Returns form field definitions
|
|
||||||
- {name}.validate - Validates form data
|
|
||||||
- {name}.submit - Validates and submits form
|
|
||||||
|
|
||||||
Each function gets a unique typed schema for better TypeScript LSP support.
|
|
||||||
"""
|
"""
|
||||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
from mizan.forms.schemas import (
|
||||||
from .schema_utils import build_form_schema
|
FormSchema,
|
||||||
from .validation_utils import validate_form_instance
|
FormSubmitFail,
|
||||||
|
FormSubmitPass,
|
||||||
|
FormValidation,
|
||||||
|
)
|
||||||
|
from mizan.forms.schema_utils import build_form_schema
|
||||||
|
from mizan.forms.validation_utils import validate_form_instance
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
config: mizanFormMeta = form_class.mizan
|
config: mizanFormMeta = form_class.mizan
|
||||||
form_name = config.name
|
form_name = config.name
|
||||||
|
|
||||||
# Mark as registered
|
|
||||||
form_class._mizan_registered = True
|
form_class._mizan_registered = True
|
||||||
|
|
||||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
# "contact" -> "Contact", "reset_password" -> "ResetPassword"
|
||||||
pascal_name = "".join(
|
pascal_name = "".join(
|
||||||
word.capitalize()
|
word.capitalize()
|
||||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
|
||||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
|
||||||
_form_data_schema_cache: dict[str, type[BaseModel]] = {}
|
|
||||||
|
|
||||||
def get_form_data_schema() -> type[BaseModel]:
|
|
||||||
"""Lazily create the form data schema (form fields aren't available at registration time)."""
|
|
||||||
if "schema" not in _form_data_schema_cache:
|
|
||||||
_form_data_schema_cache["schema"] = _create_form_input_schema(
|
|
||||||
form_class, f"{pascal_name}FormData"
|
|
||||||
)
|
|
||||||
return _form_data_schema_cache["schema"]
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Schema Function
|
# Schema Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Schema input wraps the form data for pre-populating dynamic fields
|
# `data` pre-populates dynamic fields before the schema is read off the form.
|
||||||
FormSchemaInput = create_model(
|
FormSchemaInput = create_model(
|
||||||
f"{pascal_name}SchemaInput",
|
f"{pascal_name}SchemaInput",
|
||||||
data=(dict[str, Any], {}),
|
data=(dict[str, Any], {}),
|
||||||
@@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
"form": True,
|
"form": True,
|
||||||
"form_name": form_name,
|
"form_name": form_name,
|
||||||
"form_role": "schema",
|
"form_role": "schema",
|
||||||
"form_class": form_class, # Store reference for schema generation
|
"form_class": form_class,
|
||||||
}
|
}
|
||||||
|
|
||||||
def call(self, input) -> FormSchema:
|
def call(self, input) -> FormSchema:
|
||||||
@@ -347,13 +176,12 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
data=input.data if input else {},
|
data=input.data if input else {},
|
||||||
**init_kwargs,
|
**init_kwargs,
|
||||||
)
|
)
|
||||||
# Override with mizanFormMeta values
|
# mizanFormMeta wins over anything derived from the form class.
|
||||||
if config.title is not None:
|
if config.title is not None:
|
||||||
schema.title = config.title
|
schema.title = config.title
|
||||||
if config.subtitle is not None:
|
if config.subtitle is not None:
|
||||||
schema.subtitle = config.subtitle
|
schema.subtitle = config.subtitle
|
||||||
schema.submit_label = config.submit_label
|
schema.submit_label = config.submit_label
|
||||||
# Behavior settings are nested in schema.meta
|
|
||||||
schema.meta.live_validation = config.live_validation
|
schema.meta.live_validation = config.live_validation
|
||||||
schema.meta.live_form_errors = config.live_form_errors
|
schema.meta.live_form_errors = config.live_form_errors
|
||||||
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
||||||
@@ -367,7 +195,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
# Validate Function
|
# Validate Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Use generic dict input - form fields aren't available during __init_subclass__
|
# Generic dict input — form fields are unavailable during __init_subclass__.
|
||||||
FormValidateInput = create_model(
|
FormValidateInput = create_model(
|
||||||
f"{pascal_name}ValidateInput",
|
f"{pascal_name}ValidateInput",
|
||||||
data=(dict[str, Any], ...),
|
data=(dict[str, Any], ...),
|
||||||
@@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
|
|
||||||
def call(self, input) -> FormValidation:
|
def call(self, input) -> FormValidation:
|
||||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||||
# Input data is already a dict
|
|
||||||
data = input.data
|
data = input.data
|
||||||
_, validation = validate_form_instance(
|
_, validation = validate_form_instance(
|
||||||
form_class,
|
form_class,
|
||||||
@@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
class SubmitFunction(ServerFunction):
|
class SubmitFunction(ServerFunction):
|
||||||
"""
|
# Input=None signals the executor to pass the raw dict through, since
|
||||||
Submit function handles both JSON and multipart/form-data.
|
# the Django form owns validation.
|
||||||
|
Input = None
|
||||||
The executor detects form functions and parses the request appropriately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Use dict for input - form fields unknown at registration time
|
|
||||||
Input = None # Signals executor to pass raw dict
|
|
||||||
|
|
||||||
_meta: ClassVar[dict] = {
|
_meta: ClassVar[dict] = {
|
||||||
"form": True,
|
"form": True,
|
||||||
"form_name": form_name,
|
"form_name": form_name,
|
||||||
"form_role": "submit",
|
"form_role": "submit",
|
||||||
"multipart": True, # Signal that this function accepts multipart
|
"multipart": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||||
"""Execute form submission."""
|
|
||||||
request = self.request
|
request = self.request
|
||||||
|
|
||||||
# Check if we have multipart data from executor
|
# Multipart bodies are parsed onto the request before dispatch.
|
||||||
if hasattr(request, "_mizan_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
data = request._mizan_form_data
|
data = request._mizan_form_data
|
||||||
files = request._mizan_form_files
|
files = request._mizan_form_files
|
||||||
elif input is not None:
|
elif input is not None:
|
||||||
# JSON input - already a dict
|
|
||||||
data = input if isinstance(input, dict) else input.model_dump()
|
data = input if isinstance(input, dict) else input.model_dump()
|
||||||
files = None
|
files = None
|
||||||
else:
|
else:
|
||||||
@@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
|
|
||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Create and validate form
|
|
||||||
form, validation = validate_form_instance(
|
form, validation = validate_form_instance(
|
||||||
form_class,
|
form_class,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Call the form's on_submit_success
|
|
||||||
result_data = form.on_submit_success(request)
|
result_data = form.on_submit_success(request)
|
||||||
return FormSubmitPass(success=True, data=result_data)
|
return FormSubmitPass(success=True, data=result_data)
|
||||||
|
|
||||||
# Call the form's on_submit_failure
|
|
||||||
form.on_submit_failure(request, validation)
|
form.on_submit_failure(request, validation)
|
||||||
return FormSubmitFail(success=False, errors=validation)
|
return FormSubmitFail(success=False, errors=validation)
|
||||||
|
|
||||||
@@ -472,36 +289,34 @@ def _register_formset_functions(
|
|||||||
form_class: type,
|
form_class: type,
|
||||||
form_name: str,
|
form_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register formset server functions for a form."""
|
"""Register the `{name}.formset.*` server functions for a form."""
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
|
||||||
from .schemas import (
|
from mizan.forms.schemas import (
|
||||||
FormsetSchema,
|
FormsetSchema,
|
||||||
FormsetSubmitFail,
|
FormsetSubmitFail,
|
||||||
FormsetSubmitPass,
|
FormsetSubmitPass,
|
||||||
FormsetValidation,
|
FormsetValidation,
|
||||||
)
|
)
|
||||||
from .schema_utils import build_form_schema
|
from mizan.forms.schema_utils import build_form_schema
|
||||||
from .validation_utils import build_formset_validation
|
from mizan.forms.validation_utils import build_formset_validation
|
||||||
from .formset_utils import forms_to_formset_post_data
|
from mizan.forms.formset_utils import forms_to_formset_post_data
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
formset_class = formset_factory(form_class)
|
formset_class = formset_factory(form_class)
|
||||||
|
|
||||||
# Generate PascalCase name for schemas
|
|
||||||
pascal_name = "".join(
|
pascal_name = "".join(
|
||||||
word.capitalize()
|
word.capitalize()
|
||||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
|
||||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Formset Schema Function
|
# Formset Schema Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Generic dict inputs throughout — form fields are unavailable during
|
||||||
|
# __init_subclass__, so no typed schema can be built here.
|
||||||
FormsetSchemaInput = create_model(
|
FormsetSchemaInput = create_model(
|
||||||
f"{pascal_name}FormsetSchemaInput",
|
f"{pascal_name}FormsetSchemaInput",
|
||||||
forms=(list[dict[str, Any]], []),
|
forms=(list[dict[str, Any]], []),
|
||||||
@@ -542,7 +357,6 @@ def _register_formset_functions(
|
|||||||
# Formset Validate Function
|
# Formset Validate Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Generic dict input - form fields aren't available during __init_subclass__
|
|
||||||
FormsetValidateInput = create_model(
|
FormsetValidateInput = create_model(
|
||||||
f"{pascal_name}FormsetValidateInput",
|
f"{pascal_name}FormsetValidateInput",
|
||||||
forms=(list[dict[str, Any]], ...),
|
forms=(list[dict[str, Any]], ...),
|
||||||
@@ -560,12 +374,12 @@ def _register_formset_functions(
|
|||||||
|
|
||||||
def call(self, input) -> FormsetValidation:
|
def call(self, input) -> FormsetValidation:
|
||||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||||
# Input.forms is already a list of dicts
|
|
||||||
forms_data = input.forms
|
forms_data = input.forms
|
||||||
|
|
||||||
formset_data = forms_to_formset_post_data(forms_data)
|
formset_data = forms_to_formset_post_data(forms_data)
|
||||||
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
||||||
|
|
||||||
|
# Every submitted row must validate; blank rows are not excused.
|
||||||
for form in formset:
|
for form in formset:
|
||||||
form.empty_permitted = False
|
form.empty_permitted = False
|
||||||
|
|
||||||
@@ -578,7 +392,6 @@ def _register_formset_functions(
|
|||||||
# Formset Submit Function
|
# Formset Submit Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Generic dict input - form fields aren't available during __init_subclass__
|
|
||||||
FormsetSubmitInput = create_model(
|
FormsetSubmitInput = create_model(
|
||||||
f"{pascal_name}FormsetSubmitInput",
|
f"{pascal_name}FormsetSubmitInput",
|
||||||
forms=(list[dict[str, Any]], ...),
|
forms=(list[dict[str, Any]], ...),
|
||||||
@@ -598,12 +411,10 @@ def _register_formset_functions(
|
|||||||
request = self.request
|
request = self.request
|
||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Handle multipart vs JSON
|
|
||||||
if hasattr(request, "_mizan_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
post_data = request._mizan_form_data
|
post_data = request._mizan_form_data
|
||||||
files = request._mizan_form_files
|
files = request._mizan_form_files
|
||||||
elif input and hasattr(input, "forms"):
|
elif input and hasattr(input, "forms"):
|
||||||
# Input.forms is already a list of dicts
|
|
||||||
forms_data = input.forms
|
forms_data = input.forms
|
||||||
post_data = forms_to_formset_post_data(forms_data)
|
post_data = forms_to_formset_post_data(forms_data)
|
||||||
files = None
|
files = None
|
||||||
@@ -620,9 +431,7 @@ def _register_formset_functions(
|
|||||||
return FormsetSubmitPass(success=True)
|
return FormsetSubmitPass(success=True)
|
||||||
|
|
||||||
validation = build_formset_validation(formset)
|
validation = build_formset_validation(formset)
|
||||||
# Call failure handler on each form
|
|
||||||
for form in formset.forms:
|
for form in formset.forms:
|
||||||
if hasattr(form, "on_submit_failure"):
|
|
||||||
form.on_submit_failure(request, validation)
|
form.on_submit_failure(request, validation)
|
||||||
|
|
||||||
return FormsetSubmitFail(success=False, errors=validation)
|
return FormsetSubmitFail(success=False, errors=validation)
|
||||||
@@ -641,10 +450,8 @@ def register_form(
|
|||||||
submit_handler: Any = None,
|
submit_handler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Register a Django Form class as Mizan server functions.
|
Register a plain Django Form as `{name}.schema`, `{name}.validate`, and —
|
||||||
|
when `submit_handler` is given — `{name}.submit`.
|
||||||
Creates and registers `{name}.schema`, `{name}.validate`, and
|
|
||||||
`{name}.submit` (if a submit_handler is provided).
|
|
||||||
"""
|
"""
|
||||||
from mizan_core.client.function import create_form_functions
|
from mizan_core.client.function import create_form_functions
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
@@ -660,10 +467,8 @@ def register_form(
|
|||||||
|
|
||||||
def get_forms() -> dict[str, list]:
|
def get_forms() -> dict[str, list]:
|
||||||
"""
|
"""
|
||||||
Group registered form-related functions by their form name.
|
Group registered form-related functions by their form name, e.g.
|
||||||
|
`{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`.
|
||||||
Returns a mapping like:
|
|
||||||
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
|
||||||
"""
|
"""
|
||||||
from mizan_core.registry import get_all_functions
|
from mizan_core.registry import get_all_functions
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Any, Optional
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import Field
|
from django.forms import Field
|
||||||
|
|
||||||
from .schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
from mizan.forms.schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||||
|
|
||||||
|
|
||||||
def create_form_instance(
|
def create_form_instance(
|
||||||
@@ -14,16 +14,14 @@ def create_form_instance(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> forms.BaseForm:
|
) -> forms.BaseForm:
|
||||||
"""
|
"""
|
||||||
Create a form instance, gracefully handling kwargs that the form doesn't accept.
|
Instantiate `form_class`, dropping kwargs its __init__ rejects.
|
||||||
|
|
||||||
Some Django forms (like allauth's) accept `request` in __init__, others don't.
|
Django form __init__ signatures vary — some accept `request`, others do
|
||||||
This function tries with all kwargs first, then progressively removes kwargs
|
not — so instantiation is retried with the offending kwarg removed until
|
||||||
that cause TypeErrors until instantiation succeeds.
|
it succeeds or the TypeError is not about an unexpected keyword.
|
||||||
"""
|
"""
|
||||||
# Common kwargs that forms may or may not accept
|
|
||||||
optional_kwargs = ['request', 'user', 'instance']
|
optional_kwargs = ['request', 'user', 'instance']
|
||||||
|
|
||||||
# Build init kwargs
|
|
||||||
init_kwargs = dict(kwargs)
|
init_kwargs = dict(kwargs)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
init_kwargs['data'] = data
|
init_kwargs['data'] = data
|
||||||
@@ -36,11 +34,9 @@ def create_form_instance(
|
|||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
|
|
||||||
# Check if it's an unexpected keyword argument error
|
|
||||||
if "unexpected keyword argument" not in error_msg:
|
if "unexpected keyword argument" not in error_msg:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Find which kwarg caused the problem and remove it
|
|
||||||
removed = False
|
removed = False
|
||||||
for kwarg in optional_kwargs:
|
for kwarg in optional_kwargs:
|
||||||
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
||||||
@@ -48,31 +44,28 @@ def create_form_instance(
|
|||||||
removed = True
|
removed = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# If we couldn't identify/remove the problematic kwarg, re-raise
|
|
||||||
if not removed:
|
if not removed:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
||||||
"""
|
"""
|
||||||
Extract choices from a field, handling ModelChoiceField properly.
|
Extract a field's choices as JSON-serializable pairs. ModelChoiceField
|
||||||
ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable.
|
yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`.
|
||||||
"""
|
"""
|
||||||
if not hasattr(field, "choices"):
|
if not hasattr(field, "choices"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
choices: list[FieldChoice] = []
|
choices: list[FieldChoice] = []
|
||||||
for raw_value, label in field.choices:
|
for raw_value, label in field.choices:
|
||||||
value = getattr(
|
value = getattr(raw_value, "value", raw_value)
|
||||||
raw_value, "value", raw_value
|
|
||||||
) # ModelChoiceIteratorValue -> .value
|
|
||||||
choices.append(FieldChoice(value=str(value), label=str(label)))
|
choices.append(FieldChoice(value=str(value), label=str(label)))
|
||||||
|
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
|
|
||||||
def _get_initial(value: Any) -> Any:
|
def _get_initial(value: Any) -> Any:
|
||||||
"""Convert initial value to JSON-serializable format."""
|
"""Convert an initial value to a JSON-serializable form."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if hasattr(value, "isoformat"):
|
if hasattr(value, "isoformat"):
|
||||||
@@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _class_name_to_title(name: str) -> str:
|
def _class_name_to_title(name: str) -> str:
|
||||||
"""
|
"""'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'."""
|
||||||
Convert a class name to a human-readable title.
|
|
||||||
e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'
|
|
||||||
"""
|
|
||||||
# Remove 'Form' suffix
|
|
||||||
name = re.sub(r"Form$", "", name)
|
name = re.sub(r"Form$", "", name)
|
||||||
# Insert spaces before capital letters
|
|
||||||
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _class_name_to_slug(name: str) -> str:
|
def _class_name_to_slug(name: str) -> str:
|
||||||
"""
|
"""'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'."""
|
||||||
Convert a class name to a slug.
|
|
||||||
e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'
|
|
||||||
"""
|
|
||||||
# Remove 'Form' suffix
|
|
||||||
name = re.sub(r"Form$", "", name)
|
name = re.sub(r"Form$", "", name)
|
||||||
# Insert underscores before capital letters and lowercase
|
|
||||||
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
||||||
return name.lower()
|
return name.lower()
|
||||||
|
|
||||||
@@ -114,48 +97,31 @@ def build_form_schema(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FormSchema:
|
) -> FormSchema:
|
||||||
"""
|
"""
|
||||||
Produce a FormSchema for the given Django form class and (optional) data.
|
Produce a FormSchema for a Django form class and optional bound data.
|
||||||
|
|
||||||
The form class can define metadata via an inner Meta class:
|
Attributes on the form's inner `Meta` class — `form_name`, `title`,
|
||||||
|
`subtitle`, `submit_label`, `refetch_schema_on_validate`,
|
||||||
class MyForm(forms.Form):
|
`live_validation`, `live_form_errors` — override the values otherwise
|
||||||
class Meta:
|
derived from the class name.
|
||||||
form_name = "my_form"
|
|
||||||
title = "My Form Title"
|
|
||||||
subtitle = "Optional description"
|
|
||||||
submit_label = "Submit"
|
|
||||||
|
|
||||||
# Frontend behavior (optional)
|
|
||||||
refetch_schema_on_validate = False # Set True for dynamic choice fields
|
|
||||||
live_validation = True # Set False to disable live validation
|
|
||||||
live_form_errors = False # Set True to show form errors live
|
|
||||||
|
|
||||||
If not provided, sensible defaults are derived from the class name.
|
|
||||||
"""
|
"""
|
||||||
form = create_form_instance(form_class, data=data, **kwargs)
|
form = create_form_instance(form_class, data=data, **kwargs)
|
||||||
|
|
||||||
# Extract metadata from form's Meta class
|
|
||||||
form_meta = getattr(form_class, "Meta", None)
|
form_meta = getattr(form_class, "Meta", None)
|
||||||
|
|
||||||
# Get form name (used as identifier)
|
|
||||||
name = getattr(form_meta, "form_name", None)
|
name = getattr(form_meta, "form_name", None)
|
||||||
if name is None:
|
if name is None:
|
||||||
name = _class_name_to_slug(form_class.__name__)
|
name = _class_name_to_slug(form_class.__name__)
|
||||||
|
|
||||||
# Get title (human-readable heading)
|
|
||||||
title = getattr(form_meta, "title", None)
|
title = getattr(form_meta, "title", None)
|
||||||
if title is None:
|
if title is None:
|
||||||
title = _class_name_to_title(form_class.__name__)
|
title = _class_name_to_title(form_class.__name__)
|
||||||
|
|
||||||
# Get optional subtitle
|
|
||||||
subtitle = getattr(form_meta, "subtitle", None)
|
subtitle = getattr(form_meta, "subtitle", None)
|
||||||
|
|
||||||
# Get submit button label
|
|
||||||
submit_label = getattr(form_meta, "submit_label", None)
|
submit_label = getattr(form_meta, "submit_label", None)
|
||||||
if submit_label is None:
|
if submit_label is None:
|
||||||
submit_label = "Submit"
|
submit_label = "Submit"
|
||||||
|
|
||||||
# Build frontend behavior metadata
|
|
||||||
frontend_meta = FormMeta(
|
frontend_meta = FormMeta(
|
||||||
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
||||||
live_validation=getattr(form_meta, "live_validation", True),
|
live_validation=getattr(form_meta, "live_validation", True),
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ from django import forms
|
|||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.utils.datastructures import MultiValueDict
|
from django.utils.datastructures import MultiValueDict
|
||||||
|
|
||||||
from .schemas import (
|
from mizan.forms.schemas import (
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldErrorList,
|
FieldErrorList,
|
||||||
FormValidation,
|
FormValidation,
|
||||||
FormsetValidation,
|
FormsetValidation,
|
||||||
)
|
)
|
||||||
from .schema_utils import create_form_instance
|
from mizan.forms.schema_utils import create_form_instance
|
||||||
|
|
||||||
|
|
||||||
def validate_form_instance(
|
def validate_form_instance(
|
||||||
@@ -19,12 +19,9 @@ def validate_form_instance(
|
|||||||
files: MultiValueDict[str, UploadedFile] | None = None,
|
files: MultiValueDict[str, UploadedFile] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> tuple[forms.BaseForm, FormValidation]:
|
) -> tuple[forms.BaseForm, FormValidation]:
|
||||||
"""
|
"""Build a form instance and return it alongside its structured field errors."""
|
||||||
Build a form instance and return (form, structured_validation_errors).
|
|
||||||
"""
|
|
||||||
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
||||||
|
|
||||||
# Run validation
|
|
||||||
form.is_valid()
|
form.is_valid()
|
||||||
|
|
||||||
validation = FormValidation(
|
validation = FormValidation(
|
||||||
@@ -46,9 +43,7 @@ def validate_form_instance(
|
|||||||
|
|
||||||
|
|
||||||
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
||||||
"""
|
"""Turn a Django formset's non-form and per-form errors into a FormsetValidation."""
|
||||||
Turn a Django formset into a FormsetValidation structure.
|
|
||||||
"""
|
|
||||||
return FormsetValidation(
|
return FormsetValidation(
|
||||||
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
||||||
per_form=[
|
per_form=[
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
mizan.jwt - JWT authentication for server functions.
|
JWT issuance and validation for mizan server functions.
|
||||||
|
|
||||||
Provides:
|
`jwt_obtain` / `jwt_refresh` are server functions; importing
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
`mizan.jwt.functions` is what registers them. The Ninja auth class
|
||||||
- JWT authentication utilities for validating tokens
|
`JWTAuth` / `jwt_auth` resolves through `__getattr__` so that importing this
|
||||||
|
package does not pull django-ninja's settings access in at module load time.
|
||||||
Server Functions:
|
|
||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
|
||||||
|
|
||||||
Usage in apps.py or urls.py (to register the functions):
|
|
||||||
import mizan.jwt.functions # noqa: F401
|
|
||||||
|
|
||||||
Note: This module is purpose-built for mizan server functions.
|
|
||||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Server functions (import to register with @client decorator)
|
from mizan.jwt.functions import jwt_obtain, jwt_refresh
|
||||||
from .functions import jwt_obtain, jwt_refresh
|
|
||||||
|
|
||||||
# Token utilities
|
from mizan.jwt.tokens import (
|
||||||
from .tokens import (
|
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
@@ -31,17 +20,12 @@ from .tokens import (
|
|||||||
JWTUser,
|
JWTUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
from .settings import get_settings, JWTSettings
|
|
||||||
|
|
||||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
|
||||||
# django-ninja's settings access at module load time.
|
|
||||||
# Use: from mizan.jwt.security import jwt_auth
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
if name in ("JWTAuth", "jwt_auth"):
|
if name in ("JWTAuth", "jwt_auth"):
|
||||||
from .security import JWTAuth, jwt_auth
|
from mizan.jwt.security import JWTAuth, jwt_auth
|
||||||
|
|
||||||
globals()["JWTAuth"] = JWTAuth
|
globals()["JWTAuth"] = JWTAuth
|
||||||
globals()["jwt_auth"] = jwt_auth
|
globals()["jwt_auth"] = jwt_auth
|
||||||
|
|||||||
@@ -1,64 +1,33 @@
|
|||||||
"""
|
"""
|
||||||
Django Ninja Security Classes for JWT Authentication
|
Django Ninja security class for JWT bearer authentication, usable as
|
||||||
|
`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list.
|
||||||
Provides authentication classes that can be used with Django Ninja's
|
|
||||||
auth parameter to protect API endpoints.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja.security import HttpBearer
|
from ninja.security import HttpBearer
|
||||||
|
|
||||||
from .tokens import decode_token, JWTUser
|
from mizan.jwt.tokens import decode_token, JWTUser
|
||||||
|
|
||||||
|
|
||||||
class JWTAuth(HttpBearer):
|
class JWTAuth(HttpBearer):
|
||||||
"""
|
"""
|
||||||
JWT Bearer token authentication for Django Ninja.
|
Reads `Authorization: Bearer <access_token>` and sets `request.user` to a
|
||||||
|
JWTUser built from the token claims. No database query is made, so the
|
||||||
Usage:
|
resulting user carries only id, is_staff, and is_superuser.
|
||||||
from ninja_jwt_session import jwt_auth
|
|
||||||
|
|
||||||
@api.get("/protected/", auth=jwt_auth)
|
|
||||||
def protected_endpoint(request):
|
|
||||||
return {"user_id": request.user.id}
|
|
||||||
|
|
||||||
Or globally:
|
|
||||||
api = NinjaExtraAPI(auth=[django_auth, jwt_auth])
|
|
||||||
|
|
||||||
The token must be passed in the Authorization header:
|
|
||||||
Authorization: Bearer <access_token>
|
|
||||||
|
|
||||||
IMPORTANT: This is stateless - no database query is made.
|
|
||||||
request.user is a JWTUser object with id, is_staff, is_superuser.
|
|
||||||
If you need the full User object, query it explicitly:
|
|
||||||
user = User.objects.get(pk=request.user.id)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def authenticate(self, request: HttpRequest, token: str):
|
def authenticate(self, request: HttpRequest, token: str):
|
||||||
"""
|
"""Return a JWTUser for a valid access token, or None to fail auth."""
|
||||||
Validate the JWT and return a JWTUser if valid.
|
|
||||||
|
|
||||||
Returns None (authentication failed) if:
|
|
||||||
- Token is invalid or expired
|
|
||||||
- Token is not an access token
|
|
||||||
|
|
||||||
Note: No database query is made. The JWTUser is created from
|
|
||||||
token claims. This is truly stateless authentication.
|
|
||||||
"""
|
|
||||||
# Decode and validate the token
|
|
||||||
payload = decode_token(token, expected_type="access")
|
payload = decode_token(token, expected_type="access")
|
||||||
|
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
jwt_user = JWTUser(payload)
|
jwt_user = JWTUser(payload)
|
||||||
|
|
||||||
# Set request.user for compatibility with code expecting it
|
|
||||||
request.user = jwt_user
|
request.user = jwt_user
|
||||||
|
|
||||||
return jwt_user
|
return jwt_user
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance for convenience
|
|
||||||
jwt_auth = JWTAuth()
|
jwt_auth = JWTAuth()
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
JWT Token Creation and Validation
|
JWT creation and validation over PyJWT.
|
||||||
|
|
||||||
Uses PyJWT directly - no allauth dependency.
|
Every token carries the Django session key in `sid`; `validate_session`
|
||||||
Tokens are tied to Django sessions for immediate revocation on logout.
|
re-checks that the session still exists, which is what makes logout revoke
|
||||||
|
outstanding tokens immediately.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from django.contrib.sessions.backends.base import SessionBase
|
|
||||||
|
|
||||||
from .settings import get_settings
|
from mizan.jwt.settings import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("mizan.jwt")
|
||||||
|
|
||||||
|
|
||||||
class TokenPair(NamedTuple):
|
class TokenPair(NamedTuple):
|
||||||
@@ -34,13 +37,9 @@ class TokenPayload(NamedTuple):
|
|||||||
|
|
||||||
class JWTUser:
|
class JWTUser:
|
||||||
"""
|
"""
|
||||||
Minimal user object created from JWT claims.
|
Stand-in for `request.user` built entirely from JWT claims — no database
|
||||||
|
row is loaded, so only id, is_staff, and is_superuser are real. Anything
|
||||||
Used as request.user for JWT-authenticated requests.
|
else about the user has to be queried explicitly by the caller.
|
||||||
No database query required - all data comes from the token.
|
|
||||||
|
|
||||||
If you need the full User object with all fields, query explicitly:
|
|
||||||
user = User.objects.get(pk=request.user.id)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, payload: TokenPayload):
|
def __init__(self, payload: TokenPayload):
|
||||||
@@ -50,7 +49,7 @@ class JWTUser:
|
|||||||
self.is_superuser = payload.is_superuser
|
self.is_superuser = payload.is_superuser
|
||||||
self.is_authenticated = True
|
self.is_authenticated = True
|
||||||
self.is_anonymous = False
|
self.is_anonymous = False
|
||||||
self.is_active = True # Assumed active if they have a valid token
|
self.is_active = True # A valid unexpired token stands in for the flag
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"JWTUser(id={self.id})"
|
return f"JWTUser(id={self.id})"
|
||||||
@@ -66,18 +65,7 @@ def create_access_token(
|
|||||||
is_staff: bool = False,
|
is_staff: bool = False,
|
||||||
is_superuser: bool = False,
|
is_superuser: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Create a short-lived access token."""
|
||||||
Create a short-lived access token.
|
|
||||||
|
|
||||||
The token contains:
|
|
||||||
- sub: user ID
|
|
||||||
- sid: session key (for revocation checking)
|
|
||||||
- staff: is_staff flag
|
|
||||||
- super: is_superuser flag
|
|
||||||
- type: "access"
|
|
||||||
- iat: issued at
|
|
||||||
- exp: expiration
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
|
|
||||||
@@ -105,18 +93,7 @@ def create_refresh_token(
|
|||||||
is_staff: bool = False,
|
is_staff: bool = False,
|
||||||
is_superuser: bool = False,
|
is_superuser: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Create a longer-lived refresh token."""
|
||||||
Create a longer-lived refresh token.
|
|
||||||
|
|
||||||
The token contains:
|
|
||||||
- sub: user ID
|
|
||||||
- sid: session key (for revocation checking)
|
|
||||||
- staff: is_staff flag
|
|
||||||
- super: is_superuser flag
|
|
||||||
- type: "refresh"
|
|
||||||
- iat: issued at
|
|
||||||
- exp: expiration
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
|
|
||||||
@@ -157,13 +134,10 @@ def create_token_pair(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||||
"""
|
"""
|
||||||
Decode and validate a JWT token.
|
Decode and validate a JWT, returning None when it is malformed, expired,
|
||||||
|
or not of `expected_type`.
|
||||||
Returns None if:
|
|
||||||
- Token is invalid or expired
|
|
||||||
- Token type doesn't match expected_type (if specified)
|
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -173,11 +147,18 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
|||||||
settings.public_key,
|
settings.public_key,
|
||||||
algorithms=[settings.algorithm],
|
algorithms=[settings.algorithm],
|
||||||
)
|
)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError as exc:
|
||||||
|
# Expired and forged tokens are routine on a public endpoint, so this
|
||||||
|
# stays at debug rather than flooding the log on every bad request.
|
||||||
|
logger.debug("JWT rejected: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Validate token type if specified
|
|
||||||
if expected_type and payload.get("type") != expected_type:
|
if expected_type and payload.get("type") != expected_type:
|
||||||
|
logger.debug(
|
||||||
|
"JWT rejected: expected type %r, got %r",
|
||||||
|
expected_type,
|
||||||
|
payload.get("type"),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return TokenPayload(
|
return TokenPayload(
|
||||||
@@ -193,10 +174,8 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
|||||||
|
|
||||||
def validate_session(session_key: str) -> bool:
|
def validate_session(session_key: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a session is still valid (exists and not expired).
|
Report whether the Django session backing a token still exists. Returns
|
||||||
|
True unconditionally when session validation is switched off in settings.
|
||||||
This is the key to immediate logout revocation - if the session
|
|
||||||
is destroyed, tokens tied to it become invalid.
|
|
||||||
"""
|
"""
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
@@ -207,36 +186,30 @@ def validate_session(session_key: str) -> bool:
|
|||||||
if not jwt_settings.validate_session:
|
if not jwt_settings.validate_session:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Use the configured session engine
|
|
||||||
engine = import_module(django_settings.SESSION_ENGINE)
|
engine = import_module(django_settings.SESSION_ENGINE)
|
||||||
SessionStore = engine.SessionStore
|
SessionStore = engine.SessionStore
|
||||||
|
|
||||||
# Try to load the session
|
|
||||||
session = SessionStore(session_key=session_key)
|
session = SessionStore(session_key=session_key)
|
||||||
|
|
||||||
# Check if session exists and is not empty
|
# exists() reads the backend directly; load() would silently hand back an
|
||||||
# exists() is more reliable than checking load() result
|
# empty session for a missing key.
|
||||||
return session.exists(session_key)
|
return session.exists(session_key)
|
||||||
|
|
||||||
|
|
||||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||||
"""
|
"""
|
||||||
Use a refresh token to obtain new tokens.
|
Exchange a refresh token for a fresh pair carrying the same claims.
|
||||||
|
Returns None when the token is invalid or its session is gone.
|
||||||
Returns None if:
|
|
||||||
- Refresh token is invalid or expired
|
|
||||||
- Associated session no longer exists
|
|
||||||
"""
|
"""
|
||||||
payload = decode_token(refresh_token, expected_type="refresh")
|
payload = decode_token(refresh_token, expected_type="refresh")
|
||||||
|
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Validate the session still exists
|
|
||||||
if not validate_session(payload.session_key):
|
if not validate_session(payload.session_key):
|
||||||
|
logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Issue new token pair with same claims
|
|
||||||
return create_token_pair(
|
return create_token_pair(
|
||||||
payload.user_id,
|
payload.user_id,
|
||||||
payload.session_key,
|
payload.session_key,
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Export channels schema as OpenAPI JSON for TypeScript generation.
|
Writes the channels schema to stdout as OpenAPI JSON.
|
||||||
|
|
||||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
|
||||||
The schema is consumed by openapi-typescript for type generation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py export_channels_schema
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -29,6 +23,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
schema = get_channels_openapi_schema()
|
schema = get_channels_openapi_schema()
|
||||||
|
|
||||||
|
# indent=0 is not compact in json.dumps; None is.
|
||||||
indent = options["indent"] if options["indent"] > 0 else None
|
indent = options["indent"] if options["indent"] > 0 else None
|
||||||
output = json.dumps(schema, indent=indent)
|
output = json.dumps(schema, indent=indent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
"""
|
"""Management command emitting the edge cache manifest as JSON."""
|
||||||
Export Edge Manifest
|
|
||||||
|
|
||||||
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
|
||||||
to configure CDN cache rules and invalidation routing.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py export_edge_manifest
|
|
||||||
python manage.py export_edge_manifest --output mizan-manifest.json
|
|
||||||
python manage.py export_edge_manifest --base-url /api/mizan
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Mizan IR (KDL) export — Django management command.
|
Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary
|
||||||
|
consumes. Nothing else in this command may write to stdout.
|
||||||
Usage:
|
|
||||||
python manage.py export_mizan_ir
|
|
||||||
|
|
||||||
Triggers Mizan client discovery to populate the registry, then writes
|
|
||||||
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
|
||||||
consumes this directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from mizan.setup.discovery import mizan_clients
|
||||||
from mizan_core.ir import build_ir
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +15,6 @@ class Command(BaseCommand):
|
|||||||
help = "Export every registered @client function as Mizan IR (KDL)."
|
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||||
|
|
||||||
def handle(self, *args, **options) -> None:
|
def handle(self, *args, **options) -> None:
|
||||||
# Load every project-side @client function so the registry is
|
# Discovery populates the registry build_ir() reads.
|
||||||
# populated before we emit. Conventionally apps/*/clients.py.
|
mizan_clients()
|
||||||
from mizan.setup.discovery import mizan_clients
|
|
||||||
|
|
||||||
mizan_clients("apps")
|
|
||||||
self.stdout.write(build_ir(), ending="")
|
self.stdout.write(build_ir(), ending="")
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
mizan.setup - Django integration helpers.
|
Curated Django-side surface: registration, lookup, discovery, and settings
|
||||||
|
helpers, re-exported from `mizan_core.registry`, `mizan.channels`,
|
||||||
The function/composition registry now lives in `mizan_core.registry`.
|
`mizan.forms`, and this package's own modules.
|
||||||
Channels register themselves through the channel-specific registry in
|
|
||||||
`mizan.channels`. Forms register through `mizan.forms`. This module
|
|
||||||
re-exports the helpers that Django mizan users typically reach for, so
|
|
||||||
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
|
||||||
working as a single curated surface.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from mizan_core.registry import (
|
from mizan_core.registry import (
|
||||||
@@ -35,12 +30,13 @@ from mizan.forms import (
|
|||||||
get_forms,
|
get_forms,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .discovery import (
|
from mizan.setup.discovery import (
|
||||||
|
discover_apps_roots,
|
||||||
mizan_clients,
|
mizan_clients,
|
||||||
mizan_module,
|
mizan_module,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .settings import (
|
from mizan.setup.settings import (
|
||||||
mizanSettings,
|
mizanSettings,
|
||||||
get_settings,
|
get_settings,
|
||||||
clear_settings_cache,
|
clear_settings_cache,
|
||||||
@@ -67,6 +63,7 @@ __all__ = [
|
|||||||
"validate_registry",
|
"validate_registry",
|
||||||
"clear_registry",
|
"clear_registry",
|
||||||
# Discovery
|
# Discovery
|
||||||
|
"discover_apps_roots",
|
||||||
"mizan_clients",
|
"mizan_clients",
|
||||||
"mizan_module",
|
"mizan_module",
|
||||||
# Settings
|
# Settings
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
from django.apps import apps as django_apps
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
from mizan._vendor.app_visitor import (
|
||||||
|
DjangoAppVisitor,
|
||||||
|
DjangoAppVisitorHandler,
|
||||||
|
get_members,
|
||||||
|
)
|
||||||
|
|
||||||
from mizan_core.registry import register, get_function
|
from mizan_core.registry import register, get_function
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
@@ -9,22 +17,20 @@ from mizan_core.client.function import ServerFunction
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
class _RegisterServerFunctions(DjangoAppVisitorHandler):
|
||||||
def on_module(
|
def on_module(
|
||||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
for name, member in members:
|
for name, member in members:
|
||||||
# Register ServerFunction subclasses
|
|
||||||
if (
|
if (
|
||||||
isinstance(member, type)
|
isinstance(member, type)
|
||||||
and issubclass(member, ServerFunction)
|
and issubclass(member, ServerFunction)
|
||||||
and member is not ServerFunction
|
and member is not ServerFunction
|
||||||
and hasattr(member, "__name__")
|
and hasattr(member, "__name__")
|
||||||
):
|
):
|
||||||
# Use the function name as registration name
|
|
||||||
fn_name = getattr(member, "name", None) or member.__name__
|
fn_name = getattr(member, "name", None) or member.__name__
|
||||||
|
|
||||||
# Skip already registered (idempotent)
|
# Idempotent: the same class under the same name is a re-visit.
|
||||||
if get_function(fn_name) is member:
|
if get_function(fn_name) is member:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -40,14 +46,48 @@ class _RegisterServerFunctions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root
|
def discover_apps_roots() -> list[str]:
|
||||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
"""
|
||||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
Dotted package prefixes the project's own apps sit under, relative to
|
||||||
visitor.visit(_RegisterServerFunctions())
|
BASE_DIR. `MIZAN_APPS_ROOT` in Django settings pins the answer.
|
||||||
|
|
||||||
|
An app counts as the project's own only when its directory is exactly
|
||||||
|
BASE_DIR joined with its dotted name — the same resolution DjangoAppVisitor
|
||||||
|
performs. That excludes installed packages even when the virtualenv holding
|
||||||
|
them sits inside BASE_DIR.
|
||||||
|
|
||||||
|
An app declared as "apps.blog" yields "apps"; a top-level "blog" yields "".
|
||||||
|
"""
|
||||||
|
pinned = getattr(django_settings, "MIZAN_APPS_ROOT", None)
|
||||||
|
if pinned is not None:
|
||||||
|
return [pinned]
|
||||||
|
|
||||||
|
base_dir = Path(django_settings.BASE_DIR).resolve()
|
||||||
|
|
||||||
|
roots: list[str] = []
|
||||||
|
for app_config in django_apps.get_app_configs():
|
||||||
|
expected = base_dir.joinpath(*app_config.name.split("."))
|
||||||
|
if Path(app_config.path).resolve() != expected:
|
||||||
|
continue
|
||||||
|
root = app_config.name.rpartition(".")[0]
|
||||||
|
if root not in roots:
|
||||||
|
roots.append(root)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def mizan_clients(apps_root: str | None = None, layer: str = "clients") -> None:
|
||||||
|
"""
|
||||||
|
Scan <app>/<layer>.py and <app>/<layer>/**/*.py and register every
|
||||||
|
ServerFunction found. `apps_root` of None scans every discovered root.
|
||||||
|
"""
|
||||||
|
handler = _RegisterServerFunctions()
|
||||||
|
roots = [apps_root] if apps_root is not None else discover_apps_roots()
|
||||||
|
for root in roots:
|
||||||
|
DjangoAppVisitor(layer=layer, apps_root=root).visit(handler)
|
||||||
|
|
||||||
|
|
||||||
# Registers server functions from one module path, e.g. 'mizan.jwt.functions'
|
|
||||||
def mizan_module(module_path: str) -> None:
|
def mizan_module(module_path: str) -> None:
|
||||||
|
"""Register the server functions defined in one module, e.g. 'mizan.jwt.functions'."""
|
||||||
members = get_members(module_path)
|
members = get_members(module_path)
|
||||||
handler = _RegisterServerFunctions()
|
handler = _RegisterServerFunctions()
|
||||||
handler.on_module("", [], members)
|
handler.on_module("", [], members)
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
mizan.ssr — Server-side rendering via Bun subprocess.
|
Server-side rendering as a Django template backend: the template name is the
|
||||||
|
React component's file path and the context dict becomes its props.
|
||||||
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
|
||||||
'OPTIONS': {
|
|
||||||
'worker_path': 'frontend/ssr-worker.tsx',
|
|
||||||
'timeout': 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
Then use Django's standard render():
|
|
||||||
|
|
||||||
return render(request, 'ProfilePage', {'user_id': 5})
|
|
||||||
|
|
||||||
The component name is the template name. The context dict becomes props.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .backend import MizanTemplates
|
from mizan.ssr.backend import MizanTemplates
|
||||||
|
|
||||||
__all__ = ["MizanTemplates"]
|
__all__ = ["MizanTemplates"]
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
Django template backend that resolves a template name to a .tsx/.jsx file
|
||||||
|
under DIRS and renders it through a Bun subprocess. `OPTIONS['worker']` names
|
||||||
TEMPLATES = [
|
the worker script; `OPTIONS['timeout']` bounds a single render.
|
||||||
{
|
|
||||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
|
||||||
'DIRS': [BASE_DIR / 'frontend'],
|
|
||||||
'OPTIONS': {
|
|
||||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist
|
|||||||
from django.template.backends.base import BaseEngine
|
from django.template.backends.base import BaseEngine
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from .bridge import SSRBridge
|
from mizan.ssr.bridge import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
class MizanTemplate:
|
class MizanTemplate:
|
||||||
@@ -38,12 +28,12 @@ class MizanTemplate:
|
|||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
props = dict(context) if context else {}
|
props = dict(context) if context else {}
|
||||||
|
# Neither is JSON-serializable, and neither belongs in client hydration.
|
||||||
props.pop("request", None)
|
props.pop("request", None)
|
||||||
props.pop("csrf_token", None)
|
props.pop("csrf_token", None)
|
||||||
|
|
||||||
result = self._bridge.render(self.file_path, props)
|
result = self._bridge.render(self.file_path, props)
|
||||||
|
|
||||||
# Serialize props as hydration data for client-side React
|
|
||||||
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||||
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
@@ -54,10 +44,12 @@ class MizanTemplate:
|
|||||||
|
|
||||||
class MizanTemplates(BaseEngine):
|
class MizanTemplates(BaseEngine):
|
||||||
"""
|
"""
|
||||||
Django template backend that renders React components via Bun.
|
Template backend whose template names are file paths resolved against
|
||||||
|
DIRS. The bridge subprocess is created on first template lookup.
|
||||||
|
|
||||||
Template names are file paths resolved against DIRS.
|
A template is a module the Bun worker imports by path, so a source string
|
||||||
Same model as Django's built-in template engines.
|
names nothing this engine can render — `from_string` is left to BaseEngine,
|
||||||
|
which rejects it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, params: dict[str, Any]) -> None:
|
def __init__(self, params: dict[str, Any]) -> None:
|
||||||
@@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine):
|
|||||||
self.get_bridge(),
|
self.get_bridge(),
|
||||||
)
|
)
|
||||||
raise TemplateDoesNotExist(template_name)
|
raise TemplateDoesNotExist(template_name)
|
||||||
|
|
||||||
def from_string(self, template_code: str) -> MizanTemplate:
|
|
||||||
raise TemplateDoesNotExist(
|
|
||||||
"MizanTemplates renders .tsx files, not template strings."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
Persistent Bun subprocess speaking newline-delimited JSON-RPC over
|
||||||
|
stdin/stdout.
|
||||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
|
||||||
|
|
||||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||||
Response: {"id": 1, "html": "<div>...</div>"}
|
Response: {"id": 1, "html": "<div>...</div>"}
|
||||||
|
|
||||||
The subprocess stays alive across requests. It is started on first use
|
Message id 0 is reserved for the worker's unsolicited ready signal.
|
||||||
and restarted automatically if it crashes.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,10 +29,9 @@ class RenderResult:
|
|||||||
|
|
||||||
class SSRBridge:
|
class SSRBridge:
|
||||||
"""
|
"""
|
||||||
Manages a persistent Bun subprocess for server-side rendering.
|
Owns the Bun subprocess. Thread-safe: concurrent render() callers are
|
||||||
|
matched to their response by message id, and stdin writes are serialized
|
||||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
so requests never interleave mid-line.
|
||||||
Request-response matching via message IDs.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||||
@@ -42,18 +39,17 @@ class SSRBridge:
|
|||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._proc: subprocess.Popen | None = None
|
self._proc: subprocess.Popen | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
self._write_lock = threading.Lock()
|
||||||
self._counter = 0
|
self._counter = 0
|
||||||
self._pending: dict[int, threading.Event] = {}
|
self._pending: dict[int, threading.Event] = {}
|
||||||
self._results: dict[int, dict] = {}
|
self._results: dict[int, dict] = {}
|
||||||
self._reader_thread: threading.Thread | None = None
|
self._reader_thread: threading.Thread | None = None
|
||||||
self._ready = threading.Event()
|
self._ready = threading.Event()
|
||||||
|
|
||||||
# Ensure cleanup on process exit
|
|
||||||
atexit.register(self.shutdown)
|
atexit.register(self.shutdown)
|
||||||
|
|
||||||
def _ensure_running(self) -> None:
|
def _ensure_running(self) -> None:
|
||||||
"""Start the Bun subprocess if it's not running."""
|
"""Start the Bun subprocess if it is not already running."""
|
||||||
if self._proc is not None and self._proc.poll() is None:
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -73,7 +69,6 @@ class SSRBridge:
|
|||||||
)
|
)
|
||||||
self._reader_thread.start()
|
self._reader_thread.start()
|
||||||
|
|
||||||
# Wait for the "ready" signal from the worker
|
|
||||||
if not self._ready.wait(timeout=self._timeout):
|
if not self._ready.wait(timeout=self._timeout):
|
||||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
@@ -82,7 +77,7 @@ class SSRBridge:
|
|||||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||||
|
|
||||||
def _read_responses(self) -> None:
|
def _read_responses(self) -> None:
|
||||||
"""Background thread that reads JSON responses from stdout."""
|
"""Background thread that reads JSON responses from the worker's stdout."""
|
||||||
try:
|
try:
|
||||||
for line in self._proc.stdout:
|
for line in self._proc.stdout:
|
||||||
if isinstance(line, bytes):
|
if isinstance(line, bytes):
|
||||||
@@ -99,7 +94,6 @@ class SSRBridge:
|
|||||||
|
|
||||||
msg_id = msg.get("id")
|
msg_id = msg.get("id")
|
||||||
|
|
||||||
# Ready signal (id=0)
|
|
||||||
if msg_id == 0 and msg.get("ready"):
|
if msg_id == 0 and msg.get("ready"):
|
||||||
self._ready.set()
|
self._ready.set()
|
||||||
continue
|
continue
|
||||||
@@ -112,18 +106,10 @@ class SSRBridge:
|
|||||||
|
|
||||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||||
"""
|
"""
|
||||||
Render a React component to HTML.
|
Render the component at absolute path `file` with `props` to HTML.
|
||||||
|
|
||||||
Args:
|
Raises TimeoutError past the configured timeout and RuntimeError when
|
||||||
file: Absolute path to the .tsx/.jsx file to render.
|
the worker reports a render error or its pipe is broken.
|
||||||
props: Props to pass to the component.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RenderResult with the HTML string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TimeoutError: If the render takes longer than the configured timeout.
|
|
||||||
RuntimeError: If the render fails.
|
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._ensure_running()
|
self._ensure_running()
|
||||||
@@ -139,7 +125,6 @@ class SSRBridge:
|
|||||||
"params": {"file": file, "props": props or {}},
|
"params": {"file": file, "props": props or {}},
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
|
||||||
with self._write_lock:
|
with self._write_lock:
|
||||||
try:
|
try:
|
||||||
self._proc.stdin.write(request.encode("utf-8"))
|
self._proc.stdin.write(request.encode("utf-8"))
|
||||||
@@ -163,19 +148,24 @@ class SSRBridge:
|
|||||||
return RenderResult(html=result["html"])
|
return RenderResult(html=result["html"])
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Stop the Bun subprocess."""
|
"""Stop the Bun subprocess, escalating to kill if terminate does not land."""
|
||||||
if self._proc is not None:
|
if self._proc is None:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._proc.stdin.close()
|
self._proc.stdin.close()
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
logger.warning("Closing SSR worker stdin failed", exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._proc.terminate()
|
self._proc.terminate()
|
||||||
self._proc.wait(timeout=3)
|
self._proc.wait(timeout=3)
|
||||||
except Exception:
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
logger.warning("SSR worker did not terminate; killing it", exc_info=True)
|
||||||
try:
|
try:
|
||||||
self._proc.kill()
|
self._proc.kill()
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
logger.warning("Killing SSR worker failed", exc_info=True)
|
||||||
|
|
||||||
self._proc = None
|
self._proc = None
|
||||||
logger.info("Bun SSR worker stopped")
|
logger.info("Bun SSR worker stopped")
|
||||||
|
|||||||
@@ -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.
|
These measure rather than assert on timing; each one still checks that the
|
||||||
Includes realistic scenarios with ORM queries.
|
function under measurement returned the right answer. Timings printed here are
|
||||||
|
only meaningful when the module is run in isolation.
|
||||||
Usage:
|
|
||||||
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
|
||||||
|
|
||||||
Note:
|
|
||||||
These are not unit tests - they measure performance. Results are printed
|
|
||||||
to stdout and should be run in isolation for accurate measurements.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import statistics
|
import statistics
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, AsyncMock
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.db import connection
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
from django.test import RequestFactory, TransactionTestCase
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||||
@@ -141,9 +133,10 @@ def setup_benchmark_functions():
|
|||||||
|
|
||||||
class ProtocolBenchmark(TransactionTestCase):
|
class ProtocolBenchmark(TransactionTestCase):
|
||||||
"""
|
"""
|
||||||
Benchmark comparing HTTP vs WebSocket (simulated) performance.
|
Per-call latency for the executor path versus the HTTP view path.
|
||||||
|
|
||||||
Uses TransactionTestCase to ensure database state is realistic.
|
TransactionTestCase rather than TestCase: the timings must include real
|
||||||
|
commits instead of running inside one rolled-back transaction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Number of iterations for each benchmark
|
# Number of iterations for each benchmark
|
||||||
@@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
# Create test users for ORM benchmarks
|
|
||||||
self._create_test_users()
|
self._create_test_users()
|
||||||
|
|
||||||
def _create_test_users(self):
|
def _create_test_users(self):
|
||||||
"""Create test users for benchmarks."""
|
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||||
# Create 100 test users
|
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(
|
users.append(
|
||||||
User(
|
User(
|
||||||
email=f"bench{i}@example.com",
|
email=f"bench{i}@example.com",
|
||||||
is_active=i % 10 != 0, # 90% active
|
is_active=i % 10 != 0,
|
||||||
is_staff=i < 5, # 5 staff
|
is_staff=i < 5,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||||
@@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
return request
|
return request
|
||||||
|
|
||||||
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
||||||
"""
|
"""Time direct executor calls and return timing statistics."""
|
||||||
Benchmark direct executor calls (simulates WebSocket RPC).
|
|
||||||
|
|
||||||
Returns timing statistics.
|
|
||||||
"""
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
times = []
|
times = []
|
||||||
|
|
||||||
@@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
return self._compute_stats(times, f"Executor ({label})")
|
return self._compute_stats(times, f"Executor ({label})")
|
||||||
|
|
||||||
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
||||||
"""
|
"""Time HTTP view calls and return timing statistics."""
|
||||||
Benchmark HTTP view calls.
|
|
||||||
|
|
||||||
Returns timing statistics.
|
|
||||||
"""
|
|
||||||
times = []
|
times = []
|
||||||
|
|
||||||
# Warmup
|
# Warmup
|
||||||
@@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
self.assertIn("bench", user["email"].lower())
|
self.assertIn("bench", user["email"].lower())
|
||||||
|
|
||||||
def test_summary(self):
|
def test_summary(self):
|
||||||
"""Print summary of all benchmarks."""
|
"""Print the legend for the preceding benchmark tables."""
|
||||||
print("\n\n" + "=" * 80)
|
print("\n\n" + "=" * 80)
|
||||||
print("BENCHMARK SUMMARY")
|
print("BENCHMARK SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
||||||
print(f"Warmup iterations: {self.WARMUP}")
|
print(f"Warmup iterations: {self.WARMUP}")
|
||||||
print("\nKey findings:")
|
print("\nColumns:")
|
||||||
print("- 'Executor' simulates WebSocket RPC (direct function call)")
|
print("- 'Executor' calls execute_function directly")
|
||||||
print("- 'HTTP' measures full request/response cycle")
|
print("- 'HTTP' calls function_call_view, so it includes JSON parsing,")
|
||||||
print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch")
|
print(" CSRF handling, and view dispatch")
|
||||||
print("- For I/O-bound operations, protocol overhead is negligible")
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
# Verify bench_simple still produces correct output after all benchmarks
|
# Verify bench_simple still produces correct output after all benchmarks
|
||||||
@@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ThroughputBenchmark(TransactionTestCase):
|
class ThroughputBenchmark(TransactionTestCase):
|
||||||
"""
|
"""Requests per second for the executor path versus the HTTP view path."""
|
||||||
Measure requests per second (throughput) for server functions.
|
|
||||||
|
|
||||||
Tests both sequential and concurrent scenarios.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DURATION_SECONDS = 2 # How long to run each throughput test
|
DURATION_SECONDS = 2 # How long to run each throughput test
|
||||||
|
|
||||||
@@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
self._create_test_users()
|
self._create_test_users()
|
||||||
|
|
||||||
def _create_test_users(self):
|
def _create_test_users(self):
|
||||||
"""Create test users for benchmarks."""
|
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(
|
users.append(
|
||||||
@@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
self.assertGreaterEqual(result.data["total_users"], 0)
|
self.assertGreaterEqual(result.data["total_users"], 0)
|
||||||
|
|
||||||
def test_throughput_summary(self):
|
def test_throughput_summary(self):
|
||||||
"""Print throughput summary."""
|
"""Print the measurement conditions for the preceding throughput tests."""
|
||||||
print("\n\n" + "=" * 80)
|
print("\n\n" + "=" * 80)
|
||||||
print("THROUGHPUT SUMMARY")
|
print("THROUGHPUT SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
||||||
print("\nNotes:")
|
print("\nConditions:")
|
||||||
print("- These are single-threaded sequential measurements")
|
print("- Single-threaded and sequential")
|
||||||
print("- Real throughput scales with worker processes (gunicorn -w N)")
|
print("- SQLite in-memory database")
|
||||||
print("- Database queries are the bottleneck, not protocol overhead")
|
|
||||||
print("- Async workers (uvicorn) can handle more concurrent connections")
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
# Verify bench_simple still produces correct output after all throughput tests
|
# Verify bench_simple still produces correct output after all throughput tests
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ Tests for mizan.channels module.
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.channels import (
|
from mizan.channels import (
|
||||||
ReactChannel,
|
Channel,
|
||||||
register,
|
register,
|
||||||
get_channel,
|
get_channel,
|
||||||
get_registered_channels,
|
get_registered_channels,
|
||||||
@@ -18,9 +17,6 @@ from mizan.channels import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Test Fixtures
|
# Test Fixtures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -42,52 +38,47 @@ class MockAnonymousUser:
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ReactChannel Base Class Tests
|
# Channel Base Class Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ReactChannelBaseTests(TestCase):
|
class ChannelBaseTests(TestCase):
|
||||||
"""Tests for ReactChannel base class."""
|
"""Tests for Channel base class."""
|
||||||
|
|
||||||
def test_react_channel_default_class_vars(self):
|
def test_channel_default_class_vars(self):
|
||||||
"""ReactChannel should have None defaults for nested classes."""
|
"""Channel should have None defaults for nested classes."""
|
||||||
self.assertIsNone(ReactChannel.Params)
|
self.assertIsNone(Channel.Params)
|
||||||
self.assertIsNone(ReactChannel.ReactMessage)
|
self.assertIsNone(Channel.ClientMessage)
|
||||||
self.assertIsNone(ReactChannel.DjangoMessage)
|
self.assertIsNone(Channel.ServerMessage)
|
||||||
|
|
||||||
def test_react_channel_requires_authorize_override(self):
|
def test_channel_requires_authorize_override(self):
|
||||||
"""ReactChannel subclass must override authorize()."""
|
"""A subclass without authorize() cannot be instantiated."""
|
||||||
|
|
||||||
class IncompleteChannel(ReactChannel):
|
class NoAuthorizeChannel(Channel):
|
||||||
pass
|
def group(self, params=None):
|
||||||
|
return "test"
|
||||||
|
|
||||||
channel = IncompleteChannel()
|
with self.assertRaises(TypeError) as ctx:
|
||||||
channel.user = MockUser()
|
NoAuthorizeChannel()
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError) as ctx:
|
self.assertIn("authorize", str(ctx.exception))
|
||||||
channel.authorize()
|
|
||||||
|
|
||||||
self.assertIn("must implement authorize()", str(ctx.exception))
|
def test_channel_requires_group_override(self):
|
||||||
|
"""A subclass without group() cannot be instantiated."""
|
||||||
|
|
||||||
def test_react_channel_requires_group_override(self):
|
class NoGroupChannel(Channel):
|
||||||
"""ReactChannel subclass must override group()."""
|
|
||||||
|
|
||||||
class IncompleteChannel(ReactChannel):
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
channel = IncompleteChannel()
|
with self.assertRaises(TypeError) as ctx:
|
||||||
channel.user = MockUser()
|
NoGroupChannel()
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError) as ctx:
|
self.assertIn("group", str(ctx.exception))
|
||||||
channel.group()
|
|
||||||
|
|
||||||
self.assertIn("must implement group()", str(ctx.exception))
|
def test_channel_receive_default(self):
|
||||||
|
"""Channel.receive() should return None by default."""
|
||||||
|
|
||||||
def test_react_channel_receive_default(self):
|
class BasicChannel(Channel):
|
||||||
"""ReactChannel.receive() should return None by default."""
|
|
||||||
|
|
||||||
class BasicChannel(ReactChannel):
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_react_channel_init_creates_empty_groups(self):
|
def test_channel_init_creates_empty_groups(self):
|
||||||
"""ReactChannel.__init__() should create empty _groups set."""
|
"""Channel.__init__() should create empty _groups set."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase):
|
|||||||
def test_channel_with_params(self):
|
def test_channel_with_params(self):
|
||||||
"""Channel should accept Params Pydantic model."""
|
"""Channel should accept Params Pydantic model."""
|
||||||
|
|
||||||
class ParamsChannel(ReactChannel):
|
class ParamsChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
limit: int = 10
|
limit: int = 10
|
||||||
@@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase):
|
|||||||
|
|
||||||
self.assertIsNotNone(ParamsChannel.Params)
|
self.assertIsNotNone(ParamsChannel.Params)
|
||||||
|
|
||||||
# Test params model
|
|
||||||
params = ParamsChannel.Params(room="general")
|
params = ParamsChannel.Params(room="general")
|
||||||
self.assertEqual(params.room, "general")
|
self.assertEqual(params.room, "general")
|
||||||
self.assertEqual(params.limit, 10)
|
self.assertEqual(params.limit, 10)
|
||||||
|
|
||||||
def test_channel_with_react_message(self):
|
def test_channel_with_client_message(self):
|
||||||
"""Channel should accept ReactMessage Pydantic model."""
|
"""Channel should accept ClientMessage Pydantic model."""
|
||||||
|
|
||||||
class MessageChannel(ReactChannel):
|
class MessageChannel(Channel):
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
timestamp: int
|
timestamp: int
|
||||||
|
|
||||||
@@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "messages"
|
return "messages"
|
||||||
|
|
||||||
self.assertIsNotNone(MessageChannel.ReactMessage)
|
self.assertIsNotNone(MessageChannel.ClientMessage)
|
||||||
|
|
||||||
# Test message model
|
msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345)
|
||||||
msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345)
|
|
||||||
self.assertEqual(msg.text, "Hello")
|
self.assertEqual(msg.text, "Hello")
|
||||||
self.assertEqual(msg.timestamp, 12345)
|
self.assertEqual(msg.timestamp, 12345)
|
||||||
|
|
||||||
def test_channel_with_django_message(self):
|
def test_channel_with_server_message(self):
|
||||||
"""Channel should accept DjangoMessage Pydantic model."""
|
"""Channel should accept ServerMessage Pydantic model."""
|
||||||
|
|
||||||
class BroadcastChannel(ReactChannel):
|
class BroadcastChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "broadcast"
|
return "broadcast"
|
||||||
|
|
||||||
self.assertIsNotNone(BroadcastChannel.DjangoMessage)
|
self.assertIsNotNone(BroadcastChannel.ServerMessage)
|
||||||
|
|
||||||
# Test message model
|
msg = BroadcastChannel.ServerMessage(
|
||||||
msg = BroadcastChannel.DjangoMessage(
|
|
||||||
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||||
)
|
)
|
||||||
self.assertEqual(msg.user, "john")
|
self.assertEqual(msg.user, "john")
|
||||||
@@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase):
|
|||||||
def test_channel_receive_with_typed_messages(self):
|
def test_channel_receive_with_typed_messages(self):
|
||||||
"""Channel.receive() should work with typed messages."""
|
"""Channel.receive() should work with typed messages."""
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase):
|
|||||||
return f"chat_{params.room}"
|
return f"chat_{params.room}"
|
||||||
|
|
||||||
def receive(self, params, msg):
|
def receive(self, params, msg):
|
||||||
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
return self.ServerMessage(user=self.user.email, text=msg.text)
|
||||||
|
|
||||||
channel = ChatChannel()
|
channel = ChatChannel()
|
||||||
channel.user = MockUser(email="test@example.com")
|
channel.user = MockUser(email="test@example.com")
|
||||||
|
|
||||||
params = ChatChannel.Params(room="general")
|
params = ChatChannel.Params(room="general")
|
||||||
incoming = ChatChannel.ReactMessage(text="Hello!")
|
incoming = ChatChannel.ClientMessage(text="Hello!")
|
||||||
|
|
||||||
result = channel.receive(params, incoming)
|
result = channel.receive(params, incoming)
|
||||||
|
|
||||||
self.assertIsInstance(result, ChatChannel.DjangoMessage)
|
self.assertIsInstance(result, ChatChannel.ServerMessage)
|
||||||
self.assertEqual(result.user, "test@example.com")
|
self.assertEqual(result.user, "test@example.com")
|
||||||
self.assertEqual(result.text, "Hello!")
|
self.assertEqual(result.text, "Hello!")
|
||||||
|
|
||||||
@@ -243,7 +231,7 @@ class RegistrationTests(TestCase):
|
|||||||
def test_register_adds_to_registry(self):
|
def test_register_adds_to_registry(self):
|
||||||
"""register() should add channel to registry."""
|
"""register() should add channel to registry."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -255,17 +243,31 @@ class RegistrationTests(TestCase):
|
|||||||
self.assertIn("test-channel", _registry)
|
self.assertIn("test-channel", _registry)
|
||||||
self.assertEqual(_registry["test-channel"], TestChannel)
|
self.assertEqual(_registry["test-channel"], TestChannel)
|
||||||
|
|
||||||
def test_register_duplicate_raises(self):
|
def test_register_sets_registered_name(self):
|
||||||
"""register() should raise on duplicate name."""
|
"""register() should stamp the wire name onto the class."""
|
||||||
|
|
||||||
class Channel1(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "test"
|
return "test"
|
||||||
|
|
||||||
class Channel2(ReactChannel):
|
register(TestChannel, "named-channel")
|
||||||
|
|
||||||
|
self.assertEqual(TestChannel._registered_name, "named-channel")
|
||||||
|
|
||||||
|
def test_register_duplicate_raises(self):
|
||||||
|
"""register() should raise on duplicate name."""
|
||||||
|
|
||||||
|
class Channel1(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "test"
|
||||||
|
|
||||||
|
class Channel2(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -279,21 +281,10 @@ class RegistrationTests(TestCase):
|
|||||||
|
|
||||||
self.assertIn("already registered", str(ctx.exception))
|
self.assertIn("already registered", str(ctx.exception))
|
||||||
|
|
||||||
def test_register_validates_authorize(self):
|
|
||||||
"""register() should validate that authorize method exists."""
|
|
||||||
|
|
||||||
class NoAuthorizeChannel(ReactChannel):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Should still pass because ReactChannel has authorize
|
|
||||||
# (just raises NotImplementedError when called)
|
|
||||||
register(NoAuthorizeChannel, "no-authorize-test")
|
|
||||||
self.assertIn("no-authorize-test", _registry)
|
|
||||||
|
|
||||||
def test_get_channel_returns_registered(self):
|
def test_get_channel_returns_registered(self):
|
||||||
"""get_channel() should return registered channel."""
|
"""get_channel() should return registered channel."""
|
||||||
|
|
||||||
class MyChannel(ReactChannel):
|
class MyChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -315,7 +306,7 @@ class RegistrationTests(TestCase):
|
|||||||
def test_get_registered_channels_returns_copy(self):
|
def test_get_registered_channels_returns_copy(self):
|
||||||
"""get_registered_channels() should return a copy of registry."""
|
"""get_registered_channels() should return a copy of registry."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -326,7 +317,6 @@ class RegistrationTests(TestCase):
|
|||||||
|
|
||||||
result = get_registered_channels()
|
result = get_registered_channels()
|
||||||
|
|
||||||
# Modifying result shouldn't affect original
|
|
||||||
result["modified"] = "test"
|
result["modified"] = "test"
|
||||||
|
|
||||||
self.assertIn("copy-test", _registry)
|
self.assertIn("copy-test", _registry)
|
||||||
@@ -360,7 +350,7 @@ class SchemaExportTests(TestCase):
|
|||||||
def test_get_channels_schema_with_basic_channel(self):
|
def test_get_channels_schema_with_basic_channel(self):
|
||||||
"""get_channels_schema() should include basic channel info."""
|
"""get_channels_schema() should include basic channel info."""
|
||||||
|
|
||||||
class BasicChannel(ReactChannel):
|
class BasicChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -376,13 +366,13 @@ class SchemaExportTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(channel_schema["name"], "basic")
|
self.assertEqual(channel_schema["name"], "basic")
|
||||||
self.assertIsNone(channel_schema["params"])
|
self.assertIsNone(channel_schema["params"])
|
||||||
self.assertIsNone(channel_schema["reactMessage"])
|
self.assertIsNone(channel_schema["clientMessage"])
|
||||||
self.assertIsNone(channel_schema["djangoMessage"])
|
self.assertIsNone(channel_schema["serverMessage"])
|
||||||
|
|
||||||
def test_get_channels_schema_with_params(self):
|
def test_get_channels_schema_with_params(self):
|
||||||
"""get_channels_schema() should include params schema."""
|
"""get_channels_schema() should include params schema."""
|
||||||
|
|
||||||
class ParamsChannel(ReactChannel):
|
class ParamsChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
limit: int = 50
|
limit: int = 50
|
||||||
@@ -407,14 +397,14 @@ class SchemaExportTests(TestCase):
|
|||||||
def test_get_channels_schema_with_messages(self):
|
def test_get_channels_schema_with_messages(self):
|
||||||
"""get_channels_schema() should include message schemas."""
|
"""get_channels_schema() should include message schemas."""
|
||||||
|
|
||||||
class FullChannel(ReactChannel):
|
class FullChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
channel_id: int
|
channel_id: int
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
@@ -431,24 +421,21 @@ class SchemaExportTests(TestCase):
|
|||||||
|
|
||||||
channel_schema = schema["channels"]["full-channel"]
|
channel_schema = schema["channels"]["full-channel"]
|
||||||
|
|
||||||
# Check params
|
|
||||||
self.assertIsNotNone(channel_schema["params"])
|
self.assertIsNotNone(channel_schema["params"])
|
||||||
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
||||||
|
|
||||||
# Check ReactMessage
|
self.assertIsNotNone(channel_schema["clientMessage"])
|
||||||
self.assertIsNotNone(channel_schema["reactMessage"])
|
self.assertIn("text", channel_schema["clientMessage"]["properties"])
|
||||||
self.assertIn("text", channel_schema["reactMessage"]["properties"])
|
|
||||||
|
|
||||||
# Check DjangoMessage
|
self.assertIsNotNone(channel_schema["serverMessage"])
|
||||||
self.assertIsNotNone(channel_schema["djangoMessage"])
|
self.assertIn("user", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("user", channel_schema["djangoMessage"]["properties"])
|
self.assertIn("text", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("text", channel_schema["djangoMessage"]["properties"])
|
self.assertIn("timestamp", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"])
|
|
||||||
|
|
||||||
def test_get_channels_schema_multiple_channels(self):
|
def test_get_channels_schema_multiple_channels(self):
|
||||||
"""get_channels_schema() should include all registered channels."""
|
"""get_channels_schema() should include all registered channels."""
|
||||||
|
|
||||||
class Channel1(ReactChannel):
|
class Channel1(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@@ -458,7 +445,7 @@ class SchemaExportTests(TestCase):
|
|||||||
def group(self, params):
|
def group(self, params):
|
||||||
return f"c1_{params.id}"
|
return f"c1_{params.id}"
|
||||||
|
|
||||||
class Channel2(ReactChannel):
|
class Channel2(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -473,13 +460,117 @@ class SchemaExportTests(TestCase):
|
|||||||
self.assertIn("channel-one", schema["channels"])
|
self.assertIn("channel-one", schema["channels"])
|
||||||
self.assertIn("channel-two", schema["channels"])
|
self.assertIn("channel-two", schema["channels"])
|
||||||
|
|
||||||
# Channel 1 has params
|
|
||||||
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
||||||
|
|
||||||
# Channel 2 has no params
|
|
||||||
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Registry Extension Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsExtensionTests(TestCase):
|
||||||
|
"""Tests for the channels extension plugged into mizan_core.registry."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._original_registry = dict(_registry)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_registry.clear()
|
||||||
|
_registry.update(self._original_registry)
|
||||||
|
|
||||||
|
def _extension(self):
|
||||||
|
from mizan_core.registry import _extensions
|
||||||
|
|
||||||
|
return _extensions["channels"]
|
||||||
|
|
||||||
|
def test_extension_all_returns_registry_copy(self):
|
||||||
|
"""all() should return name -> channel class, decoupled from the registry."""
|
||||||
|
|
||||||
|
class TestChannel(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "ext"
|
||||||
|
|
||||||
|
register(TestChannel, "ext-all")
|
||||||
|
|
||||||
|
result = self._extension().all()
|
||||||
|
|
||||||
|
self.assertEqual(result["ext-all"], TestChannel)
|
||||||
|
|
||||||
|
result["modified"] = TestChannel
|
||||||
|
self.assertNotIn("modified", _registry)
|
||||||
|
|
||||||
|
def test_extension_schema_names_both_directions(self):
|
||||||
|
"""schema() should carry client_message and server_message slots."""
|
||||||
|
|
||||||
|
class ChatChannel(Channel):
|
||||||
|
class Params(BaseModel):
|
||||||
|
room: str
|
||||||
|
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def authorize(self, params):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params):
|
||||||
|
return f"chat_{params.room}"
|
||||||
|
|
||||||
|
register(ChatChannel, "ext-chat")
|
||||||
|
|
||||||
|
entry = self._extension().schema()["ext-chat"]
|
||||||
|
|
||||||
|
self.assertEqual(entry["type"], "channel")
|
||||||
|
self.assertTrue(entry["bidirectional"])
|
||||||
|
self.assertIn("params", entry)
|
||||||
|
self.assertIn("client_message", entry)
|
||||||
|
self.assertIn("server_message", entry)
|
||||||
|
|
||||||
|
def test_extension_schema_omits_absent_client_message(self):
|
||||||
|
"""A server-push-only channel is not bidirectional."""
|
||||||
|
|
||||||
|
class NotificationsChannel(Channel):
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "notifications"
|
||||||
|
|
||||||
|
register(NotificationsChannel, "ext-notifications")
|
||||||
|
|
||||||
|
entry = self._extension().schema()["ext-notifications"]
|
||||||
|
|
||||||
|
self.assertNotIn("client_message", entry)
|
||||||
|
self.assertIn("server_message", entry)
|
||||||
|
self.assertFalse(entry["bidirectional"])
|
||||||
|
|
||||||
|
def test_extension_clear_empties_registry(self):
|
||||||
|
"""clear() should drop every registration."""
|
||||||
|
|
||||||
|
class TestChannel(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "ext"
|
||||||
|
|
||||||
|
register(TestChannel, "ext-clear")
|
||||||
|
|
||||||
|
self._extension().clear()
|
||||||
|
|
||||||
|
self.assertEqual(_registry, {})
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Authorization Tests
|
# Authorization Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -491,7 +582,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_authenticated_user(self):
|
def test_authorize_with_authenticated_user(self):
|
||||||
"""authorize() should work with authenticated users."""
|
"""authorize() should work with authenticated users."""
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return self.user.is_authenticated
|
return self.user.is_authenticated
|
||||||
|
|
||||||
@@ -506,7 +597,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_anonymous_user(self):
|
def test_authorize_with_anonymous_user(self):
|
||||||
"""authorize() should work with anonymous users."""
|
"""authorize() should work with anonymous users."""
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return self.user.is_authenticated
|
return self.user.is_authenticated
|
||||||
|
|
||||||
@@ -521,7 +612,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_params(self):
|
def test_authorize_with_params(self):
|
||||||
"""authorize() should have access to params."""
|
"""authorize() should have access to params."""
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
@@ -553,7 +644,7 @@ class GroupTests(TestCase):
|
|||||||
def test_group_returns_string(self):
|
def test_group_returns_string(self):
|
||||||
"""group() should return a string group name."""
|
"""group() should return a string group name."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -567,7 +658,7 @@ class GroupTests(TestCase):
|
|||||||
def test_group_with_params(self):
|
def test_group_with_params(self):
|
||||||
"""group() should use params for dynamic group names."""
|
"""group() should use params for dynamic group names."""
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room_id: int
|
room_id: int
|
||||||
|
|
||||||
@@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_join_group() should add group to _groups set."""
|
"""_join_group() should add group to _groups set."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_leave_group() should remove group from _groups set."""
|
"""_leave_group() should remove group from _groups set."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_leave_group() should ignore groups not in _groups."""
|
"""_leave_group() should ignore groups not in _groups."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase):
|
|||||||
await channel._leave_group("unknown-group")
|
await channel._leave_group("unknown-group")
|
||||||
return channel._groups
|
return channel._groups
|
||||||
|
|
||||||
groups = asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
# Should not have called group_discard
|
|
||||||
channel._channel_layer.group_discard.assert_not_called()
|
channel._channel_layer.group_discard.assert_not_called()
|
||||||
|
|
||||||
def test_leave_all_groups(self):
|
def test_leave_all_groups(self):
|
||||||
"""_leave_all_groups() should leave all joined groups."""
|
"""_leave_all_groups() should leave all joined groups."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_broadcast() should send message to channel layer."""
|
"""_broadcast() should send message to channel layer."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
channel = TestChannel()
|
channel = TestChannel()
|
||||||
channel._channel_layer = AsyncMock()
|
channel._channel_layer = AsyncMock()
|
||||||
|
|
||||||
message = TestChannel.DjangoMessage(text="Hello")
|
message = TestChannel.ServerMessage(text="Hello")
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await channel._broadcast("my-group", message)
|
await channel._broadcast("my-group", message)
|
||||||
@@ -747,8 +837,8 @@ class ServerPushTests(TestCase):
|
|||||||
"""push() should work for channels without params."""
|
"""push() should work for channels without params."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class NotificationChannel(ReactChannel):
|
class NotificationChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
@@ -762,7 +852,7 @@ class ServerPushTests(TestCase):
|
|||||||
mock_layer = AsyncMock()
|
mock_layer = AsyncMock()
|
||||||
mock_get_layer.return_value = mock_layer
|
mock_get_layer.return_value = mock_layer
|
||||||
|
|
||||||
message = NotificationChannel.DjangoMessage(
|
message = NotificationChannel.ServerMessage(
|
||||||
title="Alert", body="Something happened"
|
title="Alert", body="Something happened"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -781,11 +871,11 @@ class ServerPushTests(TestCase):
|
|||||||
"""push() should work for channels with params."""
|
"""push() should work for channels with params."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
@@ -798,7 +888,7 @@ class ServerPushTests(TestCase):
|
|||||||
mock_layer = AsyncMock()
|
mock_layer = AsyncMock()
|
||||||
mock_get_layer.return_value = mock_layer
|
mock_get_layer.return_value = mock_layer
|
||||||
|
|
||||||
message = RoomChannel.DjangoMessage(text="Hello room!")
|
message = RoomChannel.ServerMessage(text="Hello room!")
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await RoomChannel.push(room="general", message=message)
|
await RoomChannel.push(room="general", message=message)
|
||||||
@@ -814,10 +904,9 @@ class ServerPushTests(TestCase):
|
|||||||
def test_push_without_channel_layer_warns(self):
|
def test_push_without_channel_layer_warns(self):
|
||||||
"""push() should warn when no channel layer is configured."""
|
"""push() should warn when no channel layer is configured."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -829,7 +918,7 @@ class ServerPushTests(TestCase):
|
|||||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||||
mock_get_layer.return_value = None
|
mock_get_layer.return_value = None
|
||||||
|
|
||||||
message = TestChannel.DjangoMessage(text="test")
|
message = TestChannel.ServerMessage(text="test")
|
||||||
|
|
||||||
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||||
|
|
||||||
@@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase):
|
|||||||
|
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
# Should be valid JSON with OpenAPI structure
|
|
||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
self.assertIn("openapi", schema)
|
self.assertIn("openapi", schema)
|
||||||
@@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase):
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase):
|
|||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
# Check that channel is in x-mizan-channels metadata
|
|
||||||
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
||||||
self.assertIn("export-test", channel_names)
|
self.assertIn("export-test", channel_names)
|
||||||
|
|
||||||
|
def test_export_command_names_message_slots(self):
|
||||||
|
"""The x-mizan-channels table should name the client and server slots."""
|
||||||
|
from io import StringIO
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
class SlotChannel(Channel):
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "slots"
|
||||||
|
|
||||||
|
register(SlotChannel, "slot_channel")
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
|
schema = json.loads(out.getvalue())
|
||||||
|
entry = next(
|
||||||
|
c for c in schema["x-mizan-channels"] if c["name"] == "slot_channel"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(entry["pascalName"], "SlotChannel")
|
||||||
|
self.assertTrue(entry["hasClientMessage"])
|
||||||
|
self.assertTrue(entry["hasServerMessage"])
|
||||||
|
self.assertEqual(entry["clientMessageType"], "SlotChannelClientMessage")
|
||||||
|
self.assertEqual(entry["serverMessageType"], "SlotChannelServerMessage")
|
||||||
|
|
||||||
|
def test_export_command_type_names_match_the_ir(self):
|
||||||
|
"""A dotted-and-hyphenated wire name yields the same type names the IR
|
||||||
|
emits — the OpenAPI document and the IR describe one set of types."""
|
||||||
|
from io import StringIO
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from mizan_core.ir import build_ir, wire_to_pascal
|
||||||
|
|
||||||
|
class ActivityFeedChannel(Channel):
|
||||||
|
class Params(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
ack: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
event: str
|
||||||
|
|
||||||
|
def authorize(self, params):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params):
|
||||||
|
return f"activity_{params.user_id}"
|
||||||
|
|
||||||
|
register(ActivityFeedChannel, "activity.live-feed")
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
|
schema = json.loads(out.getvalue())
|
||||||
|
entry = next(
|
||||||
|
c for c in schema["x-mizan-channels"] if c["name"] == "activity.live-feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
pascal = wire_to_pascal("activity.live-feed")
|
||||||
|
self.assertEqual(pascal, "ActivityLiveFeed")
|
||||||
|
self.assertEqual(entry["pascalName"], pascal)
|
||||||
|
self.assertEqual(entry["paramsType"], f"{pascal}Params")
|
||||||
|
self.assertEqual(entry["clientMessageType"], f"{pascal}ClientMessage")
|
||||||
|
self.assertEqual(entry["serverMessageType"], f"{pascal}ServerMessage")
|
||||||
|
|
||||||
|
components = schema["components"]["schemas"]
|
||||||
|
ir = build_ir()
|
||||||
|
for slot in ("Params", "ClientMessage", "ServerMessage"):
|
||||||
|
self.assertIn(f"{pascal}{slot}", components)
|
||||||
|
self.assertIn(f'type "{pascal}{slot}"', ir)
|
||||||
|
|
||||||
def test_export_command_respects_indent(self):
|
def test_export_command_respects_indent(self):
|
||||||
"""export_channels_schema should respect --indent option."""
|
"""export_channels_schema should respect --indent option."""
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
# With indent
|
|
||||||
out_indent = StringIO()
|
out_indent = StringIO()
|
||||||
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
||||||
|
|
||||||
# Without indent (compact)
|
|
||||||
out_compact = StringIO()
|
out_compact = StringIO()
|
||||||
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
||||||
|
|
||||||
# Indented should be longer (has whitespace)
|
|
||||||
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
||||||
|
|
||||||
|
|
||||||
@@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase):
|
|||||||
"""Tests for WebSocket RPC functionality."""
|
"""Tests for WebSocket RPC functionality."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Clear mizan registry
|
|
||||||
from mizan_core.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
# Register test functions
|
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from mizan_core.registry import (
|
|||||||
)
|
)
|
||||||
from mizan.forms import register_form
|
from mizan.forms import register_form
|
||||||
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -61,10 +61,9 @@ class ErrorOutput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def setup_function_style_tests():
|
def setup_function_style_tests():
|
||||||
"""Register function-style test functions.
|
"""
|
||||||
|
Register the function-style test functions. Applying @client does not put
|
||||||
Note: Since @client no longer auto-registers (registration happens via
|
a function in the registry, so each one is passed to register() here.
|
||||||
mizan_clients() discovery), we explicitly register each function here.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -514,8 +513,8 @@ class ContextTests(TestCase):
|
|||||||
fn = get_function("global_context")
|
fn = get_function("global_context")
|
||||||
self.assertEqual(fn._meta.get("context"), "global")
|
self.assertEqual(fn._meta.get("context"), "global")
|
||||||
|
|
||||||
def test_context_local(self):
|
def test_context_arbitrary_name_is_verbatim_and_silent(self):
|
||||||
"""Test @client(context='local') still works with deprecation warning."""
|
"""Any non-empty context string becomes the name verbatim, with no warning."""
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
class CtxOutput(BaseModel):
|
class CtxOutput(BaseModel):
|
||||||
@@ -528,8 +527,7 @@ class ContextTests(TestCase):
|
|||||||
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
||||||
return CtxOutput(data=f"user_{user_id}")
|
return CtxOutput(data=f"user_{user_id}")
|
||||||
|
|
||||||
self.assertEqual(len(w), 1)
|
self.assertEqual([str(entry.message) for entry in w], [])
|
||||||
self.assertIn("deprecated", str(w[0].message).lower())
|
|
||||||
|
|
||||||
register(local_context, "local_context")
|
register(local_context, "local_context")
|
||||||
|
|
||||||
@@ -1019,7 +1017,7 @@ class ServerDrivenInvalidationTests(TestCase):
|
|||||||
self.assertIn("team_info", data)
|
self.assertIn("team_info", data)
|
||||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||||
|
|
||||||
# Mizan handles caching via its protocol; origin emits no-store
|
# Origin emits no-store
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_context_error_not_cached(self):
|
def test_context_error_not_cached(self):
|
||||||
@@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelTests(TestCase):
|
class ChannelTests(TestCase):
|
||||||
"""Tests for ReactChannel."""
|
"""Tests for Channel."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -1187,8 +1185,8 @@ class ChannelTests(TestCase):
|
|||||||
"""Test channel registration."""
|
"""Test channel registration."""
|
||||||
from mizan.channels import register as register_channel, get_channel
|
from mizan.channels import register as register_channel, get_channel
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -1201,14 +1199,14 @@ class ChannelTests(TestCase):
|
|||||||
"""Test channel schema export."""
|
"""Test channel schema export."""
|
||||||
from mizan.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: int
|
room: int
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -1225,16 +1223,16 @@ class ChannelTests(TestCase):
|
|||||||
chat_schema = schema["channels"]["chat"]
|
chat_schema = schema["channels"]["chat"]
|
||||||
self.assertEqual(chat_schema["type"], "channel")
|
self.assertEqual(chat_schema["type"], "channel")
|
||||||
self.assertIn("params", chat_schema)
|
self.assertIn("params", chat_schema)
|
||||||
self.assertIn("react_message", chat_schema)
|
self.assertIn("client_message", chat_schema)
|
||||||
self.assertIn("django_message", chat_schema)
|
self.assertIn("server_message", chat_schema)
|
||||||
self.assertTrue(chat_schema["bidirectional"])
|
self.assertTrue(chat_schema["bidirectional"])
|
||||||
|
|
||||||
def test_server_push_only_channel(self):
|
def test_server_push_only_channel(self):
|
||||||
"""Test channel without ReactMessage (server-push only)."""
|
"""Test channel without ClientMessage (server-push only)."""
|
||||||
from mizan.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class NotificationsChannel(ReactChannel):
|
class NotificationsChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -1244,7 +1242,7 @@ class ChannelTests(TestCase):
|
|||||||
schema = get_schema()
|
schema = get_schema()
|
||||||
notif_schema = schema["channels"]["notifications"]
|
notif_schema = schema["channels"]["notifications"]
|
||||||
|
|
||||||
self.assertNotIn("react_message", notif_schema)
|
self.assertNotIn("client_message", notif_schema)
|
||||||
self.assertFalse(notif_schema["bidirectional"])
|
self.assertFalse(notif_schema["bidirectional"])
|
||||||
|
|
||||||
|
|
||||||
@@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
||||||
|
|
||||||
This is a regression test for the bug where `UserOutput | None` was
|
Union types are not recognized by `isinstance(t, type)`, so
|
||||||
incorrectly treated as a primitive type (because Union types aren't
|
`UserOutput | None` can be mistaken for a primitive and wrapped in a
|
||||||
recognized by `isinstance(t, type)`), causing the output to be wrapped
|
'result' field. This pins that it is not.
|
||||||
in a 'result' field.
|
|
||||||
"""
|
"""
|
||||||
import types
|
import types
|
||||||
|
|
||||||
@@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase):
|
|||||||
self.assertFalse(result.data["success"])
|
self.assertFalse(result.data["success"])
|
||||||
self.assertIn("errors", result.data)
|
self.assertIn("errors", result.data)
|
||||||
|
|
||||||
|
def test_form_submit_failure_calls_hook(self):
|
||||||
|
"""A rejected submission calls on_submit_failure with the validation errors."""
|
||||||
|
from django import forms
|
||||||
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class HookForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="failure_hook_test")
|
||||||
|
required_field = forms.CharField()
|
||||||
|
|
||||||
|
def on_submit_failure(self, request, errors):
|
||||||
|
seen.append(errors)
|
||||||
|
|
||||||
|
request = self._make_request()
|
||||||
|
result = execute_function(request, "failure_hook_test.submit", {})
|
||||||
|
|
||||||
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
self.assertFalse(result.data["success"])
|
||||||
|
self.assertEqual(len(seen), 1)
|
||||||
|
self.assertEqual([entry.field for entry in seen[0].errors], ["required_field"])
|
||||||
|
|
||||||
def test_form_meta_serialization(self):
|
def test_form_meta_serialization(self):
|
||||||
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
||||||
from mizan.forms import mizanFormMeta
|
from mizan.forms import mizanFormMeta
|
||||||
@@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase):
|
|||||||
self.assertEqual(len(result.data["fields"]), 1)
|
self.assertEqual(len(result.data["fields"]), 1)
|
||||||
self.assertEqual(result.data["fields"][0]["type"], "text")
|
self.assertEqual(result.data["fields"][0]["type"], "text")
|
||||||
|
|
||||||
|
def test_default_init_kwargs_forwards_request_only_when_declared(self):
|
||||||
|
"""The base get_init_kwargs passes `request` to a form whose __init__ names it."""
|
||||||
|
from django import forms
|
||||||
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
|
class PlainInitForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="plain_init_test")
|
||||||
|
field = forms.CharField()
|
||||||
|
|
||||||
|
class RequestInitForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="request_init_test")
|
||||||
|
field = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, *args, request=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.seen_request = request
|
||||||
|
|
||||||
|
request = self._make_request()
|
||||||
|
|
||||||
|
self.assertEqual(PlainInitForm.get_init_kwargs(request), {})
|
||||||
|
self.assertEqual(RequestInitForm.get_init_kwargs(request), {"request": request})
|
||||||
|
|
||||||
|
# And the form actually constructs with the forwarded kwarg.
|
||||||
|
form = RequestInitForm(**RequestInitForm.get_init_kwargs(request))
|
||||||
|
self.assertIs(form.seen_request, request)
|
||||||
|
|
||||||
def test_formset_functions_not_registered_by_default(self):
|
def test_formset_functions_not_registered_by_default(self):
|
||||||
"""Test that formset functions are not registered by default."""
|
"""Test that formset functions are not registered by default."""
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -1847,7 +1892,7 @@ class HTTPIntegrationTests(TestCase):
|
|||||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||||
self.assertEqual(data["user_orders"]["count"], 50)
|
self.assertEqual(data["user_orders"]["count"], 50)
|
||||||
|
|
||||||
# Mizan handles caching; origin emits no-store
|
# Origin emits no-store
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_context_fetch_string_to_int_coercion(self):
|
def test_context_fetch_string_to_int_coercion(self):
|
||||||
@@ -2133,14 +2178,15 @@ class ReturnTypeBranchingTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Edge Compatibility Tests — Prove CDN caching works before Edge exists
|
# Edge Compatibility Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class EdgeCompatibilityTests(TestCase):
|
class EdgeCompatibilityTests(TestCase):
|
||||||
"""
|
"""
|
||||||
Tests that prove Edge caching is possible. Every failure mode that
|
Response properties a CDN layer reads: byte-identical bodies for identical
|
||||||
would break a CDN layer is tested here without building the CDN.
|
requests, sorted JSON keys, no-store on mutations and errors, and an
|
||||||
|
X-Mizan-Invalidate header that parses back to the JSON body's targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Deterministic JSON ──────────────────────────────────────────────────
|
# ── Deterministic JSON ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_deterministic_json_output(self):
|
def test_deterministic_json_output(self):
|
||||||
"""Same request produces byte-identical response body. Cache keys depend on this."""
|
"""Same request produces a byte-identical response body."""
|
||||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
|
|
||||||
@@ -2204,12 +2250,12 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
def test_context_get_no_store(self):
|
def test_context_get_no_store(self):
|
||||||
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
"""Context GET emits no-store."""
|
||||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_mutation_post_not_cacheable(self):
|
def test_mutation_post_not_cacheable(self):
|
||||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
"""Mutation POST emits no-store."""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/mizan/call/",
|
"/api/mizan/call/",
|
||||||
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
||||||
@@ -2219,14 +2265,14 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_error_response_not_cacheable(self):
|
def test_error_response_not_cacheable(self):
|
||||||
"""Error responses have no-store. CDN must not cache errors."""
|
"""Error responses emit no-store."""
|
||||||
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_different_params_different_response(self):
|
def test_different_params_different_response(self):
|
||||||
"""Different query params produce different response bodies (different cache entries)."""
|
"""Different query params produce different response bodies."""
|
||||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
||||||
|
|
||||||
@@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
|
|
||||||
header = response["X-Mizan-Invalidate"]
|
header = response["X-Mizan-Invalidate"]
|
||||||
|
|
||||||
# Parse the header (this is what Edge would do)
|
# Parse the header back into structured entries
|
||||||
entries = []
|
entries = []
|
||||||
for part in header.split(", "):
|
for part in header.split(", "):
|
||||||
segments = part.split(";")
|
segments = part.split(";")
|
||||||
@@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Query param ordering doesn't affect content ─────────────────────────
|
# ── Query param ordering doesn't affect content ─────────────────────────
|
||||||
|
|
||||||
def test_param_order_irrelevant(self):
|
def test_param_order_irrelevant(self):
|
||||||
"""Different query param ordering produces same content (cache key normalization)."""
|
"""Different query param ordering produces the same content."""
|
||||||
@client(context=ReactContext("multi"))
|
@client(context=ReactContext("multi"))
|
||||||
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
||||||
return ValidOutput(valid=True)
|
return ValidOutput(valid=True)
|
||||||
@@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
]
|
]
|
||||||
header = _format_invalidate_header(original)
|
header = _format_invalidate_header(original)
|
||||||
|
|
||||||
# Parse (what Edge would do)
|
# Parse back
|
||||||
segments = header.split(";")
|
segments = header.split(";")
|
||||||
ctx = segments[0]
|
ctx = segments[0]
|
||||||
params = {}
|
params = {}
|
||||||
@@ -3339,7 +3385,8 @@ def _redis_available() -> bool:
|
|||||||
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
||||||
client.ping()
|
client.ping()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
Grouped by the surface each one drives: memory exhaustion, type confusion at
|
||||||
the protocol. Focus areas:
|
the serialization boundary, concurrent execution, Pydantic validation bypass,
|
||||||
|
WebSocket protocol framing, timing measurement, Unicode normalization, JSON
|
||||||
1. Race conditions and TOCTOU vulnerabilities
|
parsing limits, authorization boundaries, and registration collisions.
|
||||||
2. Memory exhaustion and resource depletion
|
|
||||||
3. Type confusion at serialization boundaries
|
|
||||||
4. Session/authentication state manipulation
|
|
||||||
5. Pydantic validation bypass attempts
|
|
||||||
6. WebSocket protocol-level attacks
|
|
||||||
7. Timing side-channel attacks
|
|
||||||
8. Concurrent state corruption
|
|
||||||
9. Deserialization attacks
|
|
||||||
10. Unicode normalization exploits
|
|
||||||
|
|
||||||
SAFE TO RUN: These tests don't execute actual exploits - they verify
|
|
||||||
that the defenses hold against attack patterns.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -485,9 +473,6 @@ class RaceConditionTests(TestCase):
|
|||||||
|
|
||||||
result = execute_function(request, "timed_auth_func", None)
|
result = execute_function(request, "timed_auth_func", None)
|
||||||
|
|
||||||
# The result reflects the state at time of check
|
|
||||||
# This test documents the behavior - the user's is_authenticated
|
|
||||||
# is read during execution, and if it changes, that's reflected
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
# On first access is_authenticated returns True, on second it returns False
|
# On first access is_authenticated returns True, on second it returns False
|
||||||
# The function reads is_authenticated once, so it sees the first value (True)
|
# The function reads is_authenticated once, so it sees the first value (True)
|
||||||
@@ -503,9 +488,8 @@ class PydanticBypassTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Attempt to bypass Pydantic validation.
|
Attempt to bypass Pydantic validation.
|
||||||
|
|
||||||
Note: The @client decorator creates dynamic Pydantic models from function
|
The @client decorator builds the Input model from the function's parameter
|
||||||
parameters. Custom validators must be on the parameter types themselves,
|
annotations, so a custom validator has to live on the parameter type itself.
|
||||||
not separate classes. This tests the actual validation behavior.
|
|
||||||
|
|
||||||
Attack vectors:
|
Attack vectors:
|
||||||
- Type coercion bypass
|
- Type coercion bypass
|
||||||
@@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
|
|
||||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||||
"""
|
"""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, Channel
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
@@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test attempting to subscribe to the same channel twice.
|
Test attempting to subscribe to the same channel twice.
|
||||||
"""
|
"""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, Channel
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase):
|
|||||||
# Large differences could leak function existence
|
# Large differences could leak function existence
|
||||||
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
||||||
|
|
||||||
# Document the ratio but don't fail - this is informational
|
|
||||||
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
||||||
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
||||||
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
||||||
@@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# These look like "admin" but use different Unicode characters
|
# Built from chr(): each of these renders identically to its ASCII
|
||||||
|
# counterpart, so a literal would be unreadable in source.
|
||||||
lookalikes = [
|
lookalikes = [
|
||||||
"\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a'
|
chr(0x0430) + "dmin", # Cyrillic small a
|
||||||
"adm\u0131n", # Turkish dotless i (U+0131)
|
"adm" + chr(0x0131) + "n", # Turkish dotless i
|
||||||
"\u00e1dmin", # Latin a with acute
|
chr(0x00E1) + "dmin", # Latin a with acute
|
||||||
"\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters
|
"".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)),
|
||||||
"\u0251dmin", # Latin alpha
|
chr(0x0251) + "dmin", # Latin alpha
|
||||||
"\u0430\u0501m\u0456n", # Mix of Cyrillic characters
|
chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix
|
||||||
]
|
]
|
||||||
|
|
||||||
for lookalike in lookalikes:
|
for lookalike in lookalikes:
|
||||||
@@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# é can be represented as:
|
# Built from chr() so the two spellings stay distinguishable in source:
|
||||||
# 1. U+00E9 (precomposed)
|
# U+00E9 precomposed vs. "e" + U+0301 combining acute.
|
||||||
# 2. U+0065 U+0301 (decomposed: e + combining acute)
|
precomposed = "caf" + chr(0x00E9)
|
||||||
precomposed = "caf\u00e9" # café with precomposed é
|
decomposed = "cafe" + chr(0x0301)
|
||||||
decomposed = "cafe\u0301" # café with combining acute
|
|
||||||
|
|
||||||
# These look identical but are different byte sequences
|
# These look identical but are different byte sequences
|
||||||
self.assertNotEqual(precomposed, decomposed)
|
self.assertNotEqual(precomposed, decomposed)
|
||||||
@@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
|
# Built from chr(): every one of these renders as nothing at all.
|
||||||
zero_width_chars = [
|
zero_width_chars = [
|
||||||
"\u200b", # Zero-width space
|
chr(0x200B), # Zero-width space
|
||||||
"\u200c", # Zero-width non-joiner
|
chr(0x200C), # Zero-width non-joiner
|
||||||
"\u200d", # Zero-width joiner
|
chr(0x200D), # Zero-width joiner
|
||||||
"\u2060", # Word joiner
|
chr(0x2060), # Word joiner
|
||||||
"\ufeff", # Zero-width no-break space (BOM)
|
chr(0xFEFF), # Zero-width no-break space (BOM)
|
||||||
]
|
]
|
||||||
|
|
||||||
for zwc in zero_width_chars:
|
for zwc in zero_width_chars:
|
||||||
@@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = execute_function(request, "json_func", {"data": nested})
|
result = execute_function(request, "json_func", {"data": nested})
|
||||||
# Should either succeed or fail gracefully
|
|
||||||
self.assertIn(type(result), [FunctionResult, FunctionError])
|
self.assertIn(type(result), [FunctionResult, FunctionError])
|
||||||
except RecursionError:
|
except RecursionError as exc:
|
||||||
# This is acceptable - Python's recursion limit hit
|
print(f"\nCPython recursion limit reached at 500 levels: {exc}")
|
||||||
pass
|
|
||||||
|
|
||||||
def test_json_number_precision(self):
|
def test_json_number_precision(self):
|
||||||
"""
|
"""
|
||||||
@@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test that a different function cannot override an existing one.
|
Test that a different function cannot override an existing one.
|
||||||
|
|
||||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
Re-registering the same object under its own name is allowed; a
|
||||||
But a DIFFERENT function cannot take over an existing name.
|
different object claiming a taken name raises.
|
||||||
"""
|
"""
|
||||||
from mizan.client import ServerFunction
|
from mizan.client import ServerFunction
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Security-focused E2E tests for mizan server functions.
|
Adversarial-input tests: hostile payloads driven through execute_function,
|
||||||
|
function_call_view, and the WebSocket consumer.
|
||||||
These tests probe for potential vulnerabilities without running any
|
|
||||||
malicious code - they simply verify that defenses work correctly.
|
|
||||||
|
|
||||||
Security areas covered:
|
|
||||||
1. Input Validation - Large inputs, nested objects, type confusion
|
|
||||||
2. Authorization - Bypass attempts, permission checks
|
|
||||||
3. HTTP Endpoint - CSRF, method restrictions, JSON parsing
|
|
||||||
4. WebSocket RPC - Malformed messages, unauthorized calls
|
|
||||||
5. Information Disclosure - Error enumeration, internal detail leakage
|
|
||||||
6. Injection Prevention - Special characters, unicode edge cases
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import RequestFactory, TestCase, Client, override_settings
|
from django.test import RequestFactory, TestCase, Client, override_settings
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.client.executor import (
|
from mizan.client.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
@@ -29,9 +19,9 @@ from mizan.client.executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
function_call_view,
|
||||||
)
|
)
|
||||||
from mizan_core.registry import clear_registry, register, register_as, get_function
|
from mizan_core.registry import clear_registry, register
|
||||||
from mizan.client import ServerFunction, client
|
from mizan.client import client
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -46,10 +36,6 @@ class SimpleOutput(BaseModel):
|
|||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class NestedInput(BaseModel):
|
|
||||||
level1: dict
|
|
||||||
|
|
||||||
|
|
||||||
class DeeplyNestedOutput(BaseModel):
|
class DeeplyNestedOutput(BaseModel):
|
||||||
depth: int
|
depth: int
|
||||||
|
|
||||||
@@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class InputValidationSecurityTests(TestCase):
|
class InputValidationSecurityTests(TestCase):
|
||||||
"""
|
"""Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields."""
|
||||||
Test input validation for security edge cases.
|
|
||||||
|
|
||||||
Verifies that Pydantic validation catches malicious or malformed input
|
|
||||||
BEFORE any function code executes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
"""Test that null bytes in strings are handled safely."""
|
"""Test that null bytes in strings are handled safely."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Null byte injection attempt
|
|
||||||
payload = "normal\x00injected"
|
payload = "normal\x00injected"
|
||||||
result = execute_function(request, "echo_any", {"message": payload})
|
result = execute_function(request, "echo_any", {"message": payload})
|
||||||
|
|
||||||
@@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
"""Test various unicode edge cases."""
|
"""Test various unicode edge cases."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
|
# Escapes, not literals: these codepoints are invisible in an editor, and
|
||||||
|
# a literal NUL cannot appear in Python source at all.
|
||||||
test_cases = [
|
test_cases = [
|
||||||
# Zero-width characters
|
# Zero-width characters
|
||||||
"normal\u200btext",
|
"normaltext",
|
||||||
# Right-to-left override (potential display issues)
|
# Right-to-left override (potential display issues)
|
||||||
"test\u202eevil",
|
"testevil",
|
||||||
# Emoji sequences
|
# Emoji sequences
|
||||||
"👨👩👧👦",
|
"\U0001f468\U0001f469\U0001f467\U0001f466",
|
||||||
# Combining characters
|
# Combining characters
|
||||||
"a\u0300\u0301\u0302",
|
"à́̂",
|
||||||
# Null character
|
# Null character
|
||||||
"test\u0000null",
|
"test\x00null",
|
||||||
# Replacement character
|
# Replacement character
|
||||||
"test\ufffdreplace",
|
"test<EFBFBD>replace",
|
||||||
]
|
]
|
||||||
|
|
||||||
for payload in test_cases:
|
for payload in test_cases:
|
||||||
@@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AuthorizationSecurityTests(TestCase):
|
class AuthorizationSecurityTests(TestCase):
|
||||||
"""
|
"""execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers."""
|
||||||
Test authorization bypass attempts.
|
|
||||||
|
|
||||||
Verifies that authentication/authorization checks can't be bypassed
|
|
||||||
through various attack vectors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
||||||
# Bad pattern: returns different errors for auth vs not found
|
|
||||||
# This is intentionally bad to test we detect it
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("User not logged in")
|
raise PermissionError("User not logged in")
|
||||||
return SimpleOutput(value="ok")
|
return SimpleOutput(value="ok")
|
||||||
@@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
|
||||||
def test_spoofed_is_authenticated_attribute(self):
|
def test_spoofed_is_authenticated_attribute(self):
|
||||||
"""Test that spoofing is_authenticated doesn't work."""
|
"""Test that a duck-typed user carrying is_authenticated is accepted."""
|
||||||
|
|
||||||
# Create object that claims to be authenticated but isn't a real user
|
|
||||||
class FakeUser:
|
class FakeUser:
|
||||||
is_authenticated = True
|
is_authenticated = True
|
||||||
id = 999
|
id = 999
|
||||||
@@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
request = self._make_request(user=FakeUser())
|
request = self._make_request(user=FakeUser())
|
||||||
result = execute_function(request, "requires_auth", None)
|
result = execute_function(request, "requires_auth", None)
|
||||||
|
|
||||||
# This actually works because we only check is_authenticated
|
# execute_function only reads is_authenticated, so this duck-type passes
|
||||||
# This test documents the behavior - real Django handles this
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
|
||||||
def test_user_id_manipulation_blocked(self):
|
def test_user_id_manipulation_blocked(self):
|
||||||
@@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
||||||
# Properly checking: can only access own data
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
if request.user.id != target_user_id:
|
if request.user.id != target_user_id:
|
||||||
@@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class HTTPEndpointSecurityTests(TestCase):
|
class HTTPEndpointSecurityTests(TestCase):
|
||||||
"""
|
"""Method restrictions, JSON body parsing, and function-name lookup on the HTTP view."""
|
||||||
Test HTTP endpoint security.
|
|
||||||
|
|
||||||
Verifies CSRF protection, method restrictions, and JSON parsing security.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
# Bypass CSRF for this test
|
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
response = function_call_view(request)
|
response = function_call_view(request)
|
||||||
@@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_function_identifier_traversal(self):
|
def test_function_identifier_traversal(self):
|
||||||
"""Test that path traversal-style function identifiers are handled."""
|
"""Test that path traversal-style function identifiers are handled."""
|
||||||
# Try various path traversal attempts as function identifiers
|
|
||||||
malicious_names = [
|
malicious_names = [
|
||||||
"../../../etc/passwd",
|
"../../../etc/passwd",
|
||||||
"..\\..\\windows\\system32",
|
"..\\..\\windows\\system32",
|
||||||
@@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WebSocketRPCSecurityTests(TestCase):
|
class WebSocketRPCSecurityTests(TestCase):
|
||||||
"""
|
"""Malformed and unresolvable RPC frames over the WebSocket consumer."""
|
||||||
Test WebSocket RPC security.
|
|
||||||
|
|
||||||
Verifies that malformed messages and unauthorized calls are handled safely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
consumer.channel_layer = MagicMock()
|
consumer.channel_layer = MagicMock()
|
||||||
consumer.channel_name = "test"
|
consumer.channel_name = "test"
|
||||||
|
|
||||||
# Track sent messages
|
|
||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call without id
|
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{"fn": "ws_echo", "args": {"message": "test"}}
|
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||||
)
|
)
|
||||||
@@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call without fn
|
|
||||||
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
||||||
|
|
||||||
# Should return error
|
|
||||||
self.assertEqual(len(sent_messages), 1)
|
self.assertEqual(len(sent_messages), 1)
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
||||||
@@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call with wrong input type
|
# Pydantic coerces an int to str, so an omitted required field is what
|
||||||
|
# actually produces a validation error here.
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{
|
{
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"fn": "ws_echo",
|
"fn": "ws_echo",
|
||||||
"args": {"message": 12345}, # Should be string
|
"args": {"message": 12345},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pydantic coerces int to string, so this actually succeeds
|
|
||||||
# Let's test with missing required field instead
|
|
||||||
sent_messages.clear()
|
sent_messages.clear()
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
{"id": "124", "fn": "ws_echo", "args": {}}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
@@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InformationDisclosureTests(TestCase):
|
class InformationDisclosureTests(TestCase):
|
||||||
"""
|
"""Contents of FunctionError responses with DEBUG=False."""
|
||||||
Test information disclosure vulnerabilities.
|
|
||||||
|
|
||||||
Verifies that error messages don't leak sensitive information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
||||||
# Simulate accessing sensitive config that might leak in error
|
|
||||||
secret_key = "super_secret_key_12345"
|
secret_key = "super_secret_key_12345"
|
||||||
raise RuntimeError(f"Database error with key: {secret_key}")
|
raise RuntimeError(f"Database error with key: {secret_key}")
|
||||||
|
|
||||||
@@ -703,7 +655,6 @@ class InformationDisclosureTests(TestCase):
|
|||||||
"""Test that error messages don't help enumerate functions in production."""
|
"""Test that error messages don't help enumerate functions in production."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Try various function names/UUIDs
|
|
||||||
test_names = [
|
test_names = [
|
||||||
"admin_panel",
|
"admin_panel",
|
||||||
"get_all_users",
|
"get_all_users",
|
||||||
@@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
result = execute_function(request, name, None)
|
result = execute_function(request, name, None)
|
||||||
self.assertIsInstance(result, FunctionError)
|
self.assertIsInstance(result, FunctionError)
|
||||||
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
||||||
# In production (DEBUG=False), error message is generic
|
# With DEBUG=False the message is identical for every name
|
||||||
# - doesn't reveal function name or UUID existence
|
|
||||||
self.assertEqual(result.message, "Function not found")
|
self.assertEqual(result.message, "Function not found")
|
||||||
|
|
||||||
def test_validation_errors_dont_leak_internals(self):
|
def test_validation_errors_dont_leak_internals(self):
|
||||||
@@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
result = execute_function(request, "validated_func", {"secret_field": 123})
|
result = execute_function(request, "validated_func", {"secret_field": 123})
|
||||||
|
|
||||||
# Pydantic coerces to string, so let's try with wrong structure
|
# Pydantic coerces to string, so an unknown field is what fails here.
|
||||||
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
||||||
|
|
||||||
self.assertIsInstance(result, FunctionError)
|
self.assertIsInstance(result, FunctionError)
|
||||||
@@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InjectionPreventionTests(TestCase):
|
class InjectionPreventionTests(TestCase):
|
||||||
"""
|
"""SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions."""
|
||||||
Test injection attack prevention.
|
|
||||||
|
|
||||||
Verifies that input validation prevents various injection attacks.
|
|
||||||
Note: These tests verify the framework's security, not actual injection
|
|
||||||
attempts - they just ensure malicious input is handled safely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
||||||
# This function just echoes - the test is about validation
|
|
||||||
return SimpleOutput(value=user_input)
|
return SimpleOutput(value=user_input)
|
||||||
|
|
||||||
register(echo_safe, "echo_safe")
|
register(echo_safe, "echo_safe")
|
||||||
@@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
for payload in sql_payloads:
|
for payload in sql_payloads:
|
||||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||||
# Should succeed - it's just a string, not executed as SQL
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
# The payload is returned as-is (no SQL execution)
|
|
||||||
self.assertEqual(result.data["value"], payload)
|
self.assertEqual(result.data["value"], payload)
|
||||||
|
|
||||||
def test_command_injection_in_string_field(self):
|
def test_command_injection_in_string_field(self):
|
||||||
@@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
for payload in cmd_payloads:
|
for payload in cmd_payloads:
|
||||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||||
# Should succeed - it's just a string
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["value"], payload)
|
self.assertEqual(result.data["value"], payload)
|
||||||
|
|
||||||
@@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase):
|
|||||||
"""Test that special JSON values are handled safely."""
|
"""Test that special JSON values are handled safely."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Various JSON edge cases
|
|
||||||
test_cases = [
|
test_cases = [
|
||||||
{"__proto__": {"polluted": True}},
|
{"__proto__": {"polluted": True}},
|
||||||
{"constructor": {"prototype": {}}},
|
{"constructor": {"prototype": {}}},
|
||||||
{"key": None},
|
{"key": None},
|
||||||
{"key": float("inf")}, # This will fail JSON serialization
|
{"key": float("inf")},
|
||||||
]
|
]
|
||||||
|
|
||||||
for data in test_cases:
|
for data in test_cases:
|
||||||
@@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelAuthorizationTests(TestCase):
|
class ChannelAuthorizationTests(TestCase):
|
||||||
"""
|
"""Subscription outcomes when authorize() returns False, raises, or gets bad params."""
|
||||||
Test WebSocket channel authorization.
|
|
||||||
|
|
||||||
Verifies that channel subscriptions properly check permissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
# Also clear the channels registry
|
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
@@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def _register_test_channels(self):
|
def _register_test_channels(self):
|
||||||
"""Register test channels using the channels module's register."""
|
"""Register test channels using the channels module's register."""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class PublicChannel(ReactChannel):
|
class PublicChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "public"
|
return "public"
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "auth"
|
return "auth"
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room_id: int
|
room_id: int
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
# Only allow access to room 1 and 2
|
|
||||||
return params.room_id in [1, 2]
|
return params.room_id in [1, 2]
|
||||||
|
|
||||||
def group(self, params):
|
def group(self, params):
|
||||||
@@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def test_authorize_exception_handling(self):
|
def test_authorize_exception_handling(self):
|
||||||
"""Test that exceptions in authorize() are handled safely."""
|
"""Test that exceptions in authorize() are handled safely."""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel
|
||||||
from mizan.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
class ErrorChannel(ReactChannel):
|
class ErrorChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
{"channel": "auth-channel", "params": {}}
|
{"channel": "auth-channel", "params": {}}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should be rejected
|
|
||||||
self.assertIn("error", sent_messages[0])
|
self.assertIn("error", sent_messages[0])
|
||||||
self.assertIn("Not authorized", sent_messages[0]["error"])
|
self.assertIn("Not authorized", sent_messages[0]["error"])
|
||||||
|
|
||||||
@@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should fail validation
|
|
||||||
self.assertIn("error", sent_messages[0])
|
self.assertIn("error", sent_messages[0])
|
||||||
|
|
||||||
def test_room_authorization_enforced(self):
|
def test_room_authorization_enforced(self):
|
||||||
@@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AbusePreventionTests(TestCase):
|
class AbusePreventionTests(TestCase):
|
||||||
"""
|
"""Repeated and batched execute_function calls."""
|
||||||
Test abuse prevention capabilities.
|
|
||||||
|
|
||||||
Note: The current implementation doesn't have built-in rate limiting,
|
|
||||||
so these tests document the expected behavior and identify areas
|
|
||||||
where rate limiting should be added.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase):
|
|||||||
"""Test that rapid function calls don't cause issues."""
|
"""Test that rapid function calls don't cause issues."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Make 100 rapid calls
|
|
||||||
results = []
|
results = []
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
result = execute_function(request, "simple_func", None)
|
result = execute_function(request, "simple_func", None)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
# All should succeed (no rate limiting currently) and return expected data
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["value"], "ok")
|
self.assertEqual(result.data["value"], "ok")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the Mizan SSR bridge and template backend.
|
Tests for the SSR bridge and the MizanTemplates Django template backend.
|
||||||
|
|
||||||
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
The bridge shells out to Bun, so every test here skips unless `bun` is on PATH
|
||||||
Tests skip gracefully if Bun is not available.
|
and the worker script is present.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -11,13 +11,12 @@ import threading
|
|||||||
|
|
||||||
from django.test import SimpleTestCase, RequestFactory
|
from django.test import SimpleTestCase, RequestFactory
|
||||||
|
|
||||||
# Path to the test worker
|
_REPO_ROOT = os.path.normpath(
|
||||||
_SSR_WORKER = os.path.join(
|
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
|
||||||
os.path.dirname(__file__),
|
|
||||||
"..", "..", "..", "..", "..", # up to repo root
|
|
||||||
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
|
||||||
)
|
)
|
||||||
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
_SSR_WORKER = os.path.join(_REPO_ROOT, "workers", "mizan-ssr", "src", "worker.tsx")
|
||||||
|
_COMPONENT_DIR = os.path.join(os.path.dirname(__file__), "ssr_components")
|
||||||
|
_HELLO = os.path.join(_COMPONENT_DIR, "Hello.tsx")
|
||||||
|
|
||||||
_BUN_AVAILABLE = shutil.which("bun") is not None
|
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||||
_SKIP_MSG = "Bun not available"
|
_SKIP_MSG = "Bun not available"
|
||||||
@@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase):
|
|||||||
if not _BUN_AVAILABLE:
|
if not _BUN_AVAILABLE:
|
||||||
self.skipTest(_SKIP_MSG)
|
self.skipTest(_SKIP_MSG)
|
||||||
if not os.path.exists(_SSR_WORKER):
|
if not os.path.exists(_SSR_WORKER):
|
||||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
from mizan.ssr.bridge import SSRBridge
|
from mizan.ssr.bridge import SSRBridge
|
||||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=10.0)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if hasattr(self, "bridge"):
|
if hasattr(self, "bridge"):
|
||||||
self.bridge.shutdown()
|
self.bridge.shutdown()
|
||||||
|
|
||||||
def test_ping(self):
|
def test_render_starts_worker_and_returns_html(self):
|
||||||
"""Worker starts and responds to ping."""
|
"""The first render boots the worker and returns rendered markup."""
|
||||||
self.assertTrue(self.bridge.ping())
|
result = self.bridge.render(_HELLO, {"name": "World"})
|
||||||
|
|
||||||
def test_render_simple(self):
|
|
||||||
"""Renders a simple component to HTML."""
|
|
||||||
result = self.bridge.render("Hello", {"name": "World"})
|
|
||||||
self.assertIn("Hello,", result.html)
|
self.assertIn("Hello,", result.html)
|
||||||
self.assertIn("World", result.html)
|
self.assertIn("World", result.html)
|
||||||
|
|
||||||
def test_render_with_props(self):
|
def test_render_passes_props_through(self):
|
||||||
"""Renders a component with multiple props."""
|
"""Props reach the component."""
|
||||||
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
result = self.bridge.render(_HELLO, {"name": "Alice"})
|
||||||
self.assertIn("Alice", result.html)
|
self.assertIn("Alice", result.html)
|
||||||
self.assertIn("42", result.html)
|
self.assertIn('data-mizan-component="Hello"', result.html)
|
||||||
|
|
||||||
def test_render_missing_component(self):
|
def test_render_missing_file_raises(self):
|
||||||
"""Rendering an unregistered component raises RuntimeError."""
|
"""Rendering a path with no module raises RuntimeError naming the failure."""
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx")
|
||||||
self.bridge.render("NonExistent", {})
|
with self.assertRaises(RuntimeError):
|
||||||
self.assertIn("not registered", str(ctx.exception))
|
self.bridge.render(missing, {})
|
||||||
|
|
||||||
def test_render_error(self):
|
|
||||||
"""Component that throws during render raises RuntimeError."""
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
self.bridge.render("Broken", {})
|
|
||||||
self.assertIn("Render error", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_crash_recovery(self):
|
def test_crash_recovery(self):
|
||||||
"""Bridge restarts the worker if it dies."""
|
"""The bridge restarts the worker if it dies."""
|
||||||
# First render works
|
result = self.bridge.render(_HELLO, {"name": "Before"})
|
||||||
result = self.bridge.render("Hello", {"name": "Before"})
|
|
||||||
self.assertIn("Before", result.html)
|
self.assertIn("Before", result.html)
|
||||||
|
|
||||||
# Kill the subprocess
|
|
||||||
self.bridge._proc.kill()
|
self.bridge._proc.kill()
|
||||||
self.bridge._proc.wait()
|
self.bridge._proc.wait()
|
||||||
|
|
||||||
# Next render should restart and work
|
result = self.bridge.render(_HELLO, {"name": "After"})
|
||||||
result = self.bridge.render("Hello", {"name": "After"})
|
|
||||||
self.assertIn("After", result.html)
|
self.assertIn("After", result.html)
|
||||||
|
|
||||||
def test_concurrent_renders(self):
|
def test_concurrent_renders(self):
|
||||||
"""Multiple threads can render simultaneously."""
|
"""Concurrent callers each get their own response matched by message id."""
|
||||||
results = {}
|
results = {}
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
def render_in_thread(name: str, idx: int):
|
def render_in_thread(name: str, idx: int):
|
||||||
try:
|
try:
|
||||||
result = self.bridge.render("Hello", {"name": name})
|
results[idx] = self.bridge.render(_HELLO, {"name": name}).html
|
||||||
results[idx] = result.html
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors[idx] = e
|
errors[idx] = e
|
||||||
|
|
||||||
@@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase):
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
for t in threads:
|
for t in threads:
|
||||||
t.join(timeout=10)
|
t.join(timeout=20)
|
||||||
|
|
||||||
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
self.assertEqual(errors, {})
|
||||||
self.assertEqual(len(results), 5)
|
self.assertEqual(len(results), 5)
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
self.assertIn(f"User{i}", results[i])
|
self.assertIn(f"User{i}", results[i])
|
||||||
@@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
|||||||
if not _BUN_AVAILABLE:
|
if not _BUN_AVAILABLE:
|
||||||
self.skipTest(_SKIP_MSG)
|
self.skipTest(_SKIP_MSG)
|
||||||
if not os.path.exists(_SSR_WORKER):
|
if not os.path.exists(_SSR_WORKER):
|
||||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
from mizan.ssr.backend import MizanTemplates
|
from mizan.ssr.backend import MizanTemplates
|
||||||
self.engine = MizanTemplates({
|
self.engine = MizanTemplates({
|
||||||
"NAME": "mizan-test",
|
"NAME": "mizan-test",
|
||||||
"DIRS": [],
|
"DIRS": [_COMPONENT_DIR],
|
||||||
"APP_DIRS": False,
|
"APP_DIRS": False,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"worker_path": _SSR_WORKER,
|
"worker": _SSR_WORKER,
|
||||||
"timeout": 5,
|
"timeout": 10,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
@@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
|||||||
if hasattr(self, "engine") and self.engine._bridge is not None:
|
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||||
self.engine._bridge.shutdown()
|
self.engine._bridge.shutdown()
|
||||||
|
|
||||||
def test_get_template(self):
|
def test_get_template_resolves_name_to_file(self):
|
||||||
"""get_template returns a MizanTemplate."""
|
"""get_template resolves the name against DIRS to an absolute file path."""
|
||||||
from mizan.ssr.backend import MizanTemplate
|
from mizan.ssr.backend import MizanTemplate
|
||||||
template = self.engine.get_template("Hello")
|
template = self.engine.get_template("Hello.tsx")
|
||||||
self.assertIsInstance(template, MizanTemplate)
|
self.assertIsInstance(template, MizanTemplate)
|
||||||
self.assertEqual(template.component_name, "Hello")
|
self.assertEqual(template.file_path, os.path.abspath(_HELLO))
|
||||||
|
|
||||||
def test_template_render(self):
|
def test_missing_template_raises(self):
|
||||||
"""MizanTemplate.render() produces HTML."""
|
"""A name that resolves to no file under DIRS raises TemplateDoesNotExist."""
|
||||||
template = self.engine.get_template("Hello")
|
from django.template import TemplateDoesNotExist
|
||||||
|
with self.assertRaises(TemplateDoesNotExist):
|
||||||
|
self.engine.get_template("NoSuchComponent.tsx")
|
||||||
|
|
||||||
|
def test_template_render_emits_html_and_hydration_data(self):
|
||||||
|
"""render() wraps the markup and serializes the props for hydration."""
|
||||||
|
template = self.engine.get_template("Hello.tsx")
|
||||||
html = template.render({"name": "Django"})
|
html = template.render({"name": "Django"})
|
||||||
self.assertIn("Hello,", html)
|
self.assertIn("Hello,", html)
|
||||||
self.assertIn("Django", html)
|
self.assertIn("Django", html)
|
||||||
self.assertIn('data-mizan-component="Hello"', html)
|
self.assertIn('id="mizan-root"', html)
|
||||||
|
self.assertIn('window.__MIZAN_SSR_DATA__={"name": "Django"}', html)
|
||||||
|
|
||||||
def test_template_render_strips_django_internals(self):
|
def test_template_render_strips_django_internals(self):
|
||||||
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
"""request and csrf_token are dropped from props and from hydration data."""
|
||||||
template = self.engine.get_template("Hello")
|
template = self.engine.get_template("Hello.tsx")
|
||||||
request = self.factory.get("/")
|
request = self.factory.get("/")
|
||||||
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
html = template.render(
|
||||||
|
{"name": "Test", "request": request, "csrf_token": "abc"}, request
|
||||||
|
)
|
||||||
self.assertIn("Test", html)
|
self.assertIn("Test", html)
|
||||||
|
self.assertNotIn("csrf_token", html)
|
||||||
|
self.assertNotIn("abc", html)
|
||||||
|
|
||||||
def test_from_string_raises(self):
|
def test_from_string_is_unsupported(self):
|
||||||
"""from_string is not supported."""
|
"""This engine renders modules by path, so it has no source-string form."""
|
||||||
from django.template import TemplateDoesNotExist
|
with self.assertRaises(NotImplementedError):
|
||||||
with self.assertRaises(TemplateDoesNotExist):
|
|
||||||
self.engine.from_string("<div>Not supported</div>")
|
self.engine.from_string("<div>Not supported</div>")
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
mizan URL Configuration
|
mizan's HTTP endpoints: session bootstrap, the server-function call endpoint,
|
||||||
|
and the bundled per-context fetch. Schema export is reachable only through the
|
||||||
HTTP endpoints:
|
`export_mizan_ir` management command, never over HTTP.
|
||||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
|
||||||
- POST /call/ - Server function calls (HTTP transport)
|
|
||||||
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
|
||||||
- Use the management command instead: python manage.py export_mizan_ir
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -16,7 +9,7 @@ from django.middleware.csrf import get_token
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
||||||
from .client.executor import function_call_view, context_fetch_view
|
from mizan.client.executor import function_call_view, context_fetch_view
|
||||||
|
|
||||||
app_name = "mizan"
|
app_name = "mizan"
|
||||||
|
|
||||||
@@ -24,13 +17,8 @@ app_name = "mizan"
|
|||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def session_init_view(request):
|
def session_init_view(request):
|
||||||
"""
|
"""
|
||||||
Initialize a Django session and return the CSRF token.
|
Start a Django session and return `{"csrfToken": ...}`. The decorator is
|
||||||
|
what puts the csrftoken cookie on the response.
|
||||||
Used by SSR to establish a session before making authenticated requests.
|
|
||||||
The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{ "csrfToken": "..." }
|
|
||||||
"""
|
"""
|
||||||
return JsonResponse({"csrfToken": get_token(request)})
|
return JsonResponse({"csrfToken": get_token(request)})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
AbstractBaseUser,
|
AbstractBaseUser,
|
||||||
BaseUserManager,
|
BaseUserManager,
|
||||||
@@ -7,8 +9,6 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class EmailUserManager(BaseUserManager):
|
class EmailUserManager(BaseUserManager):
|
||||||
"""Custom user manager using email as the unique identifier."""
|
|
||||||
|
|
||||||
def create_user(self, email, password=None, **extra_fields):
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
if not email:
|
if not email:
|
||||||
raise ValueError("Email is required")
|
raise ValueError("Email is required")
|
||||||
@@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager):
|
|||||||
|
|
||||||
|
|
||||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||||
"""Minimal user model with email as USERNAME_FIELD.
|
|
||||||
|
|
||||||
Matches the calling convention used in mizan's test suite:
|
|
||||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
|
||||||
"""
|
|
||||||
|
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
@@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
app_label = "tests"
|
app_label = "tests"
|
||||||
|
|
||||||
|
|
||||||
# ─── Shape test models ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin(models.Model):
|
class TimestampMixin(models.Model):
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
"""
|
|
||||||
Django settings for running mizan's test suite standalone.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
cd django/
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
pytest
|
|
||||||
"""
|
|
||||||
|
|
||||||
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
@@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls"
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# JWT settings for test_auth.py (can be overridden per-class with @override_settings)
|
|
||||||
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
|
|
||||||
# Session engine (for test_auth.py SessionStore usage)
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||||
|
|
||||||
HTTP RPC dispatch, context bundling, and a WebSocket carrying channel
|
Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket),
|
||||||
subscriptions and RPC on mizan-django's envelope. Forms, Shapes and SSR are
|
the error hierarchy with its exception handlers, and the channel base class with
|
||||||
out of scope — FastAPI projects use native equivalents.
|
its registry.
|
||||||
|
|
||||||
Usage:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(router, prefix="/api/mizan")
|
|
||||||
app.include_router(ws_router, prefix="/api/mizan")
|
|
||||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
|
||||||
|
|
||||||
# Register your @client-decorated functions
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.registry import register
|
|
||||||
from .my_functions import echo
|
|
||||||
register(echo, "echo")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .executor import (
|
from mizan_fastapi.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
MizanError,
|
MizanError,
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -34,14 +19,19 @@ from .executor import (
|
|||||||
compute_invalidation,
|
compute_invalidation,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
from .websocket import ws_router
|
from mizan_fastapi.websocket import ws_router
|
||||||
from .channels import ReactChannel, broadcast, get_channel, register as register_channel
|
from mizan_fastapi.channels import (
|
||||||
|
Channel,
|
||||||
|
broadcast,
|
||||||
|
get_channel,
|
||||||
|
register as register_channel,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
"ws_router",
|
"ws_router",
|
||||||
"ReactChannel",
|
"Channel",
|
||||||
"register_channel",
|
"register_channel",
|
||||||
"get_channel",
|
"get_channel",
|
||||||
"broadcast",
|
"broadcast",
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ A channel names a group of subscribers and decides who may join it. `group(param
|
|||||||
is the fan-out key, so two subscribers with the same params share a group and a push
|
is the fan-out key, so two subscribers with the same params share a group and a push
|
||||||
addressed to those params reaches both.
|
addressed to those params reaches both.
|
||||||
|
|
||||||
Membership is held in this process. Django's channel layer carries groups across
|
Membership is held in this process. A push therefore reaches only the subscribers
|
||||||
workers through Redis; nothing here does, so a push reaches only the subscribers
|
whose socket is attached to the process that sent it.
|
||||||
connected to the process that sent it. One process — a desktop shell, a single
|
|
||||||
uvicorn worker — is the shape this serves.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,20 +18,27 @@ from typing import Any, ClassVar
|
|||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.registry import RegistryExtension, register_extension
|
||||||
|
|
||||||
# group name -> the live sockets subscribed to it
|
# group name -> the live sockets subscribed to it
|
||||||
_groups: dict[str, set[Any]] = defaultdict(set)
|
_groups: dict[str, set[Any]] = defaultdict(set)
|
||||||
_registry: dict[str, type["ReactChannel"]] = {}
|
_registry: dict[str, type["Channel"]] = {}
|
||||||
_lock = asyncio.Lock()
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
class ReactChannel:
|
class Channel:
|
||||||
"""A named fan-out. Subclass, override what the channel decides, and register it.
|
"""A named fan-out. Subclass, override what the channel decides, and register it.
|
||||||
|
|
||||||
A subclass may define `on_connect(params)` / `on_disconnect()`; the socket handler
|
The three nested models are the channel's payload types, named from the client's
|
||||||
calls them when they exist.
|
side: `Params` keys the fan-out, `ClientMessage` travels up, `ServerMessage`
|
||||||
|
travels down. A subclass may also define `on_connect(params)` / `on_disconnect()`;
|
||||||
|
the socket handler calls them when they exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: ClassVar[str] = ""
|
name: ClassVar[str] = ""
|
||||||
|
Params: ClassVar[type[BaseModel] | None] = None
|
||||||
|
ClientMessage: ClassVar[type[BaseModel] | None] = None
|
||||||
|
ServerMessage: ClassVar[type[BaseModel] | None] = None
|
||||||
|
|
||||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||||
"""Whether this subscriber may join. Default: anyone may."""
|
"""Whether this subscriber may join. Default: anyone may."""
|
||||||
@@ -68,16 +73,16 @@ class _Params:
|
|||||||
return self._values
|
return self._values
|
||||||
|
|
||||||
|
|
||||||
def register(channel_class: type[ReactChannel], name: str) -> None:
|
def register(channel_class: type[Channel], name: str) -> None:
|
||||||
channel_class.name = name
|
channel_class.name = name
|
||||||
_registry[name] = channel_class
|
_registry[name] = channel_class
|
||||||
|
|
||||||
|
|
||||||
def get_channel(name: str) -> type[ReactChannel] | None:
|
def get_channel(name: str) -> type[Channel] | None:
|
||||||
return _registry.get(name)
|
return _registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
def registered() -> dict[str, type[ReactChannel]]:
|
def registered() -> dict[str, type[Channel]]:
|
||||||
return dict(_registry)
|
return dict(_registry)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,3 +127,36 @@ async def broadcast(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
|
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
|
||||||
await leave(group, socket)
|
await leave(group, socket)
|
||||||
|
|
||||||
|
|
||||||
|
class _ChannelsExtension(RegistryExtension):
|
||||||
|
"""The `channels` slot of the core registry — one entry per registered channel,
|
||||||
|
each carrying the JSON schema of whichever payload models the channel declares.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def all(self) -> dict[str, type[Channel]]:
|
||||||
|
return dict(_registry)
|
||||||
|
|
||||||
|
def schema(self) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for name, channel_class in _registry.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"type": "channel",
|
||||||
|
"bidirectional": False,
|
||||||
|
}
|
||||||
|
if channel_class.Params is not None:
|
||||||
|
entry["params"] = channel_class.Params.model_json_schema()
|
||||||
|
if channel_class.ClientMessage is not None:
|
||||||
|
entry["client_message"] = channel_class.ClientMessage.model_json_schema()
|
||||||
|
entry["bidirectional"] = True
|
||||||
|
if channel_class.ServerMessage is not None:
|
||||||
|
entry["server_message"] = channel_class.ServerMessage.model_json_schema()
|
||||||
|
out[name] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
|
register_extension("channels", _ChannelsExtension())
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ Usage:
|
|||||||
|
|
||||||
Imports the named module (whose import side effects must register every
|
Imports the named module (whose import side effects must register every
|
||||||
@client function with `mizan_core.registry`), then writes the canonical
|
@client function with `mizan_core.registry`), then writes the canonical
|
||||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
Mizan IR as KDL to stdout.
|
||||||
directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
FastAPI router exposing Mizan's HTTP endpoints:
|
FastAPI router exposing Mizan's HTTP endpoints:
|
||||||
|
|
||||||
|
GET /session/ — session-init probe
|
||||||
POST /call/ — RPC dispatch
|
POST /call/ — RPC dispatch
|
||||||
GET /ctx/{context_name}/ — bundled context fetch
|
GET /ctx/{context_name}/ — bundled context fetch
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(router, prefix="/api/mizan")
|
|
||||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,7 +17,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from mizan_core.registry import get_context_groups, get_function
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
|
|
||||||
from .executor import (
|
from mizan_fastapi.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
MizanError,
|
MizanError,
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -45,12 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
|||||||
|
|
||||||
@router.get("/session/")
|
@router.get("/session/")
|
||||||
async def session_init() -> JSONResponse:
|
async def session_init() -> JSONResponse:
|
||||||
"""Session-init probe. Parity with mizan-django's session endpoint.
|
"""Session-init probe. The CSRF slot is null — nothing on this backend issues a token."""
|
||||||
|
|
||||||
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
|
||||||
null token so the response shape stays uniform across backends. The
|
|
||||||
wire-parity harness uses this endpoint as its readiness probe.
|
|
||||||
"""
|
|
||||||
return _no_store({"csrfToken": None})
|
return _no_store({"csrfToken": None})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
|
|||||||
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
|
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||||
{"error": "..."}
|
{"error": "..."}
|
||||||
|
|
||||||
The envelope is mizan-django's, so a client speaks to either backend unchanged. RPC
|
An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the
|
||||||
dispatches through the same `execute_function` the HTTP route calls — one dispatch path,
|
HTTP route builds, and both are produced by `execute_function`.
|
||||||
so the two transports cannot disagree about what a function does.
|
|
||||||
|
|
||||||
`data` is the `{result, invalidate, merge}` envelope every non-HTTP transport hands the
|
|
||||||
kernel, so `mizanCall` applies a socket mutation's invalidation exactly as it applies an
|
|
||||||
HTTP one. mizan-django's socket sends a bare result and drops it.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -47,9 +42,9 @@ ws_router = APIRouter()
|
|||||||
class _SocketRequest:
|
class _SocketRequest:
|
||||||
"""What a server function receives when the call arrived over the socket.
|
"""What a server function receives when the call arrived over the socket.
|
||||||
|
|
||||||
The HTTP path hands functions a Starlette `Request`; there is none here, so this
|
There is no Starlette `Request` on a socket, so this carries the surface a function
|
||||||
carries the surface a function actually reads — `state`, headers, and a method, since
|
actually reads — `state`, headers, and a method, since a socket RPC sends data and
|
||||||
a socket RPC sends data and expects an answer.
|
expects an answer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
method = "POST"
|
method = "POST"
|
||||||
@@ -64,7 +59,7 @@ class _SocketRequest:
|
|||||||
|
|
||||||
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any:
|
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any:
|
||||||
"""Params as the channel's declared model, or a bare holder when it declares none."""
|
"""Params as the channel's declared model, or a bare holder when it declares none."""
|
||||||
model = getattr(channel_cls, "Params", None)
|
model = channel_cls.Params
|
||||||
if model is not None and raw:
|
if model is not None and raw:
|
||||||
return model(**raw)
|
return model(**raw)
|
||||||
return channels._Params(raw) if raw else None
|
return channels._Params(raw) if raw else None
|
||||||
@@ -203,10 +198,10 @@ _ACTIONS = {
|
|||||||
|
|
||||||
@ws_router.websocket("/ws/")
|
@ws_router.websocket("/ws/")
|
||||||
async def mizan_socket(socket: WebSocket) -> None:
|
async def mizan_socket(socket: WebSocket) -> None:
|
||||||
"""One connection, every action. A disconnect ends the loop and clears the membership.
|
"""One connection, every action.
|
||||||
|
|
||||||
A close is how a socket ends, so the disconnect is reported as the departure it is
|
A close is how a socket ends, so the disconnect is logged rather than raised;
|
||||||
rather than raised as a fault; `finally` clears the membership either way.
|
`finally` clears the membership either way.
|
||||||
"""
|
"""
|
||||||
await socket.accept()
|
await socket.accept()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def app():
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
async def async_echo(request, text: str) -> EchoOutput:
|
async def async_echo(request, text: str) -> EchoOutput:
|
||||||
# await something on the loop to prove we're really running async
|
# Yielding to the loop fails outright if the handler is not awaited.
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
return EchoOutput(message=f"async: {text}")
|
return EchoOutput(message=f"async: {text}")
|
||||||
|
|
||||||
@@ -183,18 +183,19 @@ class ContextFetchTests:
|
|||||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
# ─── Auth gating ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class AuthTests:
|
class AuthTests:
|
||||||
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
|
|
||||||
|
|
||||||
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class InvalidationTests:
|
class InvalidationTests:
|
||||||
def test_mutation_emits_invalidate_list(self, http):
|
def test_mutation_emits_invalidate_list(self, http):
|
||||||
r = http.post(
|
r = http.post(
|
||||||
@@ -211,8 +212,6 @@ class InvalidationTests:
|
|||||||
|
|
||||||
|
|
||||||
class StructuredOutputTests:
|
class StructuredOutputTests:
|
||||||
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
|
||||||
|
|
||||||
def test_list_of_basemodel_returns_bare_array(self, http):
|
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -232,21 +231,20 @@ class StructuredOutputTests:
|
|||||||
assert r_missing.json()["result"] is None
|
assert r_missing.json()["result"] is None
|
||||||
|
|
||||||
|
|
||||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
# ─── Async handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class AsyncHandlerTests:
|
class AsyncHandlerTests:
|
||||||
"""`async def` handlers dispatch on the loop via view.acall."""
|
|
||||||
|
|
||||||
def test_async_handler_returns_awaited_result(self, http):
|
def test_async_handler_returns_awaited_result(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["result"] == {"message": "async: hello"}
|
assert r.json()["result"] == {"message": "async: hello"}
|
||||||
|
|
||||||
|
|
||||||
class MergeTests:
|
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||||
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
|
||||||
|
|
||||||
|
|
||||||
|
class MergeTests:
|
||||||
def test_merge_target_emits_merge_entry(self, http):
|
def test_merge_target_emits_merge_entry(self, http):
|
||||||
r = http.post(
|
r = http.post(
|
||||||
"/api/mizan/call/",
|
"/api/mizan/call/",
|
||||||
@@ -254,9 +252,8 @@ class MergeTests:
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
# items_list returns list[ItemOutput], so the slot resolves to items_list.
|
||||||
assert body["merge"] == [
|
assert body["merge"] == [
|
||||||
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||||
]
|
]
|
||||||
# invalidate stays empty when only merge is declared
|
|
||||||
assert body["invalidate"] == []
|
assert body["invalidate"] == []
|
||||||
|
|||||||
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -300,6 +316,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"linkme",
|
"linkme",
|
||||||
|
"minijinja",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
//! Render a `MizanError` as an axum `Response`: the JSON envelope
|
||||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
|
||||||
//! with a Cache-Control: no-store header.
|
//! `Cache-Control: no-store` header.
|
||||||
|
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each variant's status spelled as an axum constant. Naming the constant
|
||||||
|
/// rather than round-tripping a `u16` leaves no numeric value axum could
|
||||||
|
/// reject, so the mapping is total.
|
||||||
|
fn status_of(err: &MizanError) -> StatusCode {
|
||||||
|
match err {
|
||||||
|
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
|
||||||
|
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = StatusCode::from_u16(self.0.http_status())
|
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
|
||||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
let mut resp = (status, Json(self.0.to_json())).into_response();
|
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
resp
|
resp
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
//! HTTP handlers for the Mizan endpoints.
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Number, Value};
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
|
|||||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
/// Body for POST /call/.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CallBody {
|
pub struct CallBody {
|
||||||
pub fn_: Option<String>,
|
/// `fn` is a Rust keyword, hence the serde rename.
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
pub function_name: Option<String>,
|
pub function_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Map<String, Value>,
|
pub args: Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CallBody {
|
|
||||||
fn resolved_name(&self) -> Option<&str> {
|
|
||||||
self.function_name
|
|
||||||
.as_deref()
|
|
||||||
.or(self.fn_.as_deref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CallResponse {
|
pub struct CallResponse {
|
||||||
pub result: Value,
|
pub result: Value,
|
||||||
@@ -47,28 +39,37 @@ pub struct CallResponse {
|
|||||||
pub merge: Option<Vec<Value>>,
|
pub merge: Option<Vec<Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn no_store(json: Value) -> Response {
|
fn no_store<T: Serialize>(body: T) -> Response {
|
||||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
let mut resp = (StatusCode::OK, Json(body)).into_response();
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /call/ — RPC dispatch.
|
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
|
||||||
|
/// handler selects the registrations that string names and matches over the
|
||||||
|
/// two shapes that selection has; `[]` is the selection a string nothing
|
||||||
|
/// registered under makes, and it is answered with the NOT_FOUND envelope.
|
||||||
pub async fn function_call(
|
pub async fn function_call(
|
||||||
State(app_state): State<AppStateAny>,
|
State(app_state): State<AppStateAny>,
|
||||||
Json(body): Json<CallBody>,
|
Json(body): Json<CallBody>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let fn_name = body
|
let registered = function_named(&body.function_name);
|
||||||
.resolved_name()
|
let fn_spec = match registered.as_slice() {
|
||||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
[] => {
|
||||||
.to_string();
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
|
"function {:?} not registered",
|
||||||
let fn_spec = lookup_function(&fn_name)
|
body.function_name
|
||||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
))))
|
||||||
|
}
|
||||||
|
[fn_spec, ..] => *fn_spec,
|
||||||
|
};
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => return Err(ApiError(e)),
|
||||||
|
};
|
||||||
|
|
||||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||||
.iter()
|
.iter()
|
||||||
@@ -81,82 +82,86 @@ pub async fn function_call(
|
|||||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = CallResponse {
|
Ok(no_store(CallResponse {
|
||||||
result,
|
result,
|
||||||
invalidate,
|
invalidate,
|
||||||
merge: merge_payload,
|
merge: merge_payload,
|
||||||
};
|
}))
|
||||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
|
||||||
|
/// path segment, so `[]` is the selection a segment no registered function
|
||||||
|
/// declares membership in makes, answered with the NOT_FOUND envelope.
|
||||||
pub async fn context_fetch(
|
pub async fn context_fetch(
|
||||||
State(app_state): State<AppStateAny>,
|
State(app_state): State<AppStateAny>,
|
||||||
Path(context_name): Path<String>,
|
Path(context_name): Path<String>,
|
||||||
Query(params): Query<BTreeMap<String, String>>,
|
Query(params): Query<BTreeMap<String, String>>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
if lookup_context(&context_name).is_none() {
|
let members = context_members(&context_name);
|
||||||
|
let selected = match members.as_slice() {
|
||||||
|
[] => {
|
||||||
return Err(ApiError(MizanError::NotFound(format!(
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
"context {context_name:?} not registered"
|
"context {context_name:?} names no registered functions"
|
||||||
))));
|
))))
|
||||||
}
|
}
|
||||||
|
selected => selected,
|
||||||
|
};
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|f| f.context() == Some(&context_name))
|
|
||||||
.collect();
|
|
||||||
if members.is_empty() {
|
|
||||||
return Err(ApiError(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} has no registered members"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
|
||||||
// params get parsed via the per-function input_params primitive table.
|
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in selected {
|
||||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||||
|
Ok(result) => {
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
Err(e) => return Err(ApiError(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(no_store(Value::Object(bundled)))
|
Ok(no_store(Value::Object(bundled)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coerce string-valued query params into typed JSON values using the
|
/// A query string carries every value as text, so each declared input param
|
||||||
/// function's declared input_params. Strings that don't parse stay as
|
/// reads its raw text as the primitive it declares. Text spelling something
|
||||||
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
/// else stays the text it already is: `dispatch` validates every arg against
|
||||||
|
/// the declared shape and is the one step that words the VALIDATION_FAILED
|
||||||
|
/// answer, so re-wording it here would give one request two spellings of the
|
||||||
|
/// same complaint.
|
||||||
fn coerce_query_args(
|
fn coerce_query_args(
|
||||||
fn_spec: &dyn FunctionSpec,
|
fn_spec: &dyn FunctionSpec,
|
||||||
params: &BTreeMap<String, String>,
|
params: &BTreeMap<String, String>,
|
||||||
) -> Map<String, Value> {
|
) -> Map<String, Value> {
|
||||||
let mut out = Map::new();
|
let mut out = Map::new();
|
||||||
for ip in fn_spec.input_params() {
|
for ip in fn_spec.input_params() {
|
||||||
if let Some(raw) = params.get(ip.name) {
|
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
|
||||||
let parsed = match ip.primitive {
|
let as_text = Value::from(raw.clone());
|
||||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
let coerced = match ip.primitive {
|
||||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
Primitive::String => as_text,
|
||||||
serde_json::Number::from_f64(v).map(Value::Number)
|
Primitive::Boolean => match raw.as_str() {
|
||||||
}),
|
"true" => Value::Bool(true),
|
||||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
"false" => Value::Bool(false),
|
||||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
_spells_neither => as_text,
|
||||||
|
},
|
||||||
|
Primitive::Integer => match raw.parse::<i64>() {
|
||||||
|
Ok(integer) => Value::from(integer),
|
||||||
|
Err(_spells_no_integer) => as_text,
|
||||||
|
},
|
||||||
|
Primitive::Number => match raw.parse::<f64>() {
|
||||||
|
Ok(float) => match Number::from_f64(float) {
|
||||||
|
Some(number) => Value::Number(number),
|
||||||
|
None => as_text,
|
||||||
|
},
|
||||||
|
Err(_spells_no_number) => as_text,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if let Some(v) = parsed {
|
out.insert(ip.name.into(), coerced);
|
||||||
out.insert(ip.name.into(), v);
|
|
||||||
} else {
|
|
||||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
/// GET /session/ — emits `{"csrfToken": null}`.
|
||||||
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
|
||||||
/// readiness-probe consumers see a well-formed response.
|
|
||||||
pub async fn session_init() -> Response {
|
pub async fn session_init() -> Response {
|
||||||
let body = serde_json::json!({ "csrfToken": null });
|
no_store(serde_json::json!({ "csrfToken": null }))
|
||||||
no_store(body)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
//! Exposed endpoints:
|
||||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
//! * `GET /session/` — session-init probe
|
||||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||||
//! * `GET /ctx/:name/` — bundled context fetch
|
//! * `GET /ctx/:name/` — bundled context fetch
|
||||||
|
|
||||||
@@ -51,8 +51,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Router variant for callers that have no app state to thread — the
|
/// Router variant for callers that have no app state to thread — the
|
||||||
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
/// dispatch path receives a unit-typed handle.
|
||||||
/// and other stateless test apps.
|
|
||||||
pub fn router_stateless() -> Router {
|
pub fn router_stateless() -> Router {
|
||||||
router(())
|
router(())
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1762,6 +1768,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -1789,6 +1805,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"linkme",
|
"linkme",
|
||||||
|
"minijinja",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||||
//!
|
//!
|
||||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
//! Ships as a Tauri plugin:
|
||||||
//!
|
//!
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! tauri::Builder::default()
|
//! tauri::Builder::default()
|
||||||
//! .plugin(mizan_tauri::init())
|
//! .plugin(mizan_tauri::init())
|
||||||
//! .run(tauri::generate_context!())
|
//! .run(tauri::generate_context!())
|
||||||
//! .expect("error while running tauri application");
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
|
||||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
//! FUNCTIONS / CONTEXTS registries. There is no per-function
|
||||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
//! `tauri::command`; the registry IS the dispatch table.
|
||||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
|
||||||
//! consumes. There is no per-function tauri::command; the registry IS
|
|
||||||
//! the dispatch table.
|
|
||||||
//!
|
//!
|
||||||
//! Wire envelope:
|
//! Wire envelope:
|
||||||
//!
|
//!
|
||||||
@@ -24,23 +20,20 @@
|
|||||||
//! { "op": "fetch", "context": "session", "params": {} }
|
//! { "op": "fetch", "context": "session", "params": {} }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
//! Response shapes:
|
||||||
//! mizan-rust-axum:
|
|
||||||
//!
|
//!
|
||||||
//! * `call` → `{ result, invalidate, merge? }`
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
//!
|
//!
|
||||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
//! Tauri serializes into the JS-side `Promise.reject`.
|
||||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
|
||||||
//! see one error surface regardless of transport.
|
|
||||||
|
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
InvalidationTarget, MergeEntry, MizanError, RequestHandle,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Runtime,
|
Runtime,
|
||||||
@@ -79,9 +72,8 @@ pub enum Envelope {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
/// Error payload returned to the frontend. The JS-side transport reads
|
||||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
/// `code` / `message` / `details` and constructs a `MizanError`.
|
||||||
/// this and constructs a `MizanError`.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ErrorPayload {
|
pub struct ErrorPayload {
|
||||||
pub code: &'static str,
|
pub code: &'static str,
|
||||||
@@ -114,6 +106,11 @@ impl From<MizanError> for ErrorPayload {
|
|||||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||||
/// emission. Stateless functions ignore the handle.
|
/// emission. Stateless functions ignore the handle.
|
||||||
|
///
|
||||||
|
/// Each arm selects the registrations its envelope names and matches over
|
||||||
|
/// the two shapes that selection has. Both shapes are ordinary: the JS side
|
||||||
|
/// picks the string, so `[]` is the selection a string nothing registered
|
||||||
|
/// under makes, and it is answered with the NOT_FOUND envelope.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn mizan_invoke<R: Runtime>(
|
async fn mizan_invoke<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
@@ -123,98 +120,79 @@ async fn mizan_invoke<R: Runtime>(
|
|||||||
Envelope::Call {
|
Envelope::Call {
|
||||||
function_name,
|
function_name,
|
||||||
args,
|
args,
|
||||||
} => handle_call(&app, &function_name, args).await,
|
} => {
|
||||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
let registered = function_named(&function_name);
|
||||||
|
let fn_spec = match registered.as_slice() {
|
||||||
|
[] => {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"function {function_name:?} not registered"
|
||||||
|
))))
|
||||||
}
|
}
|
||||||
}
|
[fn_spec, ..] => *fn_spec,
|
||||||
|
|
||||||
async fn handle_call<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
fn_name: &str,
|
|
||||||
args: Map<String, Value>,
|
|
||||||
) -> Result<Value, ErrorPayload> {
|
|
||||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
|
||||||
ErrorPayload::from(MizanError::NotFound(format!(
|
|
||||||
"function {fn_name:?} not registered"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
let result = fn_spec
|
|
||||||
.dispatch(req, Value::Object(args.clone()))
|
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)?;
|
|
||||||
|
|
||||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
|
||||||
.iter()
|
|
||||||
.map(InvalidationTarget::to_json)
|
|
||||||
.collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
|
||||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut payload = json!({
|
let req = RequestHandle::new(&app);
|
||||||
"result": result,
|
match fn_spec.dispatch(req, Value::Object(args.clone())).await {
|
||||||
"invalidate": invalidate,
|
Ok(result) => Ok(call_payload(fn_spec, &args, result)),
|
||||||
});
|
Err(e) => Err(ErrorPayload::from(e)),
|
||||||
if let Some(merge) = merge_payload {
|
|
||||||
payload
|
|
||||||
.as_object_mut()
|
|
||||||
.expect("payload is a JSON object")
|
|
||||||
.insert("merge".into(), Value::Array(merge));
|
|
||||||
}
|
}
|
||||||
Ok(payload)
|
}
|
||||||
}
|
Envelope::Fetch { context, params } => {
|
||||||
|
let members = context_members(&context);
|
||||||
async fn handle_fetch<R: Runtime>(
|
let selected = match members.as_slice() {
|
||||||
app: &tauri::AppHandle<R>,
|
[] => {
|
||||||
context_name: &str,
|
|
||||||
params: Map<String, Value>,
|
|
||||||
) -> Result<Value, ErrorPayload> {
|
|
||||||
if lookup_context(context_name).is_none() {
|
|
||||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
"context {context_name:?} not registered"
|
"context {context:?} names no registered functions"
|
||||||
))));
|
))))
|
||||||
}
|
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|f| f.context() == Some(context_name))
|
|
||||||
.collect();
|
|
||||||
if members.is_empty() {
|
|
||||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} has no registered members"
|
|
||||||
))));
|
|
||||||
}
|
}
|
||||||
|
selected => selected,
|
||||||
|
};
|
||||||
|
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in selected {
|
||||||
let args = filter_args(*fn_spec, ¶ms);
|
let args = filter_args(*fn_spec, ¶ms);
|
||||||
let req = RequestHandle::new(app);
|
let req = RequestHandle::new(&app);
|
||||||
let result = fn_spec
|
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||||
.dispatch(req, Value::Object(args))
|
Ok(result) => {
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)?;
|
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
Err(e) => return Err(ErrorPayload::from(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Value::Object(bundled))
|
Ok(Value::Object(bundled))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter the envelope's params down to keys this function declares as
|
/// The `call` response body — the handler's result alongside the
|
||||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
/// invalidation targets and merge entries the registry derives from the
|
||||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
/// arguments and that result.
|
||||||
/// carries typed JSON, so the filter is sufficient on its own.
|
fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
|
||||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
|
||||||
let mut out = Map::new();
|
.iter()
|
||||||
for ip in fn_spec.input_params() {
|
.map(InvalidationTarget::to_json)
|
||||||
if let Some(v) = params.get(ip.name) {
|
.collect();
|
||||||
out.insert(ip.name.into(), v.clone());
|
let merges = compute_merges(fn_spec, args, &result);
|
||||||
|
|
||||||
|
let mut payload = Map::new();
|
||||||
|
payload.insert("result".into(), result);
|
||||||
|
payload.insert("invalidate".into(), Value::Array(invalidate));
|
||||||
|
if !merges.is_empty() {
|
||||||
|
let entries: Vec<Value> = merges.iter().map(MergeEntry::to_json).collect();
|
||||||
|
payload.insert("merge".into(), Value::Array(entries));
|
||||||
}
|
}
|
||||||
}
|
Value::Object(payload)
|
||||||
out
|
}
|
||||||
|
|
||||||
|
/// The envelope's params narrowed to the keys this function declares as
|
||||||
|
/// input. The Tauri arg channel already carries typed JSON, so no
|
||||||
|
/// string-to-primitive coercion is needed here.
|
||||||
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||||
|
let declared = fn_spec.input_params();
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str()))
|
||||||
|
.map(|(name, value)| (name.clone(), value.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
export interface CacheBackend {
|
||||||
get(key: string): string | null
|
get(key: string): string | null
|
||||||
set(key: string, value: string): void
|
set(key: string, value: string): void
|
||||||
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
|
|||||||
|
|
||||||
deleteByPrefix(prefix: string): number {
|
deleteByPrefix(prefix: string): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
// Snapshot the keys — deleting while iterating the live view is UB.
|
||||||
for (const key of [...this._store.keys()]) {
|
for (const key of [...this._store.keys()]) {
|
||||||
if (key.startsWith(prefix)) {
|
if (key.startsWith(prefix)) {
|
||||||
this._store.delete(key)
|
this._store.delete(key)
|
||||||
|
|||||||
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 { MemoryCache } from './backend'
|
||||||
export type { CacheBackend } from './backend'
|
export type { CacheBackend } from './backend'
|
||||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||||
@@ -52,6 +44,13 @@ export function cachePut(
|
|||||||
backend.set(key, value)
|
backend.set(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached entries for a context. Returns the number removed.
|
||||||
|
*
|
||||||
|
* With params and a secret the exact key is recomputed and dropped; without
|
||||||
|
* them every key carrying the context prefix is scanned and dropped. There is
|
||||||
|
* no reverse index from a context to its keys, so those are the only two forms.
|
||||||
|
*/
|
||||||
export function cachePurge(
|
export function cachePurge(
|
||||||
backend: CacheBackend,
|
backend: CacheBackend,
|
||||||
context: string,
|
context: string,
|
||||||
@@ -61,11 +60,9 @@ export function cachePurge(
|
|||||||
rev: number = 0,
|
rev: number = 0,
|
||||||
): number {
|
): number {
|
||||||
if (params && secret) {
|
if (params && secret) {
|
||||||
// Scoped purge — recompute key and delete directly
|
|
||||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||||
return backend.delete(key) ? 1 : 0
|
return backend.delete(key) ? 1 : 0
|
||||||
} else {
|
} else {
|
||||||
// Broad purge — prefix scan
|
|
||||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||||
return backend.deleteByPrefix(prefix)
|
return backend.deleteByPrefix(prefix)
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
|
||||||
* Cross-language conformance verified by pin tests.
|
* broad purge run as a prefix scan over the backend's keyspace.
|
||||||
*
|
|
||||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac } from 'crypto'
|
||||||
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
*
|
||||||
|
* Hand-rolled rather than JSON.stringify because the bytes must match
|
||||||
|
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
|
||||||
|
* a key derived here is looked up by the Python side under the same secret,
|
||||||
|
* so any serialization drift silently splits the keyspace in two.
|
||||||
*/
|
*/
|
||||||
function stableStringify(obj: any): string {
|
function stableStringify(obj: any): string {
|
||||||
if (obj === null || obj === undefined) return 'null'
|
if (obj === null || obj === undefined) return 'null'
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
/**
|
|
||||||
* Mizan @client decorator and function wrapper.
|
|
||||||
*
|
|
||||||
* Two registration styles:
|
|
||||||
*
|
|
||||||
* 1. Function wrapper (standalone functions):
|
|
||||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
|
||||||
*
|
|
||||||
* 2. Class decorator (methods):
|
|
||||||
* class Handlers {
|
|
||||||
* @client({ context: UserCtx })
|
|
||||||
* async userProfile(userId: number) { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||||
import { register } from './registry'
|
import { register } from './registry'
|
||||||
|
|
||||||
@@ -35,8 +20,14 @@ function normalizeAffects(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover parameter names by parsing the function's own source text.
|
||||||
|
*
|
||||||
|
* Names are the wire contract — the dispatcher matches request params to
|
||||||
|
* positional arguments by name — and JS erases them at runtime, so the
|
||||||
|
* source string is the only place they survive.
|
||||||
|
*/
|
||||||
function extractParams(fn: Function): ParamDef[] {
|
function extractParams(fn: Function): ParamDef[] {
|
||||||
// Extract parameter names from function.toString()
|
|
||||||
const source = fn.toString()
|
const source = fn.toString()
|
||||||
const match = source.match(/\(([^)]*)\)/)
|
const match = source.match(/\(([^)]*)\)/)
|
||||||
if (!match || !match[1].trim()) return []
|
if (!match || !match[1].trim()) return []
|
||||||
@@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] {
|
|||||||
.map(p => p.trim())
|
.map(p => p.trim())
|
||||||
.filter(p => p && !p.startsWith('...'))
|
.filter(p => p && !p.startsWith('...'))
|
||||||
.map(p => {
|
.map(p => {
|
||||||
// Handle destructured defaults: name = default, name: type
|
// Strips a default value or a type annotation off the name
|
||||||
const name = p.split(/[=:]/)[0].trim()
|
const name = p.split(/[=:]/)[0].trim()
|
||||||
return { name, type: 'any', required: !p.includes('=') }
|
return { name, type: 'any', required: !p.includes('=') }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Wrap and register a standalone function. */
|
||||||
* Function wrapper — registers a standalone function.
|
|
||||||
*
|
|
||||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
|
||||||
*/
|
|
||||||
export function client<T extends (...args: any[]) => Promise<any>>(
|
export function client<T extends (...args: any[]) => Promise<any>>(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
fn: T,
|
fn: T,
|
||||||
): T
|
): T
|
||||||
|
|
||||||
/**
|
/** Register a class method. */
|
||||||
* Class method decorator.
|
|
||||||
*
|
|
||||||
* class Handlers {
|
|
||||||
* @client({ context: UserCtx })
|
|
||||||
* async userProfile(userId: number) { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export function client(options: ClientOptions): MethodDecorator
|
export function client(options: ClientOptions): MethodDecorator
|
||||||
|
|
||||||
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
|
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
||||||
// Function wrapper form: client(options, fn)
|
// Function wrapper form: client(options, fn)
|
||||||
if (fn && typeof fn === 'function') {
|
if (fn && typeof fn === 'function') {
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
@@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
|
|
||||||
const name = fn.name || 'anonymous'
|
const name = fn.name || 'anonymous'
|
||||||
const params = extractParams(fn)
|
const params = extractParams(fn)
|
||||||
const isView = false // Determined at call time for function wrappers
|
|
||||||
|
|
||||||
const entry: RegistryEntry = {
|
const entry: RegistryEntry = {
|
||||||
name,
|
name,
|
||||||
fn: fn as any,
|
fn: fn as RegistryEntry['fn'],
|
||||||
context,
|
context,
|
||||||
affects,
|
affects,
|
||||||
params,
|
params,
|
||||||
private: options.private ?? false,
|
private: options.private ?? false,
|
||||||
viewPath: isView,
|
// A wrapped function's return is only known once called, so the
|
||||||
|
// view-vs-RPC split is decided by the dispatcher, not here.
|
||||||
|
viewPath: false,
|
||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Request dispatch — context GET and mutation POST handlers.
|
* Context GET and mutation POST handlers.
|
||||||
*
|
*
|
||||||
* Framework-agnostic. Returns plain objects. The router adapter
|
* Handlers return plain MizanResponse objects; turning one into a
|
||||||
* (Express, Hono, etc.) converts to framework-specific responses.
|
* framework's own response type is the router adapter's job.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
@@ -62,7 +62,10 @@ export async function handleContextFetch(
|
|||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* cache miss on error */ }
|
} catch (e: any) {
|
||||||
|
// A failed lookup degrades to a miss, so recompute below rather than fail the request.
|
||||||
|
console.error(`mizan: cache lookup failed for context '${contextName}'`, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Record<string, any> = {}
|
const results: Record<string, any> = {}
|
||||||
@@ -86,6 +89,7 @@ export async function handleContextFetch(
|
|||||||
|
|
||||||
results[fnName] = result
|
results[fnName] = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error(`mizan: context function '${fnName}' raised`, e)
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
@@ -111,7 +115,10 @@ export async function handleContextFetch(
|
|||||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||||
try {
|
try {
|
||||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||||
} catch { /* cache store failure is non-fatal */ }
|
} catch (e: any) {
|
||||||
|
// The results are already computed, so a store failure costs a future hit, not this response.
|
||||||
|
console.error(`mizan: cache store failed for context '${contextName}'`, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -186,20 +193,25 @@ export async function handleMutationCall(
|
|||||||
// Purge origin-side cache
|
// Purge origin-side cache
|
||||||
const cb = getCache()
|
const cb = getCache()
|
||||||
if (cb) {
|
if (cb) {
|
||||||
|
for (const target of invalidate) {
|
||||||
try {
|
try {
|
||||||
for (const entry of invalidate) {
|
if (typeof target === 'string') {
|
||||||
if (typeof entry === 'string') {
|
cachePurge(cb, target)
|
||||||
cachePurge(cb, entry)
|
|
||||||
} else {
|
} else {
|
||||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
cachePurge(cb, target.context, target.params, _cacheSecret)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// The client still gets X-Mizan-Invalidate, so a stale origin entry
|
||||||
|
// is recoverable; one bad target must not skip the remaining ones.
|
||||||
|
console.error(`mizan: cache purge failed for`, target, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* purge failure is non-fatal */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 200, body: responseData, headers }
|
return { status: 200, body: responseData, headers }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error(`mizan: mutation '${fnName}' raised`, e)
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
/**
|
|
||||||
* Invalidation protocol — header formatting, auto-scoping.
|
|
||||||
*
|
|
||||||
* Matches Django's implementation exactly. Same format. Same rules.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RegistryEntry } from './types'
|
import type { RegistryEntry } from './types'
|
||||||
import { getContextGroups, getContextParamNames, getFunction } from './registry'
|
import { getContextGroups, getContextParamNames } from './registry'
|
||||||
|
|
||||||
type InvalidateEntry = string | { context: string; params: Record<string, any> }
|
type InvalidateEntry = string | { context: string; params: Record<string, any> }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve invalidation targets with three-tier auto-scoping.
|
* Resolve what a mutation's `affects` targets invalidate.
|
||||||
*
|
*
|
||||||
* Tier 1: Argument name matching
|
* Each target narrows to the call arguments whose names the target's context
|
||||||
* Tier 2: Auth inference (Edge-side, not handled here)
|
* also declares as params — that intersection is the scoped purge. A target
|
||||||
* Tier 3: Broad fallback
|
* with no such overlap emits as a bare context name, meaning purge every
|
||||||
|
* entry under that context.
|
||||||
*/
|
*/
|
||||||
export function resolveInvalidation(
|
export function resolveInvalidation(
|
||||||
entry: RegistryEntry,
|
entry: RegistryEntry,
|
||||||
@@ -34,7 +29,6 @@ export function resolveInvalidation(
|
|||||||
const resolved = resolveAffectsTarget(targetName)
|
const resolved = resolveAffectsTarget(targetName)
|
||||||
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
|
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
|
||||||
|
|
||||||
// Tier 1: argument name matching
|
|
||||||
if (callArgs && ctxForParams) {
|
if (callArgs && ctxForParams) {
|
||||||
const contextParams = getContextParamNames(ctxForParams)
|
const contextParams = getContextParamNames(ctxForParams)
|
||||||
const matched: Record<string, any> = {}
|
const matched: Record<string, any> = {}
|
||||||
@@ -47,7 +41,6 @@ export function resolveInvalidation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 3: broad fallback
|
|
||||||
result.push(targetName)
|
result.push(targetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +48,10 @@ export function resolveInvalidation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether an affects target is a context name or function name.
|
* Determine whether an affects target names a context or a function.
|
||||||
|
*
|
||||||
|
* An unrecognized name resolves as a context, so a target registered later
|
||||||
|
* still purges by name rather than being dropped here.
|
||||||
*/
|
*/
|
||||||
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
|
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
|
||||||
const groups = getContextGroups()
|
const groups = getContextGroups()
|
||||||
@@ -74,9 +70,10 @@ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; nam
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format invalidation targets as X-Mizan-Invalidate header value.
|
* Format invalidation targets as an X-Mizan-Invalidate header value.
|
||||||
*
|
*
|
||||||
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
|
* Comma-separated targets; within a target, semicolon-separated URL-encoded
|
||||||
|
* params follow the context name.
|
||||||
*/
|
*/
|
||||||
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
/**
|
import type { EdgeManifest, ManifestFunction } from './types'
|
||||||
* Edge Manifest Generator
|
|
||||||
*
|
|
||||||
* Produces the same JSON format as mizan-django. One Edge Worker.
|
|
||||||
* Two backend languages. Same manifest.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EdgeManifest } from './types'
|
|
||||||
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
|
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
|
||||||
|
|
||||||
// Both camelCase and snake_case forms included for cross-language matching.
|
// Wire format is snake_case; camelCase is the TS-local convention. Both forms
|
||||||
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
|
// are listed because a param name arrives here as whichever the author wrote.
|
||||||
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
|
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
|
||||||
|
|
||||||
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||||
@@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
|||||||
// Contexts
|
// Contexts
|
||||||
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
||||||
const paramNames = new Set<string>()
|
const paramNames = new Set<string>()
|
||||||
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
|
const functions: ManifestFunction[] = []
|
||||||
const pageRoutes: string[] = []
|
const pageRoutes: string[] = []
|
||||||
|
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
@@ -29,14 +22,14 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
|||||||
|
|
||||||
for (const p of entry.params) paramNames.add(p.name)
|
for (const p of entry.params) paramNames.add(p.name)
|
||||||
|
|
||||||
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
const fnEntry: ManifestFunction = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||||
if (entry.route) {
|
if (entry.route) {
|
||||||
fnEntry.route = entry.route
|
fnEntry.route = entry.route
|
||||||
fnEntry.methods = entry.methods || ['GET']
|
fnEntry.methods = entry.methods || ['GET']
|
||||||
pageRoutes.push(entry.route)
|
pageRoutes.push(entry.route)
|
||||||
}
|
}
|
||||||
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
||||||
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
|
if (entry.cache !== undefined) fnEntry.cache = entry.cache
|
||||||
functions.push(fnEntry)
|
functions.push(fnEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
/**
|
|
||||||
* Mizan Registry — Central registration for server functions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RegistryEntry } from './types'
|
import type { RegistryEntry } from './types'
|
||||||
|
|
||||||
const _functions: Map<string, RegistryEntry> = new Map()
|
const _functions: Map<string, RegistryEntry> = new Map()
|
||||||
|
|
||||||
export function register(entry: RegistryEntry): void {
|
export function register(entry: RegistryEntry): void {
|
||||||
|
// Re-registering the same function object is a module re-evaluation, not a
|
||||||
|
// name collision, so only a different fn under a taken name is an error.
|
||||||
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
|
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
|
||||||
throw new Error(`Function '${entry.name}' already registered`)
|
throw new Error(`Function '${entry.name}' already registered`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Mizan TypeScript Adapter — Shared Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ReactContext {
|
export class ReactContext {
|
||||||
constructor(public readonly name: string) {
|
constructor(public readonly name: string) {
|
||||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||||
@@ -42,8 +38,17 @@ export interface RegistryEntry {
|
|||||||
cache?: number | false
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManifestFunction {
|
||||||
|
name: string
|
||||||
|
path: 'rpc' | 'view'
|
||||||
|
route?: string
|
||||||
|
methods?: string[]
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
|
}
|
||||||
|
|
||||||
export interface ManifestContext {
|
export interface ManifestContext {
|
||||||
functions: Array<{ name: string; path: 'rpc' | 'view' }>
|
functions: ManifestFunction[]
|
||||||
endpoints: string[]
|
endpoints: string[]
|
||||||
params: string[]
|
params: string[]
|
||||||
user_scoped: boolean
|
user_scoped: boolean
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
/**
|
|
||||||
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
|
|
||||||
*
|
|
||||||
* These prove that a Cloudflare Worker (Edge) can sit in front of a
|
|
||||||
* TypeScript backend and behave identically to sitting in front of Django.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
||||||
|
|
||||||
const UserCtx = new ReactContext('user')
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
function setupUserContext() {
|
function setupUserContext() {
|
||||||
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
|
client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||||
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
||||||
})
|
})
|
||||||
|
|
||||||
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
|
client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||||
return { count: userId * 10 }
|
return { count: userId * 10 }
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||||
return { name, email: `user${userId}@test.com` }
|
return { name, email: `user${userId}@test.com` }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,7 +115,7 @@ describe('Edge Compatibility', () => {
|
|||||||
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
||||||
])
|
])
|
||||||
|
|
||||||
// Parse (what Edge does)
|
// Parse the header the way the Edge worker does
|
||||||
const segments = header.split(';')
|
const segments = header.split(';')
|
||||||
const ctx = segments[0]
|
const ctx = segments[0]
|
||||||
const params: Record<string, string> = {}
|
const params: Record<string, string> = {}
|
||||||
@@ -139,12 +132,6 @@ describe('Edge Compatibility', () => {
|
|||||||
// ── Empty invalidation ─────────────────────────────────────────────
|
// ── Empty invalidation ─────────────────────────────────────────────
|
||||||
|
|
||||||
test('no affects = no header, no body key', async () => {
|
test('no affects = no header, no body key', async () => {
|
||||||
client({ context: new ReactContext('plain') }, async function plainFn() {
|
|
||||||
return { ok: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
// A context function called via mutation dispatch (shouldn't have invalidation)
|
|
||||||
// Actually test a function without affects
|
|
||||||
clearRegistry()
|
clearRegistry()
|
||||||
client({}, async function noAffects() { return { ok: true } })
|
client({}, async function noAffects() { return { ok: true } })
|
||||||
const r = await handleMutationCall('noAffects', {})
|
const r = await handleMutationCall('noAffects', {})
|
||||||
@@ -251,6 +238,18 @@ describe('Manifest', () => {
|
|||||||
expect(fn.cache).toBe(60)
|
expect(fn.cache).toBe(60)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cache=false appears in manifest', () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('nocache')
|
||||||
|
client({ context: Ctx, cache: false }, async function uncachedFn() {
|
||||||
|
return { value: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
const fn = m.contexts.nocache.functions[0]
|
||||||
|
expect(fn.cache).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test('cache=60 still emits no-store on HTTP', async () => {
|
test('cache=60 still emits no-store on HTTP', async () => {
|
||||||
clearRegistry()
|
clearRegistry()
|
||||||
const Ctx = new ReactContext('live')
|
const Ctx = new ReactContext('live')
|
||||||
@@ -294,8 +293,9 @@ describe('Cache Conformance', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
||||||
// These exact values are pinned from Python's derive_cache_key output.
|
// Literals captured from Python's derive_cache_key under the same
|
||||||
// If this test fails, cross-language cache key compatibility is broken.
|
// secret. A key derived here is looked up by the Python side, so a
|
||||||
|
// mismatch means the two runtimes address different keyspaces.
|
||||||
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||||
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||||
|
|
||||||
@@ -362,6 +362,27 @@ describe('Cache Conformance', () => {
|
|||||||
setCacheSecret(null)
|
setCacheSecret(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cache=false context is never stored', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('volatile')
|
||||||
|
client({ context: Ctx, cache: false }, async function volatileFn(itemId: number) {
|
||||||
|
return { value: itemId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
setCache(cache)
|
||||||
|
setCacheSecret(SECRET)
|
||||||
|
|
||||||
|
const r1 = await handleContextFetch('volatile', { itemId: '1' })
|
||||||
|
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
||||||
|
|
||||||
|
const r2 = await handleContextFetch('volatile', { itemId: '1' })
|
||||||
|
expect(r2.headers['X-Mizan-Cache']).toBe('MISS')
|
||||||
|
|
||||||
|
resetCache()
|
||||||
|
setCacheSecret(null)
|
||||||
|
})
|
||||||
|
|
||||||
test('handleMutationCall purges cache', async () => {
|
test('handleMutationCall purges cache', async () => {
|
||||||
clearRegistry()
|
clearRegistry()
|
||||||
const Ctx = new ReactContext('product')
|
const Ctx = new ReactContext('product')
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"pydantic>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
"ckdl>=1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
"""
|
"""Cache backends — a key/value store keyed by the derived HMAC cache key."""
|
||||||
Cache backends — MemoryCache (testing) and RedisCache (production).
|
|
||||||
|
|
||||||
Simple key-value stores. No reverse indexes. Cache keys are derived
|
|
||||||
from HMAC, so scoped purge just recomputes the key and deletes it.
|
|
||||||
Broad purge uses key-prefix scan (rare operation).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Protocol
|
import abc
|
||||||
|
|
||||||
|
|
||||||
class CacheBackend(Protocol):
|
class CacheBackend(abc.ABC):
|
||||||
"""Interface that all Mizan cache backends implement."""
|
"""A key/value store holding serialized context payloads."""
|
||||||
|
|
||||||
def get(self, key: str) -> bytes | None: ...
|
@abc.abstractmethod
|
||||||
def set(self, key: str, value: bytes) -> None: ...
|
def get(self, key: str) -> bytes | None:
|
||||||
def delete(self, key: str) -> bool: ...
|
"""The stored value for `key`, or None when absent."""
|
||||||
def delete_by_prefix(self, prefix: str) -> int: ...
|
|
||||||
def clear(self) -> None: ...
|
@abc.abstractmethod
|
||||||
|
def set(self, key: str, value: bytes) -> None:
|
||||||
|
"""Store `value` under `key`, replacing anything already there."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
"""Drop `key`. True if it was present."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int:
|
||||||
|
"""Drop every key starting with `prefix`. Returns how many were dropped."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Drop every key this backend owns."""
|
||||||
|
|
||||||
|
|
||||||
class MemoryCache:
|
class MemoryCache(CacheBackend):
|
||||||
"""
|
"""A process-local dict. No persistence, no cross-process sharing."""
|
||||||
In-memory cache backend for testing.
|
|
||||||
|
|
||||||
Uses a Python dict. No persistence, no cross-process sharing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._store: dict[str, bytes] = {}
|
self._store: dict[str, bytes] = {}
|
||||||
@@ -53,13 +57,8 @@ class MemoryCache:
|
|||||||
self._store.clear()
|
self._store.clear()
|
||||||
|
|
||||||
|
|
||||||
class RedisCache:
|
class RedisCache(CacheBackend):
|
||||||
"""
|
"""Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops."""
|
||||||
Redis-backed cache backend for production.
|
|
||||||
|
|
||||||
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
|
|
||||||
the HMAC key and deletes directly. Broad purge uses SCAN.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEFAULT_TTL = 86400 # 24h safety-net
|
DEFAULT_TTL = 86400 # 24h safety-net
|
||||||
|
|
||||||
@@ -71,11 +70,11 @@ class RedisCache:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
import redis as redis_lib
|
import redis as redis_lib
|
||||||
except ImportError:
|
except ImportError as exc:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Redis is required for Mizan's cache backend. "
|
"Redis is required for Mizan's cache backend. "
|
||||||
"Install it with: pip install mizan[cache]"
|
"Install it with: pip install mizan[cache]"
|
||||||
)
|
) from exc
|
||||||
self._client = redis_lib.from_url(
|
self._client = redis_lib.from_url(
|
||||||
redis_url,
|
redis_url,
|
||||||
socket_connect_timeout=5,
|
socket_connect_timeout=5,
|
||||||
|
|||||||
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 a canonical JSON form."""
|
||||||
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
|
||||||
|
|
||||||
Protocol-critical: every Mizan adapter must produce identical output
|
|
||||||
for identical inputs. Cross-language conformance verified by pin tests.
|
|
||||||
|
|
||||||
Scoped purge recomputes the key directly — no reverse index needed.
|
|
||||||
Broad purge uses a context prefix scan.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,7 +7,6 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# Context prefix for broad purge (SCAN pattern)
|
|
||||||
CONTEXT_KEY_PREFIX = "ctx:"
|
CONTEXT_KEY_PREFIX = "ctx:"
|
||||||
|
|
||||||
|
|
||||||
@@ -33,8 +24,9 @@ def derive_cache_key(
|
|||||||
broad purge can SCAN by prefix "ctx:{context}:*".
|
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||||
"""
|
"""
|
||||||
def _normalize(v: Any) -> str:
|
def _normalize(v: Any) -> str:
|
||||||
"""Normalize values for cross-language HMAC consistency.
|
"""Render a param value in its JSON-native spelling.
|
||||||
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
Python str(True) is "True" but JS String(true) is "true", and the two
|
||||||
|
must hash identically."""
|
||||||
if v is True:
|
if v is True:
|
||||||
return "true"
|
return "true"
|
||||||
if v is False:
|
if v is False:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
mizan Server Functions - Core Primitive
|
Server functions: the `@client` decorator and the `ServerFunction` class it
|
||||||
|
produces, `ReactContext` for grouping them, and `compose` for combining
|
||||||
|
contexts into one provider.
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
Function form:
|
||||||
|
@client
|
||||||
Two styles supported:
|
def update_profile(request, name: str) -> UpdateProfileOutput:
|
||||||
|
|
||||||
1. Function-based (recommended, Django Ninja style):
|
|
||||||
@client("update-profile")
|
|
||||||
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
|
|
||||||
return UpdateProfileOutput(success=True)
|
return UpdateProfileOutput(success=True)
|
||||||
|
|
||||||
2. Class-based (for complex cases):
|
Class form:
|
||||||
class UpdateProfile(ServerFunction):
|
class UpdateProfile(ServerFunction):
|
||||||
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
||||||
return UpdateProfileOutput(success=True)
|
return UpdateProfileOutput(success=True)
|
||||||
@@ -21,7 +19,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import warnings
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
@@ -30,24 +27,16 @@ from typing import (
|
|||||||
Generic,
|
Generic,
|
||||||
Literal,
|
Literal,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
|
||||||
get_args,
|
|
||||||
get_origin,
|
|
||||||
get_type_hints,
|
get_type_hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
# ─── Framework-response-base hook ───────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# View-path detection — distinguishing functions that return data (RPC path)
|
# View-path detection — distinguishing functions that return data (RPC path)
|
||||||
# from functions that return a framework-native response object (view path) —
|
# from functions that return a framework-native response object (view path) —
|
||||||
# requires knowing the framework's response base class. Each backend adapter
|
# needs the framework's response base class, which only the backend adapter
|
||||||
# registers its base class here at import time.
|
# knows. While it is unset, every function is treated as RPC.
|
||||||
#
|
|
||||||
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
|
|
||||||
# starlette.responses.Response. If unset, all functions are treated as RPC.
|
|
||||||
|
|
||||||
_framework_response_base: type | None = None
|
_framework_response_base: type | None = None
|
||||||
|
|
||||||
@@ -101,7 +90,7 @@ class ReactContext:
|
|||||||
return f"ReactContext({self.name!r})"
|
return f"ReactContext({self.name!r})"
|
||||||
|
|
||||||
|
|
||||||
# Built-in global context (auto-mounted at root, SSR-hydrated)
|
# The context named 'global', pre-made so callers share one instance of it.
|
||||||
GlobalContext = ReactContext("global")
|
GlobalContext = ReactContext("global")
|
||||||
|
|
||||||
|
|
||||||
@@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
|||||||
class _FunctionWrapper(ServerFunction):
|
class _FunctionWrapper(ServerFunction):
|
||||||
"""Internal wrapper that makes a plain function behave like a ServerFunction."""
|
"""Internal wrapper that makes a plain function behave like a ServerFunction."""
|
||||||
|
|
||||||
# Will be set per-wrapper instance
|
# Set per-wrapper subclass by _create_server_function
|
||||||
_wrapped_fn: ClassVar[Callable]
|
_wrapped_fn: ClassVar[Callable]
|
||||||
_input_cls: ClassVar[type[BaseModel] | None]
|
_input_cls: ClassVar[type[BaseModel] | None]
|
||||||
_output_cls: ClassVar[type[BaseModel]]
|
_output_cls: ClassVar[type[BaseModel]]
|
||||||
@@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
|||||||
if isinstance(context, str):
|
if isinstance(context, str):
|
||||||
if not context.strip():
|
if not context.strip():
|
||||||
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
||||||
if context == "local":
|
|
||||||
warnings.warn(
|
|
||||||
"context='local' is deprecated. Use ReactContext('name') instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=3,
|
|
||||||
)
|
|
||||||
return context
|
return context
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
||||||
@@ -327,7 +310,6 @@ def client(
|
|||||||
context: Named context for React state management.
|
context: Named context for React state management.
|
||||||
- False (default): Not a context, just a callable function.
|
- False (default): Not a context, just a callable function.
|
||||||
- ReactContext instance: groups functions into a named context.
|
- ReactContext instance: groups functions into a named context.
|
||||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
|
||||||
|
|
||||||
affects: Declare which contexts or functions this mutation invalidates.
|
affects: Declare which contexts or functions this mutation invalidates.
|
||||||
Mutually exclusive with context=.
|
Mutually exclusive with context=.
|
||||||
@@ -526,8 +508,8 @@ def _create_server_function(
|
|||||||
is_view_path = is_framework_response(output_type)
|
is_view_path = is_framework_response(output_type)
|
||||||
|
|
||||||
if is_view_path:
|
if is_view_path:
|
||||||
# View path — no Pydantic output wrapping needed
|
# A view path serializes nothing, so Output is never read off this class.
|
||||||
output_cls = BaseModel # placeholder, never used for serialization
|
output_cls = BaseModel
|
||||||
is_primitive_output = False
|
is_primitive_output = False
|
||||||
else:
|
else:
|
||||||
# RPC path — resolve output type
|
# RPC path — resolve output type
|
||||||
@@ -555,7 +537,7 @@ def _create_server_function(
|
|||||||
FunctionWrapper._output_cls = output_cls
|
FunctionWrapper._output_cls = output_cls
|
||||||
FunctionWrapper._is_primitive_output = is_primitive_output
|
FunctionWrapper._is_primitive_output = is_primitive_output
|
||||||
|
|
||||||
# Set Input/Output class attributes for compatibility
|
# Input/Output are the names the ServerFunction contract exposes them under
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
FunctionWrapper.Input = input_cls
|
FunctionWrapper.Input = input_cls
|
||||||
FunctionWrapper.Output = output_cls
|
FunctionWrapper.Output = output_cls
|
||||||
@@ -615,8 +597,8 @@ def _create_server_function(
|
|||||||
# Always assign a fresh dict to prevent shared-dict mutation across classes
|
# Always assign a fresh dict to prevent shared-dict mutation across classes
|
||||||
FunctionWrapper._meta = {**meta}
|
FunctionWrapper._meta = {**meta}
|
||||||
|
|
||||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
# Registration happens via discovery (mizan_clients), so the decorator has
|
||||||
# This allows the decorator to be used without import-time side effects.
|
# no import-time side effects.
|
||||||
|
|
||||||
return FunctionWrapper
|
return FunctionWrapper
|
||||||
|
|
||||||
@@ -657,15 +639,14 @@ class ComposedContext:
|
|||||||
"leaves": [leaf.name for leaf in leaves],
|
"leaves": [leaf.name for leaf in leaves],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
def get_schema_export(self) -> dict[str, Any]:
|
||||||
def get_schema_export(cls) -> dict[str, Any]:
|
|
||||||
"""Export schema for TypeScript generation."""
|
"""Export schema for TypeScript generation."""
|
||||||
return {
|
return {
|
||||||
"name": cls.name,
|
"name": self.name,
|
||||||
"type": "compose",
|
"type": "compose",
|
||||||
"meta": cls._meta,
|
"meta": self._meta,
|
||||||
"children": cls._meta.get("children", []),
|
"children": self._meta["children"],
|
||||||
"leaves": cls._meta.get("leaves", []),
|
"leaves": self._meta["leaves"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
|||||||
elif isinstance(item, ComposedContext):
|
elif isinstance(item, ComposedContext):
|
||||||
return item._leaves.copy()
|
return item._leaves.copy()
|
||||||
elif hasattr(item, "_leaves"):
|
elif hasattr(item, "_leaves"):
|
||||||
# Duck typing for composed contexts
|
|
||||||
return item._leaves.copy()
|
return item._leaves.copy()
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
|
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
|
||||||
@@ -714,21 +694,18 @@ def compose(
|
|||||||
- True: Bundled call over WebSocket. All children must have websocket=True.
|
- True: Bundled call over WebSocket. All children must have websocket=True.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@client(context='local')
|
UserContext = ReactContext('user')
|
||||||
|
|
||||||
|
@client(context=UserContext)
|
||||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||||
|
|
||||||
@client(context='local')
|
@client(context=UserContext)
|
||||||
def user_posts(request, user_id: int) -> PostsOutput: ...
|
def user_posts(request, user_id: int) -> PostsOutput: ...
|
||||||
|
|
||||||
@compose(user_profile, user_posts)
|
@compose(user_profile, user_posts)
|
||||||
def user_page():
|
def user_page():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Frontend generates:
|
|
||||||
# <UserPageProvider user_id={123}>
|
|
||||||
# <App />
|
|
||||||
# </UserPageProvider>
|
|
||||||
|
|
||||||
Nesting:
|
Nesting:
|
||||||
@compose(ctx_a, ctx_b)
|
@compose(ctx_a, ctx_b)
|
||||||
def ab(): pass
|
def ab(): pass
|
||||||
@@ -767,7 +744,7 @@ def compose(
|
|||||||
if id(leaf) in seen:
|
if id(leaf) in seen:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Duplicate context '{leaf.name}' in @compose({name}). "
|
f"Duplicate context '{leaf.name}' in @compose({name}). "
|
||||||
f"Each context can only appear once. Use named kwargs for reuse (future feature)."
|
f"Each context appears at most once across the flattened children."
|
||||||
)
|
)
|
||||||
seen.add(id(leaf))
|
seen.add(id(leaf))
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
"""
|
"""
|
||||||
Mizan IR — KDL emission from the live `mizan_core.registry`.
|
Mizan IR — KDL emission from the live `mizan_core.registry`.
|
||||||
|
|
||||||
`build_ir()` walks every registered function class, introspects its
|
`build_ir()` walks every registered function class, introspects its Pydantic
|
||||||
Pydantic Input/Output models directly (not via JSON-Schema), and emits
|
Input/Output models directly (not via JSON-Schema), computes a plain-data
|
||||||
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
|
document, and renders it through `templates/ir/document.kdl.j2`.
|
||||||
this via a backend-specific entry point (Django management command,
|
|
||||||
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
|
|
||||||
|
|
||||||
KDL grammar — locked contract:
|
KDL grammar:
|
||||||
|
|
||||||
type "<Name>" {
|
type "<Name>" {
|
||||||
struct {
|
struct {
|
||||||
field "<name>" required=#true|#false default=<lit> {
|
field "<name>" required=#false default=<lit> {
|
||||||
primitive "integer|number|boolean|string"
|
<type-child>
|
||||||
| ref "<TypeName>"
|
|
||||||
| list { <type-child> }
|
|
||||||
| optional { <type-child> }
|
|
||||||
| enum "<v1>" "<v2>" ...
|
|
||||||
}
|
}
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
| list { <type-child> }
|
|
||||||
| enum "<v1>" "<v2>" ...
|
|
||||||
| alias { <type-child> }
|
| alias { <type-child> }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<type-child> =
|
||||||
|
primitive "integer|number|boolean|string"
|
||||||
|
| ref "<TypeName>"
|
||||||
|
| enum "<v1>" "<v2>" ...
|
||||||
|
| list { <type-child> }
|
||||||
|
| optional { <type-child> }
|
||||||
|
| union { <type-child> ... }
|
||||||
|
|
||||||
function "<wire_name>" {
|
function "<wire_name>" {
|
||||||
camel "<camelCase>"
|
camel "<camelCase>"
|
||||||
has-input #true|#false
|
has-input #true|#false
|
||||||
input "<TypeName>" // omitted if has-input=#false
|
input "<TypeName>" // omitted if has-input=#false
|
||||||
output "<TypeName>"
|
output "<TypeName>"
|
||||||
output-nullable #true|#false // omitted when #false (default)
|
output-nullable #true // omitted when #false (default)
|
||||||
transport "http"|"websocket"|"both"
|
transport "http"|"websocket"
|
||||||
context "<ctx_name>" // omitted unless context-grouped
|
context "<ctx_name>" // omitted unless context-grouped
|
||||||
affects "<ctx_name>" // 0..N occurrences
|
affects "<ctx_name>" // 0..N occurrences
|
||||||
merge "<ctx_name>" // 0..N occurrences
|
merge "<ctx_name>" // 0..N occurrences
|
||||||
@@ -51,44 +51,58 @@ KDL grammar — locked contract:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel "<name>" {
|
channel "<wire_name>" {
|
||||||
pascal-name "<PascalCase>"
|
pascal-name "<PascalCase>"
|
||||||
params "<TypeName>" // omitted if no params
|
params "<TypeName>" // omitted when the channel takes no params
|
||||||
react-message "<TypeName>" // omitted if no react message
|
client-message "<TypeName>" // client -> server; omitted if none
|
||||||
django-message "<TypeName>" // omitted if no django message
|
server-message "<TypeName>" // server -> client; omitted if none
|
||||||
}
|
}
|
||||||
|
|
||||||
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
|
Channel slots are named from the client's point of view — `client-message`
|
||||||
the Pydantic→json-schema converter — all gone.
|
travels up, `server-message` travels down — and their type names are
|
||||||
|
`<Pascal>Params`, `<Pascal>ClientMessage` and `<Pascal>ServerMessage`, where
|
||||||
|
`<Pascal>` comes from `wire_to_pascal`. Backends that publish channel types
|
||||||
|
into their own schema documents call `wire_to_pascal` rather than deriving a
|
||||||
|
second Pascal form.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import types
|
import types
|
||||||
from typing import Any, Literal, Union, get_args, get_origin
|
from typing import Any, Literal, Union, get_args, get_origin
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from jinja2 import Environment, PackageLoader, StrictUndefined
|
||||||
|
from pydantic import BaseModel, create_model
|
||||||
from pydantic_core import PydanticUndefined
|
from pydantic_core import PydanticUndefined
|
||||||
|
|
||||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
from mizan_core.registry import (
|
||||||
|
get_all_functions,
|
||||||
|
get_context_groups,
|
||||||
|
get_function,
|
||||||
|
get_registry,
|
||||||
|
)
|
||||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["build_ir"]
|
__all__ = ["build_ir", "wire_to_pascal"]
|
||||||
|
|
||||||
|
|
||||||
# Common user-identity param names; mirrors the equivalent in mizan-django /
|
# ─── Wire-name derivations ──────────────────────────────────────────────────
|
||||||
# mizan-fastapi schema-export logic.
|
|
||||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── KDL value formatting ───────────────────────────────────────────────────
|
def wire_to_pascal(wire_name: str) -> str:
|
||||||
|
"""The PascalCase stem every emitted type name for `wire_name` is built on."""
|
||||||
|
return "".join(part.title() for part in re.split(r"[._-]", wire_name))
|
||||||
|
|
||||||
|
|
||||||
def _kdl_string(s: str) -> str:
|
# ─── KDL value encoding ─────────────────────────────────────────────────────
|
||||||
"""KDL-escape a string and wrap in quotes."""
|
|
||||||
|
|
||||||
|
def _kdl(value: str) -> str:
|
||||||
|
"""KDL-escape a string and wrap it in quotes."""
|
||||||
escaped = (
|
escaped = (
|
||||||
s.replace("\\", "\\\\")
|
value.replace("\\", "\\\\")
|
||||||
.replace("\"", "\\\"")
|
.replace("\"", "\\\"")
|
||||||
.replace("\n", "\\n")
|
.replace("\n", "\\n")
|
||||||
.replace("\r", "\\r")
|
.replace("\r", "\\r")
|
||||||
@@ -97,257 +111,114 @@ def _kdl_string(s: str) -> str:
|
|||||||
return f'"{escaped}"'
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
def _kdl_bool(b: bool) -> str:
|
def _kdlbool(value: bool) -> str:
|
||||||
return "#true" if b else "#false"
|
return "#true" if value else "#false"
|
||||||
|
|
||||||
|
|
||||||
def _kdl_value(v: Any) -> str:
|
_ENV = Environment(
|
||||||
"""Render a JSON-shape Python value as a KDL literal."""
|
loader=PackageLoader("mizan_core", "templates"),
|
||||||
if v is None:
|
undefined=StrictUndefined,
|
||||||
return "#null"
|
keep_trailing_newline=True,
|
||||||
if v is True or v is False:
|
trim_blocks=True,
|
||||||
return _kdl_bool(v)
|
lstrip_blocks=True,
|
||||||
if isinstance(v, (int, float)):
|
)
|
||||||
return repr(v)
|
_ENV.filters["kdl"] = _kdl
|
||||||
if isinstance(v, str):
|
_ENV.filters["kdlbool"] = _kdlbool
|
||||||
return _kdl_string(v)
|
|
||||||
# Fallback for compound values — defaults aren't typed in our IR.
|
|
||||||
import json
|
|
||||||
return _kdl_string(json.dumps(v))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── KDL Builder ────────────────────────────────────────────────────────────
|
def _default_literal(value: Any) -> dict[str, Any] | None:
|
||||||
|
"""Tag a field default so the template can pick its KDL literal form.
|
||||||
|
|
||||||
|
A KDL entry value is a scalar, so the literal forms are exactly bool,
|
||||||
|
number and string. A default of any other shape — a nested model, a list,
|
||||||
|
a dict, an enum member — has no scalar form and yields `None`: the field
|
||||||
|
emits `required=#false` with no `default`, and the server-side Pydantic
|
||||||
|
model stays the authority for the value it fills in.
|
||||||
|
"""
|
||||||
|
if value is True or value is False:
|
||||||
|
return {"kind": "bool", "value": value}
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return {"kind": "number", "value": value}
|
||||||
|
if isinstance(value, str):
|
||||||
|
return {"kind": "string", "value": value}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _Block:
|
# ─── Type shapes ────────────────────────────────────────────────────────────
|
||||||
"""Open-children context for a KDL node. Tracks indent level."""
|
|
||||||
|
|
||||||
__slots__ = ("lines", "indent")
|
|
||||||
|
|
||||||
def __init__(self, lines: list[str], indent: int):
|
|
||||||
self.lines = lines
|
|
||||||
self.indent = indent
|
|
||||||
|
|
||||||
def _prefix(self) -> str:
|
|
||||||
return " " * self.indent
|
|
||||||
|
|
||||||
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
|
|
||||||
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
|
|
||||||
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
|
|
||||||
|
|
||||||
def leaf(self, name: str, *args: str, **props: str) -> None:
|
|
||||||
"""Emit a leaf node — no children block."""
|
|
||||||
parts = [name]
|
|
||||||
parts.extend(args)
|
|
||||||
for k, v in props.items():
|
|
||||||
parts.append(f"{k}={v}")
|
|
||||||
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
|
|
||||||
|
|
||||||
|
|
||||||
class _OpenNode:
|
def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]:
|
||||||
"""A KDL node whose children are being built."""
|
"""Reduce a Python annotation to a shape tree, appending every model it
|
||||||
|
references to `refs`."""
|
||||||
|
inner, is_optional = extract_optional(annotation)
|
||||||
|
if is_optional:
|
||||||
|
return {"kind": "optional", "of": _shape(inner, refs)}
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
lines: list[str],
|
|
||||||
indent: int,
|
|
||||||
name: str,
|
|
||||||
args: list[str],
|
|
||||||
props: dict[str, str],
|
|
||||||
):
|
|
||||||
self.lines = lines
|
|
||||||
self.indent = indent
|
|
||||||
self.name = name
|
|
||||||
self.args = args
|
|
||||||
self.props = props
|
|
||||||
self._children_emitted = False
|
|
||||||
|
|
||||||
def __enter__(self) -> _Block:
|
|
||||||
parts = [self.name]
|
|
||||||
parts.extend(self.args)
|
|
||||||
for k, v in self.props.items():
|
|
||||||
parts.append(f"{k}={v}")
|
|
||||||
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
|
|
||||||
self._children_emitted = True
|
|
||||||
return _Block(self.lines, self.indent + 1)
|
|
||||||
|
|
||||||
def __exit__(self, *_exc: Any) -> None:
|
|
||||||
if self._children_emitted:
|
|
||||||
self.lines.append(f"{' ' * self.indent}}}")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Type emission ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
|
||||||
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
|
|
||||||
# Strip Optional[T] → emit `optional` wrapper.
|
|
||||||
inner, is_opt = extract_optional(annotation)
|
|
||||||
if is_opt:
|
|
||||||
with block.node("optional") as inner_block:
|
|
||||||
_emit_type_child(inner_block, inner, named_types)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
|
|
||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
if origin is Union or isinstance(annotation, types.UnionType):
|
if origin is Union or isinstance(annotation, types.UnionType):
|
||||||
branches = [a for a in get_args(annotation) if a is not type(None)]
|
branches = [a for a in get_args(annotation) if a is not type(None)]
|
||||||
if len(branches) > 1:
|
if len(branches) > 1:
|
||||||
with block.node("union") as inner_block:
|
return {
|
||||||
for branch in branches:
|
"kind": "union",
|
||||||
_emit_type_child(inner_block, branch, named_types)
|
"branches": [_shape(branch, refs) for branch in branches],
|
||||||
return
|
}
|
||||||
|
|
||||||
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
|
element = extract_list_element(annotation)
|
||||||
elem = extract_list_element(annotation)
|
if element is not None:
|
||||||
if elem is not None:
|
return {"kind": "list", "of": _shape(element, refs)}
|
||||||
with block.node("list") as inner_block:
|
|
||||||
_emit_type_child(inner_block, elem, named_types)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Literal[a, b, c] → enum
|
|
||||||
if origin is Literal:
|
if origin is Literal:
|
||||||
args = get_args(annotation)
|
values = get_args(annotation)
|
||||||
if all(isinstance(a, str) for a in args):
|
if all(isinstance(v, str) for v in values):
|
||||||
quoted = " ".join(_kdl_string(a) for a in args)
|
return {"kind": "enum", "values": list(values)}
|
||||||
block.lines.append(f"{block._prefix()}enum {quoted}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pydantic model → reference by name.
|
|
||||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
type_name = annotation.__name__
|
refs.append(annotation)
|
||||||
named_types.setdefault(type_name, _StructShape(annotation))
|
return {"kind": "ref", "name": annotation.__name__}
|
||||||
block.leaf("ref", _kdl_string(type_name))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Primitives
|
|
||||||
if annotation is int:
|
if annotation is int:
|
||||||
block.leaf("primitive", _kdl_string("integer"))
|
return {"kind": "primitive", "name": "integer"}
|
||||||
return
|
|
||||||
if annotation is float:
|
if annotation is float:
|
||||||
block.leaf("primitive", _kdl_string("number"))
|
return {"kind": "primitive", "name": "number"}
|
||||||
return
|
|
||||||
if annotation is bool:
|
if annotation is bool:
|
||||||
block.leaf("primitive", _kdl_string("boolean"))
|
return {"kind": "primitive", "name": "boolean"}
|
||||||
return
|
|
||||||
if annotation is str:
|
|
||||||
block.leaf("primitive", _kdl_string("string"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Open-shape fallback (dict / Any / etc).
|
# str, dict, Any and every other open shape collapse to string.
|
||||||
block.leaf("primitive", _kdl_string("string"))
|
return {"kind": "primitive", "name": "string"}
|
||||||
|
|
||||||
|
|
||||||
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
def _struct_fields(
|
||||||
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
|
model: type[BaseModel], refs: list[type[BaseModel]]
|
||||||
with block.node("alias") as alias_block:
|
) -> list[dict[str, Any]]:
|
||||||
_emit_type_child(alias_block, annotation, named_types)
|
fields: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
|
||||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
|
||||||
with block.node("struct") as struct_block:
|
|
||||||
for field_name, field_info in model.model_fields.items():
|
for field_name, field_info in model.model_fields.items():
|
||||||
props: dict[str, str] = {}
|
# `is_required()` covers both the explicit Required marker and the
|
||||||
# `field_info.is_required()` checks both the explicit Required
|
# presence of a default.
|
||||||
# marker and the presence of a default.
|
|
||||||
required = field_info.is_required()
|
required = field_info.is_required()
|
||||||
if not required:
|
|
||||||
props["required"] = _kdl_bool(False)
|
|
||||||
default = field_info.default
|
default = field_info.default
|
||||||
if default is not None and default is not PydanticUndefined and default is not ...:
|
has_default = (
|
||||||
props["default"] = _kdl_value(default)
|
not required
|
||||||
|
and default is not None
|
||||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
and default is not PydanticUndefined
|
||||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
and default is not ...
|
||||||
|
)
|
||||||
|
fields.append(
|
||||||
|
{
|
||||||
|
"name": field_name,
|
||||||
|
"required": required,
|
||||||
|
"default": _default_literal(default) if has_default else None,
|
||||||
|
"shape": _shape(field_info.annotation, refs),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
class _StructShape:
|
# ─── Named types ────────────────────────────────────────────────────────────
|
||||||
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
|
|
||||||
__slots__ = ("model",)
|
|
||||||
def __init__(self, model: type[BaseModel]):
|
|
||||||
self.model = model
|
|
||||||
|
|
||||||
|
|
||||||
class _AliasShape:
|
def _snake_to_camel(name: str) -> str:
|
||||||
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
|
parts = name.replace(".", "_").replace("-", "_").split("_")
|
||||||
__slots__ = ("annotation",)
|
return parts[0] + "".join(p.title() for p in parts[1:] if p)
|
||||||
def __init__(self, annotation: Any):
|
|
||||||
self.annotation = annotation
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""First pass: collect every named type the IR's `function` section references.
|
|
||||||
|
|
||||||
Two kinds:
|
|
||||||
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
|
|
||||||
as `type "X" { struct { ... } }`.
|
|
||||||
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
|
|
||||||
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
|
|
||||||
so the consumer has a single named type to reference.
|
|
||||||
"""
|
|
||||||
seen: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def visit_model(model: type[BaseModel]) -> None:
|
|
||||||
if model.__name__ in seen:
|
|
||||||
return
|
|
||||||
seen[model.__name__] = _StructShape(model)
|
|
||||||
for field_info in model.model_fields.values():
|
|
||||||
for nested in _nested_models(field_info.annotation):
|
|
||||||
visit_model(nested)
|
|
||||||
|
|
||||||
def visit_annotation(ann: Any) -> None:
|
|
||||||
for nested in _nested_models(ann):
|
|
||||||
visit_model(nested)
|
|
||||||
|
|
||||||
for fn_class in functions.values():
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
if _has_input(input_cls):
|
|
||||||
input_named = _name_input_model(fn_class)
|
|
||||||
visit_model(input_named)
|
|
||||||
|
|
||||||
output_cls = getattr(fn_class, "Output", None)
|
|
||||||
if output_cls is None:
|
|
||||||
continue
|
|
||||||
camel = _snake_to_camel(fn_class.name)
|
|
||||||
output_name = f"{camel}Output"
|
|
||||||
|
|
||||||
inner, _ = extract_optional(output_cls)
|
|
||||||
elem = extract_list_element(inner)
|
|
||||||
|
|
||||||
if elem is not None:
|
|
||||||
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
|
|
||||||
# Visit the element type so its struct shape gets emitted too.
|
|
||||||
visit_annotation(output_cls)
|
|
||||||
if output_name not in seen:
|
|
||||||
seen[output_name] = _AliasShape(output_cls)
|
|
||||||
elif isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
||||||
# `<Model>` or `Optional[<Model>]` — emit the model under the
|
|
||||||
# canonical name (rename if necessary).
|
|
||||||
output_named = _name_output_model(fn_class, inner)
|
|
||||||
visit_model(output_named)
|
|
||||||
# If the Optional wrapper differs from the bare model, emit an
|
|
||||||
# alias under the canonical output name too.
|
|
||||||
if output_named.__name__ != output_name:
|
|
||||||
seen.setdefault(output_name, _AliasShape(output_cls))
|
|
||||||
else:
|
|
||||||
# Primitive-wrapped output (`result: int`) — emit as alias.
|
|
||||||
seen.setdefault(output_name, _AliasShape(output_cls))
|
|
||||||
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
|
|
||||||
"""All Pydantic models that appear anywhere inside `annotation`."""
|
|
||||||
out: list[type[BaseModel]] = []
|
|
||||||
inner, _ = extract_optional(annotation)
|
|
||||||
elem = extract_list_element(inner)
|
|
||||||
if elem is not None:
|
|
||||||
out.extend(_nested_models(elem))
|
|
||||||
return out
|
|
||||||
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
||||||
out.append(inner)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _has_input(input_cls: Any) -> bool:
|
def _has_input(input_cls: Any) -> bool:
|
||||||
@@ -359,206 +230,135 @@ def _has_input(input_cls: Any) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _snake_to_camel(name: str) -> str:
|
|
||||||
parts = name.replace(".", "_").replace("-", "_").split("_")
|
|
||||||
return parts[0] + "".join(p.title() for p in parts[1:] if p)
|
|
||||||
|
|
||||||
|
|
||||||
def _name_input_model(fn_class: Any) -> type[BaseModel]:
|
def _name_input_model(fn_class: Any) -> type[BaseModel]:
|
||||||
"""Return a copy of the function's Input model named `<CamelName>Input`."""
|
"""The function's Input model under the canonical `<CamelName>Input` name."""
|
||||||
from pydantic import create_model
|
canonical = f"{_snake_to_camel(fn_class.name)}Input"
|
||||||
|
|
||||||
camel = _snake_to_camel(fn_class.name)
|
|
||||||
canonical = f"{camel}Input"
|
|
||||||
src = fn_class.Input
|
src = fn_class.Input
|
||||||
if src.__name__ == canonical:
|
if src.__name__ == canonical:
|
||||||
return src
|
return src
|
||||||
# Re-derive under the canonical name so codegen consumers see a stable name.
|
|
||||||
return create_model(canonical, __base__=src)
|
return create_model(canonical, __base__=src)
|
||||||
|
|
||||||
|
|
||||||
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
|
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
|
||||||
"""Return a copy of the model named `<CamelName>Output`."""
|
"""`base` under the canonical `<CamelName>Output` name."""
|
||||||
from pydantic import create_model
|
canonical = f"{_snake_to_camel(fn_class.name)}Output"
|
||||||
|
|
||||||
camel = _snake_to_camel(fn_class.name)
|
|
||||||
canonical = f"{camel}Output"
|
|
||||||
if base.__name__ == canonical:
|
if base.__name__ == canonical:
|
||||||
return base
|
return base
|
||||||
return create_model(canonical, __base__=base)
|
return create_model(canonical, __base__=base)
|
||||||
|
|
||||||
|
|
||||||
# ─── Function / context / channel emission ──────────────────────────────────
|
def _bind(
|
||||||
|
sources: dict[str, tuple[str, Any]], name: str, source: tuple[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Claim `name` for one declaration. Two different declarations under one
|
||||||
|
name would emit two `type` blocks that no `ref` can tell apart, so the
|
||||||
|
second claim raises."""
|
||||||
|
claimed = sources.setdefault(name, source)
|
||||||
|
if claimed != source:
|
||||||
|
raise ValueError(
|
||||||
|
f"named type '{name}' is claimed twice, by {claimed[1]!r} "
|
||||||
|
f"and by {source[1]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
|
def _seed_named_types(
|
||||||
"""Collect every value that goes inside a `function` block."""
|
functions: dict[str, Any], channel_models: list[tuple[str, type[BaseModel]]]
|
||||||
|
) -> dict[str, tuple[str, Any]]:
|
||||||
|
"""Name → ("struct", model) | ("alias", annotation) for every type the
|
||||||
|
function and channel sections reference directly."""
|
||||||
|
seeds: dict[str, tuple[str, Any]] = {}
|
||||||
|
|
||||||
|
for fn_class in functions.values():
|
||||||
|
if _has_input(getattr(fn_class, "Input", None)):
|
||||||
|
named_input = _name_input_model(fn_class)
|
||||||
|
_bind(seeds, named_input.__name__, ("struct", named_input))
|
||||||
|
|
||||||
|
output_cls = getattr(fn_class, "Output", None)
|
||||||
|
if output_cls is None:
|
||||||
|
continue
|
||||||
|
output_name = f"{_snake_to_camel(fn_class.name)}Output"
|
||||||
|
inner, _ = extract_optional(output_cls)
|
||||||
|
wraps_model = (
|
||||||
|
extract_list_element(inner) is None
|
||||||
|
and isinstance(inner, type)
|
||||||
|
and issubclass(inner, BaseModel)
|
||||||
|
)
|
||||||
|
if wraps_model:
|
||||||
|
_bind(seeds, output_name, ("struct", _name_output_model(fn_class, inner)))
|
||||||
|
else:
|
||||||
|
_bind(seeds, output_name, ("alias", output_cls))
|
||||||
|
|
||||||
|
for type_name, model in channel_models:
|
||||||
|
_bind(seeds, type_name, ("struct", model))
|
||||||
|
|
||||||
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_named_types(seeds: dict[str, tuple[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Resolve seeds to a fixed point — resolving one type discovers the models
|
||||||
|
it references, which are themselves resolved — then order by name."""
|
||||||
|
sources = dict(seeds)
|
||||||
|
resolved: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
unresolved = [name for name in sources if name not in resolved]
|
||||||
|
if not unresolved:
|
||||||
|
break
|
||||||
|
for name in unresolved:
|
||||||
|
kind, payload = sources[name]
|
||||||
|
refs: list[type[BaseModel]] = []
|
||||||
|
if kind == "struct":
|
||||||
|
resolved[name] = {
|
||||||
|
"name": name,
|
||||||
|
"kind": "struct",
|
||||||
|
"fields": _struct_fields(payload, refs),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
resolved[name] = {
|
||||||
|
"name": name,
|
||||||
|
"kind": "alias",
|
||||||
|
"shape": _shape(payload, refs),
|
||||||
|
}
|
||||||
|
for model in refs:
|
||||||
|
_bind(sources, model.__name__, ("struct", model))
|
||||||
|
|
||||||
|
return [resolved[name] for name in sorted(resolved)]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Functions, contexts, channels ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _is_emitted(fn_class: Any) -> bool:
|
||||||
meta = getattr(fn_class, "_meta", {})
|
meta = getattr(fn_class, "_meta", {})
|
||||||
name = fn_class.name
|
return not (meta.get("private") or meta.get("view_path"))
|
||||||
camel = _snake_to_camel(name)
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
has_input = _has_input(input_cls)
|
def _function_entry(fn_class: Any) -> dict[str, Any]:
|
||||||
is_context = meta.get("context")
|
meta = getattr(fn_class, "_meta", {})
|
||||||
is_form = meta.get("form", False)
|
camel = _snake_to_camel(fn_class.name)
|
||||||
|
has_input = _has_input(getattr(fn_class, "Input", None))
|
||||||
|
_, output_nullable = extract_optional(getattr(fn_class, "Output", None))
|
||||||
|
context = meta.get("context")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": fn_class.name,
|
||||||
"camel": camel,
|
"camel": camel,
|
||||||
"has_input": has_input,
|
"has_input": has_input,
|
||||||
"input_type": f"{camel}Input" if has_input else None,
|
"input_type": f"{camel}Input" if has_input else None,
|
||||||
"output_type": output_type_name,
|
"output_type": f"{camel}Output",
|
||||||
"output_nullable": output_nullable,
|
"output_nullable": output_nullable,
|
||||||
"transport": "websocket" if meta.get("websocket") else "http",
|
"transport": "websocket" if meta.get("websocket") else "http",
|
||||||
"context": is_context if isinstance(is_context, str) else None,
|
"context": context if isinstance(context, str) else None,
|
||||||
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
|
"affects": [
|
||||||
|
a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"
|
||||||
|
],
|
||||||
"merge": list(meta.get("merge") or []),
|
"merge": list(meta.get("merge") or []),
|
||||||
"is_form": bool(is_form),
|
"is_form": bool(meta.get("form", False)),
|
||||||
"form_name": meta.get("form_name"),
|
"form_name": meta.get("form_name"),
|
||||||
"form_role": meta.get("form_role"),
|
"form_role": meta.get("form_role"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
|
|
||||||
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
|
|
||||||
camel = _snake_to_camel(fn_class.name)
|
|
||||||
canonical = f"{camel}Output"
|
|
||||||
output_cls = getattr(fn_class, "Output", None)
|
|
||||||
if output_cls is None:
|
|
||||||
return canonical, False
|
|
||||||
_, nullable = extract_optional(output_cls)
|
|
||||||
return canonical, nullable
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_channels() -> list[dict[str, Any]]:
|
|
||||||
"""Pull channel registrations from the optional `channels` registry extension."""
|
|
||||||
from mizan_core.registry import _extensions # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
ext = _extensions.get("channels")
|
|
||||||
if ext is None:
|
|
||||||
return []
|
|
||||||
schema = ext.schema()
|
|
||||||
return list(schema or [])
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Top-level builder ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def build_ir() -> str:
|
|
||||||
"""Build the Mizan IR for every registered function. Returns KDL source."""
|
|
||||||
functions = get_all_functions()
|
|
||||||
context_groups = get_context_groups()
|
|
||||||
channels = _collect_channels()
|
|
||||||
|
|
||||||
named_types = _collect_named_types(functions)
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
root = _Block(lines, indent=0)
|
|
||||||
|
|
||||||
# ── Type definitions ──
|
|
||||||
for type_name in sorted(named_types):
|
|
||||||
shape = named_types[type_name]
|
|
||||||
with root.node("type", _kdl_string(type_name)) as type_block:
|
|
||||||
if isinstance(shape, _StructShape):
|
|
||||||
_emit_struct_type(type_block, shape.model, named_types)
|
|
||||||
elif isinstance(shape, _AliasShape):
|
|
||||||
_emit_alias_type(type_block, shape.annotation, named_types)
|
|
||||||
else:
|
|
||||||
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
|
|
||||||
|
|
||||||
if named_types:
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# ── Functions ──
|
|
||||||
# Alphabetical by wire name — the IR is a canonical contract, not a
|
|
||||||
# transcript of registration order. Both Python and Rust emitters sort
|
|
||||||
# so byte-equivalence holds across language-backed backends.
|
|
||||||
for fn_name in sorted(functions):
|
|
||||||
fn_class = functions[fn_name]
|
|
||||||
meta = getattr(fn_class, "_meta", {})
|
|
||||||
if meta.get("private") or meta.get("view_path"):
|
|
||||||
continue
|
|
||||||
output_type_name, output_nullable = _resolve_output(fn_class)
|
|
||||||
props = _function_props(fn_class, output_type_name, output_nullable)
|
|
||||||
_emit_function(root, props)
|
|
||||||
|
|
||||||
if functions:
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# ── Contexts ──
|
|
||||||
# Alphabetical by context name — same reason as functions above.
|
|
||||||
for ctx_name in sorted(context_groups):
|
|
||||||
_emit_context(root, ctx_name, context_groups[ctx_name])
|
|
||||||
|
|
||||||
if context_groups:
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# ── Channels ──
|
|
||||||
for channel in channels:
|
|
||||||
_emit_channel(root, channel)
|
|
||||||
|
|
||||||
# Trim trailing blanks then add a single terminating newline.
|
|
||||||
while lines and not lines[-1]:
|
|
||||||
lines.pop()
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
|
|
||||||
with root.node("function", _kdl_string(props["name"])) as block:
|
|
||||||
block.leaf("camel", _kdl_string(props["camel"]))
|
|
||||||
block.leaf("has-input", _kdl_bool(props["has_input"]))
|
|
||||||
if props["input_type"]:
|
|
||||||
block.leaf("input", _kdl_string(props["input_type"]))
|
|
||||||
block.leaf("output", _kdl_string(props["output_type"]))
|
|
||||||
if props["output_nullable"]:
|
|
||||||
block.leaf("output-nullable", _kdl_bool(True))
|
|
||||||
block.leaf("transport", _kdl_string(props["transport"]))
|
|
||||||
if props["context"]:
|
|
||||||
block.leaf("context", _kdl_string(props["context"]))
|
|
||||||
for affect_name in props["affects"]:
|
|
||||||
block.leaf("affects", _kdl_string(affect_name))
|
|
||||||
for merge_name in props["merge"]:
|
|
||||||
block.leaf("merge", _kdl_string(merge_name))
|
|
||||||
if props["is_form"]:
|
|
||||||
block.leaf("is-form", _kdl_bool(True))
|
|
||||||
if props["form_name"]:
|
|
||||||
block.leaf("form-name", _kdl_string(props["form_name"]))
|
|
||||||
if props["form_role"]:
|
|
||||||
block.leaf("form-role", _kdl_string(props["form_role"]))
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
|
|
||||||
# First pass: collect param info across every function in the context.
|
|
||||||
param_info: dict[str, dict[str, Any]] = {}
|
|
||||||
for fn_name in fn_names:
|
|
||||||
fn_class = get_function(fn_name)
|
|
||||||
if fn_class is None:
|
|
||||||
continue
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
if not _has_input(input_cls):
|
|
||||||
continue
|
|
||||||
for param_name, field_info in input_cls.model_fields.items():
|
|
||||||
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
|
|
||||||
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
|
||||||
slot["shared_by"].append(fn_name)
|
|
||||||
|
|
||||||
# A param is required iff every function in the context declares it.
|
|
||||||
for slot in param_info.values():
|
|
||||||
slot["required"] = len(slot["shared_by"]) == len(fn_names)
|
|
||||||
|
|
||||||
with root.node("context", _kdl_string(ctx_name)) as block:
|
|
||||||
# Members alphabetical — canonical order.
|
|
||||||
for fn_name in sorted(fn_names):
|
|
||||||
block.leaf("function", _kdl_string(fn_name))
|
|
||||||
for param_name in sorted(param_info):
|
|
||||||
slot = param_info[param_name]
|
|
||||||
with block.node("param", _kdl_string(param_name)) as param_block:
|
|
||||||
param_block.leaf("type", _kdl_string(slot["type"]))
|
|
||||||
param_block.leaf("required", _kdl_bool(slot["required"]))
|
|
||||||
# `shared-by` follows the same canonical ordering.
|
|
||||||
for sharer in sorted(slot["shared_by"]):
|
|
||||||
param_block.leaf("shared-by", _kdl_string(sharer))
|
|
||||||
|
|
||||||
|
|
||||||
def _annotation_to_primitive(annotation: Any) -> str:
|
def _annotation_to_primitive(annotation: Any) -> str:
|
||||||
inner, _ = extract_optional(annotation)
|
inner, _ = extract_optional(annotation)
|
||||||
if inner is int:
|
if inner is int:
|
||||||
@@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str:
|
|||||||
return "string"
|
return "string"
|
||||||
|
|
||||||
|
|
||||||
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
|
def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]:
|
||||||
name = channel["name"]
|
param_info: dict[str, dict[str, Any]] = {}
|
||||||
with root.node("channel", _kdl_string(name)) as block:
|
for fn_name in fn_names:
|
||||||
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
|
input_cls = getattr(get_function(fn_name), "Input", None)
|
||||||
if channel.get("hasParams") and channel.get("paramsType"):
|
if not _has_input(input_cls):
|
||||||
block.leaf("params", _kdl_string(channel["paramsType"]))
|
continue
|
||||||
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
|
for param_name, field_info in input_cls.model_fields.items():
|
||||||
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
|
slot = param_info.setdefault(param_name, {"shared_by": []})
|
||||||
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
|
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
||||||
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))
|
slot["shared_by"].append(fn_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": ctx_name,
|
||||||
|
"functions": sorted(fn_names),
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": param_name,
|
||||||
|
"type": param_info[param_name]["type"],
|
||||||
|
# A param is required iff every function in the context takes it.
|
||||||
|
"required": len(param_info[param_name]["shared_by"]) == len(fn_names),
|
||||||
|
"shared_by": sorted(param_info[param_name]["shared_by"]),
|
||||||
|
}
|
||||||
|
for param_name in sorted(param_info)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CHANNEL_SLOTS = (
|
||||||
|
("Params", "params"),
|
||||||
|
("ClientMessage", "client_message"),
|
||||||
|
("ServerMessage", "server_message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_channels() -> tuple[
|
||||||
|
list[dict[str, Any]], list[tuple[str, type[BaseModel]]]
|
||||||
|
]:
|
||||||
|
"""Channel blocks in wire-name order, plus the (emitted type name, model)
|
||||||
|
pair each declared slot resolves to."""
|
||||||
|
channel_classes = get_registry().get("channels", {})
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
models: list[tuple[str, type[BaseModel]]] = []
|
||||||
|
|
||||||
|
for wire_name in sorted(channel_classes):
|
||||||
|
channel_class = channel_classes[wire_name]
|
||||||
|
pascal = wire_to_pascal(wire_name)
|
||||||
|
record: dict[str, Any] = {"name": wire_name, "pascal_name": pascal}
|
||||||
|
for attribute, slot in _CHANNEL_SLOTS:
|
||||||
|
declared = getattr(channel_class, attribute, None)
|
||||||
|
if declared is None:
|
||||||
|
record[slot] = None
|
||||||
|
continue
|
||||||
|
if not (isinstance(declared, type) and issubclass(declared, BaseModel)):
|
||||||
|
raise TypeError(
|
||||||
|
f"channel '{wire_name}' declares {attribute} as {declared!r}, "
|
||||||
|
f"which is not a pydantic BaseModel subclass"
|
||||||
|
)
|
||||||
|
type_name = f"{pascal}{attribute}"
|
||||||
|
record[slot] = type_name
|
||||||
|
models.append((type_name, declared))
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
return records, models
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Top-level builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_ir() -> str:
|
||||||
|
"""Build the Mizan IR for every registered function. Returns KDL source.
|
||||||
|
|
||||||
|
An empty registry renders the empty document — zero bytes, zero nodes.
|
||||||
|
"""
|
||||||
|
functions = get_all_functions()
|
||||||
|
context_groups = get_context_groups()
|
||||||
|
channels, channel_models = _collect_channels()
|
||||||
|
|
||||||
|
# Every section is sorted by name, so the document does not depend on the
|
||||||
|
# order things were registered in.
|
||||||
|
return _ENV.get_template("ir/document.kdl.j2").render(
|
||||||
|
types=_resolve_named_types(_seed_named_types(functions, channel_models)),
|
||||||
|
functions=[
|
||||||
|
_function_entry(functions[name])
|
||||||
|
for name in sorted(functions)
|
||||||
|
if _is_emitted(functions[name])
|
||||||
|
],
|
||||||
|
contexts=[
|
||||||
|
_context_entry(name, context_groups[name])
|
||||||
|
for name in sorted(context_groups)
|
||||||
|
],
|
||||||
|
channels=channels,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Mizan core registry — function and composition registration with an
|
Mizan core registry — function and composition registration, plus an
|
||||||
extension hook for backend-specific registries (channels, forms, etc.)
|
extension hook backend-specific registries (channels, forms, …) plug into.
|
||||||
to plug into.
|
|
||||||
|
|
||||||
This is the framework-agnostic registry. Backends own their own
|
|
||||||
type-specific registries (channels in Django Channels, forms in Django
|
|
||||||
Forms, websockets in FastAPI, etc.) and register them as extensions
|
|
||||||
here so the unified schema export can include them.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Protocol
|
import abc
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
# ─── Core registries ────────────────────────────────────────────────────────
|
# ─── Core registries ────────────────────────────────────────────────────────
|
||||||
@@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {}
|
|||||||
|
|
||||||
# ─── Extension hook ─────────────────────────────────────────────────────────
|
# ─── Extension hook ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class RegistryExtension(Protocol):
|
class RegistryExtension(abc.ABC):
|
||||||
"""
|
"""
|
||||||
Backend-specific registries plug into core via this Protocol.
|
A backend registry of its own registrations (channels, forms, websocket
|
||||||
|
consumers, …) contributing one subdict to the unified schema export.
|
||||||
Each extension owns its own registry of backend-shaped registrations
|
|
||||||
(channels, forms, websocket consumers, etc.) and contributes a schema
|
|
||||||
subdict to the unified schema export.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def schema(self) -> dict[str, Any]: ...
|
@abc.abstractmethod
|
||||||
def clear(self) -> None: ...
|
def all(self) -> dict[str, Any]:
|
||||||
|
"""The live registry: registered name → registered class."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def schema(self) -> dict[str, Any]:
|
||||||
|
"""The schema subdict exported under this extension's name."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Drop every registration held by this extension."""
|
||||||
|
|
||||||
|
|
||||||
_extensions: dict[str, RegistryExtension] = {}
|
_extensions: dict[str, RegistryExtension] = {}
|
||||||
@@ -146,9 +147,6 @@ def get_registry() -> dict[str, Any]:
|
|||||||
"compositions": _compositions.copy(),
|
"compositions": _compositions.copy(),
|
||||||
}
|
}
|
||||||
for name, ext in _extensions.items():
|
for name, ext in _extensions.items():
|
||||||
# Extensions optionally expose their backing dict via .all()
|
|
||||||
# (Protocol doesn't require it; only schema() and clear() are mandatory)
|
|
||||||
if hasattr(ext, "all"):
|
|
||||||
out[name] = ext.all()
|
out[name] = ext.all()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
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 @@
|
|||||||
"""
|
"""Annotation-introspection helpers used when walking @client function signatures."""
|
||||||
Type-introspection helpers shared across backend adapters.
|
|
||||||
|
|
||||||
Both mizan-django and mizan-fastapi need to walk @client-decorated function
|
|
||||||
annotations the same way during schema export. Drift here breaks AFI parity,
|
|
||||||
so the helpers live in core.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -28,9 +22,8 @@ def extract_optional(annotation: Any) -> tuple[Any, bool]:
|
|||||||
Returns `(T, True)` for a union containing exactly one non-None member
|
Returns `(T, True)` for a union containing exactly one non-None member
|
||||||
and `None` itself. For anything else, returns `(annotation, False)`.
|
and `None` itself. For anything else, returns `(annotation, False)`.
|
||||||
|
|
||||||
Multi-arm unions like `A | B | None` are returned as-is — protocol-level
|
A multi-arm union like `A | B | None` is returned as-is — picking one arm
|
||||||
discriminated unions aren't supported yet, and silently picking one arm
|
would silently discard the others.
|
||||||
would hide that.
|
|
||||||
"""
|
"""
|
||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
if origin is Union or isinstance(annotation, types.UnionType):
|
if origin is Union or isinstance(annotation, types.UnionType):
|
||||||
@@ -80,10 +73,9 @@ def is_structured_output(annotation: Any) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
|
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
|
||||||
"""True if a `value_type` mutation return can splice into a `slot_type` context slot.
|
"""True if a `value_type` mutation return can splice into a `slot_type` slot.
|
||||||
|
|
||||||
Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete
|
Three shapes match:
|
||||||
function-name slot inside the context bundle. Three shapes match:
|
|
||||||
|
|
||||||
- direct: slot is `T`, value is `T` → replace
|
- direct: slot is `T`, value is `T` → replace
|
||||||
- upsert: slot is `list[T]`, value is `T` → upsert by id
|
- upsert: slot is `list[T]`, value is `T` → upsert by id
|
||||||
|
|||||||
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 quote::{format_ident, quote};
|
||||||
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
|
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
|
||||||
|
|
||||||
/// Attribute args: either nothing, or one string literal that overrides the
|
/// Where the context's wire name comes from: the attribute, or the struct's
|
||||||
/// derived snake_case context name.
|
/// own identifier when the attribute names none.
|
||||||
pub struct ContextArgs {
|
pub enum ContextName {
|
||||||
pub explicit_name: Option<String>,
|
Explicit(String),
|
||||||
|
FromIdent,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextArgs {
|
impl ContextName {
|
||||||
|
/// Both `#[mizan::context("user")]` (bare string literal) and
|
||||||
|
/// `#[mizan::context(name = "user")]` name the context explicitly.
|
||||||
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
|
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
|
||||||
if attr_tokens.is_empty() {
|
if attr_tokens.is_empty() {
|
||||||
return Ok(Self { explicit_name: None });
|
return Ok(ContextName::FromIdent);
|
||||||
}
|
}
|
||||||
// Support both `#[mizan::context("user")]` (string literal) and
|
|
||||||
// `#[mizan::context(name = "user")]` (key=value).
|
|
||||||
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
|
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
|
||||||
return Ok(Self {
|
return Ok(ContextName::Explicit(lit.value()));
|
||||||
explicit_name: Some(lit.value()),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||||
let metas = parser.parse2(attr_tokens)?;
|
let metas = parser.parse2(attr_tokens)?;
|
||||||
for meta in metas {
|
for meta in metas {
|
||||||
if let Meta::NameValue(nv) = meta {
|
if let Meta::NameValue(nv) = meta {
|
||||||
if nv.path.is_ident("name") {
|
if nv.path.is_ident("name") {
|
||||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
if let syn::Expr::Lit(syn::ExprLit {
|
||||||
return Ok(Self {
|
lit: Lit::Str(s), ..
|
||||||
explicit_name: Some(s.value()),
|
}) = nv.value
|
||||||
});
|
{
|
||||||
|
return Ok(ContextName::Explicit(s.value()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,9 +42,16 @@ impl ContextArgs {
|
|||||||
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
|
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve(self, ident: &syn::Ident) -> String {
|
||||||
|
match self {
|
||||||
|
ContextName::Explicit(name) => name,
|
||||||
|
ContextName::FromIdent => ident.to_string().to_snake_case(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
|
pub fn expand(name: ContextName, item: ItemStruct) -> TokenStream {
|
||||||
if !item.fields.is_empty() {
|
if !item.fields.is_empty() {
|
||||||
return syn::Error::new_spanned(
|
return syn::Error::new_spanned(
|
||||||
&item.fields,
|
&item.fields,
|
||||||
@@ -54,9 +61,7 @@ pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ident = item.ident.clone();
|
let ident = item.ident.clone();
|
||||||
let name = args
|
let name = name.resolve(&ident);
|
||||||
.explicit_name
|
|
||||||
.unwrap_or_else(|| ident.to_string().to_snake_case());
|
|
||||||
|
|
||||||
let register_static =
|
let register_static =
|
||||||
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
|
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
|
||||||
|
|
||||||
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
|
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
|
||||||
use proc_macro2::TokenStream;
|
use proc_macro2::{TokenStream, TokenTree};
|
||||||
use quote::quote;
|
use quote::{format_ident, quote};
|
||||||
use syn::{
|
use syn::{
|
||||||
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit,
|
parse::{Parse, ParseStream},
|
||||||
Meta, Token,
|
Data, DeriveInput, Field, Fields, FieldsNamed, Ident, Lit, Meta, Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::shape::type_shape_expr;
|
use crate::shape::{is_optional, type_shape_expr};
|
||||||
|
|
||||||
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
|
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
|
||||||
/// variant identifier so the IR's enum variant matches what serde emits
|
/// variant identifier so the IR's enum variant matches what serde emits on
|
||||||
/// on the wire. Supported casings mirror serde's set.
|
/// the wire. Supported casings mirror serde's set; any other rule — including
|
||||||
|
/// the empty rule an undecorated enum carries — leaves the identifier as
|
||||||
|
/// written.
|
||||||
fn apply_rename_all(rule: &str, ident: &str) -> String {
|
fn apply_rename_all(rule: &str, ident: &str) -> String {
|
||||||
match rule {
|
match rule {
|
||||||
"lowercase" => ident.to_lowercase(),
|
"lowercase" => ident.to_lowercase(),
|
||||||
@@ -26,87 +28,210 @@ fn apply_rename_all(rule: &str, ident: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`.
|
/// The string a `#[serde(<key> = "...")]` entry in `attrs` carries, or
|
||||||
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
|
/// `fallback` when no entry names `key`. serde owns that attribute's grammar
|
||||||
|
/// and its own derive reports a malformed body, so a body without the
|
||||||
|
/// `<key> = <string>` triple reads here as "no override".
|
||||||
|
fn serde_string(attrs: &[syn::Attribute], key: &str, fallback: String) -> String {
|
||||||
for attr in attrs {
|
for attr in attrs {
|
||||||
if !attr.path().is_ident("serde") {
|
if !attr.path().is_ident("serde") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let list = match &attr.meta {
|
let Meta::List(list) = &attr.meta else {
|
||||||
Meta::List(l) => l,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
|
||||||
let metas = match parser.parse2(list.tokens.clone()) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for meta in metas {
|
|
||||||
if let Meta::NameValue(nv) = meta {
|
|
||||||
if nv.path.is_ident("rename_all") {
|
|
||||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
|
||||||
return Some(s.value());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]`
|
|
||||||
/// override. Variant-level rename overrides the enum-level rename_all.
|
|
||||||
fn serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
|
|
||||||
for attr in attrs {
|
|
||||||
if !attr.path().is_ident("serde") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
let list = match &attr.meta {
|
|
||||||
Meta::List(l) => l,
|
|
||||||
_ => continue,
|
|
||||||
};
|
};
|
||||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
let mut on_key = false;
|
||||||
let metas = match parser.parse2(list.tokens.clone()) {
|
let mut on_value = false;
|
||||||
Ok(m) => m,
|
for tree in list.tokens.clone() {
|
||||||
Err(_) => continue,
|
match tree {
|
||||||
};
|
TokenTree::Ident(ident) => {
|
||||||
for meta in metas {
|
on_key = ident == key;
|
||||||
if let Meta::NameValue(nv) = meta {
|
on_value = false;
|
||||||
if nv.path.is_ident("rename") {
|
}
|
||||||
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
|
TokenTree::Punct(punct) => {
|
||||||
return Some(s.value());
|
on_value = on_key && punct.as_char() == '=';
|
||||||
|
}
|
||||||
|
TokenTree::Literal(literal) => {
|
||||||
|
if on_value {
|
||||||
|
if let Lit::Str(s) = Lit::new(literal) {
|
||||||
|
return s.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on_key = false;
|
||||||
|
on_value = false;
|
||||||
|
}
|
||||||
|
TokenTree::Group(_) => {
|
||||||
|
on_key = false;
|
||||||
|
on_value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
fallback
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme
|
/// A braced struct field paired with the identifier it carries. `all` is the
|
||||||
/// TypeEntry registration. Every Mizan-shaped type lands in the IR;
|
/// only constructor and it reads a `FieldsNamed` group, so `ident` is a total
|
||||||
/// the emitter's inline-substitution pass collapses primitive-aliases
|
/// accessor rather than an Option the caller has to open.
|
||||||
/// and enums at use sites so the IR stays tight.
|
struct NamedField<'a> {
|
||||||
pub fn expand(input: DeriveInput) -> TokenStream {
|
ident: &'a Ident,
|
||||||
let ident = input.ident.clone();
|
field: &'a Field,
|
||||||
let type_name = ident.to_string();
|
}
|
||||||
|
|
||||||
let rename_all = serde_rename_all(&input.attrs);
|
impl<'a> NamedField<'a> {
|
||||||
|
fn all(braced: &'a FieldsNamed) -> impl Iterator<Item = Self> {
|
||||||
|
braced
|
||||||
|
.named
|
||||||
|
.iter()
|
||||||
|
.flat_map(|field| field.ident.as_ref().map(|ident| Self { ident, field }))
|
||||||
|
}
|
||||||
|
|
||||||
let named_type_body = match &input.data {
|
/// The wire name serde emits: a `#[serde(rename)]` override, else the
|
||||||
Data::Struct(s) => emit_struct(s),
|
/// identifier with serde's `r#` raw-prefix stripping applied.
|
||||||
Data::Enum(e) => emit_enum(e, rename_all.as_deref()),
|
fn wire_name(&self) -> String {
|
||||||
|
let raw_ident = self.ident.to_string();
|
||||||
|
let default = raw_ident.trim_start_matches("r#").to_string();
|
||||||
|
serde_string(&self.field.attrs, "rename", default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One struct field reduced to what the IR carries: the name serde puts on the
|
||||||
|
/// wire and the declared Rust type.
|
||||||
|
struct FieldShape {
|
||||||
|
wire_name: String,
|
||||||
|
ty: Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two type forms the IR can express.
|
||||||
|
enum DerivedShape {
|
||||||
|
Struct(Vec<FieldShape>),
|
||||||
|
Enum(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A derive input already reduced to the IR form its body takes. The token
|
||||||
|
/// stream is parsed straight into this shape, so `expand` reads a settled
|
||||||
|
/// name and body and has nothing left to reject.
|
||||||
|
pub struct MizanDerive {
|
||||||
|
ident: Ident,
|
||||||
|
shape: DerivedShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for MizanDerive {
|
||||||
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||||
|
let input: DeriveInput = input.parse()?;
|
||||||
|
let shape = match &input.data {
|
||||||
|
Data::Struct(s) => {
|
||||||
|
let braced = match &s.fields {
|
||||||
|
Fields::Named(named) => named,
|
||||||
|
Fields::Unnamed(_) => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&s.fields,
|
||||||
|
"#[derive(Mizan)] requires named fields. Tuple structs aren't part of the IR shape.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Fields::Unit => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&s.fields,
|
||||||
|
"#[derive(Mizan)] requires named fields. Unit structs aren't part of the IR shape.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
for named in NamedField::all(braced) {
|
||||||
|
fields.push(FieldShape {
|
||||||
|
wire_name: named.wire_name(),
|
||||||
|
ty: named.field.ty.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DerivedShape::Struct(fields)
|
||||||
|
}
|
||||||
|
Data::Enum(e) => {
|
||||||
|
let rename_all = serde_string(&input.attrs, "rename_all", String::new());
|
||||||
|
let mut variants = Vec::new();
|
||||||
|
for variant in &e.variants {
|
||||||
|
match &variant.fields {
|
||||||
|
Fields::Unit => {}
|
||||||
|
Fields::Named(_) => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&variant.fields,
|
||||||
|
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Struct variants aren't expressible in the current IR.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Fields::Unnamed(_) => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&variant.fields,
|
||||||
|
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Tuple variants aren't expressible in the current IR.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Variant-level `rename` wins over the enum-level
|
||||||
|
// `rename_all` rule.
|
||||||
|
let default = apply_rename_all(&rename_all, &variant.ident.to_string());
|
||||||
|
variants.push(serde_string(&variant.attrs, "rename", default));
|
||||||
|
}
|
||||||
|
DerivedShape::Enum(variants)
|
||||||
|
}
|
||||||
Data::Union(_) => {
|
Data::Union(_) => {
|
||||||
return syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
&input,
|
&input,
|
||||||
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
|
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
|
||||||
)
|
));
|
||||||
.to_compile_error();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Ok(Self {
|
||||||
|
ident: input.ident,
|
||||||
|
shape,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let register_static =
|
/// Build the `NamedType` expression the generated `shape()` returns.
|
||||||
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case());
|
fn named_type_expr(shape: &DerivedShape) -> TokenStream {
|
||||||
|
match shape {
|
||||||
|
DerivedShape::Struct(fields) => {
|
||||||
|
let field_exprs: Vec<TokenStream> = fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| {
|
||||||
|
let name = &field.wire_name;
|
||||||
|
// A Rust struct-field declaration carries no default
|
||||||
|
// expression, so `default` is always None and `required`
|
||||||
|
// follows the Option wrapper.
|
||||||
|
let required = !is_optional(&field.ty);
|
||||||
|
let shape = type_shape_expr(&field.ty);
|
||||||
|
quote! {
|
||||||
|
::mizan_core::StructField {
|
||||||
|
name: #name,
|
||||||
|
required: #required,
|
||||||
|
default: ::std::option::Option::None,
|
||||||
|
shape: #shape,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
quote! {
|
||||||
|
::mizan_core::NamedType::Struct(::std::vec![
|
||||||
|
#(#field_exprs),*
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DerivedShape::Enum(variants) => quote! {
|
||||||
|
::mizan_core::NamedType::Enum(::std::vec![
|
||||||
|
#(#variants),*
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand `#[derive(Mizan)]` — the `MizanType` impl plus the linkme
|
||||||
|
/// `TypeEntry` registration for the derived type.
|
||||||
|
pub fn expand(derived: MizanDerive) -> TokenStream {
|
||||||
|
let MizanDerive { ident, shape } = derived;
|
||||||
|
let named_type_body = named_type_expr(&shape);
|
||||||
|
let type_name = ident.to_string();
|
||||||
|
let register_static = format_ident!(
|
||||||
|
"__MIZAN_TYPE_REGISTER_{}",
|
||||||
|
type_name.to_shouty_snake_case()
|
||||||
|
);
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
impl ::mizan_core::MizanType for #ident {
|
impl ::mizan_core::MizanType for #ident {
|
||||||
@@ -123,84 +248,3 @@ pub fn expand(input: DeriveInput) -> TokenStream {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_struct(s: &DataStruct) -> TokenStream {
|
|
||||||
let fields = match &s.fields {
|
|
||||||
Fields::Named(named) => &named.named,
|
|
||||||
Fields::Unnamed(_) | Fields::Unit => {
|
|
||||||
return syn::Error::new_spanned(
|
|
||||||
&s.fields,
|
|
||||||
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
|
|
||||||
)
|
|
||||||
.to_compile_error();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut field_exprs: Vec<TokenStream> = Vec::new();
|
|
||||||
for field in fields {
|
|
||||||
let ident = field
|
|
||||||
.ident
|
|
||||||
.as_ref()
|
|
||||||
.expect("named field always has an ident");
|
|
||||||
// Field-level `#[serde(rename = "...")]` wins; otherwise strip
|
|
||||||
// the raw-identifier prefix that Rust uses to escape keywords
|
|
||||||
// (`r#type` → `type`). Serde itself strips the prefix when
|
|
||||||
// computing the default field name; the IR has to match the
|
|
||||||
// wire form, not the Rust source form.
|
|
||||||
let raw_ident = ident.to_string();
|
|
||||||
let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident);
|
|
||||||
let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string());
|
|
||||||
let shape = type_shape_expr(&field.ty);
|
|
||||||
|
|
||||||
// A field is `required` iff its type is not `Option<...>`. Defaults
|
|
||||||
// are not encodable from Rust syntax (no `= expr` on a struct field
|
|
||||||
// declaration) — the macro emits `required: false, default: None`
|
|
||||||
// for Option-wrapped fields, leaving defaults for a future
|
|
||||||
// attribute-based extension.
|
|
||||||
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
|
|
||||||
let required = !is_optional;
|
|
||||||
field_exprs.push(quote! {
|
|
||||||
::mizan_core::StructField {
|
|
||||||
name: #name,
|
|
||||||
required: #required,
|
|
||||||
default: ::std::option::Option::None,
|
|
||||||
shape: #shape,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
quote! {
|
|
||||||
::mizan_core::NamedType::Struct(::std::vec![
|
|
||||||
#(#field_exprs),*
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream {
|
|
||||||
let mut variants: Vec<TokenStream> = Vec::new();
|
|
||||||
for variant in &e.variants {
|
|
||||||
if !matches!(variant.fields, Fields::Unit) {
|
|
||||||
return syn::Error::new_spanned(
|
|
||||||
&variant.fields,
|
|
||||||
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
|
|
||||||
)
|
|
||||||
.to_compile_error();
|
|
||||||
}
|
|
||||||
let raw = variant.ident.to_string();
|
|
||||||
// Variant-level `#[serde(rename = "...")]` wins; otherwise apply
|
|
||||||
// the enum-level `#[serde(rename_all = "...")]` rule.
|
|
||||||
let name = if let Some(explicit) = serde_rename(&variant.attrs) {
|
|
||||||
explicit
|
|
||||||
} else if let Some(rule) = rename_all {
|
|
||||||
apply_rename_all(rule, &raw)
|
|
||||||
} else {
|
|
||||||
raw
|
|
||||||
};
|
|
||||||
variants.push(quote! { #name });
|
|
||||||
}
|
|
||||||
quote! {
|
|
||||||
::mizan_core::NamedType::Enum(::std::vec![
|
|
||||||
#(#variants),*
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
|
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
|
||||||
//! * `MizanType` impl on the Input struct
|
//! * `MizanType` impl on the Input struct
|
||||||
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
|
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
|
||||||
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
|
//! * list-element sub-type entries (so `Vec<T>` outputs surface `T` too)
|
||||||
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
|
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
|
||||||
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
|
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
|
||||||
|
|
||||||
@@ -10,13 +10,25 @@ use heck::{ToLowerCamelCase, ToShoutySnakeCase};
|
|||||||
use proc_macro2::TokenStream;
|
use proc_macro2::TokenStream;
|
||||||
use quote::{format_ident, quote};
|
use quote::{format_ident, quote};
|
||||||
use syn::{
|
use syn::{
|
||||||
parse::Parser,
|
parenthesized,
|
||||||
|
parse::{Parse, ParseStream},
|
||||||
punctuated::Punctuated,
|
punctuated::Punctuated,
|
||||||
spanned::Spanned,
|
token::Paren,
|
||||||
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
|
FnArg, Ident, ItemFn, Pat, Path, ReturnType, Token, Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option};
|
use crate::shape::{
|
||||||
|
analyze_return, classify, is_optional, path_head, ref_shape_expr, type_shape_expr, Head,
|
||||||
|
ReturnForm, TypeForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod kw {
|
||||||
|
syn::custom_keyword!(context);
|
||||||
|
syn::custom_keyword!(affects);
|
||||||
|
syn::custom_keyword!(merge);
|
||||||
|
syn::custom_keyword!(websocket);
|
||||||
|
syn::custom_keyword!(private);
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed attribute args for `#[mizan(...)]`.
|
/// Parsed attribute args for `#[mizan(...)]`.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -28,125 +40,149 @@ pub struct FunctionArgs {
|
|||||||
pub private: bool,
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FunctionArgs {
|
impl Parse for FunctionArgs {
|
||||||
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||||
if attr_tokens.is_empty() {
|
|
||||||
return Ok(Self::default());
|
|
||||||
}
|
|
||||||
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
|
|
||||||
let metas = parser.parse2(attr_tokens)?;
|
|
||||||
let mut out = Self::default();
|
let mut out = Self::default();
|
||||||
for meta in metas {
|
while !input.is_empty() {
|
||||||
match meta {
|
if input.peek(kw::context) {
|
||||||
Meta::NameValue(nv) => {
|
input.parse::<kw::context>()?;
|
||||||
if nv.path.is_ident("context") {
|
input.parse::<Token![=]>()?;
|
||||||
out.context = Some(expect_path(&nv.value)?);
|
out.context = Some(input.parse()?);
|
||||||
} else if nv.path.is_ident("affects") {
|
} else if input.peek(kw::affects) {
|
||||||
out.affects = collect_paths(&nv.value)?;
|
input.parse::<kw::affects>()?;
|
||||||
} else if nv.path.is_ident("merge") {
|
input.parse::<Token![=]>()?;
|
||||||
out.merge = collect_paths(&nv.value)?;
|
out.affects = parse_path_group(input)?;
|
||||||
} else {
|
} else if input.peek(kw::merge) {
|
||||||
return Err(syn::Error::new_spanned(
|
input.parse::<kw::merge>()?;
|
||||||
nv.path,
|
input.parse::<Token![=]>()?;
|
||||||
"unknown attribute key; expected one of: context, affects, merge",
|
out.merge = parse_path_group(input)?;
|
||||||
));
|
} else if input.peek(kw::websocket) {
|
||||||
}
|
input.parse::<kw::websocket>()?;
|
||||||
}
|
|
||||||
Meta::Path(p) => {
|
|
||||||
if p.is_ident("websocket") {
|
|
||||||
out.websocket = true;
|
out.websocket = true;
|
||||||
} else if p.is_ident("private") {
|
} else if input.peek(kw::private) {
|
||||||
|
input.parse::<kw::private>()?;
|
||||||
out.private = true;
|
out.private = true;
|
||||||
} else {
|
} else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(input.error(
|
||||||
p,
|
"expected one of: `context = T`, `affects = T`, `merge = T`, `websocket`, `private`",
|
||||||
"unknown flag; expected `websocket` or `private`",
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if input.is_empty() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Meta::List(l) => {
|
input.parse::<Token![,]>()?;
|
||||||
|
}
|
||||||
|
if let Some(ctx) = &out.context {
|
||||||
|
if !out.affects.is_empty() {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
l,
|
ctx,
|
||||||
"list-shaped attribute args not supported here",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.context.is_some() && !out.affects.is_empty() {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
out.context.as_ref().unwrap(),
|
|
||||||
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
|
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if out.context.is_some() && !out.merge.is_empty() {
|
if !out.merge.is_empty() {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
out.context.as_ref().unwrap(),
|
ctx,
|
||||||
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
|
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
/// One context type (`affects = Ctx`) or a parenthesized group of them
|
||||||
if let Expr::Path(ExprPath { path, .. }) = expr {
|
/// (`affects = (CtxA, CtxB)`).
|
||||||
Ok(path.clone())
|
fn parse_path_group(input: ParseStream) -> syn::Result<Vec<Path>> {
|
||||||
|
if input.peek(Paren) {
|
||||||
|
let group;
|
||||||
|
parenthesized!(group in input);
|
||||||
|
Ok(Punctuated::<Path, Token![,]>::parse_terminated(&group)?
|
||||||
|
.into_iter()
|
||||||
|
.collect())
|
||||||
} else {
|
} else {
|
||||||
Err(syn::Error::new_spanned(
|
Ok(vec![input.parse()?])
|
||||||
expr,
|
|
||||||
"expected a type path (e.g. `UserCtx`)",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
|
||||||
match expr {
|
|
||||||
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
|
||||||
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
|
|
||||||
_ => Err(syn::Error::new_spanned(
|
|
||||||
expr,
|
|
||||||
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about one input parameter, extracted from the fn signature.
|
/// Information about one input parameter, extracted from the fn signature.
|
||||||
struct InputArg {
|
struct InputArg {
|
||||||
ident: syn::Ident,
|
ident: Ident,
|
||||||
ty: Type,
|
ty: Type,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
/// The handler grammar `#[mizan::client]` accepts: an `async fn` taking a
|
||||||
if item.sig.asyncness.is_none() {
|
/// request handle followed by plain-identifier params, with an explicit return
|
||||||
return syn::Error::new_spanned(
|
/// type. The token stream is parsed straight into this shape, so `expand`
|
||||||
&item.sig.fn_token,
|
/// reads three settled fields and has nothing left to reject.
|
||||||
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
|
///
|
||||||
)
|
/// A missing `async` or a missing request handle needs no rejection here: the
|
||||||
.to_compile_error();
|
/// dispatch wrapper `expand` emits calls the handler with `&req` and awaits
|
||||||
|
/// the call, so rustc rejects both at the generated call site.
|
||||||
|
pub struct Handler {
|
||||||
|
item: ItemFn,
|
||||||
|
input_args: Vec<InputArg>,
|
||||||
|
return_ty: Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for Handler {
|
||||||
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||||
|
let item: ItemFn = input.parse()?;
|
||||||
|
|
||||||
|
let ReturnType::Type(_, declared) = &item.sig.output else {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&item.sig,
|
||||||
|
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let return_ty = (**declared).clone();
|
||||||
|
|
||||||
|
let mut input_args = Vec::new();
|
||||||
|
// The first arg is the request handle, which the dispatch wrapper
|
||||||
|
// forwards as `req`; it never becomes an Input field.
|
||||||
|
for arg in item.sig.inputs.iter().skip(1) {
|
||||||
|
let typed = match arg {
|
||||||
|
FnArg::Typed(typed) => typed,
|
||||||
|
FnArg::Receiver(_) => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
arg,
|
||||||
|
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let Pat::Ident(bound) = &*typed.pat else {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&typed.pat,
|
||||||
|
"#[mizan] function parameters must be plain identifiers (no destructuring).",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
input_args.push(InputArg {
|
||||||
|
ident: bound.ident.clone(),
|
||||||
|
ty: (*typed.ty).clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
item,
|
||||||
|
input_args,
|
||||||
|
return_ty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand(args: FunctionArgs, handler: Handler) -> TokenStream {
|
||||||
|
let Handler {
|
||||||
|
item,
|
||||||
|
input_args,
|
||||||
|
return_ty,
|
||||||
|
} = handler;
|
||||||
|
|
||||||
let fn_name = item.sig.ident.to_string();
|
let fn_name = item.sig.ident.to_string();
|
||||||
let camel = fn_name.to_lower_camel_case();
|
let camel = fn_name.to_lower_camel_case();
|
||||||
let input_type_name = format!("{camel}Input");
|
let input_type_name = format!("{camel}Input");
|
||||||
let output_type_name = format!("{camel}Output");
|
let output_type_name = format!("{camel}Output");
|
||||||
|
|
||||||
let input_args = match collect_input_args(&item) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => return e.to_compile_error(),
|
|
||||||
};
|
|
||||||
let has_input = !input_args.is_empty();
|
let has_input = !input_args.is_empty();
|
||||||
let input_type_ident = format_ident!("{}", input_type_name);
|
let input_type_ident = format_ident!("{}", input_type_name);
|
||||||
|
|
||||||
let return_ty = match &item.sig.output {
|
|
||||||
ReturnType::Type(_, t) => (**t).clone(),
|
|
||||||
ReturnType::Default => {
|
|
||||||
return syn::Error::new_spanned(
|
|
||||||
&item.sig,
|
|
||||||
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
|
|
||||||
)
|
|
||||||
.to_compile_error();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let analysis = analyze_return(&return_ty);
|
let analysis = analyze_return(&return_ty);
|
||||||
|
|
||||||
// ─── Synthetic Input struct ────────────────────────────────────────────
|
// ─── Synthetic Input struct ────────────────────────────────────────────
|
||||||
@@ -156,12 +192,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
for arg in &input_args {
|
for arg in &input_args {
|
||||||
let ident = &arg.ident;
|
let ident = &arg.ident;
|
||||||
let ty = &arg.ty;
|
let ty = &arg.ty;
|
||||||
// Strip a leading underscore from the wire-level field name —
|
// Rust convention writes `_foo` to silence an unused-arg warning,
|
||||||
// Rust convention uses `_foo` to silence unused-arg warnings,
|
// but the wire schema names the param `foo`. The struct field
|
||||||
// but the wire schema and the Python fixture name the param
|
// keeps its source ident so the dispatch wrapper's
|
||||||
// `foo`. The struct field keeps its source ident (so the
|
// `validated.#ident` compiles, and a serde `rename` bridges the
|
||||||
// dispatch wrapper's `validated.#ident` compiles), and a serde
|
// JSON name.
|
||||||
// `rename` bridges the wire-level JSON name.
|
|
||||||
let name_str = ident.to_string();
|
let name_str = ident.to_string();
|
||||||
let wire_name = name_str.trim_start_matches('_').to_string();
|
let wire_name = name_str.trim_start_matches('_').to_string();
|
||||||
let serde_rename = if wire_name != name_str {
|
let serde_rename = if wire_name != name_str {
|
||||||
@@ -170,8 +205,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
TokenStream::new()
|
TokenStream::new()
|
||||||
};
|
};
|
||||||
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
|
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
|
||||||
let is_optional = unwrap_option(ty).is_some();
|
let required = !is_optional(ty);
|
||||||
let required = !is_optional;
|
|
||||||
let shape = type_shape_expr(ty);
|
let shape = type_shape_expr(ty);
|
||||||
field_shapes.push(quote! {
|
field_shapes.push(quote! {
|
||||||
::mizan_core::StructField {
|
::mizan_core::StructField {
|
||||||
@@ -202,11 +236,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Type entry registrations ──────────────────────────────────────────
|
// ─── Type entry registrations ──────────────────────────────────────────
|
||||||
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
|
|
||||||
// - Output: TypeEntry whose shape is a copy of the user's Output shape
|
|
||||||
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
|
|
||||||
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
|
|
||||||
// MizanType impl (so the Ref resolves in the IR).
|
|
||||||
let mut type_registrations = Vec::new();
|
let mut type_registrations = Vec::new();
|
||||||
if has_input {
|
if has_input {
|
||||||
let static_ident =
|
let static_ident =
|
||||||
@@ -222,66 +251,67 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
|
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
|
||||||
if analysis.is_vec {
|
let output_shape_expr = match &analysis.form {
|
||||||
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
|
ReturnForm::Sequence { element } => {
|
||||||
// userOrdersOutput → alias { list { ref "OrderOutput" } }
|
let element_ref = ref_shape_expr(element);
|
||||||
// The Ref name is resolved via `<T as MizanType>::type_name()`.
|
let alias = quote! {
|
||||||
|
::mizan_core::NamedType::Alias(
|
||||||
|
::mizan_core::TypeShape::List(::std::boxed::Box::new(#element_ref))
|
||||||
|
)
|
||||||
|
};
|
||||||
type_registrations.push(quote! {
|
type_registrations.push(quote! {
|
||||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||||
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||||
name: #output_type_name,
|
name: #output_type_name,
|
||||||
shape_fn: || ::mizan_core::NamedType::Alias(
|
shape_fn: || #alias,
|
||||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(
|
|
||||||
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
|
|
||||||
))
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Also register the element type itself by its own name. `TYPE_NAME`
|
// The element type also registers under its own name. The static
|
||||||
// is an associated const, so this is usable in a static initializer.
|
// ident is scoped by the function name so two handlers returning
|
||||||
// The static ident scopes by the function name so two handlers
|
// `Vec<Same>` don't collide; the emitter dedupes by entry name.
|
||||||
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap
|
let element_static =
|
||||||
// dedupes by the entry's `name` at emit time.
|
element_type_static_ident_scoped(element, &fn_name.to_shouty_snake_case());
|
||||||
let elem_static =
|
|
||||||
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
|
|
||||||
type_registrations.push(quote! {
|
type_registrations.push(quote! {
|
||||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||||
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
static #element_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||||
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
|
name: <#element as ::mizan_core::MizanType>::TYPE_NAME,
|
||||||
shape_fn: <#elem as ::mizan_core::MizanType>::shape,
|
shape_fn: <#element as ::mizan_core::MizanType>::shape,
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Non-Vec output: copy the inner type's shape under the canonical name.
|
|
||||||
let inner_ty = &analysis.inner;
|
|
||||||
type_registrations.push(quote! {
|
|
||||||
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
|
||||||
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
|
||||||
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
|
||||||
name: #output_type_name,
|
|
||||||
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
alias
|
||||||
}
|
}
|
||||||
|
ReturnForm::Scalar { inner } => {
|
||||||
|
type_registrations.push(quote! {
|
||||||
|
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
|
||||||
|
#[linkme(crate = ::mizan_core::__priv::linkme)]
|
||||||
|
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
|
||||||
|
name: #output_type_name,
|
||||||
|
shape_fn: <#inner as ::mizan_core::MizanType>::shape,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
quote! { <#inner as ::mizan_core::MizanType>::shape() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ─── InputParam slice (for context-builder shared-param elevation) ────
|
// ─── InputParam slice (for context-builder shared-param elevation) ────
|
||||||
|
// A non-primitive param is an opaque payload in the context's `param`
|
||||||
|
// block and carries the string primitive.
|
||||||
|
let opaque_primitive = || quote! { ::mizan_core::Primitive::String };
|
||||||
let mut input_params = Vec::new();
|
let mut input_params = Vec::new();
|
||||||
for arg in &input_args {
|
for arg in &input_args {
|
||||||
// Wire-level name strips the underscore prefix — see input_struct
|
// Wire-level name strips the underscore prefix — see input_struct
|
||||||
// above for the rationale.
|
// above.
|
||||||
let name_str = arg.ident.to_string();
|
let name_str = arg.ident.to_string();
|
||||||
let name_str = name_str.trim_start_matches('_').to_string();
|
let name_str = name_str.trim_start_matches('_').to_string();
|
||||||
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
|
let primitive = match classify(&arg.ty) {
|
||||||
// Non-primitive params don't surface in the context's `param`
|
TypeForm::Primitive(p) => p,
|
||||||
// block; they participate as opaque payloads. Using `String` as
|
TypeForm::Optional(_) => opaque_primitive(),
|
||||||
// the placeholder primitive matches Python's fallback in
|
TypeForm::Sequence(_) => opaque_primitive(),
|
||||||
// `_annotation_to_primitive`.
|
TypeForm::Named(_) => opaque_primitive(),
|
||||||
quote! { ::mizan_core::Primitive::String }
|
};
|
||||||
});
|
let required = !is_optional(&arg.ty);
|
||||||
let is_optional = unwrap_option(&arg.ty).is_some();
|
|
||||||
let required = !is_optional;
|
|
||||||
input_params.push(quote! {
|
input_params.push(quote! {
|
||||||
::mizan_core::InputParam {
|
::mizan_core::InputParam {
|
||||||
name: #name_str,
|
name: #name_str,
|
||||||
@@ -354,7 +384,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
let private = args.private;
|
let private = args.private;
|
||||||
|
|
||||||
let dispatch_body = build_dispatch(
|
let dispatch_body = build_dispatch(
|
||||||
&item,
|
&inner_fn_ident,
|
||||||
&input_args,
|
&input_args,
|
||||||
has_input,
|
has_input,
|
||||||
&input_type_ident,
|
&input_type_ident,
|
||||||
@@ -362,8 +392,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
);
|
);
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
// Keep the user's original fn intact — the macro never rewrites the
|
|
||||||
// body, only wraps it for dispatch.
|
|
||||||
#item
|
#item
|
||||||
|
|
||||||
#input_struct
|
#input_struct
|
||||||
@@ -383,6 +411,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
fn has_input(&self) -> bool { #has_input }
|
fn has_input(&self) -> bool { #has_input }
|
||||||
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
|
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
|
||||||
fn output_type(&self) -> &'static str { #output_type_name }
|
fn output_type(&self) -> &'static str { #output_type_name }
|
||||||
|
fn output_shape(&self) -> ::mizan_core::NamedType { #output_shape_expr }
|
||||||
fn output_nullable(&self) -> bool { #output_nullable }
|
fn output_nullable(&self) -> bool { #output_nullable }
|
||||||
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
|
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
|
||||||
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
|
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
|
||||||
@@ -416,57 +445,15 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut iter = item.sig.inputs.iter();
|
|
||||||
// First arg is the request handle — skip without inspection. The function
|
|
||||||
// body uses it directly; the dispatch wrapper forwards `req`.
|
|
||||||
if iter.next().is_none() {
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
item.sig.span(),
|
|
||||||
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for arg in iter {
|
|
||||||
match arg {
|
|
||||||
FnArg::Typed(pat) => {
|
|
||||||
let ident = match &*pat.pat {
|
|
||||||
Pat::Ident(pi) => pi.ident.clone(),
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
&pat.pat,
|
|
||||||
"#[mizan] function parameters must be plain identifiers (no destructuring).",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
out.push(InputArg {
|
|
||||||
ident,
|
|
||||||
ty: (*pat.ty).clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
FnArg::Receiver(_) => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
arg,
|
|
||||||
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_dispatch(
|
fn build_dispatch(
|
||||||
item: &ItemFn,
|
inner: &Ident,
|
||||||
input_args: &[InputArg],
|
input_args: &[InputArg],
|
||||||
has_input: bool,
|
has_input: bool,
|
||||||
input_type_ident: &syn::Ident,
|
input_type_ident: &Ident,
|
||||||
returns_result: bool,
|
returns_result: bool,
|
||||||
) -> TokenStream {
|
) -> TokenStream {
|
||||||
let inner = &item.sig.ident;
|
// `?` lifts a user `Result<T, MizanError>`'s Err into the wrapper's outer
|
||||||
// When the user returns `Result<T, MizanError>`, lift Err out into the
|
// Result; a plain `T` serializes directly.
|
||||||
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
|
|
||||||
// it as the standard error envelope. When the user returns `T`,
|
|
||||||
// serialize directly — the substrate has no error path for them.
|
|
||||||
let unwrap_user_result = if returns_result {
|
let unwrap_user_result = if returns_result {
|
||||||
quote! { ? }
|
quote! { ? }
|
||||||
} else {
|
} else {
|
||||||
@@ -501,16 +488,17 @@ fn build_dispatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident {
|
/// A static-name for the element type's registration entry, scoped by the
|
||||||
// Derive a unique static-name for the type's registration entry,
|
/// surrounding function so siblings returning the same `Vec<T>` don't collide
|
||||||
// scoped by the surrounding function so siblings returning the same
|
/// at the static-name layer.
|
||||||
// `Vec<T>` don't collide at the static-name layer. The IR-side
|
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> Ident {
|
||||||
// BTreeMap dedupes by TypeEntry.name at emission time.
|
let stem = match path_head(ty) {
|
||||||
let last = match ty {
|
Head::Path { name, .. } => name,
|
||||||
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
|
Head::Unnamed => "ANON".to_string(),
|
||||||
_ => None,
|
|
||||||
};
|
};
|
||||||
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
|
format_ident!(
|
||||||
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope)
|
"__MIZAN_TYPE_ELEM_{}_FOR_{}",
|
||||||
|
stem.to_shouty_snake_case(),
|
||||||
|
fn_scope
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,34 @@
|
|||||||
//! Proc macros for `mizan-core`. See sibling modules for each macro's body.
|
//! Proc macros for `mizan-core`. See sibling modules for each macro's body:
|
||||||
|
//! `derive` for `#[derive(Mizan)]`, `context` / `function` / `channel` for the
|
||||||
|
//! three attribute macros, `shape` for the shared `syn::Type` lowering.
|
||||||
//!
|
//!
|
||||||
//! Consumer code reads:
|
//! The function macro is named `client` so `mizan::` stays purely a module
|
||||||
//! ```ignore
|
//! path — a module path can't simultaneously be a callable macro in Rust, so
|
||||||
//! use mizan_core::prelude::*;
|
//! `#[mizan(...)]` would collide with `mizan::context`.
|
||||||
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
|
|
||||||
//!
|
|
||||||
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
|
|
||||||
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
|
|
||||||
//!
|
|
||||||
//! #[mizan::context("user")]
|
|
||||||
//! pub struct UserCtx;
|
|
||||||
//!
|
|
||||||
//! #[mizan::client(context = UserCtx)]
|
|
||||||
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The function macro is named `client` to mirror Python's `@client`
|
|
||||||
//! decorator and to keep the namespace `mizan::` purely a module path —
|
|
||||||
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
|
|
||||||
//! can't simultaneously be a callable macro in Rust).
|
|
||||||
|
|
||||||
|
mod channel;
|
||||||
mod context;
|
mod context;
|
||||||
mod derive;
|
mod derive;
|
||||||
mod function;
|
mod function;
|
||||||
mod shape;
|
mod shape;
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
|
use syn::{parse_macro_input, ItemStruct};
|
||||||
|
|
||||||
#[proc_macro_derive(Mizan)]
|
#[proc_macro_derive(Mizan)]
|
||||||
pub fn derive_mizan(input: TokenStream) -> TokenStream {
|
pub fn derive_mizan(input: TokenStream) -> TokenStream {
|
||||||
let input = parse_macro_input!(input as DeriveInput);
|
let derived = parse_macro_input!(input as derive::MizanDerive);
|
||||||
derive::expand(input).into()
|
derive::expand(derived).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
let args = match context::ContextArgs::parse(attr.into()) {
|
let name = match context::ContextName::parse(attr.into()) {
|
||||||
Ok(a) => a,
|
Ok(n) => n,
|
||||||
Err(e) => return e.to_compile_error().into(),
|
Err(e) => return e.to_compile_error().into(),
|
||||||
};
|
};
|
||||||
let item = parse_macro_input!(item as ItemStruct);
|
let item = parse_macro_input!(item as ItemStruct);
|
||||||
context::expand(args, item).into()
|
context::expand(name, item).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The function-registration attribute macro. Used as `#[mizan::client]`
|
/// The function-registration attribute macro. Used as `#[mizan::client]`
|
||||||
@@ -49,10 +36,17 @@ pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
/// websocket, private)]`.
|
/// websocket, private)]`.
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
let args = match function::FunctionArgs::parse(attr.into()) {
|
let args = parse_macro_input!(attr as function::FunctionArgs);
|
||||||
Ok(a) => a,
|
let handler = parse_macro_input!(item as function::Handler);
|
||||||
Err(e) => return e.to_compile_error().into(),
|
function::expand(args, handler).into()
|
||||||
};
|
}
|
||||||
let item = parse_macro_input!(item as ItemFn);
|
|
||||||
function::expand(args, item).into()
|
/// The channel-registration attribute macro. Used as
|
||||||
|
/// `#[mizan::channel("<wire-name>", params = P, client_message = C,
|
||||||
|
/// server_message = S)]` on a unit struct; every slot is optional.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn channel(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let args = parse_macro_input!(attr as channel::ChannelArgs);
|
||||||
|
let item = parse_macro_input!(item as ItemStruct);
|
||||||
|
channel::expand(args, item).into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,203 +6,189 @@ use proc_macro2::TokenStream;
|
|||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{GenericArgument, PathArguments, Type, TypePath};
|
use syn::{GenericArgument, PathArguments, Type, TypePath};
|
||||||
|
|
||||||
/// Result of inspecting a fn's return type.
|
/// The IR-relevant form of a Rust type. Every `syn::Type` lands in exactly
|
||||||
|
/// one arm, so classification never reports "unknown".
|
||||||
|
pub enum TypeForm {
|
||||||
|
/// `Option<T>` — the wire field is nullable.
|
||||||
|
Optional(Type),
|
||||||
|
/// `Vec<T>`, `[T; N]`, or a map whose values are `T` — a JSON array.
|
||||||
|
Sequence(Type),
|
||||||
|
/// A scalar, carrying the `::mizan_core::Primitive` variant expression.
|
||||||
|
Primitive(TokenStream),
|
||||||
|
/// Anything else: a type expected to implement `MizanType`.
|
||||||
|
Named(Type),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a type's head is, as the lowering reads it. `Unnamed` covers the
|
||||||
|
/// forms with no path to name — tuples, references, slices, bare fns — which
|
||||||
|
/// carry no keyword the callers below test for.
|
||||||
|
pub enum Head {
|
||||||
|
Path { name: String, generics: Vec<Type> },
|
||||||
|
Unnamed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the two output shapes a handler's return type produces.
|
||||||
|
pub enum ReturnForm {
|
||||||
|
/// The handler yields a list; the caller registers an alias type over
|
||||||
|
/// `element`'s Ref.
|
||||||
|
Sequence { element: Type },
|
||||||
|
/// The handler yields one value; the caller registers `inner`'s own shape
|
||||||
|
/// under the canonical output name.
|
||||||
|
Scalar { inner: Type },
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ReturnAnalysis {
|
pub struct ReturnAnalysis {
|
||||||
/// Inner type once `Option<...>` is unwrapped.
|
pub form: ReturnForm,
|
||||||
pub inner: Type,
|
/// True if the outermost wrapper (after `Result`) is `Option<...>`.
|
||||||
/// True if the outermost wrapper is `Option<...>`.
|
|
||||||
pub nullable: bool,
|
pub nullable: bool,
|
||||||
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
|
|
||||||
pub is_vec: bool,
|
|
||||||
/// When `is_vec`, this is the element type `T`.
|
|
||||||
pub vec_inner: Option<Type>,
|
|
||||||
/// True when the user's return type is `Result<T, MizanError>` — the
|
/// True when the user's return type is `Result<T, MizanError>` — the
|
||||||
/// dispatch wrapper emits `?` so user-side errors bubble out as
|
/// dispatch wrapper emits `?` so user-side errors bubble out as
|
||||||
/// `MizanError` instead of being serialized into the success payload.
|
/// `MizanError` instead of being serialized into the success payload.
|
||||||
/// The IR sees only the `T` side; the error variant is the substrate's
|
|
||||||
/// invariant, not part of the output shape.
|
|
||||||
pub returns_result: bool,
|
pub returns_result: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
|
||||||
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
|
let (effective, returns_result) = strip_result(ty);
|
||||||
(ok, true)
|
let (unwrapped, nullable) = match classify(&effective) {
|
||||||
} else {
|
TypeForm::Optional(inner) => (inner, true),
|
||||||
|
TypeForm::Sequence(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => (effective, false),
|
||||||
|
};
|
||||||
|
let form = match classify(&unwrapped) {
|
||||||
|
TypeForm::Sequence(element) => ReturnForm::Sequence { element },
|
||||||
|
TypeForm::Optional(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => {
|
||||||
|
ReturnForm::Scalar { inner: unwrapped }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ReturnAnalysis {
|
||||||
|
form,
|
||||||
|
nullable,
|
||||||
|
returns_result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peel `Result<T, E>` down to `T`. `E` is left to rustc: a non-`MizanError`
|
||||||
|
/// error type fails at the `?` site the dispatch wrapper emits.
|
||||||
|
pub fn strip_result(ty: &Type) -> (Type, bool) {
|
||||||
|
if let Head::Path { name, generics } = path_head(ty) {
|
||||||
|
if name == "Result" {
|
||||||
|
if let [ok, ..] = generics.as_slice() {
|
||||||
|
return (ok.clone(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
(ty.clone(), false)
|
(ty.clone(), false)
|
||||||
};
|
|
||||||
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
|
|
||||||
(t, true)
|
|
||||||
} else {
|
|
||||||
(effective, false)
|
|
||||||
};
|
|
||||||
if let Some(elem) = unwrap_vec(&inner) {
|
|
||||||
ReturnAnalysis {
|
|
||||||
inner: inner.clone(),
|
|
||||||
nullable,
|
|
||||||
is_vec: true,
|
|
||||||
vec_inner: Some(elem),
|
|
||||||
returns_result,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ReturnAnalysis {
|
|
||||||
inner,
|
|
||||||
nullable,
|
|
||||||
is_vec: false,
|
|
||||||
vec_inner: None,
|
|
||||||
returns_result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate
|
pub fn classify(ty: &Type) -> TypeForm {
|
||||||
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify
|
if let Type::Array(array) = ty {
|
||||||
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if
|
return TypeForm::Sequence((*array.elem).clone());
|
||||||
/// the consumer used a non-MizanError variant.
|
|
||||||
pub fn unwrap_result_ok(ty: &Type) -> Option<Type> {
|
|
||||||
let path = match ty {
|
|
||||||
Type::Path(TypePath { qself: None, path }) => path,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let last = path.segments.last()?;
|
|
||||||
if last.ident != "Result" {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
extract_single_generic(&last.arguments)
|
let Head::Path { name, generics } = path_head(ty) else {
|
||||||
|
return TypeForm::Named(ty.clone());
|
||||||
|
};
|
||||||
|
let args = generics.as_slice();
|
||||||
|
if name == "Option" {
|
||||||
|
if let [inner, ..] = args {
|
||||||
|
return TypeForm::Optional(inner.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "Vec" {
|
||||||
|
if let [element, ..] = args {
|
||||||
|
return TypeForm::Sequence(element.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "BTreeMap" || name == "HashMap" {
|
||||||
|
// A string-keyed map lands on the wire as a JSON object; the IR
|
||||||
|
// carries only the value shape, as a list element.
|
||||||
|
if let [_key, value, ..] = args {
|
||||||
|
return TypeForm::Sequence(value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classify_scalar(ty, &name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
|
pub fn is_optional(ty: &Type) -> bool {
|
||||||
/// when constructing the struct field shapes.
|
matches!(classify(ty), TypeForm::Optional(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a `TypeShape` const-expression for `ty`. Used inside
|
||||||
|
/// `#[derive(Mizan)]` when constructing the struct field shapes.
|
||||||
pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
||||||
if let Some(inner) = unwrap_option(ty) {
|
match classify(ty) {
|
||||||
|
TypeForm::Optional(inner) => {
|
||||||
let inner_shape = type_shape_expr(&inner);
|
let inner_shape = type_shape_expr(&inner);
|
||||||
return quote! {
|
quote! {
|
||||||
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
|
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(elem) = unwrap_vec(ty) {
|
}
|
||||||
let inner_shape = type_shape_expr(&elem);
|
TypeForm::Sequence(element) => {
|
||||||
return quote! {
|
let inner_shape = type_shape_expr(&element);
|
||||||
|
quote! {
|
||||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(elem) = unwrap_array(ty) {
|
|
||||||
// `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't
|
|
||||||
// carry length, so the IR contract is the same as `Vec<T>`.
|
|
||||||
let inner_shape = type_shape_expr(&elem);
|
|
||||||
return quote! {
|
|
||||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(elem) = unwrap_btreemap_value(ty) {
|
TypeForm::Primitive(primitive) => {
|
||||||
// `BTreeMap<K, V>` on the wire is a JSON object keyed by `K`'s
|
quote! { ::mizan_core::TypeShape::Primitive(#primitive) }
|
||||||
// string form. The Mizan IR doesn't model dynamic-keyed maps as a
|
|
||||||
// distinct shape — closest equivalent is a list of value entries.
|
|
||||||
let inner_shape = type_shape_expr(&elem);
|
|
||||||
return quote! {
|
|
||||||
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if let Some(p) = primitive_of(ty) {
|
TypeForm::Named(named) => ref_shape_expr(&named),
|
||||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
|
||||||
}
|
|
||||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
|
||||||
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
|
||||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If `ty` is `[T; N]`, return `T`. Otherwise None.
|
|
||||||
pub fn unwrap_array(ty: &Type) -> Option<Type> {
|
|
||||||
if let Type::Array(a) = ty {
|
|
||||||
Some((*a.elem).clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `ty` is `BTreeMap<K, V>` or `HashMap<K, V>`, return `V` (the value).
|
/// A `TypeShape::Ref` carrying both the referent's IR name and its shape
|
||||||
/// String-keyed maps land on the wire as JSON objects; the IR carries the
|
/// constructor, so resolving the reference needs no registry lookup.
|
||||||
/// value shape as a list element since KDL doesn't model dynamic-keyed maps
|
pub fn ref_shape_expr(ty: &Type) -> TokenStream {
|
||||||
/// distinctly yet.
|
quote! {
|
||||||
pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
::mizan_core::TypeShape::Ref {
|
||||||
let path = match ty {
|
name: <#ty as ::mizan_core::MizanType>::TYPE_NAME,
|
||||||
Type::Path(TypePath { qself: None, path }) => path,
|
shape: <#ty as ::mizan_core::MizanType>::shape,
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let last = path.segments.last()?;
|
|
||||||
let name = last.ident.to_string();
|
|
||||||
if name != "BTreeMap" && name != "HashMap" {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
let args = match &last.arguments {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTEGER_IDENTS: &[&str] = &[
|
||||||
|
"i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn classify_scalar(ty: &Type, name: &str) -> TypeForm {
|
||||||
|
if INTEGER_IDENTS.contains(&name) {
|
||||||
|
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Integer });
|
||||||
|
}
|
||||||
|
if name == "f32" || name == "f64" {
|
||||||
|
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Number });
|
||||||
|
}
|
||||||
|
if name == "bool" {
|
||||||
|
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Boolean });
|
||||||
|
}
|
||||||
|
if name == "String" || name == "str" {
|
||||||
|
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::String });
|
||||||
|
}
|
||||||
|
TypeForm::Named(ty.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last path segment's identifier and its generic type arguments.
|
||||||
|
pub fn path_head(ty: &Type) -> Head {
|
||||||
|
if let Type::Path(TypePath { qself: None, path }) = ty {
|
||||||
|
if let Some(last) = path.segments.last() {
|
||||||
|
return Head::Path {
|
||||||
|
name: last.ident.to_string(),
|
||||||
|
generics: generic_types(&last.arguments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Head::Unnamed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generic_types(args: &PathArguments) -> Vec<Type> {
|
||||||
|
let angled = match args {
|
||||||
PathArguments::AngleBracketed(a) => a,
|
PathArguments::AngleBracketed(a) => a,
|
||||||
_ => return None,
|
PathArguments::None => return Vec::new(),
|
||||||
|
PathArguments::Parenthesized(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
// BTreeMap<K, V> — second type argument is V.
|
let mut out = Vec::new();
|
||||||
let mut type_args = args.args.iter().filter_map(|a| {
|
for arg in &angled.args {
|
||||||
if let GenericArgument::Type(t) = a {
|
|
||||||
Some(t.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
type_args.next()?; // skip K
|
|
||||||
type_args.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
|
||||||
/// known primitive scalar.
|
|
||||||
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
|
||||||
let path = match ty {
|
|
||||||
Type::Path(TypePath { qself: None, path }) => path,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let last = path.segments.last()?;
|
|
||||||
let name = last.ident.to_string();
|
|
||||||
match name.as_str() {
|
|
||||||
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
|
|
||||||
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
|
|
||||||
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
|
|
||||||
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
|
|
||||||
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
|
|
||||||
pub fn unwrap_option(ty: &Type) -> Option<Type> {
|
|
||||||
let path = match ty {
|
|
||||||
Type::Path(TypePath { qself: None, path }) => path,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let last = path.segments.last()?;
|
|
||||||
if last.ident != "Option" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
extract_single_generic(&last.arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
|
|
||||||
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
|
|
||||||
let path = match ty {
|
|
||||||
Type::Path(TypePath { qself: None, path }) => path,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let last = path.segments.last()?;
|
|
||||||
if last.ident != "Vec" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
extract_single_generic(&last.arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
|
|
||||||
let args = match args {
|
|
||||||
PathArguments::AngleBracketed(a) => a,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
for arg in &args.args {
|
|
||||||
if let GenericArgument::Type(t) = arg {
|
if let GenericArgument::Type(t) = arg {
|
||||||
return Some(t.clone());
|
out.push(t.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
//! Mizan SSR engine.
|
//! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with
|
||||||
|
//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function
|
||||||
|
//! that bundle defines.
|
||||||
//!
|
//!
|
||||||
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
|
//! `deno_web` supplies the web-platform globals a bare isolate lacks —
|
||||||
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
|
//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
|
||||||
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
|
//! implementations rather than partial shims.
|
||||||
//! global render function; the engine evals it once and calls it per request.
|
|
||||||
//! No external JS runtime — node and bun are build-time tools only.
|
|
||||||
//!
|
//!
|
||||||
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
|
//! Only the bundle is ever `eval`'d. Per-render props enter through
|
||||||
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
|
//! `v8::json::parse` and are handed in as a call argument, so a prop string has
|
||||||
//! web-platform implementations, not shims (a partial polyfill is
|
//! no surrounding source to break out of.
|
||||||
//! silent-failure-shaped: it passes until a render path hits the gap).
|
|
||||||
//!
|
|
||||||
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
|
|
||||||
//! per-render data crosses as a `v8::json::parse`d value passed as a function
|
|
||||||
//! argument, so a prop string has no source to break out of — code injection
|
|
||||||
//! is structurally absent, not filtered.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
|
|||||||
globalThis.TextDecoder = te.TextDecoder;
|
globalThis.TextDecoder = te.TextDecoder;
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
|
/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent
|
||||||
|
/// or not callable. The script therefore either fails — arriving in Rust as
|
||||||
|
/// the evaluator's own error — or produces a callable, which is what lets the
|
||||||
|
/// engine take it as a `v8::Function` without a second check.
|
||||||
|
const TAKE_RENDER_APP: &str = r#"(() => {
|
||||||
|
const f = globalThis.renderApp;
|
||||||
|
if (typeof f !== "function") {
|
||||||
|
throw new TypeError("the SSR bundle assigns no callable `renderApp`");
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})()"#;
|
||||||
|
|
||||||
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
|
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
|
||||||
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
||||||
/// means an engine is not `Send`; hold one per worker thread).
|
/// means an engine is not `Send`; hold one per worker thread).
|
||||||
|
///
|
||||||
|
/// `render_fn` is taken during construction, so a render calls a function this
|
||||||
|
/// engine already owns and repeats no lookup.
|
||||||
pub struct SsrEngine {
|
pub struct SsrEngine {
|
||||||
runtime: JsRuntime,
|
runtime: JsRuntime,
|
||||||
|
render_fn: v8::Global<v8::Function>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SsrEngine {
|
impl SsrEngine {
|
||||||
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
|
/// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`),
|
||||||
|
/// and take hold of that function.
|
||||||
pub fn new(bundle: String) -> Result<Self> {
|
pub fn new(bundle: String) -> Result<Self> {
|
||||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||||
extensions: vec![
|
extensions: vec![
|
||||||
@@ -64,50 +75,64 @@ impl SsrEngine {
|
|||||||
runtime
|
runtime
|
||||||
.execute_script("[mizan:bundle]", bundle)
|
.execute_script("[mizan:bundle]", bundle)
|
||||||
.context("evaluating the SSR bundle")?;
|
.context("evaluating the SSR bundle")?;
|
||||||
Ok(Self { runtime })
|
let render_app = runtime
|
||||||
|
.execute_script("[mizan:render-app]", TAKE_RENDER_APP)
|
||||||
|
.context("taking `renderApp` from the evaluated bundle")?;
|
||||||
|
|
||||||
|
let render_fn = {
|
||||||
|
deno_core::scope!(scope, &mut runtime);
|
||||||
|
let func = v8::Local::new(scope, render_app).cast::<v8::Function>();
|
||||||
|
v8::Global::new(scope, func)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self { runtime, render_fn })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
|
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
|
||||||
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
||||||
/// argument — never spliced into evaluated source.
|
/// argument — never spliced into evaluated source.
|
||||||
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
||||||
|
let render_fn = self.render_fn.clone();
|
||||||
deno_core::scope!(scope, &mut self.runtime);
|
deno_core::scope!(scope, &mut self.runtime);
|
||||||
let context = scope.get_current_context();
|
let func = v8::Local::new(scope, &render_fn);
|
||||||
let global = context.global(scope);
|
|
||||||
|
|
||||||
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
|
|
||||||
let func_val = global
|
|
||||||
.get(scope, key.into())
|
|
||||||
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
|
|
||||||
let func: v8::Local<v8::Function> = func_val
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| anyhow!("renderApp is not a function"))?;
|
|
||||||
|
|
||||||
let props_str = v8::String::new(scope, props_json).context("intern props")?;
|
|
||||||
let props = v8::json::parse(scope, props_str)
|
|
||||||
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
|
|
||||||
|
|
||||||
|
let props = parse_props(scope, props_json)?;
|
||||||
let recv = v8::undefined(scope).into();
|
let recv = v8::undefined(scope).into();
|
||||||
let result = func
|
let html = func
|
||||||
.call(scope, recv, &[props])
|
.call(scope, recv, &[props])
|
||||||
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
|
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
|
||||||
Ok(result.to_rust_string_lossy(scope))
|
Ok(html.to_rust_string_lossy(scope))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one crossing where untrusted request text becomes a value inside the
|
||||||
|
/// isolate. Both steps report that boundary's failure and nothing else: V8
|
||||||
|
/// refuses a string past its length limit, and its JSON grammar rejects
|
||||||
|
/// malformed input.
|
||||||
|
fn parse_props<'s>(
|
||||||
|
scope: &v8::PinScope<'s, '_>,
|
||||||
|
props_json: &str,
|
||||||
|
) -> Result<v8::Local<'s, v8::Value>> {
|
||||||
|
let text = v8::String::new(scope, props_json)
|
||||||
|
.ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?;
|
||||||
|
v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
fn fixture_bundle() -> String {
|
||||||
async fn renders_react_bundle_in_embedded_v8() {
|
std::fs::read_to_string(concat!(
|
||||||
let bundle = std::fs::read_to_string(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
"/tests/fixture/bundle.js"
|
"/tests/fixture/bundle.js"
|
||||||
))
|
))
|
||||||
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
|
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step")
|
||||||
|
}
|
||||||
|
|
||||||
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
#[tokio::test]
|
||||||
|
async fn renders_react_bundle_in_embedded_v8() {
|
||||||
|
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
|
||||||
let html = engine.render(r#"{"name":"World"}"#).expect("render");
|
let html = engine.render(r#"{"name":"World"}"#).expect("render");
|
||||||
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
|
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
|
||||||
}
|
}
|
||||||
@@ -117,17 +142,19 @@ mod tests {
|
|||||||
// A prop value that would break out of a string-built `renderApp(...)`
|
// A prop value that would break out of a string-built `renderApp(...)`
|
||||||
// call. Through the value-call path it is inert data: it reaches the
|
// call. Through the value-call path it is inert data: it reaches the
|
||||||
// component as a string, never as source.
|
// component as a string, never as source.
|
||||||
let bundle = std::fs::read_to_string(concat!(
|
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/tests/fixture/bundle.js"
|
|
||||||
))
|
|
||||||
.expect("fixture bundle");
|
|
||||||
|
|
||||||
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
|
||||||
let html = engine
|
let html = engine
|
||||||
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
// The payload rendered as text; it did not execute.
|
// The payload rendered as text; it did not execute.
|
||||||
assert!(html.contains("__pwned"));
|
assert!(html.contains("__pwned"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_bundle_without_a_callable_render_app_is_rejected() {
|
||||||
|
let err = SsrEngine::new("globalThis.renderApp = 7;".to_string())
|
||||||
|
.map(|_| ())
|
||||||
|
.expect_err("a bundle whose renderApp is not callable must not build an engine");
|
||||||
|
assert!(err.to_string().contains("renderApp"), "unexpected: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createElement } from "react"
|
import { createElement } from "react"
|
||||||
|
|
||||||
// A trivial component: props in, element out. The keystone only needs to prove
|
// Props in, element out. The `id` is the handle the render assertions match on.
|
||||||
// a real React tree renders to HTML inside a bare JS context.
|
|
||||||
export function Hello({ name }) {
|
export function Hello({ name }) {
|
||||||
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
|
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser"
|
|||||||
import { createElement } from "react"
|
import { createElement } from "react"
|
||||||
import { Hello } from "./Hello.js"
|
import { Hello } from "./Hello.js"
|
||||||
|
|
||||||
// The bundle exposes one global the embedded engine calls. No module system at
|
// There is no module system in the embedded engine — it receives a bare
|
||||||
// runtime — the engine receives a bare script that defines `renderApp`. This is
|
// script, so the entry point has to land on `globalThis` for the Rust side to
|
||||||
// the production shape in miniature: build-time bundle, runtime eval.
|
// reach it.
|
||||||
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))
|
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
// Proxy for the embedded-V8 runtime: a bare global context with no Node
|
// Runs bundle.js inside a `vm` context holding only the globals listed below,
|
||||||
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
|
// so the bundle sees the same bare environment the embedded V8 engine gives it.
|
||||||
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
|
|
||||||
// (bundle defines a global render fn over a bare context) does not.
|
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const vm = require("vm")
|
const vm = require("vm")
|
||||||
|
|
||||||
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
|
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
|
||||||
|
|
||||||
// The minimal host globals React's bundle touches at init / sync render. The
|
// The host globals React's bundle touches at init and during a sync render.
|
||||||
// rusty_v8 engine must provide the same set — this list is the spec for it.
|
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
|
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
|
||||||
TextEncoder, TextDecoder,
|
TextEncoder, TextDecoder,
|
||||||
@@ -19,11 +16,12 @@ vm.createContext(sandbox)
|
|||||||
vm.runInContext(code, sandbox)
|
vm.runInContext(code, sandbox)
|
||||||
|
|
||||||
const html = sandbox.renderApp({ name: "World" })
|
const html = sandbox.renderApp({ name: "World" })
|
||||||
console.log("RENDERED:", html)
|
|
||||||
|
|
||||||
const expected = '<div id="greeting">Hello, World!</div>'
|
const expected = '<div id="greeting">Hello, World!</div>'
|
||||||
if (html !== expected) {
|
if (html !== expected) {
|
||||||
console.error("MISMATCH — expected:", expected)
|
console.error(`expected ${expected}, got ${html}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")
|
console.log(html)
|
||||||
|
// The sandbox's MessageChannel holds an open handle, so the event loop never
|
||||||
|
// drains on its own; exit once the render has been checked.
|
||||||
|
process.exit(0)
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
|
//! Scans the SSR fixture's authored JS for tokens that only appear when React
|
||||||
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
|
//! Server Components, the Flight protocol, or a meta-framework SSR runtime is
|
||||||
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
|
//! in play. The scan goes red the moment one of them enters the source.
|
||||||
//!
|
|
||||||
//! React Server Components and the Flight serialization protocol carry
|
use std::path::Path;
|
||||||
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
|
|
||||||
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
|
|
||||||
//! attacker reaches prototype-pollution → RCE.
|
|
||||||
//!
|
|
||||||
//! Mizan renders **synchronously from props** — data is fetched server-side
|
|
||||||
//! through the AFI and passed in, never deserialized from a client payload — so
|
|
||||||
//! it sits structurally outside that attack surface. This test keeps it there:
|
|
||||||
//! it goes red the instant any RSC / Flight / streaming surface enters the
|
|
||||||
//! authored SSR source or its dependencies. Absence is not enough; this is the
|
|
||||||
//! forcing function that makes re-entry loud.
|
|
||||||
|
|
||||||
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
|
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
|
||||||
const FORBIDDEN: &[&str] = &[
|
const FORBIDDEN: &[&str] = &[
|
||||||
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
|
// React Server Components / Flight
|
||||||
"react-server-dom",
|
"react-server-dom",
|
||||||
"renderToReadableStream",
|
"renderToReadableStream",
|
||||||
"renderToPipeableStream",
|
"renderToPipeableStream",
|
||||||
"createFromReadableStream",
|
"createFromReadableStream",
|
||||||
"createFromFetch",
|
"createFromFetch",
|
||||||
"use server",
|
"use server",
|
||||||
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
|
// SSR runtimes / meta-frameworks
|
||||||
"next/",
|
"next/",
|
||||||
"nuxt",
|
"nuxt",
|
||||||
"@sveltejs/kit",
|
"@sveltejs/kit",
|
||||||
@@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[
|
|||||||
#[test]
|
#[test]
|
||||||
fn ssr_has_no_rsc_or_flight_surface() {
|
fn ssr_has_no_rsc_or_flight_surface() {
|
||||||
for path in SCANNED {
|
for path in SCANNED {
|
||||||
let Ok(src) = std::fs::read_to_string(path) else {
|
assert!(
|
||||||
continue; // a generated/optional file absent is fine; authored source is the point
|
Path::new(path).is_file(),
|
||||||
};
|
"{path} is a tracked fixture this scan reads; it is missing",
|
||||||
|
);
|
||||||
|
let src = std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| panic!("reading {path} for the RSC scan: {e}"));
|
||||||
for needle in FORBIDDEN {
|
for needle in FORBIDDEN {
|
||||||
assert!(
|
assert!(
|
||||||
!src.contains(needle),
|
!src.contains(needle),
|
||||||
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
|
"{needle:?} found in {path}; this scan forbids it",
|
||||||
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
|
|
||||||
classic renderToString-family only, rendered synchronously from props.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
cores/mizan-rust/Cargo.lock
generated
139
cores/mizan-rust/Cargo.lock
generated
@@ -13,6 +13,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -34,6 +46,17 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kdl"
|
||||||
|
version = "6.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
|
||||||
|
dependencies = [
|
||||||
|
"miette",
|
||||||
|
"num",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linkme"
|
name = "linkme"
|
||||||
version = "0.3.36"
|
version = "0.3.36"
|
||||||
@@ -60,13 +83,41 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miette"
|
||||||
|
version = "7.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mizan-core"
|
name = "mizan-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"indoc",
|
"indoc",
|
||||||
|
"kdl",
|
||||||
"linkme",
|
"linkme",
|
||||||
|
"minijinja",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -82,6 +133,79 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-iter"
|
||||||
|
version = "0.1.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -166,6 +290,21 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.6.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
name = "mizan-core"
|
name = "mizan-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/."
|
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry."
|
||||||
license = "Elastic-2.0"
|
license = "Elastic-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
linkme = "0.3"
|
linkme = "0.3"
|
||||||
|
minijinja = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
@@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
indoc = "2"
|
indoc = "2"
|
||||||
|
kdl = "6"
|
||||||
|
|||||||
@@ -1,200 +1,313 @@
|
|||||||
//! Cross-function invariant verification — fails at `build_ir()` time, which
|
//! Cross-function invariant checks over the registered graph.
|
||||||
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
|
|
||||||
//! graph-level inconsistencies surface before any client artifact is emitted.
|
|
||||||
|
|
||||||
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
|
use crate::ir::{NamedType, Primitive, TypeShape};
|
||||||
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
|
use crate::registry::{CONTEXTS, FUNCTIONS};
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Walk the registered types and find the named type's shape. Used by both
|
/// A structural fingerprint of a type, with every reference resolved through
|
||||||
/// graph-check and runtime merge resolution.
|
/// to the shape it names. Two types are interchangeable exactly when their
|
||||||
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
|
/// fingerprints are equal, so comparison is one derived `==` instead of a
|
||||||
for entry in TYPES {
|
/// pairwise walk over both shape enums.
|
||||||
if entry.name == name {
|
#[derive(PartialEq)]
|
||||||
return Some((entry.shape_fn)());
|
enum Canonical {
|
||||||
}
|
Record(Vec<CanonicalField>),
|
||||||
}
|
Aliased(Box<Canonical>),
|
||||||
None
|
NamedEnum(Vec<&'static str>),
|
||||||
|
Primitive(&'static str),
|
||||||
|
List(Box<Canonical>),
|
||||||
|
Optional(Box<Canonical>),
|
||||||
|
InlineEnum(Vec<&'static str>),
|
||||||
|
Union(Vec<Canonical>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge-compatibility on named types. A mutation return `value` can
|
#[derive(PartialEq)]
|
||||||
/// splice into a context slot `slot` when any of three shapes hold —
|
struct CanonicalField {
|
||||||
/// matches Python's `types_match_for_merge`:
|
name: &'static str,
|
||||||
/// * direct: `slot` shape equals `value` shape → replace
|
required: bool,
|
||||||
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id
|
shape: Canonical,
|
||||||
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
|
|
||||||
///
|
|
||||||
/// The first argument is the slot (context member's output type); the
|
|
||||||
/// second is the value (mutation's output type).
|
|
||||||
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
|
|
||||||
if named_shapes_equal(slot, value) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Upsert: slot is `Alias(List(T))`, value is `T`-shaped.
|
|
||||||
if let NamedType::Alias(TypeShape::List(elem)) = slot {
|
|
||||||
if shape_matches_named(elem, value) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool {
|
fn canonical_named(named: &NamedType) -> Canonical {
|
||||||
match (a, b) {
|
match named {
|
||||||
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
|
NamedType::Struct(fields) => Canonical::Record(
|
||||||
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
|
fields
|
||||||
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
|
.iter()
|
||||||
_ => false,
|
.map(|f| CanonicalField {
|
||||||
}
|
name: f.name,
|
||||||
}
|
required: f.required,
|
||||||
|
shape: canonical_shape(&f.shape),
|
||||||
/// True when a `TypeShape` (the slot's list-element) describes the same
|
|
||||||
/// shape as a `NamedType` (the mutation's full output).
|
|
||||||
fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool {
|
|
||||||
match shape {
|
|
||||||
TypeShape::Ref(name) => {
|
|
||||||
if let Some(referenced) = resolve_type_shape(name) {
|
|
||||||
named_shapes_equal(&referenced, named)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
a.iter().zip(b.iter()).all(|(fa, fb)| {
|
|
||||||
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
|
|
||||||
})
|
})
|
||||||
}
|
.collect(),
|
||||||
|
|
||||||
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
|
|
||||||
match (a, b) {
|
|
||||||
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
|
|
||||||
std::mem::discriminant(pa) == std::mem::discriminant(pb)
|
|
||||||
}
|
|
||||||
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
|
|
||||||
// Refs match iff the named types they reference match.
|
|
||||||
match (resolve_type_shape(na), resolve_type_shape(nb)) {
|
|
||||||
(Some(ta), Some(tb)) => types_match(&ta, &tb),
|
|
||||||
_ => na == nb,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
|
|
||||||
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
|
|
||||||
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
|
|
||||||
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
|
|
||||||
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panic with a structured message if the registered function graph is
|
|
||||||
/// inconsistent. Called from `build_ir()`.
|
|
||||||
pub fn verify_invariants() {
|
|
||||||
check_affects_targets();
|
|
||||||
check_merge_targets();
|
|
||||||
check_shared_param_types();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_affects_targets() {
|
|
||||||
for fn_spec in FUNCTIONS {
|
|
||||||
for affect in fn_spec.affects() {
|
|
||||||
if let AffectTarget::Context(name) = affect {
|
|
||||||
if lookup_context(name).is_none() {
|
|
||||||
panic!(
|
|
||||||
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
|
|
||||||
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
|
|
||||||
fn_spec.name(),
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_merge_targets() {
|
|
||||||
for fn_spec in FUNCTIONS {
|
|
||||||
for merge_target in fn_spec.merge() {
|
|
||||||
let ctx_entry = match lookup_context(merge_target) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => panic!(
|
|
||||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
|
|
||||||
fn_spec.name(),
|
|
||||||
merge_target,
|
|
||||||
),
|
),
|
||||||
};
|
NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))),
|
||||||
|
NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mutation_output = fn_spec.output_type();
|
fn canonical_shape(shape: &TypeShape) -> Canonical {
|
||||||
let mutation_shape = match resolve_type_shape(mutation_output) {
|
match shape {
|
||||||
Some(s) => s,
|
TypeShape::Primitive(p) => Canonical::Primitive(p.name()),
|
||||||
None => panic!(
|
TypeShape::Ref { shape, .. } => canonical_named(&shape()),
|
||||||
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
|
TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))),
|
||||||
fn_spec.name(), mutation_output,
|
TypeShape::Optional(inner) => Canonical::Optional(Box::new(canonical_shape(inner))),
|
||||||
|
TypeShape::Enum(variants) => Canonical::InlineEnum(variants.clone()),
|
||||||
|
TypeShape::Union(branches) => {
|
||||||
|
Canonical::Union(branches.iter().map(canonical_shape).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge-compatibility on named types. A mutation return `value` can splice
|
||||||
|
/// into a context slot `slot` when either shape holds:
|
||||||
|
/// * direct: `slot` and `value` have the same fingerprint → replace
|
||||||
|
/// * upsert: `slot` is `list[T]` and `value` is `T` → upsert by id
|
||||||
|
///
|
||||||
|
/// The first argument is the slot (context member's output type); the second
|
||||||
|
/// is the value (mutation's output type).
|
||||||
|
fn types_match(slot: &NamedType, value: &NamedType) -> bool {
|
||||||
|
let value_form = canonical_named(value);
|
||||||
|
if canonical_named(slot) == value_form {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match slot {
|
||||||
|
NamedType::Alias(inner) => match inner {
|
||||||
|
TypeShape::List(elem) => canonical_shape(elem) == value_form,
|
||||||
|
TypeShape::Primitive(_)
|
||||||
|
| TypeShape::Ref { .. }
|
||||||
|
| TypeShape::Optional(_)
|
||||||
|
| TypeShape::Enum(_)
|
||||||
|
| TypeShape::Union(_) => false,
|
||||||
|
},
|
||||||
|
NamedType::Struct(_) | NamedType::Enum(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `merge` declaration read off the registry and resolved: the mutation
|
||||||
|
/// that declares it, the context it names, and the context member whose output
|
||||||
|
/// the mutation's return value splices into.
|
||||||
|
pub(crate) struct ResolvedMerge {
|
||||||
|
pub function: &'static str,
|
||||||
|
pub context: &'static str,
|
||||||
|
pub slot: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The context members whose output a mutation's return value can splice into,
|
||||||
|
/// accumulated one candidate at a time. A `merge` declaration carries a usable
|
||||||
|
/// slot exactly when the walk ends on `Unique`.
|
||||||
|
enum SlotMatch {
|
||||||
|
Absent,
|
||||||
|
Unique(&'static str),
|
||||||
|
Ambiguous(Vec<&'static str>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlotMatch {
|
||||||
|
fn with(self, candidate: &'static str) -> Self {
|
||||||
|
match self {
|
||||||
|
SlotMatch::Absent => SlotMatch::Unique(candidate),
|
||||||
|
SlotMatch::Unique(first) => SlotMatch::Ambiguous(vec![first, candidate]),
|
||||||
|
SlotMatch::Ambiguous(mut members) => {
|
||||||
|
members.push(candidate);
|
||||||
|
SlotMatch::Ambiguous(members)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ways a registered graph fails to hold together.
|
||||||
|
enum GraphDefect {
|
||||||
|
NoMergeSlot {
|
||||||
|
function: &'static str,
|
||||||
|
context: &'static str,
|
||||||
|
output_type: &'static str,
|
||||||
|
},
|
||||||
|
AmbiguousMergeSlot {
|
||||||
|
function: &'static str,
|
||||||
|
context: &'static str,
|
||||||
|
output_type: &'static str,
|
||||||
|
members: Vec<&'static str>,
|
||||||
|
},
|
||||||
|
DivergentParamType {
|
||||||
|
context: &'static str,
|
||||||
|
param: &'static str,
|
||||||
|
first_fn: &'static str,
|
||||||
|
first_type: &'static str,
|
||||||
|
second_fn: &'static str,
|
||||||
|
second_type: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for GraphDefect {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
GraphDefect::NoMergeSlot {
|
||||||
|
function,
|
||||||
|
context,
|
||||||
|
output_type,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"function `{function}` declares `merge = \"{context}\"` but no member of that \
|
||||||
|
context has output type `{output_type}`. Add a context member returning \
|
||||||
|
`{output_type}`, or declare `affects` for plain refetch."
|
||||||
),
|
),
|
||||||
};
|
GraphDefect::AmbiguousMergeSlot {
|
||||||
let mut matches: Vec<&'static str> = Vec::new();
|
function,
|
||||||
|
context,
|
||||||
|
output_type,
|
||||||
|
members,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"function `{function}` declares `merge = \"{context}\"` but members ({}) all \
|
||||||
|
share output type `{output_type}`. Merge resolution needs exactly one match. \
|
||||||
|
Distinguish the outputs, or declare `affects` for plain refetch.",
|
||||||
|
members.join(", ")
|
||||||
|
),
|
||||||
|
GraphDefect::DivergentParamType {
|
||||||
|
context,
|
||||||
|
param,
|
||||||
|
first_fn,
|
||||||
|
first_type,
|
||||||
|
second_fn,
|
||||||
|
second_type,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"context `{context}` has a parameter `{param}` whose type diverges across \
|
||||||
|
members. Function `{first_fn}` declares it as `{first_type}`, function \
|
||||||
|
`{second_fn}` declares it as `{second_type}`. A shared param has one type \
|
||||||
|
across the whole context."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every defect on its own bulleted line, under one heading.
|
||||||
|
struct GraphReport<'a>(&'a [GraphDefect]);
|
||||||
|
|
||||||
|
impl fmt::Display for GraphReport<'_> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
"Mizan graph-check: the registered function graph is inconsistent."
|
||||||
|
)?;
|
||||||
|
for defect in self.0 {
|
||||||
|
writeln!(f, " - {defect}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `merge` declaration that resolved to exactly one slot, plus every way
|
||||||
|
/// the graph failed to hold together.
|
||||||
|
struct GraphAnalysis {
|
||||||
|
merges: Vec<ResolvedMerge>,
|
||||||
|
defects: Vec<GraphDefect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static ANALYSIS: OnceLock<GraphAnalysis> = OnceLock::new();
|
||||||
|
|
||||||
|
/// `FUNCTIONS` and `CONTEXTS` are link-time data, so the walk yields the same
|
||||||
|
/// answer for every caller and runs once.
|
||||||
|
fn analysis() -> &'static GraphAnalysis {
|
||||||
|
ANALYSIS.get_or_init(analyze)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze() -> GraphAnalysis {
|
||||||
|
let mut merges = Vec::new();
|
||||||
|
let mut defects = Vec::new();
|
||||||
|
for fn_spec in FUNCTIONS {
|
||||||
|
let mutation_shape = fn_spec.output_shape();
|
||||||
|
for &context in fn_spec.merge() {
|
||||||
|
match match_slot(context, &mutation_shape) {
|
||||||
|
SlotMatch::Unique(slot) => merges.push(ResolvedMerge {
|
||||||
|
function: fn_spec.name(),
|
||||||
|
context,
|
||||||
|
slot,
|
||||||
|
}),
|
||||||
|
SlotMatch::Absent => defects.push(GraphDefect::NoMergeSlot {
|
||||||
|
function: fn_spec.name(),
|
||||||
|
context,
|
||||||
|
output_type: fn_spec.output_type(),
|
||||||
|
}),
|
||||||
|
SlotMatch::Ambiguous(members) => defects.push(GraphDefect::AmbiguousMergeSlot {
|
||||||
|
function: fn_spec.name(),
|
||||||
|
context,
|
||||||
|
output_type: fn_spec.output_type(),
|
||||||
|
members,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defects.extend(divergent_param_types());
|
||||||
|
GraphAnalysis { merges, defects }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The members of `context_name` whose output type a value of `mutation_shape`
|
||||||
|
/// splices into.
|
||||||
|
fn match_slot(context_name: &'static str, mutation_shape: &NamedType) -> SlotMatch {
|
||||||
|
let mut matched = SlotMatch::Absent;
|
||||||
for candidate in FUNCTIONS {
|
for candidate in FUNCTIONS {
|
||||||
if candidate.context() != Some(ctx_entry.name) {
|
if candidate.context() != Some(context_name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
|
if types_match(&candidate.output_shape(), mutation_shape) {
|
||||||
if types_match(&candidate_shape, &mutation_shape) {
|
matched = matched.with(candidate.name());
|
||||||
matches.push(candidate.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches.is_empty() {
|
|
||||||
panic!(
|
|
||||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
|
|
||||||
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
|
|
||||||
fn_spec.name(), merge_target, mutation_output, mutation_output,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if matches.len() > 1 {
|
|
||||||
panic!(
|
|
||||||
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
|
|
||||||
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
|
|
||||||
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
matched
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_shared_param_types() {
|
/// Params that one context's members declare under the same name but with
|
||||||
|
/// different primitives.
|
||||||
|
fn divergent_param_types() -> Vec<GraphDefect> {
|
||||||
|
let mut defects = Vec::new();
|
||||||
for ctx in CONTEXTS {
|
for ctx in CONTEXTS {
|
||||||
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
|
let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new();
|
||||||
= std::collections::HashMap::new();
|
|
||||||
for fn_spec in FUNCTIONS {
|
for fn_spec in FUNCTIONS {
|
||||||
if fn_spec.context() != Some(ctx.name) {
|
if fn_spec.context() != Some(ctx.name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for p in fn_spec.input_params() {
|
for p in fn_spec.input_params() {
|
||||||
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
|
match by_name.entry(p.name) {
|
||||||
if std::mem::discriminant(prev_primitive)
|
Entry::Occupied(seen) => {
|
||||||
!= std::mem::discriminant(&p.primitive)
|
let (first_primitive, first_fn) = *seen.get();
|
||||||
{
|
if first_primitive != p.primitive {
|
||||||
panic!(
|
defects.push(GraphDefect::DivergentParamType {
|
||||||
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
|
context: ctx.name,
|
||||||
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
|
param: p.name,
|
||||||
Shared params must have one type across the whole context.",
|
first_fn,
|
||||||
ctx.name, p.name,
|
first_type: first_primitive.name(),
|
||||||
prev_fn, prev_primitive.name(),
|
second_fn: fn_spec.name(),
|
||||||
fn_spec.name(), p.primitive.name(),
|
second_type: p.primitive.name(),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
by_name.insert(p.name, (p.primitive, fn_spec.name()));
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert((p.primitive, fn_spec.name()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
defects
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic with the full defect report when the registered function graph is
|
||||||
|
/// inconsistent.
|
||||||
|
pub fn verify_invariants() {
|
||||||
|
let defects = &analysis().defects;
|
||||||
|
if !defects.is_empty() {
|
||||||
|
panic!("{}", GraphReport(defects));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The merges `function` declares. Reading them verifies the graph first, so a
|
||||||
|
/// declaration that resolved to no slot is reported rather than passed over.
|
||||||
|
pub(crate) fn merges_for(function: &str) -> impl Iterator<Item = &'static ResolvedMerge> + '_ {
|
||||||
|
verify_invariants();
|
||||||
|
analysis()
|
||||||
|
.merges
|
||||||
|
.iter()
|
||||||
|
.filter(move |resolved| resolved.function == function)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
|
//! The IR data model the KDL emitter walks: named types, inline type shapes,
|
||||||
//!
|
//! and the descriptors a registered function or channel carries.
|
||||||
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
|
|
||||||
//! side produces byte-equivalent KDL to the Python emitter against the same
|
|
||||||
//! function registry.
|
|
||||||
|
|
||||||
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
|
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum NamedType {
|
pub enum NamedType {
|
||||||
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
|
/// `type "X" { struct { field ... } }` — a record.
|
||||||
Struct(Vec<StructField>),
|
Struct(Vec<StructField>),
|
||||||
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
|
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
|
||||||
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
|
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
|
||||||
@@ -21,14 +18,20 @@ pub enum NamedType {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TypeShape {
|
pub enum TypeShape {
|
||||||
Primitive(Primitive),
|
Primitive(Primitive),
|
||||||
Ref(&'static str),
|
/// A reference to a named type. `shape` is the referent's own shape
|
||||||
|
/// constructor, so resolving a reference never consults a registry and
|
||||||
|
/// never fails.
|
||||||
|
Ref {
|
||||||
|
name: &'static str,
|
||||||
|
shape: fn() -> NamedType,
|
||||||
|
},
|
||||||
List(Box<TypeShape>),
|
List(Box<TypeShape>),
|
||||||
Optional(Box<TypeShape>),
|
Optional(Box<TypeShape>),
|
||||||
Enum(Vec<&'static str>),
|
Enum(Vec<&'static str>),
|
||||||
Union(Vec<TypeShape>),
|
Union(Vec<TypeShape>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Primitive {
|
pub enum Primitive {
|
||||||
Integer,
|
Integer,
|
||||||
Number,
|
Number,
|
||||||
@@ -64,8 +67,8 @@ pub enum DefaultValue {
|
|||||||
Null,
|
Null,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One descriptor of what a mutation `affects`. Mirrors Python's
|
/// One descriptor of what a mutation `affects` — either a named context or a
|
||||||
/// `_normalize_affects` shape — either a named context or a named function.
|
/// named function.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AffectTarget {
|
pub enum AffectTarget {
|
||||||
Context(&'static str),
|
Context(&'static str),
|
||||||
@@ -75,6 +78,37 @@ pub enum AffectTarget {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One payload slot of a channel. Direction is named from the client's point
|
||||||
|
/// of view: a `ClientMessage` travels client → server, a `ServerMessage`
|
||||||
|
/// travels server → client.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ChannelSlotKind {
|
||||||
|
Params,
|
||||||
|
ClientMessage,
|
||||||
|
ServerMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelSlotKind {
|
||||||
|
/// The KDL child-node name the slot emits under.
|
||||||
|
pub fn node_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ChannelSlotKind::Params => "params",
|
||||||
|
ChannelSlotKind::ClientMessage => "client-message",
|
||||||
|
ChannelSlotKind::ServerMessage => "server-message",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suffix appended to the channel's Pascal stem to name the slot's
|
||||||
|
/// emitted type.
|
||||||
|
pub fn type_suffix(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ChannelSlotKind::Params => "Params",
|
||||||
|
ChannelSlotKind::ClientMessage => "ClientMessage",
|
||||||
|
ChannelSlotKind::ServerMessage => "ServerMessage",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Transport {
|
pub enum Transport {
|
||||||
Http,
|
Http,
|
||||||
|
|||||||
@@ -1,17 +1,104 @@
|
|||||||
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
//! KDL emitter — collects the registries (named types, functions, contexts,
|
||||||
//!
|
//! channels) into a KDL node tree and renders it through
|
||||||
//! The Python emitter is the spec; this is the second implementation under
|
//! `templates/ir.kdl.jinja`.
|
||||||
//! the same contract. Any divergence is a bug here, not a contract change.
|
|
||||||
|
|
||||||
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
|
use crate::ir::{
|
||||||
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
|
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape,
|
||||||
|
};
|
||||||
|
use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES};
|
||||||
use crate::traits::FunctionSpec;
|
use crate::traits::FunctionSpec;
|
||||||
|
use minijinja::value::ViaDeserialize;
|
||||||
|
use minijinja::{context, Environment};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
const INDENT: &str = " ";
|
const INDENT: &str = " ";
|
||||||
|
const IR_TEMPLATE: &str = include_str!("../templates/ir.kdl.jinja");
|
||||||
|
|
||||||
/// Escape a string for KDL — same escape set as the Python emitter.
|
/// A KDL scalar, carried structurally so the template's `kdl` filter — not
|
||||||
fn kdl_string(s: &str) -> String {
|
/// the node builders — decides its written form.
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "kind", content = "v")]
|
||||||
|
enum KdlValue {
|
||||||
|
Str(String),
|
||||||
|
Bool(bool),
|
||||||
|
Integer(i64),
|
||||||
|
Number(f64),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdlValue {
|
||||||
|
fn str(s: &str) -> Self {
|
||||||
|
KdlValue::Str(s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn of_default(v: &DefaultValue) -> Self {
|
||||||
|
match v {
|
||||||
|
DefaultValue::Null => KdlValue::Null,
|
||||||
|
DefaultValue::Boolean(b) => KdlValue::Bool(*b),
|
||||||
|
DefaultValue::Integer(i) => KdlValue::Integer(*i),
|
||||||
|
DefaultValue::Number(f) => KdlValue::Number(*f),
|
||||||
|
DefaultValue::String(s) => KdlValue::str(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct KdlProp {
|
||||||
|
name: &'static str,
|
||||||
|
value: KdlValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One KDL node: its own line, plus a brace-delimited child block when
|
||||||
|
/// `block` is set. `indent` is the literal prefix its line carries.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct KdlNode {
|
||||||
|
indent: String,
|
||||||
|
name: &'static str,
|
||||||
|
args: Vec<KdlValue>,
|
||||||
|
props: Vec<KdlProp>,
|
||||||
|
block: bool,
|
||||||
|
children: Vec<KdlNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdlNode {
|
||||||
|
fn new(depth: usize, name: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
indent: INDENT.repeat(depth),
|
||||||
|
name,
|
||||||
|
args: Vec::new(),
|
||||||
|
props: Vec::new(),
|
||||||
|
block: false,
|
||||||
|
children: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg(mut self, value: KdlValue) -> Self {
|
||||||
|
self.args.push(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn args(mut self, values: impl IntoIterator<Item = KdlValue>) -> Self {
|
||||||
|
self.args.extend(values);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop(mut self, name: &'static str, value: KdlValue) -> Self {
|
||||||
|
self.props.push(KdlProp { name, value });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(mut self, children: Vec<KdlNode>) -> Self {
|
||||||
|
self.block = true;
|
||||||
|
self.children = children;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `kdl` template filter — writes one scalar in KDL surface syntax.
|
||||||
|
fn render_kdl_value(value: ViaDeserialize<KdlValue>) -> String {
|
||||||
|
match &*value {
|
||||||
|
KdlValue::Str(s) => {
|
||||||
let mut out = String::with_capacity(s.len() + 2);
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
out.push('"');
|
out.push('"');
|
||||||
for c in s.chars() {
|
for c in s.chars() {
|
||||||
@@ -26,35 +113,29 @@ fn kdl_string(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
out.push('"');
|
out.push('"');
|
||||||
out
|
out
|
||||||
}
|
|
||||||
|
|
||||||
fn kdl_bool(b: bool) -> &'static str {
|
|
||||||
if b {
|
|
||||||
"#true"
|
|
||||||
} else {
|
|
||||||
"#false"
|
|
||||||
}
|
}
|
||||||
}
|
KdlValue::Bool(b) => {
|
||||||
|
if *b {
|
||||||
fn kdl_default(v: &DefaultValue) -> String {
|
"#true".to_string()
|
||||||
match v {
|
} else {
|
||||||
DefaultValue::Null => "#null".into(),
|
"#false".to_string()
|
||||||
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
|
}
|
||||||
DefaultValue::Integer(i) => i.to_string(),
|
}
|
||||||
DefaultValue::Number(f) => {
|
KdlValue::Integer(i) => i.to_string(),
|
||||||
// Match Python's `repr(float)` for whole-number-equal-but-float
|
KdlValue::Number(f) => {
|
||||||
// values: e.g. 1.0 → "1.0", not "1".
|
// A whole-valued float still writes with its fractional part, so
|
||||||
|
// `1.0` does not collapse into the integer spelling `1`.
|
||||||
if f.fract() == 0.0 && f.is_finite() {
|
if f.fract() == 0.0 && f.is_finite() {
|
||||||
format!("{f:.1}")
|
format!("{f:.1}")
|
||||||
} else {
|
} else {
|
||||||
f.to_string()
|
f.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DefaultValue::String(s) => kdl_string(s),
|
KdlValue::Null => "#null".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`.
|
/// Convert snake_case to camelCase.
|
||||||
pub fn snake_to_camel(name: &str) -> String {
|
pub fn snake_to_camel(name: &str) -> String {
|
||||||
let normalized = name.replace('.', "_").replace('-', "_");
|
let normalized = name.replace('.', "_").replace('-', "_");
|
||||||
let mut parts = normalized.split('_');
|
let mut parts = normalized.split('_');
|
||||||
@@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Emitter<'a> {
|
/// The PascalCase stem every emitted type name for `wire_name` is built on:
|
||||||
lines: Vec<String>,
|
/// split on `[._-]`, then title-case each part, where a character is
|
||||||
/// Types whose references should be substituted with their inline
|
/// uppercased only when the character before it is not a letter.
|
||||||
/// shape at the use site (and which don't emit as their own
|
pub fn wire_to_pascal(wire_name: &str) -> String {
|
||||||
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`.
|
let mut out = String::with_capacity(wire_name.len());
|
||||||
|
for part in wire_name.split(['.', '_', '-']) {
|
||||||
|
let mut prev_is_letter = false;
|
||||||
|
for c in part.chars() {
|
||||||
|
if prev_is_letter {
|
||||||
|
out.extend(c.to_lowercase());
|
||||||
|
} else {
|
||||||
|
out.extend(c.to_uppercase());
|
||||||
|
}
|
||||||
|
prev_is_letter = c.is_alphabetic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the node tree for one document.
|
||||||
|
struct NodeBuilder<'a> {
|
||||||
|
/// Types whose references are substituted with their inline shape at the
|
||||||
|
/// use site, and which emit no `type "X" { ... }` entry of their own.
|
||||||
inlines: &'a BTreeMap<&'static str, TypeShape>,
|
inlines: &'a BTreeMap<&'static str, TypeShape>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Emitter<'a> {
|
impl NodeBuilder<'_> {
|
||||||
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self {
|
fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode {
|
||||||
Self {
|
|
||||||
lines: Vec::new(),
|
|
||||||
inlines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prefix(&self, indent: usize) -> String {
|
|
||||||
INDENT.repeat(indent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn leaf(&mut self, indent: usize, parts: &[&str]) {
|
|
||||||
let mut line = self.prefix(indent);
|
|
||||||
line.push_str(&parts.join(" "));
|
|
||||||
self.lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open(&mut self, indent: usize, parts: &[&str]) {
|
|
||||||
let mut line = self.prefix(indent);
|
|
||||||
line.push_str(&parts.join(" "));
|
|
||||||
line.push_str(" {");
|
|
||||||
self.lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&mut self, indent: usize) {
|
|
||||||
let mut line = self.prefix(indent);
|
|
||||||
line.push('}');
|
|
||||||
self.lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blank(&mut self) {
|
|
||||||
self.lines.push(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
|
|
||||||
match shape {
|
match shape {
|
||||||
TypeShape::Primitive(p) => {
|
TypeShape::Primitive(p) => {
|
||||||
let name = kdl_string(p.name());
|
KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name()))
|
||||||
self.leaf(indent, &["primitive", &name]);
|
|
||||||
}
|
|
||||||
TypeShape::Ref(name) => {
|
|
||||||
// Inline-substitute when the referenced type is a
|
|
||||||
// primitive-alias or string-enum. Matches Python's
|
|
||||||
// Pydantic Literal/alias inlining.
|
|
||||||
if let Some(inline_shape) = self.inlines.get(name).cloned() {
|
|
||||||
self.emit_type_child(indent, &inline_shape);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let n = kdl_string(name);
|
|
||||||
self.leaf(indent, &["ref", &n]);
|
|
||||||
}
|
}
|
||||||
|
TypeShape::Ref { name, .. } => match self.inlines.get(name) {
|
||||||
|
Some(inline_shape) => self.type_child(depth, &inline_shape.clone()),
|
||||||
|
None => KdlNode::new(depth, "ref").arg(KdlValue::str(name)),
|
||||||
|
},
|
||||||
TypeShape::List(inner) => {
|
TypeShape::List(inner) => {
|
||||||
self.open(indent, &["list"]);
|
KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)])
|
||||||
self.emit_type_child(indent + 1, inner);
|
|
||||||
self.close(indent);
|
|
||||||
}
|
}
|
||||||
TypeShape::Optional(inner) => {
|
TypeShape::Optional(inner) => {
|
||||||
self.open(indent, &["optional"]);
|
KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)])
|
||||||
self.emit_type_child(indent + 1, inner);
|
|
||||||
self.close(indent);
|
|
||||||
}
|
}
|
||||||
TypeShape::Enum(variants) => {
|
TypeShape::Enum(variants) => {
|
||||||
let mut parts: Vec<String> = vec!["enum".into()];
|
KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||||
for v in variants {
|
|
||||||
parts.push(kdl_string(v));
|
|
||||||
}
|
|
||||||
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
|
|
||||||
self.leaf(indent, &line);
|
|
||||||
}
|
|
||||||
TypeShape::Union(branches) => {
|
|
||||||
self.open(indent, &["union"]);
|
|
||||||
for b in branches {
|
|
||||||
self.emit_type_child(indent + 1, b);
|
|
||||||
}
|
|
||||||
self.close(indent);
|
|
||||||
}
|
}
|
||||||
|
TypeShape::Union(branches) => KdlNode::new(depth, "union").block(
|
||||||
|
branches
|
||||||
|
.iter()
|
||||||
|
.map(|b| self.type_child(depth + 1, b))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) {
|
fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode {
|
||||||
let name_lit = kdl_string(name);
|
let inner = match body {
|
||||||
self.open(indent, &["type", &name_lit]);
|
NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block(
|
||||||
match body {
|
fields
|
||||||
NamedType::Struct(fields) => {
|
.iter()
|
||||||
self.open(indent + 1, &["struct"]);
|
.map(|field| self.struct_field(depth + 2, field))
|
||||||
for field in fields {
|
.collect(),
|
||||||
self.emit_struct_field(indent + 2, field);
|
),
|
||||||
}
|
NamedType::Alias(shape) => {
|
||||||
self.close(indent + 1);
|
KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)])
|
||||||
}
|
|
||||||
NamedType::Alias(inner) => {
|
|
||||||
self.open(indent + 1, &["alias"]);
|
|
||||||
self.emit_type_child(indent + 2, inner);
|
|
||||||
self.close(indent + 1);
|
|
||||||
}
|
}
|
||||||
NamedType::Enum(variants) => {
|
NamedType::Enum(variants) => {
|
||||||
let mut parts: Vec<String> = vec!["enum".into()];
|
KdlNode::new(depth + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
|
||||||
for v in variants {
|
|
||||||
parts.push(kdl_string(v));
|
|
||||||
}
|
}
|
||||||
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
|
};
|
||||||
self.leaf(indent + 1, &line);
|
KdlNode::new(depth, "type")
|
||||||
}
|
.arg(KdlValue::str(name))
|
||||||
}
|
.block(vec![inner])
|
||||||
self.close(indent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
|
fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode {
|
||||||
let name = kdl_string(field.name);
|
let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name));
|
||||||
let mut header: Vec<String> = vec!["field".into(), name];
|
|
||||||
if !field.required {
|
if !field.required {
|
||||||
header.push(format!("required={}", kdl_bool(false)));
|
node = node.prop("required", KdlValue::Bool(false));
|
||||||
if let Some(default) = &field.default {
|
if let Some(default) = &field.default {
|
||||||
header.push(format!("default={}", kdl_default(default)));
|
node = node.prop("default", KdlValue::of_default(default));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect();
|
node.block(vec![self.type_child(depth + 1, &field.shape)])
|
||||||
self.open(indent, &line_parts);
|
|
||||||
self.emit_type_child(indent + 1, &field.shape);
|
|
||||||
self.close(indent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
|
fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode {
|
||||||
let name = kdl_string(fn_spec.name());
|
let inner = depth + 1;
|
||||||
self.open(indent, &["function", &name]);
|
let mut children = vec![
|
||||||
|
KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())),
|
||||||
let camel = kdl_string(fn_spec.camel_name());
|
KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())),
|
||||||
self.leaf(indent + 1, &["camel", &camel]);
|
];
|
||||||
|
|
||||||
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
|
|
||||||
|
|
||||||
if let Some(input_type) = fn_spec.input_type() {
|
if let Some(input_type) = fn_spec.input_type() {
|
||||||
let lit = kdl_string(input_type);
|
children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type)));
|
||||||
self.leaf(indent + 1, &["input", &lit]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let output_lit = kdl_string(fn_spec.output_type());
|
children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type())));
|
||||||
self.leaf(indent + 1, &["output", &output_lit]);
|
|
||||||
|
|
||||||
if fn_spec.output_nullable() {
|
if fn_spec.output_nullable() {
|
||||||
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]);
|
children.push(KdlNode::new(inner, "output-nullable").arg(KdlValue::Bool(true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let transport_lit = kdl_string(fn_spec.transport().name());
|
children
|
||||||
self.leaf(indent + 1, &["transport", &transport_lit]);
|
.push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name())));
|
||||||
|
|
||||||
if let Some(ctx) = fn_spec.context() {
|
if let Some(ctx) = fn_spec.context() {
|
||||||
let lit = kdl_string(ctx);
|
children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx)));
|
||||||
self.leaf(indent + 1, &["context", &lit]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for affect in fn_spec.affects() {
|
for affect in fn_spec.affects() {
|
||||||
// Mirror Python's behavior: only context-typed affects make it
|
match affect {
|
||||||
// into the KDL `affects` leaf. Function-typed affects are
|
// The `affects` leaf names a context; a function-typed target
|
||||||
// reserved for a future IR extension.
|
// has no leaf in the document.
|
||||||
if let crate::ir::AffectTarget::Context(name) = affect {
|
AffectTarget::Context(name) => {
|
||||||
let lit = kdl_string(name);
|
children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name)));
|
||||||
self.leaf(indent + 1, &["affects", &lit]);
|
}
|
||||||
|
AffectTarget::Function { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for merge in fn_spec.merge() {
|
for merge in fn_spec.merge() {
|
||||||
let lit = kdl_string(merge);
|
children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge)));
|
||||||
self.leaf(indent + 1, &["merge", &lit]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if fn_spec.is_form() {
|
if fn_spec.is_form() {
|
||||||
self.leaf(indent + 1, &["is-form", kdl_bool(true)]);
|
children.push(KdlNode::new(inner, "is-form").arg(KdlValue::Bool(true)));
|
||||||
if let Some(form_name) = fn_spec.form_name() {
|
if let Some(form_name) = fn_spec.form_name() {
|
||||||
let lit = kdl_string(form_name);
|
children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name)));
|
||||||
self.leaf(indent + 1, &["form-name", &lit]);
|
|
||||||
}
|
}
|
||||||
if let Some(form_role) = fn_spec.form_role() {
|
if let Some(form_role) = fn_spec.form_role() {
|
||||||
let lit = kdl_string(form_role);
|
children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role)));
|
||||||
self.leaf(indent + 1, &["form-role", &lit]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.close(indent);
|
KdlNode::new(depth, "function")
|
||||||
|
.arg(KdlValue::str(fn_spec.name()))
|
||||||
|
.block(children)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) {
|
fn context(
|
||||||
let name_lit = kdl_string(ctx_name);
|
&self,
|
||||||
self.open(indent, &["context", &name_lit]);
|
depth: usize,
|
||||||
|
ctx_name: &str,
|
||||||
|
members: &[&'static dyn FunctionSpec],
|
||||||
|
) -> KdlNode {
|
||||||
|
let inner = depth + 1;
|
||||||
|
let mut children: Vec<KdlNode> = members
|
||||||
|
.iter()
|
||||||
|
.map(|fn_spec| KdlNode::new(inner, "function").arg(KdlValue::str(fn_spec.name())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Function membership in registration order.
|
// Params collected across every member, keyed so they emit
|
||||||
for fn_spec in members {
|
// alphabetized by param name.
|
||||||
let lit = kdl_string(fn_spec.name());
|
|
||||||
self.leaf(indent + 1, &["function", &lit]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Param info — collect across every member, then emit alphabetized
|
|
||||||
// by param name to match Python.
|
|
||||||
struct ParamSlot {
|
struct ParamSlot {
|
||||||
primitive: Primitive,
|
primitive: Primitive,
|
||||||
shared_by: Vec<&'static str>,
|
shared_by: Vec<&'static str>,
|
||||||
@@ -295,120 +327,163 @@ impl<'a> Emitter<'a> {
|
|||||||
|
|
||||||
let member_count = members.len();
|
let member_count = members.len();
|
||||||
for (param_name, slot) in params.iter() {
|
for (param_name, slot) in params.iter() {
|
||||||
let name_lit = kdl_string(param_name);
|
let mut param_children = vec![
|
||||||
self.open(indent + 1, &["param", &name_lit]);
|
KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())),
|
||||||
let type_lit = kdl_string(slot.primitive.name());
|
KdlNode::new(inner + 1, "required")
|
||||||
self.leaf(indent + 2, &["type", &type_lit]);
|
.arg(KdlValue::Bool(slot.shared_by.len() == member_count)),
|
||||||
let required = slot.shared_by.len() == member_count;
|
];
|
||||||
self.leaf(indent + 2, &["required", kdl_bool(required)]);
|
|
||||||
for sharer in &slot.shared_by {
|
for sharer in &slot.shared_by {
|
||||||
let lit = kdl_string(sharer);
|
param_children
|
||||||
self.leaf(indent + 2, &["shared-by", &lit]);
|
.push(KdlNode::new(inner + 1, "shared-by").arg(KdlValue::str(sharer)));
|
||||||
}
|
}
|
||||||
self.close(indent + 1);
|
children.push(
|
||||||
|
KdlNode::new(inner, "param")
|
||||||
|
.arg(KdlValue::str(param_name))
|
||||||
|
.block(param_children),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.close(indent);
|
KdlNode::new(depth, "context")
|
||||||
|
.arg(KdlValue::str(ctx_name))
|
||||||
|
.block(children)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn into_string(mut self) -> String {
|
fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode {
|
||||||
// Trim trailing blanks, then add a single terminating newline.
|
let inner = depth + 1;
|
||||||
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
|
let mut children =
|
||||||
self.lines.pop();
|
vec![KdlNode::new(inner, "pascal-name").arg(KdlValue::str(&channel.pascal_name))];
|
||||||
|
for slot in &channel.slots {
|
||||||
|
children.push(
|
||||||
|
KdlNode::new(inner, slot.kind.node_name()).arg(KdlValue::str(&slot.type_name)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut out = self.lines.join("\n");
|
KdlNode::new(depth, "channel")
|
||||||
out.push('\n');
|
.arg(KdlValue::str(channel.name))
|
||||||
out
|
.block(children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One channel as the document carries it: the wire name, the Pascal stem its
|
||||||
|
/// slot type names are built on, and the slots it declares.
|
||||||
|
pub(crate) struct ChannelRecord {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub pascal_name: String,
|
||||||
|
pub slots: Vec<ChannelSlotRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ChannelSlotRecord {
|
||||||
|
pub kind: ChannelSlotKind,
|
||||||
|
pub type_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Collected typed registries view used by `build_ir`.
|
/// Collected typed registries view used by `build_ir`.
|
||||||
pub(crate) struct IrSnapshot {
|
pub(crate) struct IrSnapshot {
|
||||||
pub types: BTreeMap<&'static str, NamedType>,
|
pub types: BTreeMap<String, NamedType>,
|
||||||
pub functions: Vec<&'static dyn FunctionSpec>,
|
pub functions: Vec<&'static dyn FunctionSpec>,
|
||||||
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
|
||||||
/// Types that inline to a `TypeShape` at every reference site rather
|
pub channels: Vec<ChannelRecord>,
|
||||||
/// than emitting as their own `type "X" { ... }` entry. Populated from
|
/// Types that inline to a `TypeShape` at every reference site rather than
|
||||||
/// `Alias(Primitive(_))` and `Enum` named types — both are
|
/// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`,
|
||||||
/// information-zero indirections that the codegen consumer doesn't
|
/// both of which carry no structure a named entry would add.
|
||||||
/// gain anything from naming. Matches the Python emitter's behavior
|
|
||||||
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
|
|
||||||
/// materialize as named types).
|
|
||||||
pub inlines: BTreeMap<&'static str, TypeShape>,
|
pub inlines: BTreeMap<&'static str, TypeShape>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IrSnapshot {
|
impl IrSnapshot {
|
||||||
pub(crate) fn collect() -> Self {
|
pub(crate) fn collect() -> Self {
|
||||||
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
|
// Types: alphabetized, which is the document's canonical ordering.
|
||||||
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||||
for entry in TYPES {
|
for entry in TYPES {
|
||||||
all_types.insert(entry.name, (entry.shape_fn)());
|
all_types.insert(entry.name, (entry.shape_fn)());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partition into emit-candidate types vs inlines. An inline is a
|
// Partition into emit-candidate types vs inlines.
|
||||||
// named type whose shape collapses to a single `TypeShape` at the
|
|
||||||
// field site — primitive aliases and string enums.
|
|
||||||
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
|
||||||
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
|
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
|
||||||
for (name, body) in all_types {
|
for (name, body) in all_types {
|
||||||
match &body {
|
match body {
|
||||||
NamedType::Alias(TypeShape::Primitive(p)) => {
|
|
||||||
inlines.insert(name, TypeShape::Primitive(*p));
|
|
||||||
}
|
|
||||||
NamedType::Enum(variants) => {
|
NamedType::Enum(variants) => {
|
||||||
inlines.insert(name, TypeShape::Enum(variants.clone()));
|
inlines.insert(name, TypeShape::Enum(variants));
|
||||||
}
|
}
|
||||||
_ => {
|
NamedType::Alias(TypeShape::Primitive(p)) => {
|
||||||
candidates.insert(name, body);
|
inlines.insert(name, TypeShape::Primitive(p));
|
||||||
|
}
|
||||||
|
NamedType::Alias(shape) => {
|
||||||
|
candidates.insert(name, NamedType::Alias(shape));
|
||||||
|
}
|
||||||
|
NamedType::Struct(fields) => {
|
||||||
|
candidates.insert(name, NamedType::Struct(fields));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tree-shake: keep only types reachable from a registered function's
|
// Channels: alphabetical by wire name, each declared slot's type
|
||||||
// input/output. The function macro registers canonical-named
|
// named `<Pascal><Slot>`. The slot shapes enter the type section
|
||||||
// entries (e.g. `userPrefsOutput`); derive registers original-named
|
// directly, so they are emitted whether or not a function reaches
|
||||||
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached
|
// them.
|
||||||
// via Ref-walk from a function's input/output names belong in the
|
let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> =
|
||||||
// emitted IR. Mirrors Python's `_collect_named_types`.
|
CHANNELS.iter().collect();
|
||||||
|
channel_entries.sort_by_key(|c| c.name);
|
||||||
|
let mut channels: Vec<ChannelRecord> = Vec::new();
|
||||||
|
let mut channel_types: Vec<(String, NamedType)> = Vec::new();
|
||||||
|
for entry in channel_entries {
|
||||||
|
let pascal_name = wire_to_pascal(entry.name);
|
||||||
|
let mut slots: Vec<ChannelSlotRecord> = Vec::new();
|
||||||
|
for slot in entry.slots {
|
||||||
|
let type_name = format!("{pascal_name}{}", slot.kind.type_suffix());
|
||||||
|
channel_types.push((type_name.clone(), (slot.shape_fn)()));
|
||||||
|
slots.push(ChannelSlotRecord {
|
||||||
|
kind: slot.kind,
|
||||||
|
type_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
channels.push(ChannelRecord {
|
||||||
|
name: entry.name,
|
||||||
|
pascal_name,
|
||||||
|
slots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots of the tree-shake: every non-private function's input and
|
||||||
|
// output name, plus every name a channel slot's shape refs.
|
||||||
let mut reachable: std::collections::HashSet<&'static str> =
|
let mut reachable: std::collections::HashSet<&'static str> =
|
||||||
std::collections::HashSet::new();
|
std::collections::HashSet::new();
|
||||||
let mut frontier: Vec<&'static str> = Vec::new();
|
|
||||||
for fn_spec in FUNCTIONS {
|
for fn_spec in FUNCTIONS {
|
||||||
if fn_spec.private() {
|
if fn_spec.private() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(input_name) = fn_spec.input_type() {
|
if let Some(input_name) = fn_spec.input_type() {
|
||||||
if reachable.insert(input_name) {
|
reachable.insert(input_name);
|
||||||
frontier.push(input_name);
|
|
||||||
}
|
}
|
||||||
|
reachable.insert(fn_spec.output_type());
|
||||||
}
|
}
|
||||||
let output_name = fn_spec.output_type();
|
for (_, body) in &channel_types {
|
||||||
if reachable.insert(output_name) {
|
collect_refs(body, &mut |r| {
|
||||||
frontier.push(output_name);
|
reachable.insert(r);
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(name) = frontier.pop() {
|
|
||||||
// Inlines don't carry refs we care about (Primitive/Enum); skip.
|
|
||||||
if inlines.contains_key(name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let body = match candidates.get(name) {
|
|
||||||
Some(b) => b.clone(),
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
collect_refs(&body, &mut |r| {
|
|
||||||
if reachable.insert(r) {
|
|
||||||
frontier.push(r);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let types: BTreeMap<&'static str, NamedType> = candidates
|
// Grow the set until a pass adds nothing: a candidate contributes the
|
||||||
|
// names it refs once it is itself reachable.
|
||||||
|
loop {
|
||||||
|
let mut grew = false;
|
||||||
|
for (name, body) in &candidates {
|
||||||
|
if reachable.contains(name) {
|
||||||
|
collect_refs(body, &mut |r| {
|
||||||
|
grew |= reachable.insert(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !grew {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut types: BTreeMap<String, NamedType> = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(name, _)| reachable.contains(name))
|
.filter(|(name, _)| reachable.contains(name))
|
||||||
|
.map(|(name, body)| (name.to_string(), body))
|
||||||
.collect();
|
.collect();
|
||||||
|
types.extend(channel_types);
|
||||||
|
|
||||||
// Functions: alphabetical by wire name (canonical IR ordering,
|
// Functions: alphabetical by wire name. Skip `private`.
|
||||||
// matches the Python emitter's `sorted(functions)`). Skip `private`.
|
|
||||||
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -416,8 +491,8 @@ impl IrSnapshot {
|
|||||||
.collect();
|
.collect();
|
||||||
functions.sort_by_key(|f| f.name());
|
functions.sort_by_key(|f| f.name());
|
||||||
|
|
||||||
// Contexts: alphabetical by name (canonical IR ordering), each with
|
// Contexts: alphabetical by name, each with its members sorted
|
||||||
// its members sorted alphabetically too.
|
// alphabetically too.
|
||||||
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
||||||
context_names.sort();
|
context_names.sort();
|
||||||
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
|
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
|
||||||
@@ -437,6 +512,7 @@ impl IrSnapshot {
|
|||||||
types,
|
types,
|
||||||
functions,
|
functions,
|
||||||
contexts,
|
contexts,
|
||||||
|
channels,
|
||||||
inlines,
|
inlines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,7 +533,7 @@ fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
|
|||||||
|
|
||||||
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||||
match shape {
|
match shape {
|
||||||
TypeShape::Ref(name) => visit(name),
|
TypeShape::Ref { name, .. } => visit(name),
|
||||||
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
|
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
|
||||||
TypeShape::Union(branches) => {
|
TypeShape::Union(branches) => {
|
||||||
for b in branches {
|
for b in branches {
|
||||||
@@ -468,41 +544,41 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
|
/// Build the Mizan IR for every registered type, function, context and
|
||||||
|
/// channel. Returns KDL.
|
||||||
pub fn build_ir() -> String {
|
pub fn build_ir() -> String {
|
||||||
crate::graph_check::verify_invariants();
|
crate::graph_check::verify_invariants();
|
||||||
let snap = IrSnapshot::collect();
|
let snap = IrSnapshot::collect();
|
||||||
let mut em = Emitter::new(&snap.inlines);
|
let builder = NodeBuilder {
|
||||||
|
inlines: &snap.inlines,
|
||||||
|
};
|
||||||
|
|
||||||
// Type definitions
|
let sections: Vec<Vec<KdlNode>> = [
|
||||||
let types_emitted = !snap.types.is_empty();
|
snap.types
|
||||||
for (name, body) in &snap.types {
|
.iter()
|
||||||
em.emit_named_type(0, name, body);
|
.map(|(name, body)| builder.named_type(0, name, body))
|
||||||
}
|
.collect::<Vec<_>>(),
|
||||||
if types_emitted {
|
snap.functions
|
||||||
em.blank();
|
.iter()
|
||||||
}
|
.map(|fn_spec| builder.function(0, *fn_spec))
|
||||||
|
.collect(),
|
||||||
|
snap.contexts
|
||||||
|
.iter()
|
||||||
|
.map(|(ctx_name, members)| builder.context(0, ctx_name, members))
|
||||||
|
.collect(),
|
||||||
|
snap.channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| builder.channel(0, channel))
|
||||||
|
.collect(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|section: &Vec<KdlNode>| !section.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Functions
|
let mut env = Environment::new();
|
||||||
let fns_emitted = !snap.functions.is_empty();
|
env.add_filter("kdl", render_kdl_value);
|
||||||
for fn_spec in &snap.functions {
|
env.template_from_named_str("ir.kdl", IR_TEMPLATE)
|
||||||
em.emit_function(0, *fn_spec);
|
.expect("compile templates/ir.kdl.jinja")
|
||||||
}
|
.render(context! { sections })
|
||||||
if fns_emitted {
|
.expect("render templates/ir.kdl.jinja")
|
||||||
em.blank();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contexts
|
|
||||||
let ctxs_emitted = !snap.contexts.is_empty();
|
|
||||||
for (ctx_name, members) in &snap.contexts {
|
|
||||||
em.emit_context(0, ctx_name, members);
|
|
||||||
}
|
|
||||||
if ctxs_emitted {
|
|
||||||
em.blank();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future: channels — once channel registry lands on the Rust side.
|
|
||||||
|
|
||||||
em.into_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`.
|
//! Mizan server-side IR substrate.
|
||||||
//!
|
//!
|
||||||
//! Three load-bearing concerns:
|
//! Three load-bearing concerns:
|
||||||
//!
|
//!
|
||||||
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
|
//! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as
|
||||||
//! KDL to the Python emitter. Both backends emit the same contract.
|
//! one Mizan IR document.
|
||||||
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
|
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
|
||||||
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
|
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`)
|
||||||
//! consumer crate's expansion sites.
|
//! at the consumer crate's expansion sites.
|
||||||
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
|
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
|
||||||
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
|
//! `function_named` / `context_members`, which the adapters call per request.
|
||||||
//! adapter calls these per request.
|
|
||||||
//!
|
//!
|
||||||
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
|
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
|
||||||
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
|
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
|
||||||
@@ -22,12 +21,13 @@ pub mod runtime;
|
|||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
pub use ir::{
|
pub use ir::{
|
||||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
|
||||||
|
TypeShape,
|
||||||
};
|
};
|
||||||
pub use kdl::{build_ir, snake_to_camel};
|
pub use kdl::{build_ir, snake_to_camel, wire_to_pascal};
|
||||||
pub use registry::{
|
pub use registry::{
|
||||||
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS,
|
||||||
FUNCTIONS, TYPES,
|
CONTEXTS, FUNCTIONS, TYPES,
|
||||||
};
|
};
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
||||||
@@ -35,21 +35,20 @@ pub use runtime::{
|
|||||||
};
|
};
|
||||||
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||||
|
|
||||||
// Re-export proc macros so consumers depend on one crate.
|
pub use mizan_macros::{channel, client, context, Mizan};
|
||||||
pub use mizan_macros::{client, context, Mizan};
|
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use crate::ir::{
|
pub use crate::ir::{
|
||||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
|
||||||
|
TypeShape,
|
||||||
};
|
};
|
||||||
pub use crate::registry::{ContextEntry, TypeEntry};
|
pub use crate::registry::{ChannelEntry, ChannelSlot, ContextEntry, TypeEntry};
|
||||||
pub use crate::runtime::{MizanError, RequestHandle};
|
pub use crate::runtime::{MizanError, RequestHandle};
|
||||||
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||||
pub use mizan_macros::Mizan;
|
pub use mizan_macros::Mizan;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
|
/// The crates `mizan-macros` expansions name by absolute path.
|
||||||
/// the public API — consumers must not depend on names under `__priv`.
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod __priv {
|
pub mod __priv {
|
||||||
pub use linkme;
|
pub use linkme;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
|
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
|
||||||
//! statics that land here at link time.
|
//! statics that land here at link time.
|
||||||
|
|
||||||
use crate::ir::NamedType;
|
use crate::ir::{ChannelSlotKind, NamedType};
|
||||||
use crate::traits::FunctionSpec;
|
use crate::traits::FunctionSpec;
|
||||||
use linkme::distributed_slice;
|
use linkme::distributed_slice;
|
||||||
|
|
||||||
@@ -17,6 +17,21 @@ pub struct ContextEntry {
|
|||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One declared payload slot of a channel. `shape_fn` yields the shape the
|
||||||
|
/// slot's type emits under its derived name.
|
||||||
|
pub struct ChannelSlot {
|
||||||
|
pub kind: ChannelSlotKind,
|
||||||
|
pub shape_fn: fn() -> NamedType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel registration. Emitted by `#[mizan::channel]`. `slots` carries
|
||||||
|
/// only the slots the channel declares, ordered params, client-message,
|
||||||
|
/// server-message.
|
||||||
|
pub struct ChannelEntry {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub slots: &'static [ChannelSlot],
|
||||||
|
}
|
||||||
|
|
||||||
#[distributed_slice]
|
#[distributed_slice]
|
||||||
pub static TYPES: [TypeEntry] = [..];
|
pub static TYPES: [TypeEntry] = [..];
|
||||||
|
|
||||||
@@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..];
|
|||||||
#[distributed_slice]
|
#[distributed_slice]
|
||||||
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
|
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
|
||||||
|
|
||||||
/// Find a registered function by wire name. Used by the HTTP adapter.
|
#[distributed_slice]
|
||||||
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
|
pub static CHANNELS: [ChannelEntry] = [..];
|
||||||
FUNCTIONS.iter().copied().find(|f| f.name() == name)
|
|
||||||
|
/// The functions registered under `name`. Order matches `FUNCTIONS` iteration
|
||||||
|
/// order — i.e., registration order.
|
||||||
|
pub fn function_named(name: &str) -> Vec<&'static dyn FunctionSpec> {
|
||||||
|
FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| f.name() == name)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a registered context by name. Used by graph_check.
|
/// The functions that declare `ctx_name` as their `context` membership. Order
|
||||||
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
|
/// matches `FUNCTIONS` iteration order — i.e., registration order.
|
||||||
CONTEXTS.iter().find(|c| c.name == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All functions that declare a given context as their `context` membership.
|
|
||||||
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
|
|
||||||
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
|
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
|
||||||
FUNCTIONS
|
FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
//! Runtime helpers — error envelope, request handle, invalidation/merge
|
//! Runtime helpers — error envelope, request handle, and the per-response
|
||||||
//! resolution. Ports `compute_invalidation` / `compute_merges` /
|
//! invalidation / merge resolution the adapters call after a dispatch.
|
||||||
//! `_resolve_merge_slot` / `_scoped_params` from
|
|
||||||
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
|
|
||||||
|
|
||||||
use crate::registry::context_members;
|
use crate::registry::context_members;
|
||||||
use crate::traits::FunctionSpec;
|
use crate::traits::FunctionSpec;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
/// Type-erased handle to the framework's request object. The HTTP adapter
|
/// A borrow of the request object a hosting framework owns.
|
||||||
/// stuffs its native `Request` here; user code casts back via the adapter's
|
///
|
||||||
/// helper types.
|
/// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to
|
||||||
|
/// be object-safe and no type parameter can reach this handle. The reference
|
||||||
|
/// therefore rides erased, and the crate that names the framework's own type
|
||||||
|
/// is the one that casts back to it.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RequestHandle<'a> {
|
pub struct RequestHandle<'a> {
|
||||||
pub inner: &'a (dyn Any + Send + Sync),
|
inner: &'a (dyn Any + Send + Sync),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RequestHandle<'a> {
|
impl<'a> RequestHandle<'a> {
|
||||||
/// Wrap a typed reference. The most common path — handlers downcast back
|
/// Wrap a typed reference.
|
||||||
/// to `T` via `downcast::<T>()`.
|
|
||||||
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
|
||||||
Self { inner: req }
|
Self { inner: req }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
|
/// Wrap a reference the caller has already erased.
|
||||||
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
|
|
||||||
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
|
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
|
||||||
Self { inner: req }
|
Self { inner: req }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
|
/// The reference the adapter installed.
|
||||||
self.inner.downcast_ref::<T>()
|
pub fn installed(&self) -> &'a (dyn Any + Send + Sync) {
|
||||||
|
self.inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
|
/// Mizan's standard error envelope — the closed set of failures an adapter
|
||||||
|
/// renders onto the wire.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MizanError {
|
pub enum MizanError {
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `merge` list from a function's `merge` metadata. Each entry
|
/// Build the `merge` list from the function's already-resolved merge entries.
|
||||||
/// names the slot inside the context bundle the return value lands in.
|
/// Each names the slot inside the context bundle the return value lands in.
|
||||||
pub fn compute_merges(
|
pub fn compute_merges(
|
||||||
fn_spec: &dyn FunctionSpec,
|
fn_spec: &dyn FunctionSpec,
|
||||||
args: &serde_json::Map<String, Value>,
|
args: &serde_json::Map<String, Value>,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Vec<MergeEntry> {
|
) -> Vec<MergeEntry> {
|
||||||
let targets = fn_spec.merge();
|
crate::graph_check::merges_for(fn_spec.name())
|
||||||
if targets.is_empty() {
|
.map(|resolved| {
|
||||||
return Vec::new();
|
let scoped = scoped_params(resolved.context, args);
|
||||||
}
|
MergeEntry {
|
||||||
let mutation_output = fn_spec.output_type();
|
context: resolved.context.into(),
|
||||||
let mut out = Vec::new();
|
slot: resolved.slot.into(),
|
||||||
for ctx_name in targets {
|
|
||||||
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let scoped = scoped_params(ctx_name, args);
|
|
||||||
out.push(MergeEntry {
|
|
||||||
context: (*ctx_name).into(),
|
|
||||||
slot,
|
|
||||||
value: result.clone(),
|
value: result.clone(),
|
||||||
params: if scoped.is_empty() {
|
params: if scoped.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(scoped)
|
Some(scoped)
|
||||||
},
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the unique function-name slot whose Output type matches the
|
|
||||||
/// mutation's Output type. Matches Python's `types_match_for_merge` —
|
|
||||||
/// structural shape comparison, not name comparison. Returns None on no
|
|
||||||
/// match or ambiguous match.
|
|
||||||
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
|
|
||||||
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
|
|
||||||
let mut matches: Vec<&'static str> = Vec::new();
|
|
||||||
for fn_spec in context_members(context_name) {
|
|
||||||
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
|
|
||||||
{
|
|
||||||
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
|
|
||||||
matches.push(fn_spec.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matches.len() == 1 {
|
|
||||||
Some(matches[0].into())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Match input args against the context's declared Input field names.
|
/// Match input args against the context's declared Input field names.
|
||||||
@@ -258,3 +228,36 @@ fn scoped_params(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::RequestHandle;
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
fn installed_addr(handle: &RequestHandle<'_>) -> *const () {
|
||||||
|
handle.installed() as *const (dyn Any + Send + Sync) as *const ()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_handle_installs_the_very_reference_it_was_built_over() {
|
||||||
|
let state = String::from("app-state");
|
||||||
|
let source = &state as *const String as *const ();
|
||||||
|
assert_eq!(installed_addr(&RequestHandle::new(&state)), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_erased_handle_installs_what_a_typed_one_does() {
|
||||||
|
let state = String::from("app-state");
|
||||||
|
assert_eq!(
|
||||||
|
installed_addr(&RequestHandle::from_dyn(&state)),
|
||||||
|
installed_addr(&RequestHandle::new(&state))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_installed_reference_keeps_the_type_it_was_built_over() {
|
||||||
|
let state = String::from("app-state");
|
||||||
|
let handle = RequestHandle::new(&state);
|
||||||
|
assert!(handle.installed().is::<String>());
|
||||||
|
assert!(!handle.installed().is::<i64>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Surface traits the proc macros implement.
|
//! The traits a registered Mizan type, context and function implement.
|
||||||
|
|
||||||
use crate::ir::{AffectTarget, NamedType, Transport};
|
use crate::ir::{AffectTarget, NamedType, Transport};
|
||||||
use crate::runtime::{MizanError, RequestHandle};
|
use crate::runtime::{MizanError, RequestHandle};
|
||||||
@@ -6,11 +6,10 @@ use serde_json::Value;
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
|
/// A type that participates in the Mizan IR.
|
||||||
///
|
///
|
||||||
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
|
/// `TYPE_NAME` is a `const` rather than a function so it can be named from a
|
||||||
/// initializers — TypeEntry's `name` field reads it directly without an
|
/// `static` initializer.
|
||||||
/// init-time function call.
|
|
||||||
pub trait MizanType {
|
pub trait MizanType {
|
||||||
const TYPE_NAME: &'static str;
|
const TYPE_NAME: &'static str;
|
||||||
fn shape() -> NamedType;
|
fn shape() -> NamedType;
|
||||||
@@ -20,21 +19,22 @@ pub trait MizanType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
|
/// A marker type carrying one context's wire name.
|
||||||
pub trait ContextMarker {
|
pub trait ContextMarker {
|
||||||
const NAME: &'static str;
|
const NAME: &'static str;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
|
/// One Mizan-registered function: plain data throughout except `dispatch`.
|
||||||
///
|
|
||||||
/// Everything here is plain data except `dispatch`, which is the type-erased
|
|
||||||
/// runtime entry point used by the HTTP adapter.
|
|
||||||
pub trait FunctionSpec: Send + Sync {
|
pub trait FunctionSpec: Send + Sync {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
fn camel_name(&self) -> &'static str;
|
fn camel_name(&self) -> &'static str;
|
||||||
fn has_input(&self) -> bool;
|
fn has_input(&self) -> bool;
|
||||||
fn input_type(&self) -> Option<&'static str>;
|
fn input_type(&self) -> Option<&'static str>;
|
||||||
fn output_type(&self) -> &'static str;
|
fn output_type(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// The shape registered under `output_type()`.
|
||||||
|
fn output_shape(&self) -> NamedType;
|
||||||
|
|
||||||
fn output_nullable(&self) -> bool {
|
fn output_nullable(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Field-shape description of this function's Input parameters, used by
|
/// This function's Input parameters. Empty when `has_input()` is false.
|
||||||
/// the context builder to compute shared-param elevation. Empty when
|
|
||||||
/// `has_input()` is false.
|
|
||||||
fn input_params(&self) -> &'static [InputParam] {
|
fn input_params(&self) -> &'static [InputParam] {
|
||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
|
/// Deserializes `args` into this function's typed input, awaits the body,
|
||||||
/// JSON arguments; the macro-generated impl deserializes into the
|
/// and serializes the result — the whole call with its types erased behind
|
||||||
/// function's typed input, awaits the body, and serializes the result.
|
/// JSON.
|
||||||
fn dispatch<'a>(
|
fn dispatch<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
req: RequestHandle<'a>,
|
req: RequestHandle<'a>,
|
||||||
@@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync {
|
|||||||
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One parameter of a function's synthesized Input. The macro emits a static
|
/// One parameter of a function's synthesized Input.
|
||||||
/// slice of these so the context builder can find shared params across
|
|
||||||
/// context members and produce the `context { param ... shared-by ... }`
|
|
||||||
/// section of the IR.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct InputParam {
|
pub struct InputParam {
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
|||||||
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)
|
//! `build_ir()` renders the proc-macro-populated registries; the emitted KDL
|
||||||
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
|
//! is parsed by the `kdl` crate and then compared byte for byte with
|
||||||
//! Python-emitted reference).
|
//! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`.
|
||||||
//!
|
|
||||||
//! This is the Phase-2 verifier — the AFI fixture is authored against the
|
|
||||||
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
|
|
||||||
//! #[mizan::client]`), not hand-built static specs.
|
|
||||||
|
|
||||||
|
use kdl::{KdlDocument, KdlNode};
|
||||||
use mizan_core as mizan;
|
use mizan_core as mizan;
|
||||||
use mizan_core::prelude::*;
|
use mizan_core::prelude::*;
|
||||||
use mizan_core::RequestHandle;
|
use mizan_core::RequestHandle;
|
||||||
@@ -46,7 +43,17 @@ pub struct StatusOutput {
|
|||||||
#[mizan::context("user")]
|
#[mizan::context("user")]
|
||||||
pub struct UserCtx;
|
pub struct UserCtx;
|
||||||
|
|
||||||
// ─── Fixture functions (mirroring tests/afi/fixture.py) ────────────────────
|
// ─── Fixture handlers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `(order id, owning user id, total)` — the store the order handlers read.
|
||||||
|
const ORDERS: &[(i64, i64, i64)] = &[(10, 1, 4200), (11, 1, 1750), (12, 2, 990)];
|
||||||
|
|
||||||
|
fn profile_of(user_id: i64) -> ProfileOutput {
|
||||||
|
ProfileOutput {
|
||||||
|
user_id,
|
||||||
|
name: format!("user-{user_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[mizan::client]
|
#[mizan::client]
|
||||||
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
|
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
|
||||||
@@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
|
|||||||
|
|
||||||
#[mizan::client(context = UserCtx)]
|
#[mizan::client(context = UserCtx)]
|
||||||
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
|
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
|
||||||
ProfileOutput {
|
profile_of(user_id)
|
||||||
user_id,
|
|
||||||
name: "placeholder".into(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mizan::client(context = UserCtx)]
|
#[mizan::client(context = UserCtx)]
|
||||||
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
|
pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec<OrderOutput> {
|
||||||
vec![]
|
ORDERS
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, owner, _)| *owner == user_id)
|
||||||
|
.map(|(id, owner, total)| OrderOutput {
|
||||||
|
id: *id,
|
||||||
|
user_id: *owner,
|
||||||
|
total: *total,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mizan::client(affects = UserCtx)]
|
#[mizan::client(affects = UserCtx)]
|
||||||
pub async fn update_profile(
|
pub async fn update_profile(
|
||||||
_req: &RequestHandle<'_>,
|
_req: &RequestHandle<'_>,
|
||||||
_user_id: i64,
|
user_id: i64,
|
||||||
_name: String,
|
name: String,
|
||||||
) -> StatusOutput {
|
) -> StatusOutput {
|
||||||
StatusOutput { ok: true }
|
StatusOutput {
|
||||||
|
ok: user_id > 0 && !name.trim().is_empty(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mizan::client]
|
#[mizan::client]
|
||||||
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
|
pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option<ProfileOutput> {
|
||||||
None
|
ORDERS
|
||||||
|
.iter()
|
||||||
|
.any(|(_, owner, _)| *owner == user_id)
|
||||||
|
.then(|| profile_of(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mizan::client(merge = UserCtx)]
|
#[mizan::client(merge = UserCtx)]
|
||||||
@@ -99,20 +116,96 @@ pub async fn rename_user(
|
|||||||
ProfileOutput { user_id, name }
|
ProfileOutput { user_id, name }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── The byte-equivalence test ──────────────────────────────────────────────
|
// ─── Reading the parsed document ────────────────────────────────────────────
|
||||||
|
|
||||||
fn canonical_kdl_path() -> PathBuf {
|
fn canonical_kdl_path() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
|
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The node's first string argument, or the empty string when it has none.
|
||||||
|
fn label(node: &KdlNode) -> String {
|
||||||
|
for entry in node.entries() {
|
||||||
|
if let Some(s) = entry.value().as_string() {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(node name, first string argument)` for every node at one level.
|
||||||
|
fn index(nodes: &[KdlNode]) -> Vec<(String, String)> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| (node.name().value().to_string(), label(node)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The child nodes of the first `kind "name"` node in `doc`, or an empty slice
|
||||||
|
/// when the document has no such node or it carries no child block.
|
||||||
|
fn children_of<'a>(doc: &'a KdlDocument, kind: &str, name: &str) -> &'a [KdlNode] {
|
||||||
|
for node in doc.nodes() {
|
||||||
|
if node.name().value() == kind && label(node) == name {
|
||||||
|
return match node.children() {
|
||||||
|
Some(block) => block.nodes(),
|
||||||
|
None => &[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_ir_matches_canonical_afi_kdl() {
|
fn build_ir_matches_canonical_afi_kdl() {
|
||||||
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
|
let emitted = mizan_core::build_ir();
|
||||||
let actual = mizan_core::build_ir();
|
|
||||||
|
|
||||||
if actual != expected {
|
// Parsing before comparing means a malformed emission fails here rather
|
||||||
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
|
// than as a confusing textual diff.
|
||||||
|
let parsed: KdlDocument = emitted
|
||||||
|
.parse()
|
||||||
|
.expect("build_ir() output is a well-formed KDL document");
|
||||||
|
let top = index(parsed.nodes());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
top.contains(&("function".to_string(), "user_orders".to_string())),
|
||||||
|
"parsed document is missing the user_orders function node: {top:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
top.contains(&("context".to_string(), "user".to_string())),
|
||||||
|
"parsed document is missing the user context node: {top:?}",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
index(children_of(&parsed, "function", "user_orders")),
|
||||||
|
vec![
|
||||||
|
("camel".to_string(), "userOrders".to_string()),
|
||||||
|
("has-input".to_string(), String::new()),
|
||||||
|
("input".to_string(), "userOrdersInput".to_string()),
|
||||||
|
("output".to_string(), "userOrdersOutput".to_string()),
|
||||||
|
("transport".to_string(), "http".to_string()),
|
||||||
|
("context".to_string(), "user".to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
index(children_of(&parsed, "context", "user")),
|
||||||
|
vec![
|
||||||
|
("function".to_string(), "user_orders".to_string()),
|
||||||
|
("function".to_string(), "user_profile".to_string()),
|
||||||
|
("param".to_string(), "user_id".to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
|
||||||
|
let canonical: KdlDocument = expected
|
||||||
|
.parse()
|
||||||
|
.expect("the canonical fixture is a well-formed KDL document");
|
||||||
|
assert_eq!(
|
||||||
|
index(parsed.nodes()),
|
||||||
|
index(canonical.nodes()),
|
||||||
|
"emitted and canonical documents declare different top-level nodes",
|
||||||
|
);
|
||||||
|
|
||||||
|
if emitted != expected {
|
||||||
|
for (lineno, (a, b)) in emitted.lines().zip(expected.lines()).enumerate() {
|
||||||
if a != b {
|
if a != b {
|
||||||
panic!(
|
panic!(
|
||||||
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
|
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
|
||||||
@@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() {
|
|||||||
}
|
}
|
||||||
panic!(
|
panic!(
|
||||||
"KDL diverges in length: actual_len={} expected_len={}",
|
"KDL diverges in length: actual_len={} expected_len={}",
|
||||||
actual.len(),
|
emitted.len(),
|
||||||
expected.len(),
|
expected.len(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
#!/usr/bin/env python
|
||||||
"""
|
"""Launcher: runs the Django ASGI server locally and opens it in a native window."""
|
||||||
mizan Desktop — PyWebView + Django local RPC.
|
|
||||||
|
|
||||||
Starts a local Django ASGI server and opens a native desktop window.
|
|
||||||
All communication between the UI and backend uses mizan server functions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -13,19 +8,18 @@ import time
|
|||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||||
|
|
||||||
# Work around Qt WebEngine GPU crashes on some systems
|
# Qt WebEngine crashes on some GPU/driver combinations unless it renders on CPU.
|
||||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||||
|
|
||||||
|
|
||||||
def start_server(host: str, port: int):
|
def start_server(host: str, port: int):
|
||||||
"""Start the Django ASGI server in a background thread."""
|
|
||||||
import django
|
import django
|
||||||
|
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
# Run migrations on first launch
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
# --run-syncdb builds the tables on a first launch with no migration files.
|
||||||
call_command("migrate", "--run-syncdb", verbosity=0)
|
call_command("migrate", "--run-syncdb", verbosity=0)
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -38,51 +32,49 @@ def start_server(host: str, port: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def wait_for_server(url: str, timeout: float = 10.0):
|
def wait_for_server(url: str, timeout: float = 10.0) -> None:
|
||||||
"""Poll until the server responds."""
|
"""Poll until the server answers, or raise carrying the last refusal."""
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
from urllib.error import URLError
|
|
||||||
|
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
|
last_error: OSError | None = None
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
urlopen(url, timeout=1)
|
urlopen(url, timeout=1)
|
||||||
return True
|
return
|
||||||
except (URLError, OSError):
|
except OSError as e:
|
||||||
|
last_error = e
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
return False
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Django server did not answer {url} within {timeout}s"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 8765
|
port = 8765
|
||||||
|
|
||||||
# Start Django in a daemon thread
|
|
||||||
server = threading.Thread(target=start_server, args=(host, port), daemon=True)
|
server = threading.Thread(target=start_server, args=(host, port), daemon=True)
|
||||||
server.start()
|
server.start()
|
||||||
|
|
||||||
base_url = f"http://{host}:{port}"
|
base_url = f"http://{host}:{port}"
|
||||||
|
wait_for_server(f"{base_url}/api/mizan/session/")
|
||||||
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
|
||||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Backend running at {base_url}")
|
print(f"Backend running at {base_url}")
|
||||||
|
|
||||||
# Check if --headless flag is passed (for testing)
|
|
||||||
if "--headless" in sys.argv:
|
if "--headless" in sys.argv:
|
||||||
print("Headless mode — server running. Press Ctrl+C to stop.")
|
print("Headless mode — server running. Press Ctrl+C to stop.")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
print("Interrupted — stopping the server.", file=sys.stderr)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Open native window
|
|
||||||
import webview
|
import webview
|
||||||
|
|
||||||
window = webview.create_window(
|
webview.create_window(
|
||||||
title="mizan Desktop",
|
title="mizan Desktop",
|
||||||
url=base_url,
|
url=base_url,
|
||||||
width=1024,
|
width=1024,
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
"""
|
import logging
|
||||||
Desktop RPC server functions.
|
|
||||||
|
|
||||||
Tests mizan's appropriateness for desktop apps:
|
|
||||||
- Local file system access
|
|
||||||
- SQLite CRUD
|
|
||||||
- System introspection
|
|
||||||
- Real-time channels (file watcher, app status)
|
|
||||||
- No auth required (single-user desktop)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
@@ -21,11 +11,14 @@ from django.http import HttpRequest
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
from mizan.setup.registry import register
|
from mizan.setup import register
|
||||||
from mizan.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# System Info
|
# System Info
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -119,6 +112,12 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
# A broken symlink or an unreadable entry drops out of the
|
||||||
|
# listing rather than failing the whole directory.
|
||||||
|
logger.warning("Skipping %s: %s", entry, e)
|
||||||
|
continue
|
||||||
|
|
||||||
entries.append(
|
entries.append(
|
||||||
FileEntry(
|
FileEntry(
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
@@ -128,8 +127,6 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
|||||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except (PermissionError, OSError):
|
|
||||||
continue
|
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise PermissionError(f"Cannot read directory: {dir_path}")
|
raise PermissionError(f"Cannot read directory: {dir_path}")
|
||||||
|
|
||||||
@@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
|||||||
|
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
|
|
||||||
# Safety: limit to 1MB text files
|
# Reads are capped at 1MB so a large binary cannot be pulled into memory.
|
||||||
if stat.st_size > 1_048_576:
|
if stat.st_size > 1_048_576:
|
||||||
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
||||||
|
|
||||||
@@ -190,7 +187,6 @@ class WriteFileOutput(BaseModel):
|
|||||||
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
||||||
file_path = Path(path).expanduser().resolve()
|
file_path = Path(path).expanduser().resolve()
|
||||||
|
|
||||||
# Safety: only allow writing within home directory
|
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
if not str(file_path).startswith(str(home)):
|
if not str(file_path).startswith(str(home)):
|
||||||
raise PermissionError(f"Can only write files within home directory: {home}")
|
raise PermissionError(f"Can only write files within home directory: {home}")
|
||||||
@@ -339,10 +335,13 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
note = Note.objects.get(pk=id)
|
note = Note.objects.get(pk=id)
|
||||||
|
except Note.DoesNotExist:
|
||||||
|
# Deleting an absent note is reported, not raised.
|
||||||
|
logger.info("delete_note: note %s is already absent", id)
|
||||||
|
return DeleteNoteOutput(id=id, deleted=False)
|
||||||
|
|
||||||
note.delete()
|
note.delete()
|
||||||
return DeleteNoteOutput(id=id, deleted=True)
|
return DeleteNoteOutput(id=id, deleted=True)
|
||||||
except Note.DoesNotExist:
|
|
||||||
return DeleteNoteOutput(id=id, deleted=False)
|
|
||||||
|
|
||||||
|
|
||||||
register(delete_note, "delete_note")
|
register(delete_note, "delete_note")
|
||||||
@@ -353,17 +352,18 @@ register(delete_note, "delete_note")
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class AppStatusChannel(ReactChannel):
|
class AppStatusChannel(Channel):
|
||||||
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
uptime_seconds: float
|
uptime_seconds: float
|
||||||
memory_mb: float
|
memory_mb: float
|
||||||
note_count: int
|
note_count: int
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True # Desktop app, no auth needed
|
# One local user owns the whole process; there is no identity to check.
|
||||||
|
return True
|
||||||
|
|
||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "app_status"
|
return "app_status"
|
||||||
@@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel):
|
|||||||
register_channel(AppStatusChannel, "app_status")
|
register_channel(AppStatusChannel, "app_status")
|
||||||
|
|
||||||
|
|
||||||
class NotesChannel(ReactChannel):
|
class NotesChannel(Channel):
|
||||||
"""Push notifications when notes are modified."""
|
"""Push notifications when notes are modified."""
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
action: str # "created", "updated", "deleted"
|
action: str # "created", "updated", "deleted"
|
||||||
note_id: int
|
note_id: int
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
|
# One local user owns the whole process; there is no identity to check.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>mizan Desktop</title>
|
<title>mizan Desktop</title>
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,46 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
||||||
|
|
||||||
// ─── System Info ────────────────────────────────────────────────────────────
|
type SystemFacts = Record<string, unknown>
|
||||||
|
type NoteList = { notes: Note[] }
|
||||||
|
type FileListing = { directory: string; entries: FileEntry[]; parent: string | null }
|
||||||
|
|
||||||
function SystemInfo() {
|
function SystemInfo() {
|
||||||
const { call } = useMizan()
|
const { call } = useMizan()
|
||||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
const [info, setInfo] = useState<SystemFacts | null>(null)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
call('system_info').then(setInfo).catch(() => {})
|
call<undefined, SystemFacts>('system_info').then(setInfo).catch(setError)
|
||||||
}, [call])
|
}, [call])
|
||||||
|
|
||||||
if (!info) return <div style={styles.card}>Loading system info...</div>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.card}>
|
<div className="panel p-5 mb-4">
|
||||||
<h2 style={styles.h2}>System</h2>
|
<h2 className="panel-heading mb-3">System</h2>
|
||||||
<table style={styles.table}>
|
{error && <div className="load-error">{error.message}</div>}
|
||||||
|
{!info && !error && <div className="muted">Loading system info...</div>}
|
||||||
|
{info && (
|
||||||
|
<table className="info-table w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.entries(info).map(([k, v]) => (
|
{Object.entries(info).map(([k, v]) => (
|
||||||
<tr key={k}>
|
<tr key={k}>
|
||||||
<td style={styles.label}>{k}</td>
|
<td className="info-key py-1 pr-3 whitespace-nowrap">{k}</td>
|
||||||
<td style={styles.value}>{String(v)}</td>
|
<td className="py-1 break-all">{String(v)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatusBar() {
|
function StatusBar() {
|
||||||
const status = useMizanStatus()
|
const status = useMizanStatus()
|
||||||
return (
|
const tone = status === 'connected' ? 'status-bar--online' : 'status-bar--offline'
|
||||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notes ──────────────────────────────────────────────────────────────────
|
return <div className={`status-bar ${tone}`}>{status}</div>
|
||||||
|
}
|
||||||
|
|
||||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||||
|
|
||||||
@@ -51,34 +50,41 @@ function Notes() {
|
|||||||
const [selected, setSelected] = useState<Note | null>(null)
|
const [selected, setSelected] = useState<Note | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {})
|
call<undefined, NoteList>('list_notes').then(d => setNotes(d.notes)).catch(setError)
|
||||||
}, [call])
|
}, [call])
|
||||||
|
|
||||||
useEffect(() => { refresh() }, [refresh])
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
|
||||||
const create = async () => {
|
const clearDraft = () => {
|
||||||
if (!title.trim()) return
|
|
||||||
await call('create_note', { title, content })
|
|
||||||
setTitle('')
|
|
||||||
setContent('')
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!selected) return
|
|
||||||
await call('update_note', { id: selected.id, title, content })
|
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setContent('')
|
setContent('')
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id: number) => {
|
const create = () => {
|
||||||
await call('delete_note', { id })
|
if (!title.trim()) return
|
||||||
if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') }
|
call('create_note', { title, content })
|
||||||
|
.then(() => { clearDraft(); refresh() })
|
||||||
|
.catch(setError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!selected) return
|
||||||
|
call('update_note', { id: selected.id, title, content })
|
||||||
|
.then(() => { clearDraft(); refresh() })
|
||||||
|
.catch(setError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (id: number) => {
|
||||||
|
call('delete_note', { id })
|
||||||
|
.then(() => {
|
||||||
|
if (selected?.id === id) clearDraft()
|
||||||
refresh()
|
refresh()
|
||||||
|
})
|
||||||
|
.catch(setError)
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (n: Note) => {
|
const select = (n: Note) => {
|
||||||
@@ -88,44 +94,50 @@ function Notes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.card}>
|
<div className="panel p-5 mb-4">
|
||||||
<h2 style={styles.h2}>Notes ({notes.length})</h2>
|
<h2 className="panel-heading mb-3">Notes ({notes.length})</h2>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
{error && <div className="load-error mb-2">{error.message}</div>}
|
||||||
<div style={{ flex: 1 }}>
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
{notes.map(n => (
|
{notes.map(n => (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => select(n)}
|
onClick={() => select(n)}
|
||||||
style={{
|
className={
|
||||||
...styles.noteItem,
|
'note-item flex items-center justify-between py-2 px-3 mb-0.5 ' +
|
||||||
borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent',
|
(selected?.id === n.id ? 'note-item--selected' : '')
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
|
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
|
||||||
<button onClick={e => { e.stopPropagation(); remove(n.id) }} style={styles.deleteBtn}>x</button>
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); remove(n.id) }}
|
||||||
|
className="icon-btn py-0.5 px-1.5"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{notes.length === 0 && <div style={{ color: '#666', padding: 8 }}>No notes yet</div>}
|
{notes.length === 0 && <div className="muted p-2">No notes yet</div>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 2 }}>
|
<div className="flex-[2]">
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
style={styles.input}
|
className="field w-full py-2 px-3 mb-2"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={e => setContent(e.target.value)}
|
onChange={e => setContent(e.target.value)}
|
||||||
placeholder="Content"
|
placeholder="Content"
|
||||||
rows={6}
|
rows={6}
|
||||||
style={{ ...styles.input, resize: 'vertical' }}
|
className="field w-full py-2 px-3 mb-2 resize-y"
|
||||||
/>
|
/>
|
||||||
<button onClick={selected ? save : create} style={styles.btn}>
|
<button onClick={selected ? save : create} className="btn py-2 px-4 mr-2">
|
||||||
{selected ? 'Save' : 'Create'}
|
{selected ? 'Save' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
{selected && (
|
{selected && (
|
||||||
<button onClick={() => { setSelected(null); setTitle(''); setContent('') }} style={{ ...styles.btn, background: '#333' }}>
|
<button onClick={clearDraft} className="btn btn--muted py-2 px-4 mr-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -135,8 +147,6 @@ function Notes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── File Browser ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||||
|
|
||||||
function FileBrowser() {
|
function FileBrowser() {
|
||||||
@@ -144,25 +154,27 @@ function FileBrowser() {
|
|||||||
const [dir, setDir] = useState('~')
|
const [dir, setDir] = useState('~')
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||||
const [parent, setParent] = useState<string | null>(null)
|
const [parent, setParent] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
const browse = useCallback((d: string) => {
|
const browse = useCallback((d: string) => {
|
||||||
call<{ directory: string; entries: FileEntry[]; parent: string | null }>('list_files', { directory: d })
|
call<{ directory: string }, FileListing>('list_files', { directory: d })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setDir(data.directory)
|
setDir(data.directory)
|
||||||
setEntries(data.entries.slice(0, 50))
|
setEntries(data.entries.slice(0, 50))
|
||||||
setParent(data.parent)
|
setParent(data.parent)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(setError)
|
||||||
}, [call])
|
}, [call])
|
||||||
|
|
||||||
useEffect(() => { browse('~') }, [browse])
|
useEffect(() => { browse('~') }, [browse])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.card}>
|
<div className="panel p-5 mb-4">
|
||||||
<h2 style={styles.h2}>Files</h2>
|
<h2 className="panel-heading mb-3">Files</h2>
|
||||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>{dir}</div>
|
{error && <div className="load-error mb-2">{error.message}</div>}
|
||||||
|
<div className="path-line mb-2">{dir}</div>
|
||||||
{parent && (
|
{parent && (
|
||||||
<div onClick={() => browse(parent)} style={{ ...styles.fileItem, color: '#6cf', cursor: 'pointer' }}>
|
<div onClick={() => browse(parent)} className="file-item file-item--dir py-1 px-2">
|
||||||
../ (parent)
|
../ (parent)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -170,24 +182,22 @@ function FileBrowser() {
|
|||||||
<div
|
<div
|
||||||
key={e.path}
|
key={e.path}
|
||||||
onClick={() => e.is_dir && browse(e.path)}
|
onClick={() => e.is_dir && browse(e.path)}
|
||||||
style={{ ...styles.fileItem, cursor: e.is_dir ? 'pointer' : 'default', color: e.is_dir ? '#6cf' : '#ccc' }}
|
className={'file-item py-1 px-2 ' + (e.is_dir ? 'file-item--dir' : 'file-item--file')}
|
||||||
>
|
>
|
||||||
{e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name}
|
{e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name}
|
||||||
{!e.is_dir && <span style={{ color: '#666', marginLeft: 8 }}>{(e.size / 1024).toFixed(1)}K</span>}
|
{!e.is_dir && <span className="file-size ml-2">{(e.size / 1024).toFixed(1)}K</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── App ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
<div className="max-w-[960px] mx-auto p-6">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
<h1 className="app-title">mizan Desktop</h1>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
<SystemInfo />
|
<SystemInfo />
|
||||||
@@ -197,19 +207,3 @@ export function App() {
|
|||||||
</MizanProvider>
|
</MizanProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
|
||||||
card: { background: '#1a1a1a', borderRadius: 8, padding: 20, marginBottom: 16 },
|
|
||||||
h2: { fontSize: 16, marginBottom: 12, color: '#aaa', textTransform: 'uppercase', letterSpacing: 1 },
|
|
||||||
table: { width: '100%', fontSize: 14 },
|
|
||||||
label: { padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap' },
|
|
||||||
value: { padding: '4px 0', wordBreak: 'break-all' },
|
|
||||||
input: { width: '100%', padding: '8px 12px', marginBottom: 8, background: '#111', border: '1px solid #333', borderRadius: 4, color: '#e0e0e0', fontSize: 14 },
|
|
||||||
btn: { padding: '8px 16px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', marginRight: 8, fontSize: 14 },
|
|
||||||
noteItem: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', cursor: 'pointer', borderRadius: 4, marginBottom: 2 },
|
|
||||||
deleteBtn: { background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 14, padding: '2px 6px' },
|
|
||||||
fileItem: { padding: '4px 8px', fontSize: 14 },
|
|
||||||
statusBar: { fontSize: 12, fontFamily: 'monospace' },
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(<App />)
|
createRoot(document.getElementById('root')!).render(<App />)
|
||||||
|
|||||||
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 { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src')
|
const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
|
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
mizan = { path = "../django", editable = true }
|
mizan = { path = "../../backends/mizan-django", editable = true }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user