AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
README.md
28
README.md
@@ -60,32 +60,32 @@ Every capability below is **AFI-common**: each adapter owes a binding, and a ❌
|
|||||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ❌ | — | ✅ |
|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ✅ | — | ✅ |
|
||||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ❌ |
|
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| File uploads (`Upload` type) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| File uploads (`Upload` type) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### Edge, cache & enforcement
|
### Edge, cache & enforcement
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ◑ | ◑ | ✅ |
|
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Origin-side HMAC cache | ✅ | ✅ | ❌ | ❌ | ✅ |
|
| Origin-side HMAC cache | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
| Edge manifest export | ✅ | ✅ | ✅ | — | ✅ |
|
||||||
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
| PSR (`render_strategy` in manifest) | ✅ | ✅ | ✅ | — | ✅ |
|
||||||
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ❌ |
|
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ✅ |
|
||||||
|
|
||||||
### Extension points
|
### Extension points
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
| WebSocket transport (`websocket=` declared) | ✅ | ❌ | ◑ | ❌ | ❌ |
|
| WebSocket transport (`websocket=` declared) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| SSR bridge (subprocess renderer) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
| SSR bridge (subprocess renderer) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| JWT auth (access / refresh) | ✅ | ✅ | ❌ | ❌ | ◑ |
|
| JWT auth (access / refresh) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| MWT (edge identity token) | ✅ | ✅ | ❌ | — | ◑ |
|
| MWT (edge identity token) | ✅ | ✅ | ✅ | — | ✅ |
|
||||||
| Typed query projection (Shapes) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
| Typed query projection (Shapes) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ | ◑ | ❌ |
|
| Forms (schema / validate / submit) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Mizan Edge Manifest Generator.
|
Mizan Edge Manifest Generator (Django adapter surface).
|
||||||
|
|
||||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
The manifest derivation is AFI-common and lives in `mizan_core.manifest`;
|
||||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
Django exposes it through `python manage.py export_edge_manifest` and this
|
||||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
re-export. The manifest maps contexts to URL patterns and params, consumed by
|
||||||
codegen, the manifest drives CDN purging.
|
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
|
||||||
|
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||||
@@ -12,145 +13,10 @@ Usage:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from mizan_core.registry import get_context_groups, get_registry
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"generate_edge_manifest",
|
"generate_edge_manifest",
|
||||||
"generate_edge_manifest_json",
|
"generate_edge_manifest_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def generate_edge_manifest(
|
|
||||||
base_url: str = "/api/mizan",
|
|
||||||
view_urls: dict[str, list[str]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
|
||||||
patterns and params for CDN cache purging.
|
|
||||||
|
|
||||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
|
||||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
|
||||||
1. Looks up 'user' in the manifest
|
|
||||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
|
||||||
3. Purges the resolved URLs + the context API endpoint
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_url: The Mizan API mount point (default: /api/mizan)
|
|
||||||
view_urls: Optional mapping of context names to URL patterns for
|
|
||||||
view-path functions. These are URLs that Edge should
|
|
||||||
also purge when a context is invalidated.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Manifest dict suitable for JSON serialization.
|
|
||||||
"""
|
|
||||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
|
||||||
|
|
||||||
groups = get_context_groups()
|
|
||||||
registry = get_registry()
|
|
||||||
all_functions = registry.get("functions", {})
|
|
||||||
|
|
||||||
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
|
||||||
|
|
||||||
for ctx_name, fn_names in sorted(groups.items()):
|
|
||||||
param_names: set[str] = set()
|
|
||||||
functions_meta: list[dict[str, Any]] = []
|
|
||||||
page_routes: list[str] = []
|
|
||||||
|
|
||||||
for fn_name in fn_names:
|
|
||||||
fn_cls = all_functions.get(fn_name)
|
|
||||||
if fn_cls is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
input_cls = getattr(fn_cls, "Input", None)
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
for param_name in input_cls.model_fields:
|
|
||||||
param_names.add(param_name)
|
|
||||||
|
|
||||||
meta = getattr(fn_cls, "_meta", {})
|
|
||||||
route = meta.get("route")
|
|
||||||
view_path = meta.get("view_path")
|
|
||||||
|
|
||||||
fn_entry: dict[str, Any] = {
|
|
||||||
"name": fn_name,
|
|
||||||
"path": "view" if view_path else "rpc",
|
|
||||||
}
|
|
||||||
if route:
|
|
||||||
fn_entry["route"] = route
|
|
||||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
|
||||||
page_routes.append(route)
|
|
||||||
if meta.get("rev"):
|
|
||||||
fn_entry["rev"] = meta["rev"]
|
|
||||||
if meta.get("cache") is not None and meta.get("cache") is not True:
|
|
||||||
fn_entry["cache"] = meta["cache"]
|
|
||||||
functions_meta.append(fn_entry)
|
|
||||||
|
|
||||||
sorted_params = sorted(param_names)
|
|
||||||
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
|
||||||
|
|
||||||
ctx_entry: dict[str, Any] = {
|
|
||||||
"functions": functions_meta,
|
|
||||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
|
||||||
"params": sorted_params,
|
|
||||||
"user_scoped": user_scoped,
|
|
||||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
|
||||||
}
|
|
||||||
|
|
||||||
if page_routes:
|
|
||||||
ctx_entry["page_routes"] = page_routes
|
|
||||||
if view_urls and ctx_name in view_urls:
|
|
||||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
|
||||||
|
|
||||||
manifest["contexts"][ctx_name] = ctx_entry
|
|
||||||
|
|
||||||
for fn_name, fn_cls in sorted(all_functions.items()):
|
|
||||||
meta = getattr(fn_cls, "_meta", {})
|
|
||||||
if not meta.get("affects"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
affected_contexts = list({a["name"] for a in meta["affects"]})
|
|
||||||
mutation: dict[str, Any] = {"affects": affected_contexts}
|
|
||||||
|
|
||||||
# Auto-scoped params — function params that match context params
|
|
||||||
input_cls = getattr(fn_cls, "Input", None)
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
fn_params = set(input_cls.model_fields.keys())
|
|
||||||
auto_scoped: list[str] = []
|
|
||||||
for ctx_name in affected_contexts:
|
|
||||||
ctx_param_names: set[str] = set()
|
|
||||||
ctx_fns = groups.get(ctx_name, [])
|
|
||||||
for ctx_fn_name in ctx_fns:
|
|
||||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
|
||||||
if ctx_fn_cls is None:
|
|
||||||
continue
|
|
||||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
|
||||||
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
|
||||||
ctx_param_names.update(ctx_input.model_fields.keys())
|
|
||||||
for p in fn_params:
|
|
||||||
if p in ctx_param_names and p not in auto_scoped:
|
|
||||||
auto_scoped.append(p)
|
|
||||||
if auto_scoped:
|
|
||||||
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
|
||||||
|
|
||||||
if meta.get("private"):
|
|
||||||
mutation["private"] = True
|
|
||||||
if meta.get("route"):
|
|
||||||
mutation["route"] = meta["route"]
|
|
||||||
mutation["methods"] = meta.get("methods", ["POST"])
|
|
||||||
|
|
||||||
manifest["mutations"][fn_name] = mutation
|
|
||||||
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def generate_edge_manifest_json(
|
|
||||||
base_url: str = "/api/mizan",
|
|
||||||
view_urls: dict[str, list[str]] | None = None,
|
|
||||||
indent: int = 2,
|
|
||||||
) -> str:
|
|
||||||
"""JSON-serialize the Edge manifest."""
|
|
||||||
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
|
||||||
|
|||||||
@@ -23,7 +23,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_core.ssr import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
class MizanTemplate:
|
class MizanTemplate:
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ dependencies = [
|
|||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ HTTP RPC dispatch and context bundling on top of mizan-core's function
|
|||||||
registry, sharing the auth / invalidation / cache / upload core with the
|
registry, sharing the auth / invalidation / cache / upload core with the
|
||||||
Django adapter.
|
Django adapter.
|
||||||
|
|
||||||
WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities
|
The full AFI-common surface is wired here over FastAPI-native primitives,
|
||||||
this adapter does not wire yet — open gaps on the capability-parity board
|
each riding the shared core:
|
||||||
(`tests/afi/`), not out-of-scope. "Use FastAPI's native WebSocket / an ORM
|
|
||||||
of choice" is the non-goal: the AFI exists precisely to wire those to the
|
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
|
||||||
generated typed client through one decorator, so an adapter that defers to
|
functions through the same `mizan_core.dispatch` as `POST /call/`.
|
||||||
the native primitive isn't yet a complete AFI adapter. The SSR bridge in
|
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
|
||||||
particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django
|
`mizan_core.ssr.SSRBridge` Bun subprocess.
|
||||||
coupling) and is mountable here directly.
|
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
|
||||||
|
console entry) emit the manifest derived in `mizan_core.manifest`, including
|
||||||
|
each context's `render_strategy`.
|
||||||
|
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
|
||||||
|
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
|
||||||
|
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
|
||||||
|
role functions over Pydantic.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -42,11 +48,30 @@ from .executor import (
|
|||||||
compute_invalidation,
|
compute_invalidation,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
|
# Register the FastAPI/Starlette response base so view-path detection works in
|
||||||
|
# mizan_core.client.function (a @client function returning a Response is a
|
||||||
|
# view-path function — header-only invalidation, "view" in the edge manifest).
|
||||||
|
# Must run before any @client-decorated code is evaluated.
|
||||||
|
from starlette.responses import Response as _Response
|
||||||
|
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||||
|
_set_response_base(_Response)
|
||||||
|
|
||||||
|
from . import shapes, forms
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
from .auth import MizanAuthMiddleware, mizan_auth
|
from .auth import MizanAuthMiddleware, mizan_auth
|
||||||
from .config import MizanConfig, from_env
|
from .config import MizanConfig, from_env
|
||||||
|
from .manifest import edge_manifest, generate_edge_manifest, render_strategies
|
||||||
|
from .ssr import SSRRenderer
|
||||||
from mizan_core.upload import File, Upload, UploadedFile
|
from mizan_core.upload import File, Upload, UploadedFile
|
||||||
|
|
||||||
|
# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit)
|
||||||
|
# are submodule bindings; expose their public primitives at the package root.
|
||||||
|
Shape = shapes.Shape
|
||||||
|
Diff = shapes.Diff
|
||||||
|
NestedDiff = shapes.NestedDiff
|
||||||
|
mizanForm = forms.mizanForm
|
||||||
|
FormConfig = forms.FormConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Upload",
|
"Upload",
|
||||||
"File",
|
"File",
|
||||||
@@ -60,6 +85,17 @@ __all__ = [
|
|||||||
"mizan_validation_handler",
|
"mizan_validation_handler",
|
||||||
"execute_function",
|
"execute_function",
|
||||||
"compute_invalidation",
|
"compute_invalidation",
|
||||||
|
"edge_manifest",
|
||||||
|
"generate_edge_manifest",
|
||||||
|
"render_strategies",
|
||||||
|
"SSRRenderer",
|
||||||
|
"shapes",
|
||||||
|
"forms",
|
||||||
|
"Shape",
|
||||||
|
"Diff",
|
||||||
|
"NestedDiff",
|
||||||
|
"mizanForm",
|
||||||
|
"FormConfig",
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
"MizanError",
|
"MizanError",
|
||||||
"NotFound",
|
"NotFound",
|
||||||
|
|||||||
245
backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py
Normal file
245
backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Forms — the Pydantic binding (schema / validate / submit roles).
|
||||||
|
|
||||||
|
A Mizan form is exposed as three server functions — `{name}.schema`,
|
||||||
|
`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of
|
||||||
|
`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and
|
||||||
|
identical to the Django adapter's (`mizan.forms`); only the *binding* differs:
|
||||||
|
Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`.
|
||||||
|
|
||||||
|
from mizan_fastapi.forms import mizanForm, FormConfig
|
||||||
|
|
||||||
|
class ContactForm(mizanForm):
|
||||||
|
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
|
||||||
|
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def on_submit_success(self, request) -> dict:
|
||||||
|
send_email(self.model_dump())
|
||||||
|
return {"sent": True}
|
||||||
|
|
||||||
|
Subclassing registers the three role functions automatically (parity with the
|
||||||
|
Django `mizanFormMixin.__init_subclass__` auto-registration):
|
||||||
|
|
||||||
|
contact.schema → field definitions (FormSchema)
|
||||||
|
contact.validate → structured field errors (FormValidation)
|
||||||
|
contact.submit → validate, then on_submit_success / on_submit_failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, ClassVar, get_args, get_origin
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ValidationError, create_model
|
||||||
|
|
||||||
|
from mizan_core.client.function import ServerFunction
|
||||||
|
from mizan_core.registry import get_all_functions, register
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
FieldError,
|
||||||
|
FieldErrorList,
|
||||||
|
FieldSchema,
|
||||||
|
FormMeta,
|
||||||
|
FormSchema,
|
||||||
|
FormSubmitFail,
|
||||||
|
FormSubmitPass,
|
||||||
|
FormValidation,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormConfig",
|
||||||
|
"mizanForm",
|
||||||
|
"get_forms",
|
||||||
|
"FormSchema",
|
||||||
|
"FormValidation",
|
||||||
|
"FormSubmitPass",
|
||||||
|
"FormSubmitFail",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the
|
||||||
|
# Django binding's `_django_field_to_python_type` intent: hand the client a real
|
||||||
|
# field type instead of a generic string.
|
||||||
|
_TYPE_WIDGET = {
|
||||||
|
bool: ("checkbox", "CheckboxInput"),
|
||||||
|
int: ("number", "NumberInput"),
|
||||||
|
float: ("number", "NumberInput"),
|
||||||
|
str: ("text", "TextInput"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FormConfig(BaseModel):
|
||||||
|
"""Form metadata + frontend behavior (parity with `mizanFormMeta`)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
title: str | None = None
|
||||||
|
subtitle: str | None = None
|
||||||
|
submit_label: str = "Submit"
|
||||||
|
live_validation: bool = True
|
||||||
|
live_form_errors: bool = False
|
||||||
|
refetch_schema_on_validate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_optional(annotation: Any) -> Any:
|
||||||
|
"""`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged."""
|
||||||
|
if get_origin(annotation) in (None,):
|
||||||
|
return annotation
|
||||||
|
args = [a for a in get_args(annotation) if a is not type(None)]
|
||||||
|
if len(args) == 1 and type(None) in get_args(annotation):
|
||||||
|
return args[0]
|
||||||
|
return annotation
|
||||||
|
|
||||||
|
|
||||||
|
def _field_type_widget(annotation: Any) -> tuple[str, str]:
|
||||||
|
base = _unwrap_optional(annotation)
|
||||||
|
return _TYPE_WIDGET.get(base, ("text", "TextInput"))
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize(name: str) -> str:
|
||||||
|
return name.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema:
|
||||||
|
"""Derive a `FormSchema` from a Pydantic form's fields + config."""
|
||||||
|
cfg = form_cls.mizan
|
||||||
|
fields: list[FieldSchema] = []
|
||||||
|
for field_name, info in form_cls.model_fields.items():
|
||||||
|
type_str, widget = _field_type_widget(info.annotation)
|
||||||
|
required = info.is_required()
|
||||||
|
initial = None if required else info.get_default(call_default_factory=False)
|
||||||
|
if initial is None and info.default is not None and info.default is not ...:
|
||||||
|
initial = info.default
|
||||||
|
meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
||||||
|
fields.append(
|
||||||
|
FieldSchema(
|
||||||
|
name=field_name,
|
||||||
|
label=str(info.title or _humanize(field_name)),
|
||||||
|
type=type_str,
|
||||||
|
widget=widget,
|
||||||
|
required=required,
|
||||||
|
disabled=bool(meta.get("disabled", False)),
|
||||||
|
help_text=str(info.description or ""),
|
||||||
|
initial=initial if initial is not ... else None,
|
||||||
|
max_length=getattr(info, "max_length", None),
|
||||||
|
min_length=getattr(info, "min_length", None),
|
||||||
|
choices=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return FormSchema(
|
||||||
|
name=cfg.name,
|
||||||
|
title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")),
|
||||||
|
subtitle=cfg.subtitle,
|
||||||
|
submit_label=cfg.submit_label,
|
||||||
|
fields=fields,
|
||||||
|
meta=FormMeta(
|
||||||
|
refetch_schema_on_validate=cfg.refetch_schema_on_validate,
|
||||||
|
live_validation=cfg.live_validation,
|
||||||
|
live_form_errors=cfg.live_form_errors,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validation_from_error(exc: ValidationError) -> FormValidation:
|
||||||
|
"""Group a Pydantic `ValidationError` into the `FormValidation` wire shape."""
|
||||||
|
by_field: dict[str, list[FieldError]] = {}
|
||||||
|
for err in exc.errors():
|
||||||
|
loc = err.get("loc", ())
|
||||||
|
field = str(loc[0]) if loc else "__all__"
|
||||||
|
by_field.setdefault(field, []).append(
|
||||||
|
FieldError(message=err.get("msg", "Invalid value"), code=err.get("type"))
|
||||||
|
)
|
||||||
|
return FormValidation(
|
||||||
|
errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]:
|
||||||
|
"""Validate `data`; return `(instance|None, validation)` — instance None on failure."""
|
||||||
|
try:
|
||||||
|
instance = form_cls(**(data or {}))
|
||||||
|
return instance, FormValidation(errors=[])
|
||||||
|
except ValidationError as exc:
|
||||||
|
return None, _validation_from_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class mizanForm(BaseModel):
|
||||||
|
"""Base for a Pydantic-backed Mizan form.
|
||||||
|
|
||||||
|
Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing
|
||||||
|
auto-registers the schema/validate/submit role functions. Override
|
||||||
|
`on_submit_success` / `on_submit_failure` for submit-time behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mizan: ClassVar[FormConfig]
|
||||||
|
|
||||||
|
def on_submit_success(self, request: Any) -> dict | None:
|
||||||
|
"""Handle a validated submission. Override; returns optional result data."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_submit_failure(self, request: Any, errors: FormValidation) -> None:
|
||||||
|
"""Handle a failed submission (logging, etc.). Override."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
cfg = cls.__dict__.get("mizan")
|
||||||
|
if isinstance(cfg, FormConfig):
|
||||||
|
_register_form(cls)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_form(form_cls: type[mizanForm]) -> None:
|
||||||
|
"""Register `{name}.schema/.validate/.submit` for a Pydantic form class."""
|
||||||
|
cfg = form_cls.mizan
|
||||||
|
name = cfg.name
|
||||||
|
pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_"))
|
||||||
|
|
||||||
|
schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {}))
|
||||||
|
validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...))
|
||||||
|
submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...))
|
||||||
|
|
||||||
|
class SchemaFunction(ServerFunction):
|
||||||
|
Input = schema_input
|
||||||
|
Output = FormSchema
|
||||||
|
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"}
|
||||||
|
|
||||||
|
def call(self, input) -> FormSchema:
|
||||||
|
return build_form_schema(form_cls)
|
||||||
|
|
||||||
|
class ValidateFunction(ServerFunction):
|
||||||
|
Input = validate_input
|
||||||
|
Output = FormValidation
|
||||||
|
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"}
|
||||||
|
|
||||||
|
def call(self, input) -> FormValidation:
|
||||||
|
_, validation = _validate(form_cls, input.data)
|
||||||
|
return validation
|
||||||
|
|
||||||
|
class SubmitFunction(ServerFunction):
|
||||||
|
Input = submit_input
|
||||||
|
Output = FormSubmitPass
|
||||||
|
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"}
|
||||||
|
|
||||||
|
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||||
|
instance, validation = _validate(form_cls, input.data)
|
||||||
|
if instance is not None:
|
||||||
|
return FormSubmitPass(success=True, data=instance.on_submit_success(self.request))
|
||||||
|
instance_for_failure = form_cls.model_construct(**(input.data or {}))
|
||||||
|
instance_for_failure.on_submit_failure(self.request, validation)
|
||||||
|
return FormSubmitFail(success=False, errors=validation)
|
||||||
|
|
||||||
|
for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")):
|
||||||
|
fn.__name__ = f"{name}_{role}"
|
||||||
|
fn.__qualname__ = fn.__name__
|
||||||
|
register(fn, f"{name}.{role}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_forms() -> dict[str, list]:
|
||||||
|
"""Group registered form role functions by form name (parity helper)."""
|
||||||
|
forms: dict[str, list] = {}
|
||||||
|
for _, cls in get_all_functions().items():
|
||||||
|
meta = getattr(cls, "_meta", {})
|
||||||
|
if meta.get("form"):
|
||||||
|
forms.setdefault(meta.get("form_name"), []).append(cls)
|
||||||
|
return forms
|
||||||
77
backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py
Normal file
77
backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Form role output schemas — the wire shapes the schema/validate/submit roles emit.
|
||||||
|
|
||||||
|
These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta,
|
||||||
|
FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated
|
||||||
|
client is identical regardless of which backend authored the form. The only
|
||||||
|
difference is the source: Django builds these from `forms.Field` introspection;
|
||||||
|
this builds them from Pydantic `FieldInfo`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class FormMeta(BaseModel):
|
||||||
|
"""Frontend behavior flags (parity with the Django adapter)."""
|
||||||
|
|
||||||
|
refetch_schema_on_validate: bool = False
|
||||||
|
live_validation: bool = True
|
||||||
|
live_form_errors: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FieldChoice(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class FieldError(BaseModel):
|
||||||
|
message: str
|
||||||
|
code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FieldErrorList(BaseModel):
|
||||||
|
field: str
|
||||||
|
errors: list[FieldError]
|
||||||
|
|
||||||
|
|
||||||
|
class FieldSchema(BaseModel):
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
type: str
|
||||||
|
widget: str
|
||||||
|
required: bool
|
||||||
|
disabled: bool
|
||||||
|
help_text: str
|
||||||
|
initial: Any = None
|
||||||
|
max_length: Optional[int] = None
|
||||||
|
min_length: Optional[int] = None
|
||||||
|
choices: Optional[list[FieldChoice]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FormSchema(BaseModel):
|
||||||
|
"""Schema returned by the `.schema` role: form metadata + field definitions."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
submit_label: str
|
||||||
|
fields: list[FieldSchema]
|
||||||
|
meta: FormMeta = FormMeta()
|
||||||
|
|
||||||
|
|
||||||
|
class FormValidation(BaseModel):
|
||||||
|
errors: list[FieldErrorList]
|
||||||
|
|
||||||
|
|
||||||
|
class FormSubmitPass(BaseModel):
|
||||||
|
success: bool
|
||||||
|
data: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FormSubmitFail(BaseModel):
|
||||||
|
success: bool
|
||||||
|
errors: FormValidation
|
||||||
98
backends/mizan-fastapi/src/mizan_fastapi/manifest.py
Normal file
98
backends/mizan-fastapi/src/mizan_fastapi/manifest.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Edge manifest — FastAPI adapter surface.
|
||||||
|
|
||||||
|
The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`);
|
||||||
|
this module exposes it over FastAPI's surface as a callable and a console entry
|
||||||
|
(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest`
|
||||||
|
management command.
|
||||||
|
|
||||||
|
The `render_strategy` field each context carries — `"psr"` when the context has
|
||||||
|
no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge
|
||||||
|
reads to decide between one shared pre-rendered artifact and a per-user cached
|
||||||
|
one. It is derived in the core from the same registry metadata, so FastAPI and
|
||||||
|
Django emit byte-identical manifests for an identical registry.
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
mizan-fastapi-edge-manifest myproject.app
|
||||||
|
mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json
|
||||||
|
|
||||||
|
The positional argument is an import target (``module`` or ``module:attr``); it
|
||||||
|
is imported for its registration side effects (importing the module runs the
|
||||||
|
`@client` decorators and `register(...)` calls that populate the registry)
|
||||||
|
before the manifest is derived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"]
|
||||||
|
|
||||||
|
|
||||||
|
def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]:
|
||||||
|
"""The Edge manifest for the current registry.
|
||||||
|
|
||||||
|
Call after the app's `@client` functions are imported/registered. The
|
||||||
|
returned dict carries each context's ``render_strategy`` (PSR vs.
|
||||||
|
dynamic_cached) and the mutation→context invalidation routing.
|
||||||
|
"""
|
||||||
|
return generate_edge_manifest(base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]:
|
||||||
|
"""Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``.
|
||||||
|
|
||||||
|
PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a
|
||||||
|
context with no user-scoped param renders one shared artifact (``psr``) that
|
||||||
|
is re-rendered on mutation; a user-scoped context renders per-user
|
||||||
|
(``dynamic_cached``). This surfaces that decision directly so a PSR driver can
|
||||||
|
enumerate which contexts to pre-render without re-deriving it.
|
||||||
|
"""
|
||||||
|
contexts = edge_manifest(base_url)["contexts"]
|
||||||
|
return {name: entry["render_strategy"] for name, entry in contexts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _import_target(target: str) -> None:
|
||||||
|
"""Import a ``module`` or ``module:attr`` target for its registration effects."""
|
||||||
|
module_name = target.split(":", 1)[0]
|
||||||
|
importlib.import_module(module_name)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Console entry: import the app target, emit the Edge manifest as JSON."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="mizan-fastapi-edge-manifest",
|
||||||
|
description="Export the Mizan Edge manifest for a FastAPI app.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"app",
|
||||||
|
help="Import target whose @client functions to register "
|
||||||
|
"(e.g. 'myproject.app' or 'myproject.app:app').",
|
||||||
|
)
|
||||||
|
parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.")
|
||||||
|
parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.")
|
||||||
|
parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
sys.path.insert(0, "")
|
||||||
|
_import_target(args.app)
|
||||||
|
|
||||||
|
indent = args.indent if args.indent > 0 else None
|
||||||
|
text = generate_edge_manifest_json(base_url=args.base_url, indent=indent)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
@@ -25,7 +25,7 @@ from starlette.datastructures import UploadFile
|
|||||||
|
|
||||||
from mizan_core.auth import INVALID, authenticate
|
from mizan_core.auth import INVALID, authenticate
|
||||||
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
||||||
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
|
from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized
|
||||||
from mizan_core.registry import get_function
|
from mizan_core.registry import get_function
|
||||||
from mizan_core.upload import UploadedFile, bind_uploads
|
from mizan_core.upload import UploadedFile, bind_uploads
|
||||||
|
|
||||||
@@ -150,6 +150,108 @@ async def context_fetch(context_name: str, request: Request) -> Response:
|
|||||||
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
|
||||||
|
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
|
||||||
|
token decode from the handshake headers. A present-but-invalid token rejects.
|
||||||
|
|
||||||
|
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
|
||||||
|
identically over either transport.
|
||||||
|
"""
|
||||||
|
existing = getattr(getattr(websocket, "state", None), "user", None)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
ident = authenticate(websocket.headers, cfg.auth)
|
||||||
|
if ident is INVALID:
|
||||||
|
raise Unauthorized("Invalid or expired token")
|
||||||
|
return ident
|
||||||
|
|
||||||
|
|
||||||
|
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
|
||||||
|
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
|
||||||
|
if exc.details:
|
||||||
|
err["details"] = exc.details
|
||||||
|
return {"id": request_id, "ok": False, "error": err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/")
|
||||||
|
async def websocket_rpc(websocket: WebSocket) -> None:
|
||||||
|
"""WebSocket RPC transport for `@client(websocket=True)` functions.
|
||||||
|
|
||||||
|
Frame protocol (parity with mizan-django's Channels consumer):
|
||||||
|
|
||||||
|
→ {"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
|
||||||
|
← {"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
|
||||||
|
← {"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
|
||||||
|
|
||||||
|
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
|
||||||
|
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
|
||||||
|
and origin-cache purge are identical across transports. Only functions that
|
||||||
|
declared `websocket=True` are callable here; an HTTP-only function returns a
|
||||||
|
`FORBIDDEN` frame rather than executing.
|
||||||
|
"""
|
||||||
|
cfg = get_config(websocket)
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
identity = _ws_identity(websocket, cfg)
|
||||||
|
except Unauthorized as exc:
|
||||||
|
await websocket.send_json(_error_frame(None, exc))
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
content = await websocket.receive_json()
|
||||||
|
await _handle_ws_rpc(websocket, content, identity, cfg)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
|
||||||
|
"""Dispatch one WS RPC frame through the shared dispatch core."""
|
||||||
|
if content.get("action") != "rpc":
|
||||||
|
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = content.get("id")
|
||||||
|
fn_name = content.get("fn")
|
||||||
|
args = content.get("args", {})
|
||||||
|
|
||||||
|
if not fn_name:
|
||||||
|
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
|
||||||
|
return
|
||||||
|
|
||||||
|
fn_class = get_function(fn_name)
|
||||||
|
if fn_class is None:
|
||||||
|
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
|
||||||
|
return
|
||||||
|
if not getattr(fn_class, "_meta", {}).get("websocket"):
|
||||||
|
await websocket.send_json(
|
||||||
|
_error_frame(
|
||||||
|
request_id,
|
||||||
|
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await dispatch_call(
|
||||||
|
DispatchRequest(identity=identity, args=args, native_request=websocket),
|
||||||
|
fn_name, cfg.cache,
|
||||||
|
)
|
||||||
|
except MizanError as exc:
|
||||||
|
await websocket.send_json(_error_frame(request_id, exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
|
||||||
|
"invalidate": res.invalidate or []}
|
||||||
|
if res.merge:
|
||||||
|
frame["merge"] = res.merge
|
||||||
|
await websocket.send_json(frame)
|
||||||
|
|
||||||
|
|
||||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
307
backends/mizan-fastapi/src/mizan_fastapi/shapes.py
Normal file
307
backends/mizan-fastapi/src/mizan_fastapi/shapes.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
Typed query projection (Shapes) — the SQLAlchemy binding.
|
||||||
|
|
||||||
|
A Shape is a Pydantic model that declares *which* fields and relationships of an
|
||||||
|
ORM model to project. The declaration surface is identical to the Django
|
||||||
|
adapter's `mizan.shapes` (`django-readers` binding):
|
||||||
|
|
||||||
|
class AuthorShape(Shape[Author]):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
books: list[BookShape] = [] # nested relationship
|
||||||
|
|
||||||
|
AuthorShape.query(session, lambda s: s.where(Author.name == "Ann"))
|
||||||
|
|
||||||
|
Only the ORM binding differs: where the Django Shape lowers its spec to
|
||||||
|
`django-readers` pairs (queryset prepare + instance project), this lowers it to a
|
||||||
|
SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested
|
||||||
|
relationship (the projection-load that keeps the query count flat), then projects
|
||||||
|
each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a
|
||||||
|
constructed shape against current DB rows, mirroring the Django semantics.
|
||||||
|
|
||||||
|
The one surface difference SQLAlchemy forces is an explicit `session` argument to
|
||||||
|
`query` / `diff` / `diff_many` — Django models carry an implicit `objects`
|
||||||
|
manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the
|
||||||
|
Shape declaration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.inspection import inspect as sa_inspect
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
_M = TypeVar("_M")
|
||||||
|
_S = TypeVar("_S", bound="Shape")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_shape_class(hint) -> type[Shape] | None:
|
||||||
|
"""The nested Shape a field annotation projects, if any.
|
||||||
|
|
||||||
|
Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional —
|
||||||
|
the same forms the Django binding's `_extract_shape_class` accepts.
|
||||||
|
"""
|
||||||
|
origin = getattr(hint, "__origin__", None)
|
||||||
|
args = getattr(hint, "__args__", ())
|
||||||
|
|
||||||
|
if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape):
|
||||||
|
return args[0]
|
||||||
|
|
||||||
|
if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape:
|
||||||
|
return hint
|
||||||
|
|
||||||
|
if origin is Union or isinstance(hint, types.UnionType):
|
||||||
|
for arg in args:
|
||||||
|
if arg is type(None):
|
||||||
|
continue
|
||||||
|
if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape:
|
||||||
|
return arg
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model(cls) -> Any | None:
|
||||||
|
"""The mapped model a Shape subclass is parameterized on (`Shape[Model]`)."""
|
||||||
|
for base in cls.__bases__:
|
||||||
|
meta = getattr(base, "__pydantic_generic_metadata__", None) or {}
|
||||||
|
if meta.get("origin") is Shape and (args := meta.get("args")):
|
||||||
|
return args[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Shape(BaseModel, Generic[_M]):
|
||||||
|
"""Typed projection over a SQLAlchemy mapped model.
|
||||||
|
|
||||||
|
Subclass as `Shape[Model]`; annotate the fields/relationships to project.
|
||||||
|
Scalar annotations become columns to read; annotations referencing another
|
||||||
|
Shape become relationships to eager-load and project recursively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_model: ClassVar[Any]
|
||||||
|
_nested: ClassVar[dict[str, type[Shape]]]
|
||||||
|
_field_names: ClassVar[list[str]]
|
||||||
|
_pk_field: ClassVar[str]
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
if not (model := _resolve_model(cls)):
|
||||||
|
return
|
||||||
|
|
||||||
|
mapper = sa_inspect(model)
|
||||||
|
cls._model = model
|
||||||
|
cls._nested = {}
|
||||||
|
pk_cols = mapper.primary_key
|
||||||
|
cls._pk_field = pk_cols[0].key if pk_cols else "id"
|
||||||
|
|
||||||
|
hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__
|
||||||
|
field_names: list[str] = []
|
||||||
|
for name, hint in hints.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
if shape_cls := _extract_shape_class(hint):
|
||||||
|
cls._nested[name] = shape_cls
|
||||||
|
else:
|
||||||
|
field_names.append(name)
|
||||||
|
cls._field_names = field_names
|
||||||
|
|
||||||
|
# ─── Loading + projection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _loader_options(cls) -> list[Any]:
|
||||||
|
"""`selectinload(...)` chains for every nested relationship (recursive).
|
||||||
|
|
||||||
|
This is the SQLAlchemy analogue of django-readers' prefetch wiring: each
|
||||||
|
nested Shape contributes a `selectinload` on its relationship attribute,
|
||||||
|
with the child Shape's own loader options nested beneath it, so the whole
|
||||||
|
projection loads in O(depth) queries rather than N+1.
|
||||||
|
"""
|
||||||
|
options: list[Any] = []
|
||||||
|
for name, shape_cls in cls._nested.items():
|
||||||
|
attr = getattr(cls._model, name)
|
||||||
|
child = shape_cls._loader_options()
|
||||||
|
loader = selectinload(attr)
|
||||||
|
options.append(loader.options(*child) if child else loader)
|
||||||
|
return options
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _project(cls: type[_S], instance: Any) -> _S:
|
||||||
|
"""Project a loaded ORM instance into this Shape (recursively for nested)."""
|
||||||
|
data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names}
|
||||||
|
for name, shape_cls in cls._nested.items():
|
||||||
|
related = getattr(instance, name)
|
||||||
|
if related is None:
|
||||||
|
data[name] = None
|
||||||
|
elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)):
|
||||||
|
data[name] = [shape_cls._project(child) for child in related]
|
||||||
|
else:
|
||||||
|
data[name] = shape_cls._project(related)
|
||||||
|
return cls.model_validate(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]:
|
||||||
|
"""Project the model into a list of shapes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An open SQLAlchemy `Session`.
|
||||||
|
*stmt_fns: Callables `(select) -> select` applied in order to the base
|
||||||
|
`select(Model)` — filters/ordering/limits (the SQLAlchemy analogue
|
||||||
|
of the Django binding's queryset functions).
|
||||||
|
**relation_stmt: Per-relationship callables `(select) -> select` whose
|
||||||
|
criteria scope a nested relationship's load (e.g.
|
||||||
|
``books=lambda s: s.where(Book.is_published.is_(True))``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of projected shape instances.
|
||||||
|
"""
|
||||||
|
stmt = select(cls._model)
|
||||||
|
|
||||||
|
loaders = cls._loader_options_scoped(relation_stmt)
|
||||||
|
if loaders:
|
||||||
|
stmt = stmt.options(*loaders)
|
||||||
|
|
||||||
|
for fn in stmt_fns:
|
||||||
|
stmt = fn(stmt)
|
||||||
|
|
||||||
|
rows = session.execute(stmt).unique().scalars().all()
|
||||||
|
return [cls._project(obj) for obj in rows]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]:
|
||||||
|
"""`_loader_options`, but with caller-supplied criteria applied per relation."""
|
||||||
|
if not relation_stmt:
|
||||||
|
return cls._loader_options()
|
||||||
|
options: list[Any] = []
|
||||||
|
for name, shape_cls in cls._nested.items():
|
||||||
|
attr = getattr(cls._model, name)
|
||||||
|
loader = selectinload(attr)
|
||||||
|
child = shape_cls._loader_options()
|
||||||
|
if child:
|
||||||
|
loader = loader.options(*child)
|
||||||
|
scope = relation_stmt.get(name)
|
||||||
|
if scope is not None:
|
||||||
|
# `selectinload(...).and_(...)` filters the related rows loaded.
|
||||||
|
criteria = scope(select(shape_cls._model)).whereclause
|
||||||
|
if criteria is not None:
|
||||||
|
loader = selectinload(attr.and_(criteria))
|
||||||
|
if child:
|
||||||
|
loader = loader.options(*child)
|
||||||
|
options.append(loader)
|
||||||
|
return options
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_pk(cls, instance) -> Any | None:
|
||||||
|
return getattr(instance, cls._pk_field, None)
|
||||||
|
|
||||||
|
# ─── Diff ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]:
|
||||||
|
"""Diff a batch of shapes against current DB state in one fetch.
|
||||||
|
|
||||||
|
New items (no PK) diff against `None`; existing items batch-fetch by PK.
|
||||||
|
Raises if an item declares a PK that no row matches.
|
||||||
|
"""
|
||||||
|
pk_field = cls._pk_field
|
||||||
|
pk_map: dict[Any, _S] = {}
|
||||||
|
new_items: list[_S] = []
|
||||||
|
for item in items:
|
||||||
|
pk = cls._get_pk(item)
|
||||||
|
(pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item))
|
||||||
|
|
||||||
|
current_map: dict[Any, _S] = {}
|
||||||
|
if pk_map:
|
||||||
|
pk_col = getattr(cls._model, pk_field)
|
||||||
|
current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys()))))
|
||||||
|
current_map = {cls._get_pk(c): c for c in current}
|
||||||
|
|
||||||
|
results: list[tuple[_S, Diff]] = []
|
||||||
|
for item in new_items:
|
||||||
|
results.append((item, cls._diff_one(item, None)))
|
||||||
|
for pk, item in pk_map.items():
|
||||||
|
current = current_map.get(pk)
|
||||||
|
if current is None:
|
||||||
|
raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist")
|
||||||
|
results.append((item, cls._diff_one(item, current)))
|
||||||
|
return results
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff":
|
||||||
|
pk_field = cls._pk_field
|
||||||
|
changed = (
|
||||||
|
{k: getattr(incoming, k) for k in cls._field_names
|
||||||
|
if k != pk_field and getattr(incoming, k) != getattr(current, k)}
|
||||||
|
if current
|
||||||
|
else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field}
|
||||||
|
)
|
||||||
|
|
||||||
|
nested: dict[str, NestedDiff] = {}
|
||||||
|
for name, shape_cls in cls._nested.items():
|
||||||
|
incoming_items = getattr(incoming, name, None) or []
|
||||||
|
current_items = (getattr(current, name, None) or []) if current else []
|
||||||
|
if not isinstance(incoming_items, list):
|
||||||
|
incoming_items = [incoming_items]
|
||||||
|
if not isinstance(current_items, list):
|
||||||
|
current_items = [current_items]
|
||||||
|
|
||||||
|
current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None}
|
||||||
|
incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None}
|
||||||
|
|
||||||
|
nested[name] = NestedDiff(
|
||||||
|
created=[c for c in incoming_items if shape_cls._get_pk(c) is None],
|
||||||
|
updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]],
|
||||||
|
deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Diff(is_new=current is None, changed=changed, _nested=nested)
|
||||||
|
|
||||||
|
def diff(self, session: Session) -> "Diff":
|
||||||
|
"""Diff this shape against its current DB row (or `None` if new)."""
|
||||||
|
cls = type(self)
|
||||||
|
pk = cls._get_pk(self)
|
||||||
|
if pk is not None:
|
||||||
|
pk_col = getattr(cls._model, cls._pk_field)
|
||||||
|
results = cls.query(session, lambda s: s.where(pk_col == pk))
|
||||||
|
if not results:
|
||||||
|
raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist")
|
||||||
|
current = results[0]
|
||||||
|
else:
|
||||||
|
current = None
|
||||||
|
return cls._diff_one(self, current)
|
||||||
|
|
||||||
|
|
||||||
|
class NestedDiff:
|
||||||
|
__slots__ = ("created", "updated", "deleted")
|
||||||
|
|
||||||
|
def __init__(self, created=(), updated=(), deleted=()):
|
||||||
|
self.created = list(created)
|
||||||
|
self.updated = list(updated)
|
||||||
|
self.deleted = list(deleted)
|
||||||
|
|
||||||
|
|
||||||
|
class Diff:
|
||||||
|
__slots__ = ("is_new", "changed", "_nested")
|
||||||
|
|
||||||
|
def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]):
|
||||||
|
self.is_new = is_new
|
||||||
|
self.changed = changed
|
||||||
|
self._nested = _nested
|
||||||
|
|
||||||
|
def nested(self, name: str) -> NestedDiff:
|
||||||
|
"""Strict access to a nested diff. Raises `KeyError` for an unknown name."""
|
||||||
|
if name not in self._nested:
|
||||||
|
valid = ", ".join(sorted(self._nested)) or "(none)"
|
||||||
|
raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
||||||
|
return self._nested[name]
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> NestedDiff:
|
||||||
|
if name.startswith("_"):
|
||||||
|
raise AttributeError(name)
|
||||||
|
if name not in self._nested:
|
||||||
|
valid = ", ".join(sorted(self._nested)) or "(none)"
|
||||||
|
raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
||||||
|
return self._nested[name]
|
||||||
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
SSR render path — FastAPI adapter surface over the shared Bun bridge.
|
||||||
|
|
||||||
|
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
|
||||||
|
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
|
||||||
|
backend, so instead of Django's `MizanTemplates` veneer this exposes an
|
||||||
|
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
|
||||||
|
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
|
||||||
|
markup plus the hydration payload the client reads on mount.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from mizan_fastapi.ssr import SSRRenderer
|
||||||
|
|
||||||
|
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
|
||||||
|
|
||||||
|
@app.get("/profile/{user_id}")
|
||||||
|
async def profile(user_id: int):
|
||||||
|
return ssr.render("components/Profile.tsx", {"user_id": user_id})
|
||||||
|
|
||||||
|
`render` resolves the template name to an absolute file path against `dirs`
|
||||||
|
(parity with Django's `DIRS`), then renders the component's default export. The
|
||||||
|
hydration wrapping matches the Django backend byte-for-byte so the same client
|
||||||
|
bundle hydrates either server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from mizan_core.ssr import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
|
class SSRRenderer:
|
||||||
|
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
|
||||||
|
|
||||||
|
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
|
||||||
|
serializes worker I/O); a single renderer can be shared across the app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
|
||||||
|
self._dirs = list(dirs or [])
|
||||||
|
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
|
||||||
|
|
||||||
|
def _resolve(self, template_name: str) -> str:
|
||||||
|
"""Resolve a template name to an absolute file path against `dirs`.
|
||||||
|
|
||||||
|
An already-absolute, existing path is used directly; otherwise each `dirs`
|
||||||
|
entry is tried in order (parity with Django's `DIRS` resolution).
|
||||||
|
"""
|
||||||
|
if os.path.isabs(template_name) and os.path.isfile(template_name):
|
||||||
|
return template_name
|
||||||
|
for dir_path in self._dirs:
|
||||||
|
candidate = os.path.join(dir_path, template_name)
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return os.path.abspath(candidate)
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Render the component to an HTML string (markup + hydration script)."""
|
||||||
|
props = dict(props or {})
|
||||||
|
result = self._bridge.render(self._resolve(template_name), props)
|
||||||
|
hydration_json = json.dumps(props, sort_keys=True, default=str)
|
||||||
|
return (
|
||||||
|
f'<div id="mizan-root">{result.html}</div>'
|
||||||
|
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
|
||||||
|
"""Render the component and return a FastAPI `HTMLResponse`."""
|
||||||
|
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Stop the underlying Bun subprocess."""
|
||||||
|
self._bridge.shutdown()
|
||||||
167
backends/mizan-fastapi/tests/test_edge_manifest.py
Normal file
167
backends/mizan-fastapi/tests/test_edge_manifest.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Edge-manifest + PSR behavior — the genuine capability behind the
|
||||||
|
`edge_manifest` and `psr` probes.
|
||||||
|
|
||||||
|
Proves the FastAPI adapter emits the manifest the spec defines (contexts,
|
||||||
|
mutations, params, user_scoped, render_strategy, page_routes) by deriving it from
|
||||||
|
a real registry, and that `render_strategy` falls out of the user-scoped-param
|
||||||
|
rule: a context whose params overlap {user_id, user, owner_id, account_id} is
|
||||||
|
`dynamic_cached`, otherwise `psr`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
import mizan_fastapi # registers the Starlette Response base for view-path detection
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import clear_registry, register
|
||||||
|
|
||||||
|
from mizan_fastapi import edge_manifest, generate_edge_manifest
|
||||||
|
from mizan_fastapi.manifest import render_strategies
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registry():
|
||||||
|
clear_registry()
|
||||||
|
yield
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def _register(fn, name):
|
||||||
|
register(fn, name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_scoped_context_is_dynamic_cached():
|
||||||
|
@client(context="user")
|
||||||
|
def user_profile(request, user_id: int) -> dict:
|
||||||
|
return {"id": user_id}
|
||||||
|
|
||||||
|
_register(user_profile, "user_profile")
|
||||||
|
|
||||||
|
manifest = edge_manifest()
|
||||||
|
ctx = manifest["contexts"]["user"]
|
||||||
|
assert ctx["user_scoped"] is True
|
||||||
|
assert ctx["render_strategy"] == "dynamic_cached"
|
||||||
|
assert ctx["params"] == ["user_id"]
|
||||||
|
assert ctx["endpoints"] == ["/api/mizan/ctx/user/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_user_scoped_context_is_psr():
|
||||||
|
@client(context="catalog")
|
||||||
|
def catalog_items(request, category: str) -> list[dict]:
|
||||||
|
return [{"category": category}]
|
||||||
|
|
||||||
|
_register(catalog_items, "catalog_items")
|
||||||
|
|
||||||
|
ctx = edge_manifest()["contexts"]["catalog"]
|
||||||
|
assert ctx["user_scoped"] is False
|
||||||
|
assert ctx["render_strategy"] == "psr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_strategies_maps_each_context():
|
||||||
|
@client(context="user")
|
||||||
|
def me(request, user_id: int) -> dict:
|
||||||
|
return {"id": user_id}
|
||||||
|
|
||||||
|
@client(context="catalog")
|
||||||
|
def items(request) -> list[dict]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
_register(me, "me")
|
||||||
|
_register(items, "items")
|
||||||
|
|
||||||
|
strategies = render_strategies()
|
||||||
|
assert strategies == {"user": "dynamic_cached", "catalog": "psr"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_records_affects_and_auto_scope():
|
||||||
|
@client(context="user")
|
||||||
|
def user_profile(request, user_id: int) -> dict:
|
||||||
|
return {"id": user_id}
|
||||||
|
|
||||||
|
@client(affects="user")
|
||||||
|
def rename(request, user_id: int, name: str) -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
_register(user_profile, "user_profile")
|
||||||
|
_register(rename, "rename")
|
||||||
|
|
||||||
|
mutation = edge_manifest()["mutations"]["rename"]
|
||||||
|
assert mutation["affects"] == ["user"]
|
||||||
|
# user_id matches the context's param → auto-scoped
|
||||||
|
assert mutation["auto_scoped_params"] == ["user_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_and_route_mutation_carried():
|
||||||
|
@client(affects="subscription", private=True, route="/webhooks/stripe/", methods=["POST"])
|
||||||
|
def stripe_webhook(request) -> Response:
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
@client(context="subscription")
|
||||||
|
def subscription(request, user_id: int) -> dict:
|
||||||
|
return {"id": user_id}
|
||||||
|
|
||||||
|
_register(stripe_webhook, "stripe_webhook")
|
||||||
|
_register(subscription, "subscription")
|
||||||
|
|
||||||
|
mutation = edge_manifest()["mutations"]["stripe_webhook"]
|
||||||
|
assert mutation["private"] is True
|
||||||
|
assert mutation["route"] == "/webhooks/stripe/"
|
||||||
|
assert mutation["methods"] == ["POST"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_path_function_records_route_and_page_routes():
|
||||||
|
@client(context="profile", route="/profile/<user_id>/")
|
||||||
|
def profile_page(request, user_id: int) -> Response:
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
_register(profile_page, "profile_page")
|
||||||
|
|
||||||
|
ctx = edge_manifest()["contexts"]["profile"]
|
||||||
|
assert ctx["page_routes"] == ["/profile/<user_id>/"]
|
||||||
|
fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page")
|
||||||
|
assert fn_entry["path"] == "view"
|
||||||
|
assert fn_entry["route"] == "/profile/<user_id>/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fastapi_manifest_matches_core_derivation():
|
||||||
|
"""The adapter callable is a thin pass-through to the shared core derivation."""
|
||||||
|
|
||||||
|
@client(context="user")
|
||||||
|
def user_profile(request, user_id: int) -> dict:
|
||||||
|
return {"id": user_id}
|
||||||
|
|
||||||
|
_register(user_profile, "user_profile")
|
||||||
|
|
||||||
|
assert edge_manifest() == generate_edge_manifest(base_url="/api/mizan")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_entry_emits_manifest_json(tmp_path):
|
||||||
|
"""`mizan-fastapi-edge-manifest <module>` imports the module then prints JSON."""
|
||||||
|
app_module = tmp_path / "manifest_app.py"
|
||||||
|
app_module.write_text(
|
||||||
|
"from mizan_core.client.function import client\n"
|
||||||
|
"from mizan_core.registry import register\n"
|
||||||
|
"@client(context='user')\n"
|
||||||
|
"def user_profile(request, user_id: int) -> dict:\n"
|
||||||
|
" return {'id': user_id}\n"
|
||||||
|
"register(user_profile, 'user_profile')\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "mizan_fastapi.manifest", "manifest_app", "--indent", "0"],
|
||||||
|
cwd=tmp_path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
manifest = json.loads(result.stdout)
|
||||||
|
assert manifest["contexts"]["user"]["render_strategy"] == "dynamic_cached"
|
||||||
145
backends/mizan-fastapi/tests/test_forms.py
Normal file
145
backends/mizan-fastapi/tests/test_forms.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Forms behavior — the genuine capability behind the `forms` probe.
|
||||||
|
|
||||||
|
Proves the Pydantic binding exposes the same schema / validate / submit role
|
||||||
|
contract as the Django adapter: subclassing `mizanForm` auto-registers
|
||||||
|
`{name}.schema`, `{name}.validate`, `{name}.submit` with the matching
|
||||||
|
`_meta["form_role"]`, the schema role emits typed field definitions, validate
|
||||||
|
returns structured field errors, and submit validates then runs
|
||||||
|
`on_submit_success` / `on_submit_failure`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mizan_core.registry import clear_registry, get_function
|
||||||
|
|
||||||
|
from mizan_fastapi.forms import (
|
||||||
|
FormConfig,
|
||||||
|
FormSubmitFail,
|
||||||
|
FormSubmitPass,
|
||||||
|
FormValidation,
|
||||||
|
build_form_schema,
|
||||||
|
get_forms,
|
||||||
|
mizanForm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean():
|
||||||
|
clear_registry()
|
||||||
|
yield
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_contact_form():
|
||||||
|
class ContactForm(mizanForm):
|
||||||
|
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
|
||||||
|
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
def on_submit_success(self, request) -> dict:
|
||||||
|
return {"sent": True, "to": self.email}
|
||||||
|
|
||||||
|
return ContactForm
|
||||||
|
|
||||||
|
|
||||||
|
def test_subclassing_registers_three_role_functions():
|
||||||
|
_make_contact_form()
|
||||||
|
for role in ("schema", "validate", "submit"):
|
||||||
|
fn = get_function(f"contact.{role}")
|
||||||
|
assert fn is not None, f"contact.{role} not registered"
|
||||||
|
assert fn._meta["form"] is True
|
||||||
|
assert fn._meta["form_name"] == "contact"
|
||||||
|
assert fn._meta["form_role"] == role
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_role_emits_field_definitions():
|
||||||
|
form_cls = _make_contact_form()
|
||||||
|
SchemaFn = get_function("contact.schema")
|
||||||
|
schema = SchemaFn(request=None).call(None)
|
||||||
|
assert schema.name == "contact"
|
||||||
|
assert schema.title == "Contact Us"
|
||||||
|
assert schema.submit_label == "Send"
|
||||||
|
field_names = {f.name for f in schema.fields}
|
||||||
|
assert field_names == {"name", "email", "message"}
|
||||||
|
# `message` has a default → not required; `name`/`email` required
|
||||||
|
by_name = {f.name: f for f in schema.fields}
|
||||||
|
assert by_name["name"].required is True
|
||||||
|
assert by_name["message"].required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_form_schema_maps_types():
|
||||||
|
class TypedForm(mizanForm):
|
||||||
|
mizan = FormConfig(name="typed")
|
||||||
|
count: int
|
||||||
|
ratio: float
|
||||||
|
active: bool
|
||||||
|
label: str
|
||||||
|
|
||||||
|
schema = build_form_schema(TypedForm)
|
||||||
|
by_name = {f.name: f for f in schema.fields}
|
||||||
|
assert by_name["count"].type == "number"
|
||||||
|
assert by_name["ratio"].type == "number"
|
||||||
|
assert by_name["active"].type == "checkbox"
|
||||||
|
assert by_name["label"].type == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_role_passes_clean_data():
|
||||||
|
_make_contact_form()
|
||||||
|
ValidateFn = get_function("contact.validate")
|
||||||
|
ValidateInput = ValidateFn.Input
|
||||||
|
out = ValidateFn(request=None).call(ValidateInput(data={"name": "Ryth", "email": "r@x.com"}))
|
||||||
|
assert isinstance(out, FormValidation)
|
||||||
|
assert out.errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_role_reports_field_errors():
|
||||||
|
_make_contact_form()
|
||||||
|
ValidateFn = get_function("contact.validate")
|
||||||
|
ValidateInput = ValidateFn.Input
|
||||||
|
out = ValidateFn(request=None).call(ValidateInput(data={"email": "r@x.com"})) # missing 'name'
|
||||||
|
error_fields = {e.field for e in out.errors}
|
||||||
|
assert "name" in error_fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_role_runs_on_submit_success():
|
||||||
|
_make_contact_form()
|
||||||
|
SubmitFn = get_function("contact.submit")
|
||||||
|
SubmitInput = SubmitFn.Input
|
||||||
|
result = SubmitFn(request=None).call(
|
||||||
|
SubmitInput(data={"name": "Ryth", "email": "ryth@example.com", "message": "hi"})
|
||||||
|
)
|
||||||
|
assert isinstance(result, FormSubmitPass)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.data == {"sent": True, "to": "ryth@example.com"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_role_returns_fail_on_invalid():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class GuardedForm(mizanForm):
|
||||||
|
mizan = FormConfig(name="guarded")
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def on_submit_failure(self, request, errors) -> None:
|
||||||
|
captured["errors"] = errors
|
||||||
|
|
||||||
|
SubmitFn = get_function("guarded.submit")
|
||||||
|
SubmitInput = SubmitFn.Input
|
||||||
|
result = SubmitFn(request=None).call(SubmitInput(data={})) # missing required 'name'
|
||||||
|
assert isinstance(result, FormSubmitFail)
|
||||||
|
assert result.success is False
|
||||||
|
assert any(e.field == "name" for e in result.errors.errors)
|
||||||
|
# on_submit_failure hook fired with the validation
|
||||||
|
assert "errors" in captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_forms_groups_by_form_name():
|
||||||
|
_make_contact_form()
|
||||||
|
forms = get_forms()
|
||||||
|
assert set(forms.keys()) == {"contact"}
|
||||||
|
assert len(forms["contact"]) == 3
|
||||||
269
backends/mizan-fastapi/tests/test_shapes.py
Normal file
269
backends/mizan-fastapi/tests/test_shapes.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Shapes behavior — the genuine capability behind the `shapes` probe.
|
||||||
|
|
||||||
|
Proves the SQLAlchemy binding has the same Shape declaration surface and
|
||||||
|
projection/diff semantics as the Django `django-readers` binding:
|
||||||
|
|
||||||
|
- `Shape[Model]` resolves the mapped model + PK from the generic arg;
|
||||||
|
- scalar annotations project columns, Shape-typed annotations project relations;
|
||||||
|
- `.query(session, *stmt_fns, **relation_stmt)` flat / nested / scoped;
|
||||||
|
- nested loads stay flat (selectinload, not N+1);
|
||||||
|
- `.diff()` / `.diff_many()` detect field changes + nested created/updated/deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import ForeignKey, create_engine, event
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
|
||||||
|
|
||||||
|
from mizan_fastapi.shapes import Diff, NestedDiff, Shape
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Mapped models ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Publisher(Base):
|
||||||
|
__tablename__ = "publisher"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str]
|
||||||
|
country: Mapped[str]
|
||||||
|
authors: Mapped[list["Author"]] = relationship(back_populates="publisher")
|
||||||
|
|
||||||
|
|
||||||
|
class Author(Base):
|
||||||
|
__tablename__ = "author"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str]
|
||||||
|
bio: Mapped[str] = mapped_column(default="")
|
||||||
|
publisher_id: Mapped[int] = mapped_column(ForeignKey("publisher.id"))
|
||||||
|
publisher: Mapped[Publisher] = relationship(back_populates="authors")
|
||||||
|
books: Mapped[list["Book"]] = relationship(back_populates="author")
|
||||||
|
|
||||||
|
|
||||||
|
class Book(Base):
|
||||||
|
__tablename__ = "book"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str]
|
||||||
|
is_published: Mapped[bool] = mapped_column(default=True)
|
||||||
|
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
||||||
|
author: Mapped[Author] = relationship(back_populates="books")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Shapes (declaration surface identical to the Django adapter) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FlatAuthorShape(Shape[Author]):
|
||||||
|
id: int | None = None
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class FlatBookShape(Shape[Book]):
|
||||||
|
id: int | None = None
|
||||||
|
title: str
|
||||||
|
is_published: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BookCardShape(Shape[Book]):
|
||||||
|
id: int | None = None
|
||||||
|
title: str
|
||||||
|
is_published: bool
|
||||||
|
author: FlatAuthorShape # single nested FK
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorCardShape(Shape[Author]):
|
||||||
|
id: int | None = None
|
||||||
|
name: str
|
||||||
|
bio: str
|
||||||
|
books: list[FlatBookShape] = [] # list nested reverse-FK
|
||||||
|
|
||||||
|
|
||||||
|
class PublisherDetailShape(Shape[Publisher]):
|
||||||
|
id: int | None = None
|
||||||
|
name: str
|
||||||
|
authors: list[AuthorCardShape] = [] # 3-level nesting
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as s:
|
||||||
|
pub = Publisher(name="Orbit", country="UK")
|
||||||
|
ann = Author(name="Ann Leckie", bio="Imperial Radch", publisher=pub)
|
||||||
|
devi = Author(name="Devi Pillai", bio="", publisher=pub)
|
||||||
|
ann.books = [
|
||||||
|
Book(title="Ancillary Justice", is_published=True),
|
||||||
|
Book(title="Provenance", is_published=False),
|
||||||
|
]
|
||||||
|
s.add_all([pub, ann, devi])
|
||||||
|
s.commit()
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Declaration ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_resolves_model_and_pk():
|
||||||
|
assert FlatAuthorShape._model is Author
|
||||||
|
assert FlatAuthorShape._pk_field == "id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_shape_has_no_nested():
|
||||||
|
assert FlatAuthorShape._nested == {}
|
||||||
|
assert FlatAuthorShape._field_names == ["id", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_nested_detected():
|
||||||
|
assert BookCardShape._nested == {"author": FlatAuthorShape}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_nested_detected():
|
||||||
|
assert AuthorCardShape._nested == {"books": FlatBookShape}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Query ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_query_projects_fields(session):
|
||||||
|
authors = FlatAuthorShape.query(session)
|
||||||
|
assert len(authors) == 2
|
||||||
|
assert {a.name for a in authors} == {"Ann Leckie", "Devi Pillai"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_stmt_fn_filters(session):
|
||||||
|
authors = FlatAuthorShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
|
||||||
|
assert [a.name for a in authors] == ["Ann Leckie"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_nested_fk_projected(session):
|
||||||
|
books = BookCardShape.query(session, lambda s: s.where(Book.title == "Ancillary Justice"))
|
||||||
|
assert len(books) == 1
|
||||||
|
assert books[0].author.name == "Ann Leckie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_nested_reverse_fk_projected(session):
|
||||||
|
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
|
||||||
|
assert len(authors) == 1
|
||||||
|
assert {b.title for b in authors[0].books} == {"Ancillary Justice", "Provenance"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_nested_list(session):
|
||||||
|
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Devi Pillai"))
|
||||||
|
assert authors[0].books == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_level_nesting(session):
|
||||||
|
pubs = PublisherDetailShape.query(session)
|
||||||
|
assert len(pubs) == 1
|
||||||
|
leckie = next(a for a in pubs[0].authors if a.name == "Ann Leckie")
|
||||||
|
assert len(leckie.books) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_relation_stmt_scopes_nested_load(session):
|
||||||
|
authors = AuthorCardShape.query(
|
||||||
|
session,
|
||||||
|
lambda s: s.where(Author.name == "Ann Leckie"),
|
||||||
|
books=lambda s: s.where(Book.is_published.is_(True)),
|
||||||
|
)
|
||||||
|
assert [b.title for b in authors[0].books] == ["Ancillary Justice"]
|
||||||
|
assert all(b.is_published for b in authors[0].books)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_query_stays_flat(session):
|
||||||
|
"""selectinload keeps the projection at O(depth) queries, not N+1."""
|
||||||
|
counter = {"n": 0}
|
||||||
|
|
||||||
|
@event.listens_for(session.bind, "after_cursor_execute")
|
||||||
|
def _count(*args):
|
||||||
|
counter["n"] += 1
|
||||||
|
|
||||||
|
AuthorCardShape.query(session)
|
||||||
|
# one query for authors + one selectin for books
|
||||||
|
assert counter["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Diff ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_no_changes(session):
|
||||||
|
book = session.query(Book).filter_by(title="Ancillary Justice").one()
|
||||||
|
shape = FlatBookShape(id=book.id, title="Ancillary Justice", is_published=True)
|
||||||
|
d = shape.diff(session)
|
||||||
|
assert d.is_new is False
|
||||||
|
assert d.changed == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_detects_field_change(session):
|
||||||
|
book = session.query(Book).filter_by(title="Ancillary Justice").one()
|
||||||
|
shape = FlatBookShape(id=book.id, title="Ancillary Justice (rev)", is_published=True)
|
||||||
|
d = shape.diff(session)
|
||||||
|
assert d.changed["title"] == "Ancillary Justice (rev)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_new_item(session):
|
||||||
|
shape = FlatBookShape(id=None, title="Elantris", is_published=True)
|
||||||
|
d = shape.diff(session)
|
||||||
|
assert d.is_new is True
|
||||||
|
assert "title" in d.changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_nonexistent_pk_raises(session):
|
||||||
|
shape = FlatBookShape(id=999999, title="Ghost", is_published=False)
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
shape.diff(session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_diff_created_updated_deleted(session):
|
||||||
|
author = session.query(Author).filter_by(name="Ann Leckie").one()
|
||||||
|
books = sorted(author.books, key=lambda b: b.title)
|
||||||
|
# keep one (updated), drop one (deleted), add one (created)
|
||||||
|
shape = AuthorCardShape(
|
||||||
|
id=author.id,
|
||||||
|
name="Ann Leckie",
|
||||||
|
bio="Imperial Radch",
|
||||||
|
books=[
|
||||||
|
FlatBookShape(id=books[0].id, title="Ancillary Justice REWRITTEN", is_published=True),
|
||||||
|
FlatBookShape(id=None, title="Ancillary Sword", is_published=True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
d = shape.diff(session)
|
||||||
|
assert len(d.books.updated) == 1
|
||||||
|
assert len(d.books.created) == 1
|
||||||
|
assert len(d.books.deleted) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_strict_nested_access_raises_on_typo(session):
|
||||||
|
author = session.query(Author).filter_by(name="Ann Leckie").one()
|
||||||
|
shape = FlatAuthorShape(id=author.id, name="Ann Leckie")
|
||||||
|
d = shape.diff(session)
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
_ = d.bookz
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
d.nested("bookz")
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_many_batches(session):
|
||||||
|
books = session.query(Book).all()
|
||||||
|
items = [FlatBookShape(id=b.id, title=b.title + "!", is_published=b.is_published) for b in books]
|
||||||
|
results = FlatBookShape.diff_many(session, items)
|
||||||
|
assert len(results) == len(books)
|
||||||
|
assert all("title" in d.changed for _, d in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_many_mixed_new_and_existing(session):
|
||||||
|
book = session.query(Book).first()
|
||||||
|
items = [
|
||||||
|
FlatBookShape(id=book.id, title=book.title, is_published=book.is_published),
|
||||||
|
FlatBookShape(id=None, title="Brand New", is_published=False),
|
||||||
|
]
|
||||||
|
results = FlatBookShape.diff_many(session, items)
|
||||||
|
assert sum(1 for _, d in results if d.is_new) == 1
|
||||||
|
assert sum(1 for _, d in results if not d.is_new) == 1
|
||||||
138
backends/mizan-fastapi/tests/test_ssr.py
Normal file
138
backends/mizan-fastapi/tests/test_ssr.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
SSR behavior — the genuine capability behind the `ssr_bridge` probe.
|
||||||
|
|
||||||
|
The SSR subprocess lifecycle + JSON-RPC protocol live in the shared
|
||||||
|
`mizan_core.ssr.SSRBridge`; the FastAPI `SSRRenderer` resolves a component path
|
||||||
|
against `dirs`, drives the bridge, and wraps the result with the hydration script
|
||||||
|
the client reads on mount.
|
||||||
|
|
||||||
|
Bun is not assumed present in CI, so the bridge is driven against a stand-in
|
||||||
|
worker that speaks the SAME newline-delimited JSON-RPC protocol (ready signal +
|
||||||
|
`render` → `{id, html}`). That exercises the real bridge code path (spawn,
|
||||||
|
message-ID correlation, threaded reader) — only the renderer binary is swapped.
|
||||||
|
The path-resolution and hydration-wrapping are tested directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mizan_core.ssr import SSRBridge
|
||||||
|
from mizan_fastapi.ssr import SSRRenderer
|
||||||
|
|
||||||
|
|
||||||
|
# A Python stand-in for the Bun worker: emits the ready signal, then for each
|
||||||
|
# render request echoes a deterministic HTML fragment built from the props.
|
||||||
|
_FAKE_WORKER = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\\n"); sys.stdout.flush()
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
msg = json.loads(line)
|
||||||
|
if msg.get("method") == "render":
|
||||||
|
props = msg["params"]["props"]
|
||||||
|
html = "<p>" + props.get("name", "") + "</p>"
|
||||||
|
sys.stdout.write(json.dumps({"id": msg["id"], "html": html}) + "\\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_worker(tmp_path):
|
||||||
|
worker = tmp_path / "fake_worker.py"
|
||||||
|
worker.write_text(_FAKE_WORKER, encoding="utf-8")
|
||||||
|
return str(worker)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def python_bridge(fake_worker, monkeypatch):
|
||||||
|
"""An `SSRBridge` whose subprocess is python (not bun), driving the fake worker."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
real_popen = subprocess.Popen
|
||||||
|
|
||||||
|
def fake_popen(cmd, *args, **kwargs):
|
||||||
|
# Swap the `bun run <worker>` invocation for `python <worker>`.
|
||||||
|
if cmd[:2] == ["bun", "run"]:
|
||||||
|
cmd = [sys.executable, cmd[2]]
|
||||||
|
return real_popen(cmd, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
bridge = SSRBridge(worker_path=fake_worker, timeout=5.0)
|
||||||
|
yield bridge
|
||||||
|
bridge.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_round_trips_render(python_bridge):
|
||||||
|
result = python_bridge.render("/abs/Hello.tsx", {"name": "World"})
|
||||||
|
assert result.html == "<p>World</p>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_correlates_concurrent_renders(python_bridge):
|
||||||
|
# Two renders on the persistent subprocess return their own results.
|
||||||
|
a = python_bridge.render("/abs/A.tsx", {"name": "A"})
|
||||||
|
b = python_bridge.render("/abs/B.tsx", {"name": "B"})
|
||||||
|
assert (a.html, b.html) == ("<p>A</p>", "<p>B</p>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderer_resolves_against_dirs_and_wraps_hydration(fake_worker, monkeypatch, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
real_popen = subprocess.Popen
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess, "Popen",
|
||||||
|
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
||||||
|
)
|
||||||
|
|
||||||
|
components = tmp_path / "frontend"
|
||||||
|
components.mkdir()
|
||||||
|
(components / "Hello.tsx").write_text("export default () => null", encoding="utf-8")
|
||||||
|
|
||||||
|
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
||||||
|
try:
|
||||||
|
html = renderer.render_to_string("Hello.tsx", {"name": "Mizan"})
|
||||||
|
finally:
|
||||||
|
renderer.shutdown()
|
||||||
|
|
||||||
|
assert '<div id="mizan-root"><p>Mizan</p></div>' in html
|
||||||
|
assert 'window.__MIZAN_SSR_DATA__={"name": "Mizan"}' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderer_returns_html_response(fake_worker, monkeypatch, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
real_popen = subprocess.Popen
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess, "Popen",
|
||||||
|
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
||||||
|
)
|
||||||
|
|
||||||
|
components = tmp_path / "frontend"
|
||||||
|
components.mkdir()
|
||||||
|
(components / "Card.tsx").write_text("export default () => null", encoding="utf-8")
|
||||||
|
|
||||||
|
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
||||||
|
try:
|
||||||
|
response = renderer.render("Card.tsx", {"name": "x"})
|
||||||
|
finally:
|
||||||
|
renderer.shutdown()
|
||||||
|
|
||||||
|
assert isinstance(response, HTMLResponse)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderer_raises_on_missing_component(fake_worker, tmp_path):
|
||||||
|
renderer = SSRRenderer(worker=fake_worker, dirs=[str(tmp_path)])
|
||||||
|
try:
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
renderer.render_to_string("Nope.tsx", {})
|
||||||
|
finally:
|
||||||
|
renderer.shutdown()
|
||||||
145
backends/mizan-fastapi/tests/test_websocket.py
Normal file
145
backends/mizan-fastapi/tests/test_websocket.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
WebSocket RPC behavior — the genuine capability behind the `websocket` probe.
|
||||||
|
|
||||||
|
Proves the `/ws/` route dispatches `@client(websocket=True)` functions through
|
||||||
|
the SAME `mizan_core.dispatch` core as `POST /call/`: input validation, the
|
||||||
|
`{result, invalidate, merge}` envelope, `auth=` enforcement, and the
|
||||||
|
websocket=True gate that rejects HTTP-only functions. The frame protocol matches
|
||||||
|
mizan-django's Channels consumer (`action:"rpc"` → `{id, ok, data|error}`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import clear_registry, register
|
||||||
|
from mizan_fastapi import (
|
||||||
|
MizanError,
|
||||||
|
mizan_exception_handler,
|
||||||
|
mizan_validation_handler,
|
||||||
|
router as mizan_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOut(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
@client(websocket=True)
|
||||||
|
def ws_echo(request, text: str) -> EchoOut:
|
||||||
|
return EchoOut(message=f"ws: {text}")
|
||||||
|
|
||||||
|
@client(websocket=True)
|
||||||
|
def ws_add(request, a: int, b: int) -> dict:
|
||||||
|
return {"total": a + b}
|
||||||
|
|
||||||
|
@client(websocket=True, affects="user")
|
||||||
|
def ws_update(request, user_id: int) -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@client(websocket=True, auth=True)
|
||||||
|
def ws_secret(request) -> dict:
|
||||||
|
return {"secret": True}
|
||||||
|
|
||||||
|
@client # HTTP-only — must be rejected over WS
|
||||||
|
def http_only(request) -> dict:
|
||||||
|
return {"http": True}
|
||||||
|
|
||||||
|
for fn, name in (
|
||||||
|
(ws_echo, "ws_echo"), (ws_add, "ws_add"), (ws_update, "ws_update"),
|
||||||
|
(ws_secret, "ws_secret"), (http_only, "http_only"),
|
||||||
|
):
|
||||||
|
register(fn, name)
|
||||||
|
|
||||||
|
fastapi_app = FastAPI()
|
||||||
|
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||||
|
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
|
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||||
|
yield fastapi_app
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def http(app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_rpc_dispatches_and_returns_data(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "1", "fn": "ws_echo", "args": {"text": "hi"}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame == {"id": "1", "ok": True, "data": {"message": "ws: hi"}, "invalidate": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_rpc_validates_input_through_core(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "2", "fn": "ws_add", "args": {"a": "nope", "b": 3}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is False
|
||||||
|
assert frame["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_rpc_carries_invalidation(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "3", "fn": "ws_update", "args": {"user_id": 5}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is True
|
||||||
|
assert "user" in frame["invalidate"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_only_function_is_forbidden_over_ws(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "4", "fn": "http_only", "args": {}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is False
|
||||||
|
assert frame["error"]["code"] == "FORBIDDEN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_function_over_ws_is_not_found(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "5", "fn": "ghost", "args": {}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is False
|
||||||
|
assert frame["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_required_function_rejects_anonymous_over_ws(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "6", "fn": "ws_secret", "args": {}})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is False
|
||||||
|
assert frame["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_fn_field_is_bad_request(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "7"})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert frame["ok"] is False
|
||||||
|
assert frame["error"]["code"] == "BAD_REQUEST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_action_errors(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "bogus"})
|
||||||
|
frame = ws.receive_json()
|
||||||
|
assert "error" in frame
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_calls_on_one_connection(http):
|
||||||
|
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||||
|
ws.send_json({"action": "rpc", "id": "a", "fn": "ws_echo", "args": {"text": "1"}})
|
||||||
|
first = ws.receive_json()
|
||||||
|
ws.send_json({"action": "rpc", "id": "b", "fn": "ws_echo", "args": {"text": "2"}})
|
||||||
|
second = ws.receive_json()
|
||||||
|
assert first["data"]["message"] == "ws: 1"
|
||||||
|
assert second["data"]["message"] == "ws: 2"
|
||||||
312
backends/mizan-rust-axum/Cargo.lock
generated
312
backends/mizan-rust-axum/Cargo.lock
generated
@@ -27,6 +27,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -38,6 +39,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -45,8 +47,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -74,18 +78,90 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -110,6 +186,23 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -123,17 +216,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -286,10 +411,15 @@ name = "mizan-axum"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
|
"futures-util",
|
||||||
|
"http-body-util",
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
|
"multer",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
@@ -299,10 +429,13 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"hmac",
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -315,6 +448,23 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -333,6 +483,15 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -351,6 +510,36 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -429,6 +618,28 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -451,6 +662,18 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -468,12 +691,33 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -493,6 +737,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -557,12 +813,48 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
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 = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -584,6 +876,26 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ license = "Elastic-2.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mizan-core = { path = "../../cores/mizan-rust" }
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
axum = "0.7"
|
axum = { version = "0.7", features = ["ws", "multipart"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
multer = "3"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|||||||
89
backends/mizan-rust-axum/src/forms.rs
Normal file
89
backends/mizan-rust-axum/src/forms.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! Forms endpoints — schema / validate / submit over the registered form
|
||||||
|
//! functions. The Forms capability is AFI-common; the binding is
|
||||||
|
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
|
||||||
|
//! form_role=…)]` function here). A form is the set of registered functions
|
||||||
|
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
|
||||||
|
//! own route that dispatches the function whose `(form_name, form_role)`
|
||||||
|
//! matches.
|
||||||
|
//!
|
||||||
|
//! POST /form/:form_name/schema/
|
||||||
|
//! POST /form/:form_name/validate/
|
||||||
|
//! POST /form/:form_name/submit/
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::state::MizanState;
|
||||||
|
|
||||||
|
/// Find the registered form function with this `(form_name, form_role)`.
|
||||||
|
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||||
|
FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch the form function for `(form_name, role)`. Shared by the three
|
||||||
|
/// role routes below.
|
||||||
|
async fn dispatch_role(
|
||||||
|
state: &MizanState,
|
||||||
|
form_name: &str,
|
||||||
|
role: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
|
||||||
|
ApiError(MizanError::NotFound(format!(
|
||||||
|
"no form {form_name:?} with role {role:?}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let args_value = match args {
|
||||||
|
Value::Object(_) | Value::Null => args,
|
||||||
|
other => Value::Object({
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("data".into(), other);
|
||||||
|
m
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||||
|
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
|
||||||
|
|
||||||
|
let mut resp = (StatusCode::OK, Json(result)).into_response();
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
|
||||||
|
pub async fn form_schema(
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
Path(form_name): Path<String>,
|
||||||
|
Json(args): Json<Value>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
dispatch_role(&state, &form_name, "schema", args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /form/:form_name/validate/ — validate submitted data without committing.
|
||||||
|
pub async fn form_validate(
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
Path(form_name): Path<String>,
|
||||||
|
Json(args): Json<Value>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
dispatch_role(&state, &form_name, "validate", args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /form/:form_name/submit/ — validate and commit the form.
|
||||||
|
pub async fn form_submit(
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
Path(form_name): Path<String>,
|
||||||
|
Json(args): Json<Value>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
dispatch_role(&state, &form_name, "submit", args).await
|
||||||
|
}
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
|
||||||
|
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderMap, 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,
|
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
|
||||||
|
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
|
||||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::any::Any;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::ApiError;
|
||||||
|
use crate::state::MizanState;
|
||||||
/// Type-erased application state threaded into every `dispatch()` call via
|
|
||||||
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
|
||||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
|
||||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
|
||||||
|
|
||||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -33,9 +30,7 @@ pub struct CallBody {
|
|||||||
|
|
||||||
impl CallBody {
|
impl CallBody {
|
||||||
fn resolved_name(&self) -> Option<&str> {
|
fn resolved_name(&self) -> Option<&str> {
|
||||||
self.function_name
|
self.function_name.as_deref().or(self.fn_.as_deref())
|
||||||
.as_deref()
|
|
||||||
.or(self.fn_.as_deref())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,44 +49,210 @@ fn no_store(json: Value) -> Response {
|
|||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /call/ — RPC dispatch.
|
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
|
||||||
|
/// through the shared `authenticate`. A present-but-invalid token rejects with
|
||||||
|
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
|
||||||
|
pub(crate) fn identity_from_headers(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
state: &MizanState,
|
||||||
|
) -> Result<Option<Identity>, ApiError> {
|
||||||
|
let mwt = headers
|
||||||
|
.get("X-Mizan-Token")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
let bearer = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
|
||||||
|
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||||
|
AuthOutcome::Anonymous => Ok(None),
|
||||||
|
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
|
||||||
|
"Invalid or expired token".into(),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce a function's `@client(auth=...)` against the resolved identity.
|
||||||
|
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
|
||||||
|
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||||
|
enforce_auth(identity, &req).map_err(ApiError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a client call into a `private` function (no RPC endpoint).
|
||||||
|
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
|
||||||
|
if fn_spec.private() {
|
||||||
|
return Err(ApiError(MizanError::Forbidden(
|
||||||
|
"Function is not client-callable".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uid_str(identity: Option<&Identity>) -> Option<String> {
|
||||||
|
identity.map(|i| i.user_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
|
||||||
|
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
|
||||||
|
/// invalidated contexts.
|
||||||
pub async fn function_call(
|
pub async fn function_call(
|
||||||
State(app_state): State<AppStateAny>,
|
State(state): State<Arc<MizanState>>,
|
||||||
Json(body): Json<CallBody>,
|
headers: HeaderMap,
|
||||||
|
body: axum::body::Body,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let fn_name = body
|
let identity = identity_from_headers(&headers, &state)?;
|
||||||
.resolved_name()
|
let content_type = headers
|
||||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let fn_spec = lookup_function(&fn_name)
|
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
|
||||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
parse_multipart(&content_type, body).await?
|
||||||
|
} else {
|
||||||
|
parse_json_call(body).await?
|
||||||
|
};
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
|
||||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
ApiError(MizanError::NotFound(format!(
|
||||||
|
"function {fn_name:?} not registered"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
reject_if_private(fn_spec)?;
|
||||||
|
guard(fn_spec, identity.as_ref())?;
|
||||||
|
|
||||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||||
.iter()
|
let result = fn_spec
|
||||||
.map(InvalidationTarget::to_json)
|
.dispatch(req, Value::Object(args.clone()))
|
||||||
.collect();
|
.await
|
||||||
let merges = compute_merges(fn_spec, &body.args, &result);
|
.map_err(ApiError)?;
|
||||||
|
|
||||||
|
let targets = compute_invalidation(fn_spec, &args);
|
||||||
|
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||||
|
let merges = compute_merges(fn_spec, &args, &result);
|
||||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Purge the origin cache for everything this mutation invalidated.
|
||||||
|
if !targets.is_empty() {
|
||||||
|
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
let payload = CallResponse {
|
let payload = CallResponse {
|
||||||
result,
|
result,
|
||||||
invalidate,
|
invalidate,
|
||||||
merge: merge_payload,
|
merge: merge_payload,
|
||||||
};
|
};
|
||||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
|
||||||
|
if !targets.is_empty() {
|
||||||
|
let header_val = format_invalidate_header(&targets);
|
||||||
|
if let Ok(hv) = HeaderValue::from_str(&header_val) {
|
||||||
|
resp.headers_mut().insert("X-Mizan-Invalidate", hv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
|
||||||
|
let call: CallBody = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
|
||||||
|
let fn_name = call
|
||||||
|
.resolved_name()
|
||||||
|
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||||
|
.to_string();
|
||||||
|
Ok((fn_name, call.args))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
|
||||||
|
/// Each file part binds into the matching Upload-typed input field as a
|
||||||
|
/// base64-carrying value the `mizan_core::Upload` field deserializes.
|
||||||
|
async fn parse_multipart(
|
||||||
|
content_type: &str,
|
||||||
|
body: axum::body::Body,
|
||||||
|
) -> Result<(String, Map<String, Value>), ApiError> {
|
||||||
|
let boundary = multer::parse_boundary(content_type)
|
||||||
|
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
|
||||||
|
let stream = body.into_data_stream();
|
||||||
|
let mut mp = multer::Multipart::new(stream, boundary);
|
||||||
|
|
||||||
|
let mut fn_name: Option<String> = None;
|
||||||
|
let mut args: Map<String, Value> = Map::new();
|
||||||
|
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||||
|
|
||||||
|
while let Some(field) = mp
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
let filename = field.file_name().map(|s| s.to_string());
|
||||||
|
let part_content_type = field.content_type().map(|s| s.to_string());
|
||||||
|
|
||||||
|
if filename.is_some() {
|
||||||
|
// A file part → the JSON shape `mizan_core::Upload` deserializes
|
||||||
|
// (filename, content_type, base64 bytes).
|
||||||
|
let data = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
|
||||||
|
files.entry(name).or_default().push(uploaded_file_json(
|
||||||
|
filename,
|
||||||
|
part_content_type,
|
||||||
|
&data,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let text = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
|
||||||
|
if name == "fn" {
|
||||||
|
fn_name = Some(text);
|
||||||
|
} else if name == "args" {
|
||||||
|
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
|
||||||
|
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
|
||||||
|
})?;
|
||||||
|
if let Value::Object(m) = parsed {
|
||||||
|
args = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind file parts into args by field name (single vs list).
|
||||||
|
for (field_name, parts) in files {
|
||||||
|
if parts.len() == 1 {
|
||||||
|
args.insert(field_name, parts.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
args.insert(field_name, Value::Array(parts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fn_name =
|
||||||
|
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
|
||||||
|
Ok((fn_name, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a received file part as the JSON shape an `Upload` field expects.
|
||||||
|
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
serde_json::json!({
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
"data_b64": STANDARD.encode(data),
|
||||||
|
"size": data.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
|
||||||
pub async fn context_fetch(
|
pub async fn context_fetch(
|
||||||
State(app_state): State<AppStateAny>,
|
State(state): State<Arc<MizanState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
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> {
|
||||||
@@ -101,6 +262,8 @@ pub async fn context_fetch(
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let identity = identity_from_headers(&headers, &state)?;
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -112,22 +275,130 @@ pub async fn context_fetch(
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
// Origin cache: the canonical-JSON bundle body is keyed by (context,
|
||||||
// params get parsed via the per-function input_params primitive table.
|
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
|
||||||
|
let cache_params: BTreeMap<String, Value> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||||
|
.collect();
|
||||||
|
let uid = uid_str(identity.as_ref());
|
||||||
|
|
||||||
|
if let Some(cached) = state
|
||||||
|
.cache
|
||||||
|
.get(&context_name, &cache_params, uid.as_deref(), 0)
|
||||||
|
{
|
||||||
|
return Ok(cached_response(cached, "HIT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce auth per member (the bundle is only as open as its strictest fn).
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in &members {
|
||||||
|
guard(*fn_spec, identity.as_ref())?;
|
||||||
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(state.app_state.as_ref());
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
let result = fn_spec
|
||||||
|
.dispatch(req, Value::Object(args))
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(no_store(Value::Object(bundled)))
|
let body = canonical_bytes(&Value::Object(bundled));
|
||||||
|
let status = if state.cache.enabled() {
|
||||||
|
state
|
||||||
|
.cache
|
||||||
|
.put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0);
|
||||||
|
"MISS"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
Ok(cached_response(body, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coerce string-valued query params into typed JSON values using the
|
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
|
||||||
/// function's declared input_params. Strings that don't parse stay as
|
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
|
||||||
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
fn canonical_bytes(v: &Value) -> Vec<u8> {
|
||||||
|
fn sort(v: &Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Object(m) => {
|
||||||
|
let mut keys: Vec<&String> = m.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
let mut out = Map::new();
|
||||||
|
for k in keys {
|
||||||
|
out.insert(k.clone(), sort(&m[k]));
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Python's default separators add a space after ':' and ','. Match that so
|
||||||
|
// a Rust-written cache body and a Python-written one are byte-equal.
|
||||||
|
let sorted = sort(v);
|
||||||
|
python_json(&sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
|
||||||
|
/// (`", "` and `": "`).
|
||||||
|
fn python_json(v: &Value) -> Vec<u8> {
|
||||||
|
let compact = serde_json::to_string(v).unwrap();
|
||||||
|
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
|
||||||
|
// This is a structural transform on the already-sorted value, so the
|
||||||
|
// bytes match `json.dumps` for the JSON value space Mizan returns.
|
||||||
|
let spaced = respace(&compact);
|
||||||
|
spaced.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert the spaces Python's default `json.dumps` uses after structural
|
||||||
|
/// `,`/`:` — but only outside string literals.
|
||||||
|
fn respace(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + s.len() / 8);
|
||||||
|
let mut in_str = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if in_str {
|
||||||
|
out.push(c);
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if c == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if c == '"' {
|
||||||
|
in_str = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match c {
|
||||||
|
'"' => {
|
||||||
|
in_str = true;
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
',' => out.push_str(", "),
|
||||||
|
':' => out.push_str(": "),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
|
||||||
|
let mut resp = (StatusCode::OK, body).into_response();
|
||||||
|
let h = resp.headers_mut();
|
||||||
|
h.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
if !cache_status.is_empty() {
|
||||||
|
if let Ok(v) = HeaderValue::from_str(cache_status) {
|
||||||
|
h.insert("X-Mizan-Cache", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coerce string-valued query params into typed JSON via the function's
|
||||||
|
/// declared input_params.
|
||||||
fn coerce_query_args(
|
fn coerce_query_args(
|
||||||
fn_spec: &dyn FunctionSpec,
|
fn_spec: &dyn FunctionSpec,
|
||||||
params: &BTreeMap<String, String>,
|
params: &BTreeMap<String, String>,
|
||||||
@@ -137,28 +408,88 @@ fn coerce_query_args(
|
|||||||
if let Some(raw) = params.get(ip.name) {
|
if let Some(raw) = params.get(ip.name) {
|
||||||
let parsed = match ip.primitive {
|
let parsed = match ip.primitive {
|
||||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
mizan_core::Primitive::Number => raw
|
||||||
serde_json::Number::from_f64(v).map(Value::Number)
|
.parse::<f64>()
|
||||||
}),
|
.ok()
|
||||||
|
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
|
||||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||||
};
|
};
|
||||||
if let Some(v) = parsed {
|
out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone())));
|
||||||
out.insert(ip.name.into(), v);
|
|
||||||
} else {
|
|
||||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
||||||
/// mizan-django and mizan-fastapi. The CSRF *token* is a Django session
|
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
|
||||||
/// mechanism with no Rust equivalent, so this returns a null token; the endpoint
|
/// mechanism; the endpoint here returns a null token and serves as the
|
||||||
/// itself is owed and present, and readiness-probe consumers get a well-formed
|
/// readiness probe the wire-parity harness uses.
|
||||||
/// 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)
|
}
|
||||||
|
|
||||||
|
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
|
||||||
|
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
|
||||||
|
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
|
||||||
|
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
|
||||||
|
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||||
|
no_store(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
|
||||||
|
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
|
||||||
|
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
|
||||||
|
/// re-renders. This is the adapter telling Edge *how* to cache each context —
|
||||||
|
/// the PSR half of the manifest, addressable per-context.
|
||||||
|
pub async fn psr_descriptor(
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
Path(context_name): Path<String>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||||
|
let ctx = manifest
|
||||||
|
.get("contexts")
|
||||||
|
.and_then(|c| c.get(&context_name))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} not in manifest"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
let render_strategy = ctx
|
||||||
|
.get("render_strategy")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let page_routes = ctx
|
||||||
|
.get("page_routes")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| Value::Array(Vec::new()));
|
||||||
|
Ok(no_store(serde_json::json!({
|
||||||
|
"context": context_name,
|
||||||
|
"render_strategy": render_strategy,
|
||||||
|
"page_routes": page_routes,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
|
||||||
|
/// output, derived from the registered type graph by `mizan_core::shapes`.
|
||||||
|
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
|
||||||
|
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
|
||||||
|
ApiError(MizanError::NotFound(format!(
|
||||||
|
"no shape projection for {fn_name:?}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
Ok(no_store(projection_to_json(&proj)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
for f in &proj.fields {
|
||||||
|
match f {
|
||||||
|
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||||
|
shapes::ShapeField::Nested(n, sub) => {
|
||||||
|
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::json!({ "type": proj.type_name, "fields": fields })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
|
||||||
|
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! use axum::Router;
|
//! use axum::Router;
|
||||||
//! use mizan_axum::router;
|
//! use mizan_axum::{router, MizanState};
|
||||||
//!
|
//!
|
||||||
//! #[tokio::main]
|
//! #[tokio::main]
|
||||||
//! async fn main() {
|
//! async fn main() {
|
||||||
//! let app = Router::new().nest("/api/mizan", router());
|
//! let state = MizanState::builder()
|
||||||
|
//! .app_state(MyState { /* ... */ })
|
||||||
|
//! .build();
|
||||||
|
//! let app = Router::new().nest("/api/mizan", router(state));
|
||||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||||
//! axum::serve(listener, app).await.unwrap();
|
//! axum::serve(listener, app).await.unwrap();
|
||||||
//! }
|
//! }
|
||||||
@@ -15,44 +19,62 @@
|
|||||||
//!
|
//!
|
||||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
|
||||||
//! * `GET /ctx/:name/` — bundled context fetch
|
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
|
||||||
|
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
|
||||||
|
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
|
||||||
|
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
|
||||||
|
//! * `GET /shape/:fn/` — typed query projection (Shapes)
|
||||||
|
//! * `POST /ssr/` — server-side render via the Bun worker
|
||||||
|
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
|
||||||
|
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod forms;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod ssr;
|
||||||
|
mod state;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
pub use errors::ApiError;
|
pub use errors::ApiError;
|
||||||
pub use handlers::{
|
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
|
||||||
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
pub use ssr::{ssr_render, SsrRequest};
|
||||||
};
|
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Build the Mizan router with user-supplied app state. The state is
|
/// Build the Mizan router with a fully-configured [`MizanState`] (app state +
|
||||||
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
/// auth + cache + optional SSR worker). Mount under a prefix:
|
||||||
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
|
/// `Router::new().nest("/api/mizan", router(state))`.
|
||||||
/// type.
|
pub fn router(state: Arc<MizanState>) -> Router {
|
||||||
///
|
|
||||||
/// Mount under a prefix:
|
|
||||||
/// `Router::new().nest("/api/mizan", router(my_state))`.
|
|
||||||
pub fn router<S>(state: S) -> Router
|
|
||||||
where
|
|
||||||
S: Any + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let state: AppStateAny = Arc::new(state);
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/session/", get(handlers::session_init))
|
.route("/session/", get(handlers::session_init))
|
||||||
.route("/call/", post(handlers::function_call))
|
.route("/call/", post(handlers::function_call))
|
||||||
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
||||||
|
.route("/ws/", get(ws::ws_handler))
|
||||||
|
.route("/manifest/", get(handlers::edge_manifest))
|
||||||
|
.route("/psr/:context_name/", get(handlers::psr_descriptor))
|
||||||
|
.route("/shape/:fn_name/", get(handlers::shape_projection))
|
||||||
|
.route("/ssr/", post(ssr::ssr_render))
|
||||||
|
.route("/form/:form_name/schema/", post(forms::form_schema))
|
||||||
|
.route("/form/:form_name/validate/", post(forms::form_validate))
|
||||||
|
.route("/form/:form_name/submit/", post(forms::form_submit))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Router variant for callers that have no app state to thread — the
|
/// Router variant for the common case of just an app state, no auth/cache.
|
||||||
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
pub fn router_with_state<S>(app_state: S) -> Router
|
||||||
/// and other stateless test apps.
|
where
|
||||||
pub fn router_stateless() -> Router {
|
S: Any + Send + Sync + 'static,
|
||||||
router(())
|
{
|
||||||
|
router(MizanState::builder().app_state(app_state).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Router variant for callers that have no app state to thread — the dispatch
|
||||||
|
/// path receives a unit-typed handle. Used by the AFI fixture and stateless
|
||||||
|
/// test apps.
|
||||||
|
pub fn router_stateless() -> Router {
|
||||||
|
router(MizanState::builder().build())
|
||||||
}
|
}
|
||||||
|
|||||||
50
backends/mizan-rust-axum/src/ssr.rs
Normal file
50
backends/mizan-rust-axum/src/ssr.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
|
||||||
|
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
|
||||||
|
//! `SSRBridge`). The bridge spawns on first render and stays alive.
|
||||||
|
//!
|
||||||
|
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Json;
|
||||||
|
use mizan_core::MizanError;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::state::MizanState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SsrRequest {
|
||||||
|
pub file: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub props: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /ssr/ — render a component file via the Bun SSR worker.
|
||||||
|
pub async fn ssr_render(
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
Json(req): Json<SsrRequest>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let bridge = state.ssr().ok_or_else(|| {
|
||||||
|
ApiError(MizanError::NotImplementedYet(
|
||||||
|
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let props = if req.props.is_null() {
|
||||||
|
json!({})
|
||||||
|
} else {
|
||||||
|
req.props
|
||||||
|
};
|
||||||
|
let html = bridge
|
||||||
|
.render(&req.file, props)
|
||||||
|
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
|
||||||
|
|
||||||
|
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
axum::http::header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static("no-store"),
|
||||||
|
);
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
106
backends/mizan-rust-axum/src/state.rs
Normal file
106
backends/mizan-rust-axum/src/state.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//! Router state — the Mizan config (auth + origin cache) threaded alongside
|
||||||
|
//! the user's type-erased app state.
|
||||||
|
//!
|
||||||
|
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
|
||||||
|
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
|
||||||
|
//! their concrete type — unchanged from the pre-AFI router). `auth` and
|
||||||
|
//! `cache` are the AFI-common config the handlers read for enforcement and
|
||||||
|
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
|
||||||
|
|
||||||
|
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
|
||||||
|
use std::any::Any;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
|
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
|
||||||
|
pub struct MizanState {
|
||||||
|
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
|
||||||
|
pub app_state: AppStateAny,
|
||||||
|
/// JWT/MWT auth config (token → identity resolution + enforcement).
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
/// Origin-side HMAC cache orchestrator (disabled by default).
|
||||||
|
pub cache: CacheOrchestrator,
|
||||||
|
/// Mizan API mount point, used by the edge-manifest endpoint.
|
||||||
|
pub base_url: String,
|
||||||
|
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
|
||||||
|
pub(crate) ssr_worker: Option<String>,
|
||||||
|
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MizanState {
|
||||||
|
pub fn builder() -> MizanStateBuilder {
|
||||||
|
MizanStateBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SSR bridge, spawned on first use. `None` if no worker was set.
|
||||||
|
pub fn ssr(&self) -> Option<&SsrBridge> {
|
||||||
|
let worker = self.ssr_worker.as_ref()?;
|
||||||
|
Some(
|
||||||
|
self.ssr_bridge
|
||||||
|
.get_or_init(|| SsrBridge::bun(worker.clone())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
|
||||||
|
/// disabled, `/api/mizan` base URL, no SSR worker.
|
||||||
|
pub struct MizanStateBuilder {
|
||||||
|
app_state: AppStateAny,
|
||||||
|
auth: AuthConfig,
|
||||||
|
cache: CacheOrchestrator,
|
||||||
|
base_url: String,
|
||||||
|
ssr_worker: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MizanStateBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
app_state: Arc::new(()),
|
||||||
|
auth: AuthConfig::new(),
|
||||||
|
cache: CacheOrchestrator::disabled(),
|
||||||
|
base_url: "/api/mizan".to_string(),
|
||||||
|
ssr_worker: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MizanStateBuilder {
|
||||||
|
/// Set the consumer's app state (threaded into dispatch).
|
||||||
|
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
|
||||||
|
self.app_state = Arc::new(state);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth(mut self, auth: AuthConfig) -> Self {
|
||||||
|
self.auth = auth;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
|
||||||
|
self.cache = cache;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the Bun SSR worker path; the bridge spawns on first render.
|
||||||
|
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
|
||||||
|
self.ssr_worker = Some(worker_path.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> Arc<MizanState> {
|
||||||
|
Arc::new(MizanState {
|
||||||
|
app_state: self.app_state,
|
||||||
|
auth: self.auth,
|
||||||
|
cache: self.cache,
|
||||||
|
base_url: self.base_url,
|
||||||
|
ssr_worker: self.ssr_worker,
|
||||||
|
ssr_bridge: OnceLock::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
174
backends/mizan-rust-axum/src/ws.rs
Normal file
174
backends/mizan-rust-axum/src/ws.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
|
||||||
|
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
|
||||||
|
//! that dispatches call/fetch frames through the same `mizan-core` registry
|
||||||
|
//! the HTTP path uses. A call frame naming a non-websocket function is
|
||||||
|
//! rejected, so the transport boundary the IR declares is enforced.
|
||||||
|
//!
|
||||||
|
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
|
||||||
|
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
|
||||||
|
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
|
||||||
|
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
|
||||||
|
//! ← {"id": 2, "data": {fnName: result, ...}}
|
||||||
|
//! ← {"id": N, "error": {"code": ..., "message": ...}}
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Response;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use mizan_core::{
|
||||||
|
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
|
||||||
|
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::state::MizanState;
|
||||||
|
|
||||||
|
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
|
||||||
|
pub async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<MizanState>>,
|
||||||
|
) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
|
||||||
|
while let Some(Ok(msg)) = socket.next().await {
|
||||||
|
let text = match msg {
|
||||||
|
Message::Text(t) => t,
|
||||||
|
Message::Close(_) => break,
|
||||||
|
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
|
||||||
|
};
|
||||||
|
let reply = handle_frame(&state, &text).await;
|
||||||
|
if socket
|
||||||
|
.send(Message::Text(reply.to_string()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_frame(state: &MizanState, text: &str) -> Value {
|
||||||
|
let frame: Value = match serde_json::from_str(text) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
|
||||||
|
};
|
||||||
|
let id = frame.get("id").cloned().unwrap_or(Value::Null);
|
||||||
|
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
|
||||||
|
|
||||||
|
match op {
|
||||||
|
"call" => match dispatch_ws_call(state, &frame).await {
|
||||||
|
Ok(v) => with_id(id, v),
|
||||||
|
Err(e) => err_frame(id, &e),
|
||||||
|
},
|
||||||
|
"fetch" => match dispatch_ws_fetch(state, &frame).await {
|
||||||
|
Ok(v) => with_id(id, json!({ "data": v })),
|
||||||
|
Err(e) => err_frame(id, &e),
|
||||||
|
},
|
||||||
|
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||||
|
let fn_name = frame
|
||||||
|
.get("fn")
|
||||||
|
.and_then(|f| f.as_str())
|
||||||
|
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
|
||||||
|
let args = frame
|
||||||
|
.get("args")
|
||||||
|
.and_then(|a| a.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let fn_spec =
|
||||||
|
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
|
||||||
|
if fn_spec.private() {
|
||||||
|
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||||
|
}
|
||||||
|
// The WS transport only carries functions that opted into it.
|
||||||
|
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
|
||||||
|
return Err(MizanError::BadRequest(format!(
|
||||||
|
"function {fn_name:?} is not exposed over the WebSocket transport"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
enforce_anon_guard(fn_spec)?;
|
||||||
|
|
||||||
|
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||||
|
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||||
|
|
||||||
|
let targets = compute_invalidation(fn_spec, &args);
|
||||||
|
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||||
|
let merges = compute_merges(fn_spec, &args, &result);
|
||||||
|
|
||||||
|
let mut out = Map::new();
|
||||||
|
out.insert("result".into(), result);
|
||||||
|
out.insert("invalidate".into(), Value::Array(invalidate));
|
||||||
|
if !merges.is_empty() {
|
||||||
|
out.insert(
|
||||||
|
"merge".into(),
|
||||||
|
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Value::Object(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||||
|
let ctx = frame
|
||||||
|
.get("context")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
|
||||||
|
if lookup_context(ctx).is_none() {
|
||||||
|
return Err(MizanError::NotFound(format!("context {ctx:?}")));
|
||||||
|
}
|
||||||
|
let params = frame
|
||||||
|
.get("params")
|
||||||
|
.and_then(|p| p.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| f.context() == Some(ctx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut bundle = Map::new();
|
||||||
|
for fn_spec in &members {
|
||||||
|
enforce_anon_guard(*fn_spec)?;
|
||||||
|
let mut args = Map::new();
|
||||||
|
for ip in fn_spec.input_params() {
|
||||||
|
if let Some(v) = params.get(ip.name) {
|
||||||
|
args.insert(ip.name.into(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||||
|
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
||||||
|
bundle.insert(fn_spec.name().to_string(), result);
|
||||||
|
}
|
||||||
|
Ok(Value::Object(bundle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce a function's auth guard for the WS transport. The WS upgrade
|
||||||
|
/// carries no per-frame identity in this baseline, so a guarded function is
|
||||||
|
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
|
||||||
|
/// applied with an anonymous identity.
|
||||||
|
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
|
||||||
|
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||||
|
mizan_core::enforce_auth(None, &req)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_id(id: Value, mut body: Value) -> Value {
|
||||||
|
if let Some(obj) = body.as_object_mut() {
|
||||||
|
obj.insert("id".into(), id);
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err_frame(id: Value, e: &MizanError) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"error": { "code": e.code(), "message": e.message() },
|
||||||
|
})
|
||||||
|
}
|
||||||
422
backends/mizan-rust-axum/tests/behavior.rs
Normal file
422
backends/mizan-rust-axum/tests/behavior.rs
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
//! Runtime behavior tests for the axum adapter — the conformance ceiling that
|
||||||
|
//! the source-presence probes set the floor for. Each AFI-common HTTP cell is
|
||||||
|
//! driven end to end through the real router (`tower::ServiceExt::oneshot`,
|
||||||
|
//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs
|
||||||
|
//! against a real bound port.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use mizan_core as mizan;
|
||||||
|
use mizan_core::prelude::*;
|
||||||
|
use mizan_core::{
|
||||||
|
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
||||||
|
};
|
||||||
|
use mizan_axum::{router, MizanState};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// ─── Fixture: the functions these tests dispatch ────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Ok {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Secret {
|
||||||
|
pub flag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct UploadEcho {
|
||||||
|
pub filename: String,
|
||||||
|
pub size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct SchemaOut {
|
||||||
|
pub fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::context("bprofile")]
|
||||||
|
pub struct BProfileCtx;
|
||||||
|
|
||||||
|
#[mizan::client(context = BProfileCtx)]
|
||||||
|
pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
|
||||||
|
Profile {
|
||||||
|
user_id,
|
||||||
|
name: format!("user-{user_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(affects = BProfileCtx)]
|
||||||
|
pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok {
|
||||||
|
let _ = (user_id, name);
|
||||||
|
Ok { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(auth = "staff")]
|
||||||
|
pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret {
|
||||||
|
Secret {
|
||||||
|
flag: "top-secret".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(websocket)]
|
||||||
|
pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok {
|
||||||
|
let _ = n;
|
||||||
|
Ok { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client]
|
||||||
|
pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho {
|
||||||
|
let _ = user_id;
|
||||||
|
UploadEcho {
|
||||||
|
filename: avatar.filename.clone().unwrap_or_default(),
|
||||||
|
size: avatar.size() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(form_name = "contact", form_role = "submit")]
|
||||||
|
pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok {
|
||||||
|
let _ = name;
|
||||||
|
Ok { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(form_name = "contact", form_role = "schema")]
|
||||||
|
pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut {
|
||||||
|
SchemaOut {
|
||||||
|
fields: vec!["name".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn stateless_app() -> axum::Router {
|
||||||
|
router(MizanState::builder().build())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn body_json(resp: axum::response::Response) -> Value {
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/call/")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json!({"fn": fn_name, "args": args}).to_string()))
|
||||||
|
.unwrap();
|
||||||
|
app.clone().oneshot(req).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── invalidate_header + invalidate_body + rpc_call ──────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn call_emits_invalidate_body_and_header() {
|
||||||
|
let app = stateless_app();
|
||||||
|
let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// The header is co-equal with the body channel: scoped to user_id=7.
|
||||||
|
let header = resp
|
||||||
|
.headers()
|
||||||
|
.get("X-Mizan-Invalidate")
|
||||||
|
.expect("X-Mizan-Invalidate present")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(header, "bprofile;user_id=7");
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers().get("cache-control").unwrap(),
|
||||||
|
"no-store"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["result"], json!({"ok": true}));
|
||||||
|
// Body invalidate entry is the scoped object form.
|
||||||
|
assert_eq!(
|
||||||
|
body["invalidate"],
|
||||||
|
json!([{"context": "bprofile", "params": {"user_id": 7}}])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_guard_rejects_anonymous_and_admits_staff() {
|
||||||
|
// No auth config + a staff-guarded fn → anonymous is rejected 401.
|
||||||
|
let app = stateless_app();
|
||||||
|
let resp = post_call(&app, "b_secret", json!({})).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
// With a JWT config + a staff token, the same call is admitted. Mint at
|
||||||
|
// the real clock so the token is unexpired when the handler verifies it.
|
||||||
|
let cfg = JwtConfig::new("beh-secret");
|
||||||
|
let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix());
|
||||||
|
let auth = AuthConfig {
|
||||||
|
jwt: Some(cfg),
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".into(),
|
||||||
|
};
|
||||||
|
let app = router(MizanState::builder().auth(auth).build());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/call/")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
|
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["result"], json!({"flag": "top-secret"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_guard_forbids_non_staff_token() {
|
||||||
|
// A valid but non-staff token → 403 on a staff-guarded fn.
|
||||||
|
let cfg = JwtConfig::new("beh-secret");
|
||||||
|
let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix());
|
||||||
|
let auth = AuthConfig {
|
||||||
|
jwt: Some(cfg),
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".into(),
|
||||||
|
};
|
||||||
|
let app = router(MizanState::builder().auth(auth).build());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/call/")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
|
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_token_is_rejected_not_downgraded() {
|
||||||
|
// A present-but-bad bearer rejects (401) even on an unguarded context —
|
||||||
|
// the INVALID-sentinel contract.
|
||||||
|
let auth = AuthConfig {
|
||||||
|
jwt: Some(JwtConfig::new("beh-secret")),
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".into(),
|
||||||
|
};
|
||||||
|
let app = router(MizanState::builder().auth(auth).build());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/ctx/bprofile/?user_id=1")
|
||||||
|
.header("authorization", "Bearer not-a-real-token")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── origin_cache ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn context_fetch_uses_origin_cache() {
|
||||||
|
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
||||||
|
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into()));
|
||||||
|
let app = router(MizanState::builder().cache(cache).build());
|
||||||
|
|
||||||
|
// First fetch: MISS, populates the cache.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/ctx/bprofile/?user_id=3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
||||||
|
let first = body_json(resp).await;
|
||||||
|
assert_eq!(first["b_user_profile"]["user_id"], json!(3));
|
||||||
|
|
||||||
|
// Second fetch: HIT, served from cache.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/ctx/bprofile/?user_id=3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT");
|
||||||
|
let second = body_json(resp).await;
|
||||||
|
assert_eq!(first, second);
|
||||||
|
|
||||||
|
// A mutation scoped to user_id=3 purges that key → next fetch MISSes.
|
||||||
|
let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await;
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/ctx/bprofile/?user_id=3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── upload ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multipart_upload_binds_into_input() {
|
||||||
|
let app = stateless_app();
|
||||||
|
let boundary = "----mizanbeh";
|
||||||
|
let file_bytes = b"PNGDATA-0123456789";
|
||||||
|
let body = format!(
|
||||||
|
"--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\
|
||||||
|
--{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\
|
||||||
|
--{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
|
||||||
|
Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n",
|
||||||
|
b = boundary,
|
||||||
|
data = String::from_utf8_lossy(file_bytes),
|
||||||
|
);
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/call/")
|
||||||
|
.header(
|
||||||
|
"content-type",
|
||||||
|
format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["result"]["filename"], json!("a.png"));
|
||||||
|
assert_eq!(body["result"]["size"], json!(file_bytes.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── edge_manifest + psr ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manifest_and_psr_descriptor() {
|
||||||
|
let app = stateless_app();
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/manifest/")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await;
|
||||||
|
// bprofile is user-scoped (user_id) → dynamic_cached.
|
||||||
|
assert_eq!(
|
||||||
|
manifest["contexts"]["bprofile"]["render_strategy"],
|
||||||
|
json!("dynamic_cached")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest["mutations"]["b_update_profile"]["affects"],
|
||||||
|
json!(["bprofile"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-context PSR descriptor.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/psr/bprofile/")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let psr = body_json(app.oneshot(req).await.unwrap()).await;
|
||||||
|
assert_eq!(psr["render_strategy"], json!("dynamic_cached"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── shapes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shape_projection_endpoint() {
|
||||||
|
let app = stateless_app();
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/shape/b_user_profile/")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
// Output type name is camelCased by the macro (`b_user_profile` →
|
||||||
|
// `bUserProfile`), suffixed `Output`.
|
||||||
|
assert_eq!(body["type"], json!("bUserProfileOutput"));
|
||||||
|
let fields = body["fields"].as_array().unwrap();
|
||||||
|
assert!(fields.contains(&json!("user_id")));
|
||||||
|
assert!(fields.contains(&json!("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── forms ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forms_schema_and_submit_routes() {
|
||||||
|
let app = stateless_app();
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/form/contact/schema/")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from("{}"))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["fields"], json!(["name"]));
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/form/contact/submit/")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json!({"name": "Ada"}).to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert_eq!(body_json(resp).await, json!({"ok": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── websocket ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn websocket_transport_dispatches_and_rejects_non_ws_fn() {
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
// Bind a real socket — the WS upgrade needs an actual connection.
|
||||||
|
let app = stateless_app();
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let url = format!("ws://{addr}/ws/");
|
||||||
|
let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
|
||||||
|
|
||||||
|
// A websocket-declared fn dispatches.
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reply = socket.next().await.unwrap().unwrap();
|
||||||
|
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
||||||
|
assert_eq!(v["id"], json!(1));
|
||||||
|
assert_eq!(v["result"], json!({"ok": true}));
|
||||||
|
|
||||||
|
// A non-websocket fn over WS is rejected (transport boundary enforced).
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reply = socket.next().await.unwrap().unwrap();
|
||||||
|
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
||||||
|
assert_eq!(v["id"], json!(2));
|
||||||
|
assert!(v["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("WebSocket transport"));
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
101
backends/mizan-tauri/Cargo.lock
generated
101
backends/mizan-tauri/Cargo.lock
generated
@@ -558,6 +558,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1228,6 +1229,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -1545,7 +1555,7 @@ dependencies = [
|
|||||||
"cesu8",
|
"cesu8",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -1554,37 +1564,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||||
dependencies = [
|
|
||||||
"jni-sys 0.4.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jni-sys"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
|
||||||
dependencies = [
|
|
||||||
"jni-sys-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jni-sys-macros"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1682,9 +1670,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.16"
|
version = "0.1.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1788,10 +1776,13 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"hmac",
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1808,10 +1799,12 @@ dependencies = [
|
|||||||
name = "mizan-tauri"
|
name = "mizan-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1842,7 +1835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -1856,7 +1849,7 @@ version = "0.6.0+11769913"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jni-sys 0.3.1",
|
"jni-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2467,9 +2460,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2908,6 +2901,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3360,9 +3359,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3785,9 +3796,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3798,9 +3809,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.71"
|
version = "0.4.72"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -3808,9 +3819,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -3818,9 +3829,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3831,9 +3842,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -3887,9 +3898,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ mizan-core = { path = "../../cores/mizan-rust" }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
|
||||||
|
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
|
||||||
//!
|
//!
|
||||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||||
//!
|
//!
|
||||||
@@ -9,79 +10,137 @@
|
|||||||
//! .expect("error while running tauri application");
|
//! .expect("error while running tauri application");
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
//! The plugin exposes commands reachable from the JS-side
|
||||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
//! `@mizan/tauri-transport`:
|
||||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
|
||||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
|
||||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
|
||||||
//! consumes. There is no per-function tauri::command; the registry IS
|
|
||||||
//! the dispatch table.
|
|
||||||
//!
|
//!
|
||||||
//! Wire envelope:
|
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
|
||||||
|
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
|
||||||
|
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
|
||||||
|
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
|
||||||
|
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
|
||||||
|
//! Tauri `Channel<T>` carries the push stream instead.
|
||||||
|
//!
|
||||||
|
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
|
||||||
//!
|
//!
|
||||||
//! ```json
|
//! ```json
|
||||||
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
|
||||||
//! { "op": "fetch", "context": "session", "params": {} }
|
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
|
||||||
|
//! { "op": "shape", "fn": "user_profile" }
|
||||||
|
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
//! Response shapes mirror the HTTP adapter:
|
||||||
//! mizan-rust-axum:
|
|
||||||
//!
|
//!
|
||||||
//! * `call` → `{ result, invalidate, merge? }`
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
|
//! * `shape` → `{ type, fields }`
|
||||||
|
//! * `form` → the form function's result
|
||||||
//!
|
//!
|
||||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
|
||||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
|
||||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
//! `authenticate` and enforced against each function's `auth=` requirement.
|
||||||
//! see one error surface regardless of transport.
|
//! There is no header channel over IPC, so the token rides the envelope.
|
||||||
|
//!
|
||||||
|
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||||
|
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
|
||||||
|
//! into a `MizanError`.
|
||||||
|
|
||||||
|
mod ssr;
|
||||||
|
|
||||||
|
pub use ssr::{ssr_render, MizanSsr};
|
||||||
|
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
|
||||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
|
||||||
|
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Runtime,
|
Manager, Runtime,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
|
||||||
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
|
||||||
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
/// dispatch commands read it from managed state.
|
||||||
|
pub struct MizanTauriConfig {
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
pub cache: CacheOrchestrator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MizanTauriConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
auth: AuthConfig::new(),
|
||||||
|
cache: CacheOrchestrator::disabled(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
|
||||||
|
/// Registers a default (auth-off, cache-disabled) config if the consumer
|
||||||
|
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
|
||||||
|
/// and `plugin:mizan|mizan_subscribe`.
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::<R>::new("mizan")
|
Builder::<R>::new("mizan")
|
||||||
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
mizan_invoke,
|
||||||
|
mizan_subscribe,
|
||||||
|
ssr::ssr_render
|
||||||
|
])
|
||||||
|
.setup(|app, _api| {
|
||||||
|
if app.try_state::<MizanTauriConfig>().is_none() {
|
||||||
|
app.manage(MizanTauriConfig::default());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Wire envelope ===
|
// === Wire envelope ===
|
||||||
|
|
||||||
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
/// One Mizan request. Tauri's serde deserializer pulls this out of the
|
||||||
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
/// `envelope` field of the invoke payload.
|
||||||
/// field of the invoke payload.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "op")]
|
#[serde(tag = "op")]
|
||||||
pub enum Envelope {
|
pub enum Envelope {
|
||||||
#[serde(rename = "call")]
|
#[serde(rename = "call")]
|
||||||
Call {
|
Call {
|
||||||
/// Wire-level function name — registered name on the Rust side.
|
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
function_name: String,
|
function_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
args: Map<String, Value>,
|
args: Map<String, Value>,
|
||||||
|
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
|
||||||
|
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
|
||||||
|
#[serde(default)]
|
||||||
|
token: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "fetch")]
|
#[serde(rename = "fetch")]
|
||||||
Fetch {
|
Fetch {
|
||||||
context: String,
|
context: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
params: Map<String, Value>,
|
params: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
token: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "shape")]
|
||||||
|
Shape {
|
||||||
|
#[serde(rename = "fn")]
|
||||||
|
function_name: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "form")]
|
||||||
|
Form {
|
||||||
|
form: String,
|
||||||
|
role: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Value,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
/// `{"code", "message", "details?"}` shape.
|
||||||
/// 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,
|
||||||
@@ -105,110 +164,336 @@ impl From<MizanError> for ErrorPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Dispatch ===
|
// === Auth ===
|
||||||
|
|
||||||
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
|
||||||
/// handler — the consumer never wires it directly.
|
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
|
||||||
///
|
/// `INVALID`-sentinel contract); absent → anonymous.
|
||||||
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
fn identity_from_token(
|
||||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
token: Option<&str>,
|
||||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
config: &MizanTauriConfig,
|
||||||
/// emission. Stateless functions ignore the handle.
|
) -> Result<Option<Identity>, MizanError> {
|
||||||
|
let (mwt, bearer) = match token {
|
||||||
|
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
|
||||||
|
Some(t) => (Some(t), None),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
match authenticate(mwt, bearer, &config.auth, now_unix()) {
|
||||||
|
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||||
|
AuthOutcome::Anonymous => Ok(None),
|
||||||
|
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
|
||||||
|
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Dispatch commands ===
|
||||||
|
|
||||||
|
/// The single Mizan request/response command. Tauri auto-injects `app`; the
|
||||||
|
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
|
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn mizan_invoke<R: Runtime>(
|
async fn mizan_invoke<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
envelope: Envelope,
|
envelope: Envelope,
|
||||||
) -> Result<Value, ErrorPayload> {
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
|
||||||
|
/// response (or a `MizanError`). This is the programmatic entry point the
|
||||||
|
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
|
||||||
|
/// tests) can drive the Mizan protocol without the IPC serialization layer.
|
||||||
|
pub async fn dispatch<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
envelope: Envelope,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
// Read the managed config (lifetime-bound to `app`, which outlives this
|
||||||
|
// dispatch); fall back to a default if none was registered. The `State`
|
||||||
|
// guard is held across the awaits below.
|
||||||
|
let managed = app.try_state::<MizanTauriConfig>();
|
||||||
|
let default;
|
||||||
|
let cfg: &MizanTauriConfig = match managed.as_ref() {
|
||||||
|
Some(state) => state.inner(),
|
||||||
|
None => {
|
||||||
|
default = MizanTauriConfig::default();
|
||||||
|
&default
|
||||||
|
}
|
||||||
|
};
|
||||||
match envelope {
|
match envelope {
|
||||||
Envelope::Call {
|
Envelope::Call {
|
||||||
function_name,
|
function_name,
|
||||||
args,
|
args,
|
||||||
} => handle_call(&app, &function_name, args).await,
|
token,
|
||||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
|
||||||
|
Envelope::Fetch {
|
||||||
|
context,
|
||||||
|
params,
|
||||||
|
token,
|
||||||
|
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
|
||||||
|
Envelope::Shape { function_name } => handle_shape(&function_name),
|
||||||
|
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_call<R: Runtime>(
|
async fn handle_call<R: Runtime>(
|
||||||
app: &tauri::AppHandle<R>,
|
app: &tauri::AppHandle<R>,
|
||||||
|
cfg: &MizanTauriConfig,
|
||||||
fn_name: &str,
|
fn_name: &str,
|
||||||
args: Map<String, Value>,
|
mut args: Map<String, Value>,
|
||||||
) -> Result<Value, ErrorPayload> {
|
token: Option<&str>,
|
||||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
) -> Result<Value, MizanError> {
|
||||||
ErrorPayload::from(MizanError::NotFound(format!(
|
let identity = identity_from_token(token, cfg)?;
|
||||||
"function {fn_name:?} not registered"
|
|
||||||
)))
|
let fn_spec = lookup_function(fn_name)
|
||||||
})?;
|
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
|
||||||
|
if fn_spec.private() {
|
||||||
|
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||||
|
}
|
||||||
|
guard(fn_spec, identity.as_ref())?;
|
||||||
|
|
||||||
|
// Bind any file parts the envelope carries into the call args (see
|
||||||
|
// `bind_uploads`).
|
||||||
|
bind_uploads(fn_spec, &mut args)?;
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
let req = RequestHandle::new(app);
|
||||||
let result = fn_spec
|
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||||
.dispatch(req, Value::Object(args.clone()))
|
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)?;
|
|
||||||
|
|
||||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
let targets = compute_invalidation(fn_spec, &args);
|
||||||
.iter()
|
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||||
.map(InvalidationTarget::to_json)
|
|
||||||
.collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
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!({
|
// Purge the origin cache for everything this mutation invalidated.
|
||||||
"result": result,
|
if !targets.is_empty() {
|
||||||
"invalidate": invalidate,
|
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
||||||
});
|
cfg.cache.purge(&targets, uid.as_deref());
|
||||||
if let Some(merge) = merge_payload {
|
}
|
||||||
payload
|
|
||||||
.as_object_mut()
|
let mut payload = json!({ "result": result, "invalidate": invalidate });
|
||||||
.expect("payload is a JSON object")
|
if !merges.is_empty() {
|
||||||
.insert("merge".into(), Value::Array(merge));
|
payload.as_object_mut().unwrap().insert(
|
||||||
|
"merge".into(),
|
||||||
|
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_fetch<R: Runtime>(
|
async fn handle_fetch<R: Runtime>(
|
||||||
app: &tauri::AppHandle<R>,
|
app: &tauri::AppHandle<R>,
|
||||||
|
cfg: &MizanTauriConfig,
|
||||||
context_name: &str,
|
context_name: &str,
|
||||||
params: Map<String, Value>,
|
params: Map<String, Value>,
|
||||||
) -> Result<Value, ErrorPayload> {
|
token: Option<&str>,
|
||||||
if lookup_context(context_name).is_none() {
|
) -> Result<Value, MizanError> {
|
||||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
let identity = identity_from_token(token, cfg)?;
|
||||||
"context {context_name:?} not registered"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if lookup_context(context_name).is_none() {
|
||||||
|
return Err(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} not registered"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.filter(|f| f.context() == Some(context_name))
|
.filter(|f| f.context() == Some(context_name))
|
||||||
.collect();
|
.collect();
|
||||||
if members.is_empty() {
|
if members.is_empty() {
|
||||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
return Err(MizanError::NotFound(format!(
|
||||||
"context {context_name:?} has no registered members"
|
"context {context_name:?} has no registered members"
|
||||||
))));
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin cache: a desktop shell still benefits from memoizing a context
|
||||||
|
// bundle by (context, params, user). Key the params as JSON values.
|
||||||
|
let cache_params: std::collections::BTreeMap<String, Value> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
||||||
|
|
||||||
|
if let Some(cached) = cfg
|
||||||
|
.cache
|
||||||
|
.get(context_name, &cache_params, uid.as_deref(), 0)
|
||||||
|
{
|
||||||
|
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in &members {
|
||||||
|
guard(*fn_spec, identity.as_ref())?;
|
||||||
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
|
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
||||||
.dispatch(req, Value::Object(args))
|
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)?;
|
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Value::Object(bundled))
|
let body = Value::Object(bundled);
|
||||||
|
if cfg.cache.enabled() {
|
||||||
|
let bytes = serde_json::to_vec(&body).unwrap();
|
||||||
|
cfg.cache
|
||||||
|
.put(context_name, &cache_params, bytes, uid.as_deref(), 0);
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter the envelope's params down to keys this function declares as
|
/// `shape` op — the typed query projection for a function's output, derived by
|
||||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
|
||||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
|
||||||
/// carries typed JSON, so the filter is sufficient on its own.
|
let proj = shapes::project_function_output(fn_name)
|
||||||
|
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
|
||||||
|
Ok(projection_to_json(&proj))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
for f in &proj.fields {
|
||||||
|
match f {
|
||||||
|
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||||
|
shapes::ShapeField::Nested(n, sub) => {
|
||||||
|
fields.push(json!({ n.clone(): projection_to_json(sub) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({ "type": proj.type_name, "fields": fields })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
|
||||||
|
/// Forms binding). `form_validate` / `form_submit` map to the registered
|
||||||
|
/// function whose `(form_name, form_role)` matches.
|
||||||
|
async fn handle_form<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
form_name: &str,
|
||||||
|
role: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
match role {
|
||||||
|
"schema" => form_schema(app, form_name).await,
|
||||||
|
"validate" => form_validate(app, form_name, args).await,
|
||||||
|
"submit" => form_submit(app, form_name, args).await,
|
||||||
|
other => Err(MizanError::BadRequest(format!(
|
||||||
|
"unknown form role {other:?} (expected schema|validate|submit)"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||||
|
FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_form_role<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
form_name: &str,
|
||||||
|
role: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
let fn_spec = lookup_form_fn(form_name, role)
|
||||||
|
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
|
||||||
|
let args_value = match args {
|
||||||
|
Value::Object(_) | Value::Null => args,
|
||||||
|
other => json!({ "data": other }),
|
||||||
|
};
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
fn_spec.dispatch(req, args_value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn form_schema<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
form_name: &str,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
dispatch_form_role(app, form_name, "schema", Value::Null).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn form_validate<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
form_name: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
dispatch_form_role(app, form_name, "validate", args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn form_submit<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
form_name: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<Value, MizanError> {
|
||||||
|
dispatch_form_role(app, form_name, "submit", args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// === WebSocket-equivalent: IPC subscription channel ===
|
||||||
|
|
||||||
|
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct SubscriptionFrame {
|
||||||
|
pub result: Value,
|
||||||
|
pub invalidate: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
|
||||||
|
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
|
||||||
|
/// the push stream instead — the IPC transport's co-equal of the HTTP
|
||||||
|
/// WebSocket. The initial dispatch result is emitted immediately on the
|
||||||
|
/// channel; subsequent server-side pushes use the same `on_event` channel.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn mizan_subscribe<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
function_name: String,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
on_event: Channel<SubscriptionFrame>,
|
||||||
|
) -> Result<(), ErrorPayload> {
|
||||||
|
subscribe(&app, &function_name, args, on_event)
|
||||||
|
.await
|
||||||
|
.map_err(ErrorPayload::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
|
||||||
|
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
|
||||||
|
/// wraps — exposed for embedders and behavior tests.
|
||||||
|
pub async fn subscribe<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
function_name: &str,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
on_event: Channel<SubscriptionFrame>,
|
||||||
|
) -> Result<(), MizanError> {
|
||||||
|
let fn_spec = lookup_function(function_name)
|
||||||
|
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
|
||||||
|
if fn_spec.private() {
|
||||||
|
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||||
|
}
|
||||||
|
// Only `#[mizan(websocket)]` functions are exposed over the subscription
|
||||||
|
// channel — the same transport boundary the HTTP WebSocket enforces.
|
||||||
|
if !matches!(
|
||||||
|
fn_spec.transport(),
|
||||||
|
mizan_core::Transport::Websocket | mizan_core::Transport::Both
|
||||||
|
) {
|
||||||
|
return Err(MizanError::BadRequest(format!(
|
||||||
|
"function {function_name:?} is not exposed over the subscription transport"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||||
|
let invalidate = compute_invalidation(fn_spec, &args)
|
||||||
|
.iter()
|
||||||
|
.map(InvalidationTarget::to_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
on_event
|
||||||
|
.send(SubscriptionFrame { result, invalidate })
|
||||||
|
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
/// Filter the envelope's params down to keys this function declares as input.
|
||||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &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() {
|
||||||
@@ -218,3 +503,45 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind file parts carried in the IPC envelope into the call args.
|
||||||
|
///
|
||||||
|
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
|
||||||
|
/// JSON object `{filename, content_type, data_b64}` (the JS transport
|
||||||
|
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
|
||||||
|
/// deserializes, so for a single file the arg is already in place. This binder
|
||||||
|
/// performs the one transform IPC needs: a top-level `_files` map
|
||||||
|
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
|
||||||
|
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
|
||||||
|
/// also validates that anything presenting as a file carries `data_b64`,
|
||||||
|
/// surfacing a clear error before the typed `Upload` deserialize runs.
|
||||||
|
fn bind_uploads(
|
||||||
|
fn_spec: &dyn FunctionSpec,
|
||||||
|
args: &mut Map<String, Value>,
|
||||||
|
) -> Result<(), MizanError> {
|
||||||
|
if let Some(Value::Object(files)) = args.remove("_files") {
|
||||||
|
for (field, parts) in files {
|
||||||
|
args.insert(field, parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The set of param names this function declares — only validate args that
|
||||||
|
// could land in a typed field.
|
||||||
|
let declared: std::collections::HashSet<&str> =
|
||||||
|
fn_spec.input_params().iter().map(|p| p.name).collect();
|
||||||
|
for (name, value) in args.iter() {
|
||||||
|
if !declared.contains(name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Value::Object(obj) = value {
|
||||||
|
let looks_like_file =
|
||||||
|
obj.contains_key("filename") || obj.contains_key("content_type");
|
||||||
|
if looks_like_file && !obj.contains_key("data_b64") {
|
||||||
|
return Err(MizanError::BadRequest(format!(
|
||||||
|
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
67
backends/mizan-tauri/src/ssr.rs
Normal file
67
backends/mizan-tauri/src/ssr.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//! SSR over the IPC transport — drive the Bun renderer through the shared
|
||||||
|
//! `mizan_core::SsrBridge` (the same newline-delimited JSON-RPC protocol the
|
||||||
|
//! Django/FastAPI/axum adapters use). A desktop shell renders React the same
|
||||||
|
//! way the server does: spawn the Bun worker once, drive `renderToString`
|
||||||
|
//! through it, keep it alive.
|
||||||
|
//!
|
||||||
|
//! Exposed as a Tauri command + a managed `MizanSsr` holding the bridge:
|
||||||
|
//!
|
||||||
|
//! invoke('plugin:mizan|ssr_render', { file: '/abs/X.tsx', props: {...} })
|
||||||
|
//! → { html: "<div>...</div>" }
|
||||||
|
|
||||||
|
use mizan_core::SsrBridge;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{Manager, Runtime};
|
||||||
|
|
||||||
|
use crate::ErrorPayload;
|
||||||
|
|
||||||
|
/// Managed SSR state — holds the persistent Bun bridge. Register it with
|
||||||
|
/// `app.manage(MizanSsr::new("path/to/worker.tsx"))` to enable `ssr_render`.
|
||||||
|
pub struct MizanSsr {
|
||||||
|
bridge: Arc<SsrBridge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MizanSsr {
|
||||||
|
/// Build an SSR state that launches `bun run <worker_path>` on first render.
|
||||||
|
pub fn new(worker_path: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
bridge: Arc::new(SsrBridge::bun(worker_path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared `mizan_core` SSR bridge backing this state — the persistent
|
||||||
|
/// Bun subprocess that runs `renderToString` over JSON-RPC. Exposed so a
|
||||||
|
/// consumer can render directly (e.g. PSR re-render on mutation) without
|
||||||
|
/// going through the `ssr_render` IPC command.
|
||||||
|
pub fn ssr_bridge(&self) -> &SsrBridge {
|
||||||
|
&self.bridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SsrResult {
|
||||||
|
pub html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ssr_render` — render a component file to HTML via the Bun SSR worker.
|
||||||
|
/// Requires a managed `MizanSsr` (else returns a NOT_IMPLEMENTED error).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ssr_render<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
file: String,
|
||||||
|
props: Option<Value>,
|
||||||
|
) -> Result<SsrResult, ErrorPayload> {
|
||||||
|
let state = app.try_state::<MizanSsr>().ok_or_else(|| {
|
||||||
|
ErrorPayload::from(mizan_core::MizanError::NotImplementedYet(
|
||||||
|
"no SSR worker configured (app.manage(MizanSsr::new(...)))".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let bridge = state.bridge.clone();
|
||||||
|
let props = props.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
let html = bridge
|
||||||
|
.render(&file, props)
|
||||||
|
.map_err(|e| ErrorPayload::from(mizan_core::MizanError::InternalError(e.to_string())))?;
|
||||||
|
Ok(SsrResult { html })
|
||||||
|
}
|
||||||
370
backends/mizan-tauri/tests/behavior.rs
Normal file
370
backends/mizan-tauri/tests/behavior.rs
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling
|
||||||
|
//! over the source-presence probes. Each IPC-applicable cell is driven through
|
||||||
|
//! the real dispatch path against a mock Tauri `AppHandle`
|
||||||
|
//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel
|
||||||
|
//! frames. The IPC serialization boundary is exercised by Tauri's own
|
||||||
|
//! `get_ipc_response` machinery in integration; here we drive `dispatch` /
|
||||||
|
//! `subscribe` (the programmatic entry points the commands wrap) so the
|
||||||
|
//! protocol logic — auth, cache, upload binding, shapes, forms, subscription —
|
||||||
|
//! is asserted directly.
|
||||||
|
|
||||||
|
use mizan_core as mizan;
|
||||||
|
use mizan_core::prelude::*;
|
||||||
|
use mizan_core::{
|
||||||
|
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
||||||
|
};
|
||||||
|
use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::test::mock_app;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
// ─── Fixture functions (auto-registered via linkme at link time) ────────────
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct TProfile {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct TOk {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct TSecret {
|
||||||
|
pub flag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct TUploadEcho {
|
||||||
|
pub filename: String,
|
||||||
|
pub size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::context("tprofile")]
|
||||||
|
pub struct TProfileCtx;
|
||||||
|
|
||||||
|
#[mizan::client(context = TProfileCtx)]
|
||||||
|
pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile {
|
||||||
|
TProfile {
|
||||||
|
user_id,
|
||||||
|
name: format!("user-{user_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(affects = TProfileCtx)]
|
||||||
|
pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk {
|
||||||
|
let _ = (user_id, name);
|
||||||
|
TOk { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(auth = "staff")]
|
||||||
|
pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret {
|
||||||
|
TSecret {
|
||||||
|
flag: "ipc-secret".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(websocket)]
|
||||||
|
pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk {
|
||||||
|
let _ = room;
|
||||||
|
TOk { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client]
|
||||||
|
pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho {
|
||||||
|
let _ = user_id;
|
||||||
|
TUploadEcho {
|
||||||
|
filename: avatar.filename.clone().unwrap_or_default(),
|
||||||
|
size: avatar.size() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(form_name = "tcontact", form_role = "submit")]
|
||||||
|
pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk {
|
||||||
|
let _ = name;
|
||||||
|
TOk { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Harness ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a mock app with the given Mizan config managed.
|
||||||
|
fn app_with(config: MizanTauriConfig) -> AppHandle<tauri::test::MockRuntime> {
|
||||||
|
let app = mock_app();
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
handle.manage(config);
|
||||||
|
// Leak the app so its `AppHandle` stays valid for the test body; the
|
||||||
|
// process tears down at test end.
|
||||||
|
std::mem::forget(app);
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── rpc_call + invalidate_body ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_returns_result_and_invalidate() {
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
rt().block_on(async {
|
||||||
|
let env = Envelope::Call {
|
||||||
|
function_name: "t_update_profile".into(),
|
||||||
|
args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]),
|
||||||
|
token: None,
|
||||||
|
};
|
||||||
|
let resp = dispatch(&handle, env).await.unwrap();
|
||||||
|
assert_eq!(resp["result"], json!({"ok": true}));
|
||||||
|
// IPC carries invalidation in the envelope (no header channel).
|
||||||
|
assert_eq!(
|
||||||
|
resp["invalidate"],
|
||||||
|
json!([{"context": "tprofile", "params": {"user_id": 7}}])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_guard_over_ipc() {
|
||||||
|
rt().block_on(async {
|
||||||
|
// No auth config → anonymous → staff-guarded fn rejected.
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
let err = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Call {
|
||||||
|
function_name: "t_secret".into(),
|
||||||
|
args: Map::new(),
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
||||||
|
|
||||||
|
// Staff JWT on the envelope token → admitted.
|
||||||
|
let cfg = JwtConfig::new("ipc-secret");
|
||||||
|
let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix());
|
||||||
|
let config = MizanTauriConfig {
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt: Some(cfg),
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".into(),
|
||||||
|
},
|
||||||
|
cache: CacheOrchestrator::disabled(),
|
||||||
|
};
|
||||||
|
let handle = app_with(config);
|
||||||
|
let resp = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Call {
|
||||||
|
function_name: "t_secret".into(),
|
||||||
|
args: Map::new(),
|
||||||
|
token: Some(format!("Bearer {token}")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["result"]["flag"], json!("ipc-secret"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_token_rejected_over_ipc() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let config = MizanTauriConfig {
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt: Some(JwtConfig::new("ipc-secret")),
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".into(),
|
||||||
|
},
|
||||||
|
cache: CacheOrchestrator::disabled(),
|
||||||
|
};
|
||||||
|
let handle = app_with(config);
|
||||||
|
let err = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Fetch {
|
||||||
|
context: "tprofile".into(),
|
||||||
|
params: obj(&[("user_id", json!(1))]),
|
||||||
|
token: Some("Bearer garbage".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── origin_cache ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_uses_origin_cache() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
||||||
|
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into()));
|
||||||
|
let config = MizanTauriConfig {
|
||||||
|
auth: AuthConfig::new(),
|
||||||
|
cache,
|
||||||
|
};
|
||||||
|
let handle = app_with(config);
|
||||||
|
|
||||||
|
let fetch = || Envelope::Fetch {
|
||||||
|
context: "tprofile".into(),
|
||||||
|
params: obj(&[("user_id", json!(3))]),
|
||||||
|
token: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = dispatch(&handle, fetch()).await.unwrap();
|
||||||
|
assert_eq!(first["t_user_profile"]["user_id"], json!(3));
|
||||||
|
|
||||||
|
// The cache now holds the bundle — confirm a key exists under the
|
||||||
|
// context prefix (proves the put happened).
|
||||||
|
let key = mizan::derive_cache_key(
|
||||||
|
"ipc-cache-secret",
|
||||||
|
"tprofile",
|
||||||
|
&std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert!(backend.get(&key).is_some(), "fetch populated the origin cache");
|
||||||
|
|
||||||
|
// Second fetch returns the same bundle (served from cache).
|
||||||
|
let second = dispatch(&handle, fetch()).await.unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
|
||||||
|
// A scoped mutation purges the key.
|
||||||
|
let _ = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Call {
|
||||||
|
function_name: "t_update_profile".into(),
|
||||||
|
args: obj(&[("user_id", json!(3)), ("name", json!("New"))]),
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(backend.get(&key).is_none(), "mutation purged the cache key");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── upload ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_binds_from_envelope() {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
rt().block_on(async {
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
let data = b"IPC-FILE-BYTES";
|
||||||
|
let file = json!({
|
||||||
|
"filename": "a.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"data_b64": STANDARD.encode(data),
|
||||||
|
});
|
||||||
|
let resp = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Call {
|
||||||
|
function_name: "t_set_avatar".into(),
|
||||||
|
args: obj(&[("user_id", json!(9)), ("avatar", file)]),
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["result"]["filename"], json!("a.png"));
|
||||||
|
assert_eq!(resp["result"]["size"], json!(data.len()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── shapes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_op_projects_output() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
let resp = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Shape {
|
||||||
|
function_name: "t_user_profile".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["type"], json!("tUserProfileOutput"));
|
||||||
|
let fields = resp["fields"].as_array().unwrap();
|
||||||
|
assert!(fields.contains(&json!("user_id")));
|
||||||
|
assert!(fields.contains(&json!("name")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── forms ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_submit_op() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
let resp = dispatch(
|
||||||
|
&handle,
|
||||||
|
Envelope::Form {
|
||||||
|
form: "tcontact".into(),
|
||||||
|
role: "submit".into(),
|
||||||
|
args: json!({"name": "Ada"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp, json!({"ok": true}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── websocket-equivalent: subscription channel ──────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscription_pushes_frame_and_rejects_non_ws_fn() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let handle = app_with(MizanTauriConfig::default());
|
||||||
|
|
||||||
|
// A websocket-declared fn pushes a frame on the channel.
|
||||||
|
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = captured.clone();
|
||||||
|
let channel: Channel<SubscriptionFrame> = Channel::new(move |body| {
|
||||||
|
// The channel serializes the SubscriptionFrame to JSON; read it
|
||||||
|
// back as a generic Value.
|
||||||
|
let v: Value = body.deserialize().unwrap_or(Value::Null);
|
||||||
|
sink.lock().unwrap().push(v);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let frames = captured.lock().unwrap();
|
||||||
|
assert_eq!(frames.len(), 1, "subscription pushed exactly one frame");
|
||||||
|
assert_eq!(frames[0]["result"], json!({"ok": true}));
|
||||||
|
|
||||||
|
// A non-websocket fn over the subscription transport is rejected.
|
||||||
|
let reject_channel: Channel<SubscriptionFrame> = Channel::new(|_| Ok(()));
|
||||||
|
let err = subscribe(
|
||||||
|
&handle,
|
||||||
|
"t_user_profile",
|
||||||
|
obj(&[("user_id", json!(1))]),
|
||||||
|
reject_channel,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.message().contains("subscription transport"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||||
|
}
|
||||||
@@ -5,15 +5,31 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@mizan/ts",
|
"name": "@mizan/ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "latest"
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"bun-types": "latest",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19"
|
||||||
},
|
},
|
||||||
"license": "Elastic-2.0"
|
"license": "Elastic-2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types'
|
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
|
||||||
import { register } from './registry'
|
import { register } from './registry'
|
||||||
|
|
||||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||||
@@ -21,6 +21,12 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
|
||||||
|
if (!merge) return undefined
|
||||||
|
const items = Array.isArray(merge) ? merge : [merge]
|
||||||
|
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize the public auth option into the stored requirement.
|
* Normalize the public auth option into the stored requirement.
|
||||||
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
|
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
|
||||||
@@ -65,6 +71,36 @@ function extractParams(fn: Function): ParamDef[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry {
|
||||||
|
const context = resolveContext(options.context)
|
||||||
|
const affects = normalizeAffects(options.affects)
|
||||||
|
|
||||||
|
if (context && affects) {
|
||||||
|
throw new Error('context and affects are mutually exclusive')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
fn: fn as any,
|
||||||
|
context,
|
||||||
|
affects,
|
||||||
|
merge: normalizeMerge(options.merge),
|
||||||
|
params: extractParams(fn),
|
||||||
|
private: options.private ?? false,
|
||||||
|
viewPath: false,
|
||||||
|
route: options.route,
|
||||||
|
methods: options.methods,
|
||||||
|
auth: normalizeAuth(options.auth),
|
||||||
|
websocket: options.websocket,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
|
ir: options.ir,
|
||||||
|
form: options.form,
|
||||||
|
formName: options.formName,
|
||||||
|
formRole: options.formRole,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function wrapper — registers a standalone function.
|
* Function wrapper — registers a standalone function.
|
||||||
*
|
*
|
||||||
@@ -85,69 +121,19 @@ export function client<T extends (...args: any[]) => Promise<any>>(
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
const context = resolveContext(options.context)
|
|
||||||
const affects = normalizeAffects(options.affects)
|
|
||||||
|
|
||||||
if (context && affects) {
|
|
||||||
throw new Error('context and affects are mutually exclusive')
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = fn.name || 'anonymous'
|
const name = fn.name || 'anonymous'
|
||||||
const params = extractParams(fn)
|
register(buildEntry(options, name, fn))
|
||||||
const isView = false // Determined at call time for function wrappers
|
|
||||||
|
|
||||||
const entry: RegistryEntry = {
|
|
||||||
name,
|
|
||||||
fn: fn as any,
|
|
||||||
context,
|
|
||||||
affects,
|
|
||||||
params,
|
|
||||||
private: options.private ?? false,
|
|
||||||
viewPath: isView,
|
|
||||||
route: options.route,
|
|
||||||
methods: options.methods,
|
|
||||||
auth: normalizeAuth(options.auth),
|
|
||||||
rev: options.rev,
|
|
||||||
cache: options.cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
register(entry)
|
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decorator form: @client(options)
|
// Decorator form: @client(options)
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
const originalMethod = descriptor.value
|
register(buildEntry(options, propertyKey, descriptor.value))
|
||||||
const context = resolveContext(options.context)
|
|
||||||
const affects = normalizeAffects(options.affects)
|
|
||||||
|
|
||||||
if (context && affects) {
|
|
||||||
throw new Error('context and affects are mutually exclusive')
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = extractParams(originalMethod)
|
|
||||||
|
|
||||||
const entry: RegistryEntry = {
|
|
||||||
name: propertyKey,
|
|
||||||
fn: originalMethod,
|
|
||||||
context,
|
|
||||||
affects,
|
|
||||||
params,
|
|
||||||
private: options.private ?? false,
|
|
||||||
viewPath: false,
|
|
||||||
route: options.route,
|
|
||||||
methods: options.methods,
|
|
||||||
auth: normalizeAuth(options.auth),
|
|
||||||
rev: options.rev,
|
|
||||||
cache: options.cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
register(entry)
|
|
||||||
return descriptor
|
return descriptor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
|||||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
import { ANONYMOUS, type Identity } from './identity'
|
import { ANONYMOUS, type Identity } from './identity'
|
||||||
import type { AuthRequirement } from './types'
|
import type { AuthRequirement } from './types'
|
||||||
|
import { UploadedFile, bindUploads } from './upload'
|
||||||
|
|
||||||
let _cacheSecret: string | null = null
|
let _cacheSecret: string | null = null
|
||||||
|
|
||||||
@@ -186,9 +187,10 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle POST /api/mizan/call/
|
* Handle POST /api/mizan/call/ — JSON body form.
|
||||||
*
|
*
|
||||||
* Dispatches to a named function. Returns result + invalidation.
|
* Dispatches to a named function. Returns result + invalidation. The multipart
|
||||||
|
* form (`handleMultipartCall`) binds file parts first, then routes here.
|
||||||
*/
|
*/
|
||||||
export async function handleMutationCall(
|
export async function handleMutationCall(
|
||||||
fnName: string,
|
fnName: string,
|
||||||
@@ -272,3 +274,63 @@ export async function handleMutationCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badRequest(message: string): MizanResponse {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: true, code: 'BAD_REQUEST', message },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle POST /api/mizan/call/ — multipart/form-data form.
|
||||||
|
*
|
||||||
|
* Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields
|
||||||
|
* arrive in a JSON `args` part, and each file part binds into the function's
|
||||||
|
* Upload-typed inputs (by field name) with declared `File(...)` constraints
|
||||||
|
* enforced. After binding, execution is identical to the JSON path.
|
||||||
|
*
|
||||||
|
* A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other
|
||||||
|
* parts that share an Upload field name are accepted too.
|
||||||
|
*/
|
||||||
|
export async function handleMultipartCall(
|
||||||
|
form: FormData,
|
||||||
|
identity: Identity = ANONYMOUS,
|
||||||
|
): Promise<MizanResponse> {
|
||||||
|
const fnRaw = form.get('fn')
|
||||||
|
if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field")
|
||||||
|
const fnName = fnRaw
|
||||||
|
|
||||||
|
const argsRaw = form.get('args')
|
||||||
|
let args: Record<string, any>
|
||||||
|
try {
|
||||||
|
args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {}
|
||||||
|
} catch {
|
||||||
|
return badRequest("Invalid JSON in 'args' field")
|
||||||
|
}
|
||||||
|
if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object")
|
||||||
|
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
if (entry) {
|
||||||
|
// Collect file parts by field name into UploadedFile buckets.
|
||||||
|
const files = new Map<string, UploadedFile[]>()
|
||||||
|
for (const key of new Set(form.keys())) {
|
||||||
|
if (key === 'fn' || key === 'args') continue
|
||||||
|
const bucket: UploadedFile[] = []
|
||||||
|
for (const part of form.getAll(key)) {
|
||||||
|
if (part instanceof Blob) {
|
||||||
|
const data = new Uint8Array(await part.arrayBuffer())
|
||||||
|
const filename = part instanceof File ? part.name : null
|
||||||
|
bucket.push(new UploadedFile(filename, part.type || null, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bucket.length > 0) files.set(key, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = bindUploads(entry, args, files)
|
||||||
|
if (err !== null) return badRequest(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleMutationCall(fnName, args, identity)
|
||||||
|
}
|
||||||
|
|||||||
170
backends/mizan-ts/src/forms.ts
Normal file
170
backends/mizan-ts/src/forms.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Forms — schema / validate / submit, AFI-common.
|
||||||
|
*
|
||||||
|
* The binding is per-framework (Django Forms on Django; the project's form
|
||||||
|
* layer elsewhere). The TypeScript binding registers the same three `@client`
|
||||||
|
* functions `create_form_functions` registers, carrying the same
|
||||||
|
* `{ form, form_name, form_role }` meta the IR reads — `<name>-schema`,
|
||||||
|
* `<name>-validate`, and (when a submit handler is given) `<name>-submit`.
|
||||||
|
*
|
||||||
|
* schema → { fields: FieldSchema[] } — field definitions
|
||||||
|
* validate → { valid: boolean, errors: {field: [..]} } — per-field validation
|
||||||
|
* submit → the handler's return value — validate-then-handle
|
||||||
|
*
|
||||||
|
* A `FormField` declares its type/required/label and an optional `validate`
|
||||||
|
* predicate; `validateForm` runs every field's validator over the submitted
|
||||||
|
* data, mirroring Django's `form.is_valid()` / `form.errors`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { client } from './decorator'
|
||||||
|
import type { FormRole } from './types'
|
||||||
|
|
||||||
|
export interface FormField {
|
||||||
|
name: string
|
||||||
|
type?: string
|
||||||
|
required?: boolean
|
||||||
|
label?: string
|
||||||
|
helpText?: string
|
||||||
|
choices?: Array<{ value: string; label: string }>
|
||||||
|
initial?: unknown
|
||||||
|
/**
|
||||||
|
* Field validator. Return an error message (or array of messages) to
|
||||||
|
* reject, or null/undefined to accept. Required-ness is enforced before
|
||||||
|
* the validator runs.
|
||||||
|
*/
|
||||||
|
validate?: (value: unknown, data: Record<string, unknown>) => string | string[] | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormDefinition {
|
||||||
|
fields: FormField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldSchema {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
label: string
|
||||||
|
helpText: string
|
||||||
|
choices: Array<{ value: string; label: string }> | null
|
||||||
|
initial: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSchemaOutput {
|
||||||
|
fields: FieldSchema[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormValidationOutput {
|
||||||
|
valid: boolean
|
||||||
|
errors: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleize(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the field-definition schema for a form. Mirrors `build_form_schema`. */
|
||||||
|
export function formSchema(def: FormDefinition): FormSchemaOutput {
|
||||||
|
return {
|
||||||
|
fields: def.fields.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
type: f.type ?? 'text',
|
||||||
|
required: f.required ?? true,
|
||||||
|
label: f.label ?? titleize(f.name),
|
||||||
|
helpText: f.helpText ?? '',
|
||||||
|
choices: f.choices ?? null,
|
||||||
|
initial: f.initial ?? null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate submitted `data` against a form. Required fields missing/empty and
|
||||||
|
* any field whose `validate` returns a message produce per-field errors.
|
||||||
|
* Mirrors Django's `is_valid()` → `{valid, errors}`.
|
||||||
|
*/
|
||||||
|
export function validateForm(def: FormDefinition, data: Record<string, unknown>): FormValidationOutput {
|
||||||
|
const errors: Record<string, string[]> = {}
|
||||||
|
|
||||||
|
for (const field of def.fields) {
|
||||||
|
const value = data[field.name]
|
||||||
|
const missing = value === undefined || value === null || value === ''
|
||||||
|
if ((field.required ?? true) && missing) {
|
||||||
|
errors[field.name] = ['This field is required.']
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (missing) continue
|
||||||
|
const result = field.validate?.(value, data)
|
||||||
|
if (result !== null && result !== undefined) {
|
||||||
|
errors[field.name] = Array.isArray(result) ? result : [result]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: Object.keys(errors).length === 0, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A submit handler runs after validation passes. */
|
||||||
|
export type FormSubmitHandler = (data: Record<string, unknown>) => unknown | Promise<unknown>
|
||||||
|
|
||||||
|
export interface FormRegistration {
|
||||||
|
schema: string
|
||||||
|
validate: string
|
||||||
|
submit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a form's schema / validate / submit functions with the registry.
|
||||||
|
*
|
||||||
|
* Equivalent to Python's `register_form`: three `@client` functions named
|
||||||
|
* `<name>-schema`, `<name>-validate`, `<name>-submit`, each carrying
|
||||||
|
* `{ form, formName, formRole }` so the IR emits `is-form`/`form-name`/
|
||||||
|
* `form-role`. `submit` is registered only when a handler is supplied.
|
||||||
|
*
|
||||||
|
* Returns the registered wire names.
|
||||||
|
*/
|
||||||
|
export function registerForm(
|
||||||
|
def: FormDefinition,
|
||||||
|
name: string,
|
||||||
|
options: { submit?: FormSubmitHandler } = {},
|
||||||
|
): FormRegistration {
|
||||||
|
const role = (r: FormRole) => ({ form: true, formName: name, formRole: r })
|
||||||
|
|
||||||
|
const schemaName = `${name}-schema`
|
||||||
|
const validateName = `${name}-validate`
|
||||||
|
const submitName = `${name}-submit`
|
||||||
|
|
||||||
|
// schema — returns the field definitions.
|
||||||
|
const schemaFn = async function () {
|
||||||
|
return formSchema(def)
|
||||||
|
}
|
||||||
|
Object.defineProperty(schemaFn, 'name', { value: schemaName })
|
||||||
|
client(role('schema'), schemaFn)
|
||||||
|
|
||||||
|
// validate — runs per-field validation over the submitted data.
|
||||||
|
const validateFn = async function (data: Record<string, unknown>) {
|
||||||
|
return validateForm(def, data)
|
||||||
|
}
|
||||||
|
Object.defineProperty(validateFn, 'name', { value: validateName })
|
||||||
|
client(role('validate'), validateFn)
|
||||||
|
|
||||||
|
const registration: FormRegistration = { schema: schemaName, validate: validateName }
|
||||||
|
|
||||||
|
// submit — validate, then hand off. Registered only with a handler.
|
||||||
|
if (options.submit) {
|
||||||
|
const handler = options.submit
|
||||||
|
const submitFn = async function (data: Record<string, unknown>) {
|
||||||
|
const validation = validateForm(def, data)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { ok: false, errors: validation.errors }
|
||||||
|
}
|
||||||
|
const result = await handler(data)
|
||||||
|
return { ok: true, result }
|
||||||
|
}
|
||||||
|
Object.defineProperty(submitFn, 'name', { value: submitName })
|
||||||
|
client(role('submit'), submitFn)
|
||||||
|
registration.submit = submitName
|
||||||
|
}
|
||||||
|
|
||||||
|
return registration
|
||||||
|
}
|
||||||
@@ -1,23 +1,63 @@
|
|||||||
export { ReactContext } from './types'
|
export { ReactContext } from './types'
|
||||||
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types'
|
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement, FormRole } from './types'
|
||||||
|
|
||||||
export { ANONYMOUS } from './identity'
|
export { ANONYMOUS } from './identity'
|
||||||
export type { Identity, AuthPredicate } from './identity'
|
export type { Identity, AuthPredicate } from './identity'
|
||||||
|
|
||||||
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
|
export {
|
||||||
export type { MwtPayload } from './token'
|
decodeMwt,
|
||||||
|
decodeJwtBearer,
|
||||||
|
identityFromMwt,
|
||||||
|
signHs256,
|
||||||
|
signMwt,
|
||||||
|
mintMwt,
|
||||||
|
computePermissionKey,
|
||||||
|
signJwt,
|
||||||
|
createAccessToken,
|
||||||
|
createRefreshToken,
|
||||||
|
mintJwt,
|
||||||
|
} from './token'
|
||||||
|
export type { MwtPayload, MintUser, JwtConfig, JwtMintClaims, JwtTokenPair } from './token'
|
||||||
|
|
||||||
export { client } from './decorator'
|
export { client } from './decorator'
|
||||||
|
|
||||||
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
||||||
|
|
||||||
export { handleContextFetch, handleMutationCall } from './dispatch'
|
export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch'
|
||||||
export type { MizanResponse } from './dispatch'
|
export type { MizanResponse } from './dispatch'
|
||||||
|
|
||||||
|
export { UploadedFile, parseSize, validateUpload, bindUploads, uploadFields } from './upload'
|
||||||
|
export type { File as UploadFile } from './upload'
|
||||||
|
|
||||||
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
|
||||||
export { generateManifest } from './manifest'
|
export { generateManifest } from './manifest'
|
||||||
|
|
||||||
|
export { handleSessionInit, sessionInitRoute, SESSION_INIT_PATH, SESSION_INIT_METHOD } from './session'
|
||||||
|
|
||||||
|
export { SSRBridge } from './ssr'
|
||||||
|
export type { SSRBridgeOptions, RenderResult } from './ssr'
|
||||||
|
|
||||||
|
export { handleWebSocketMessage, serveWebSocket } from './websocket'
|
||||||
|
export type { MizanWsFrame, MizanWsReply, WebSocketLike } from './websocket'
|
||||||
|
|
||||||
|
export { buildIr, snakeToCamel } from './ir'
|
||||||
|
export type { IrSchema, TypeShape, NamedType, StructField, Primitive, DefaultValue } from './ir'
|
||||||
|
|
||||||
|
export { Shape, project, projectRecord } from './shapes'
|
||||||
|
export type { QueryProjection } from './shapes'
|
||||||
|
|
||||||
|
export { registerForm, formSchema, validateForm } from './forms'
|
||||||
|
export type {
|
||||||
|
FormField,
|
||||||
|
FormDefinition,
|
||||||
|
FieldSchema,
|
||||||
|
FormSchemaOutput,
|
||||||
|
FormValidationOutput,
|
||||||
|
FormSubmitHandler,
|
||||||
|
FormRegistration,
|
||||||
|
} from './forms'
|
||||||
|
|
||||||
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
||||||
export type { CacheBackend } from './cache'
|
export type { CacheBackend } from './cache'
|
||||||
export { setCacheSecret } from './dispatch'
|
export { setCacheSecret } from './dispatch'
|
||||||
|
|||||||
409
backends/mizan-ts/src/ir/build.ts
Normal file
409
backends/mizan-ts/src/ir/build.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
||||||
|
*
|
||||||
|
* The Python emitter is the spec; this is a second implementation under the
|
||||||
|
* same contract. `buildIr()` walks the registry, resolves the canonical named
|
||||||
|
* types each function references (`_collect_named_types`), and emits KDL the
|
||||||
|
* Rust codegen consumes. Any divergence is a bug here, not a contract change —
|
||||||
|
* `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAllFunctions, getContextGroups, getFunction } from '../registry'
|
||||||
|
import type { RegistryEntry } from '../types'
|
||||||
|
import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types'
|
||||||
|
|
||||||
|
const INDENT = ' '
|
||||||
|
|
||||||
|
// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ────────────────────────────
|
||||||
|
|
||||||
|
function kdlString(s: string): string {
|
||||||
|
const escaped = s
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function kdlBool(b: boolean): string {
|
||||||
|
return b ? '#true' : '#false'
|
||||||
|
}
|
||||||
|
|
||||||
|
function kdlDefault(v: DefaultValue): string {
|
||||||
|
switch (v.kind) {
|
||||||
|
case 'null':
|
||||||
|
return '#null'
|
||||||
|
case 'boolean':
|
||||||
|
return kdlBool(v.value)
|
||||||
|
case 'integer':
|
||||||
|
return String(v.value)
|
||||||
|
case 'number':
|
||||||
|
// Match Python's repr(float): whole-number floats render as "1.0".
|
||||||
|
return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value)
|
||||||
|
case 'string':
|
||||||
|
return kdlString(v.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */
|
||||||
|
export function snakeToCamel(name: string): string {
|
||||||
|
const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_')
|
||||||
|
return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function primitiveName(p: Primitive): string {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Emitter ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Emitter {
|
||||||
|
lines: string[] = []
|
||||||
|
|
||||||
|
private prefix(indent: number): string {
|
||||||
|
return INDENT.repeat(indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf(indent: number, ...parts: string[]): void {
|
||||||
|
this.lines.push(this.prefix(indent) + parts.join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
open(indent: number, ...parts: string[]): void {
|
||||||
|
this.lines.push(this.prefix(indent) + parts.join(' ') + ' {')
|
||||||
|
}
|
||||||
|
|
||||||
|
close(indent: number): void {
|
||||||
|
this.lines.push(this.prefix(indent) + '}')
|
||||||
|
}
|
||||||
|
|
||||||
|
blank(): void {
|
||||||
|
this.lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
emitTypeChild(indent: number, shape: TypeShape): void {
|
||||||
|
switch (shape.kind) {
|
||||||
|
case 'primitive':
|
||||||
|
this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive)))
|
||||||
|
return
|
||||||
|
case 'ref':
|
||||||
|
this.leaf(indent, 'ref', kdlString(shape.name))
|
||||||
|
return
|
||||||
|
case 'list':
|
||||||
|
this.open(indent, 'list')
|
||||||
|
this.emitTypeChild(indent + 1, shape.inner)
|
||||||
|
this.close(indent)
|
||||||
|
return
|
||||||
|
case 'optional':
|
||||||
|
this.open(indent, 'optional')
|
||||||
|
this.emitTypeChild(indent + 1, shape.inner)
|
||||||
|
this.close(indent)
|
||||||
|
return
|
||||||
|
case 'enum':
|
||||||
|
this.leaf(indent, 'enum', ...shape.variants.map(kdlString))
|
||||||
|
return
|
||||||
|
case 'union':
|
||||||
|
this.open(indent, 'union')
|
||||||
|
for (const b of shape.branches) this.emitTypeChild(indent + 1, b)
|
||||||
|
this.close(indent)
|
||||||
|
return
|
||||||
|
case 'upload':
|
||||||
|
this.emitUpload(indent, shape)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitUpload(indent: number, shape: Extract<TypeShape, { kind: 'upload' }>): void {
|
||||||
|
const props: string[] = []
|
||||||
|
if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`)
|
||||||
|
if (shape.contentTypes && shape.contentTypes.length > 0) {
|
||||||
|
this.open(indent, 'upload', ...props)
|
||||||
|
for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct))
|
||||||
|
this.close(indent)
|
||||||
|
} else {
|
||||||
|
this.leaf(indent, 'upload', ...props)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitNamedType(indent: number, name: string, body: NamedType): void {
|
||||||
|
this.open(indent, 'type', kdlString(name))
|
||||||
|
if (body.kind === 'struct') {
|
||||||
|
this.open(indent + 1, 'struct')
|
||||||
|
for (const field of body.fields) this.emitStructField(indent + 2, field)
|
||||||
|
this.close(indent + 1)
|
||||||
|
} else if (body.kind === 'alias') {
|
||||||
|
this.open(indent + 1, 'alias')
|
||||||
|
this.emitTypeChild(indent + 2, body.inner)
|
||||||
|
this.close(indent + 1)
|
||||||
|
} else {
|
||||||
|
this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString))
|
||||||
|
}
|
||||||
|
this.close(indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitStructField(indent: number, field: StructField): void {
|
||||||
|
const header: string[] = ['field', kdlString(field.name)]
|
||||||
|
if (!field.required) {
|
||||||
|
header.push(`required=${kdlBool(false)}`)
|
||||||
|
if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`)
|
||||||
|
}
|
||||||
|
this.open(indent, ...header)
|
||||||
|
this.emitTypeChild(indent + 1, field.shape)
|
||||||
|
this.close(indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
intoString(): string {
|
||||||
|
const lines = [...this.lines]
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
||||||
|
return lines.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ─────────────
|
||||||
|
|
||||||
|
/** Strip Optional[T] → [inner, isOptional]. */
|
||||||
|
function stripOptional(shape: TypeShape): [TypeShape, boolean] {
|
||||||
|
if (shape.kind === 'optional') return [shape.inner, true]
|
||||||
|
return [shape, false]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** list element type, or null. */
|
||||||
|
function listElement(shape: TypeShape): TypeShape | null {
|
||||||
|
if (shape.kind === 'list') return shape.inner
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All ref names reachable inside a shape. */
|
||||||
|
function refsIn(shape: TypeShape): string[] {
|
||||||
|
switch (shape.kind) {
|
||||||
|
case 'ref':
|
||||||
|
return [shape.name]
|
||||||
|
case 'list':
|
||||||
|
case 'optional':
|
||||||
|
return refsIn(shape.inner)
|
||||||
|
case 'union':
|
||||||
|
return shape.branches.flatMap(refsIn)
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All ref names a NamedType body references. */
|
||||||
|
function refsInBody(body: NamedType): string[] {
|
||||||
|
if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape))
|
||||||
|
if (body.kind === 'alias') return refsIn(body.inner)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FnTypeInfo {
|
||||||
|
schema: import('./types').IrSchema
|
||||||
|
camel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass: collect every named type the IR's `function` section references,
|
||||||
|
* keyed by emitted name. Two kinds, exactly as `_collect_named_types`:
|
||||||
|
* - structs visited anywhere in input/output traversal (under their ref name,
|
||||||
|
* and under the canonical `<camel>Input` / `<camel>Output` rename)
|
||||||
|
* - output wrapper aliases (`<camel>Output = list[T]` / primitive / renamed
|
||||||
|
* model) so the consumer has one named type to reference.
|
||||||
|
*/
|
||||||
|
function collectNamedTypes(fns: Map<string, FnTypeInfo>): Record<string, NamedType> {
|
||||||
|
const seen: Record<string, NamedType> = {}
|
||||||
|
|
||||||
|
function visitModel(name: string, types: Record<string, NamedType>): void {
|
||||||
|
if (name in seen) return
|
||||||
|
const body = types[name]
|
||||||
|
if (body === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`IR schema references type "${name}" but no definition was provided in the function's \`types\`.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seen[name] = body
|
||||||
|
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitShape(shape: TypeShape, types: Record<string, NamedType>): void {
|
||||||
|
for (const ref of refsIn(shape)) visitModel(ref, types)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { schema, camel } of fns.values()) {
|
||||||
|
const types = schema.types ?? {}
|
||||||
|
|
||||||
|
// Input — named `<camel>Input`, emitted as a struct.
|
||||||
|
if (schema.input && schema.input.length > 0) {
|
||||||
|
const inputName = `${camel}Input`
|
||||||
|
if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input }
|
||||||
|
// Visit nested refs in the input fields.
|
||||||
|
for (const field of schema.input) visitShape(field.shape, types)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output.
|
||||||
|
if (schema.output === undefined) continue
|
||||||
|
const outputName = `${camel}Output`
|
||||||
|
const [inner] = stripOptional(schema.output)
|
||||||
|
const elem = listElement(inner)
|
||||||
|
|
||||||
|
if (elem !== null) {
|
||||||
|
// list[T] (possibly Optional) — list alias. Visit element type.
|
||||||
|
visitShape(schema.output, types)
|
||||||
|
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||||
|
} else if (inner.kind === 'ref') {
|
||||||
|
// <Model> or Optional[<Model>] — emit the model under the canonical
|
||||||
|
// output name (rename). Python renames the Pydantic model to
|
||||||
|
// `<camel>Output`; we emit the referenced struct under that name.
|
||||||
|
const refName = inner.name
|
||||||
|
const body = types[refName]
|
||||||
|
if (body === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (body.kind === 'struct') {
|
||||||
|
// Emit the struct under the canonical output name (the rename),
|
||||||
|
// and visit its nested refs.
|
||||||
|
if (!(outputName in seen)) {
|
||||||
|
seen[outputName] = body
|
||||||
|
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-struct named type referenced as output — emit under its
|
||||||
|
// own name plus a canonical alias.
|
||||||
|
visitModel(refName, types)
|
||||||
|
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Primitive-wrapped output (`result: int`) — alias.
|
||||||
|
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Function / context emission ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } {
|
||||||
|
const camel = snakeToCamel(entry.name)
|
||||||
|
const canonical = `${camel}Output`
|
||||||
|
const schema = entry.ir
|
||||||
|
if (!schema || schema.output === undefined) return { name: canonical, nullable: false }
|
||||||
|
const [, nullable] = stripOptional(schema.output)
|
||||||
|
return { name: canonical, nullable }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitFunction(em: Emitter, entry: RegistryEntry): void {
|
||||||
|
const camel = snakeToCamel(entry.name)
|
||||||
|
const schema = entry.ir ?? {}
|
||||||
|
const hasInput = !!(schema.input && schema.input.length > 0)
|
||||||
|
const { name: outputName, nullable } = resolveOutput(entry)
|
||||||
|
|
||||||
|
em.open(0, 'function', kdlString(entry.name))
|
||||||
|
em.leaf(1, 'camel', kdlString(camel))
|
||||||
|
em.leaf(1, 'has-input', kdlBool(hasInput))
|
||||||
|
if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`))
|
||||||
|
em.leaf(1, 'output', kdlString(outputName))
|
||||||
|
if (nullable) em.leaf(1, 'output-nullable', kdlBool(true))
|
||||||
|
em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http'))
|
||||||
|
if (entry.context) em.leaf(1, 'context', kdlString(entry.context))
|
||||||
|
// Only context-typed affects make it into the KDL (matches ir.py).
|
||||||
|
for (const a of entry.affects ?? []) {
|
||||||
|
if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name))
|
||||||
|
}
|
||||||
|
for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m))
|
||||||
|
if (entry.form) {
|
||||||
|
em.leaf(1, 'is-form', kdlBool(true))
|
||||||
|
if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName))
|
||||||
|
if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole))
|
||||||
|
}
|
||||||
|
em.close(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationToPrimitive(shape: TypeShape | undefined): Primitive {
|
||||||
|
if (shape === undefined) return 'string'
|
||||||
|
const [inner] = stripOptional(shape)
|
||||||
|
if (inner.kind === 'primitive') return inner.primitive
|
||||||
|
return 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void {
|
||||||
|
// Collect param info across every function in the context.
|
||||||
|
interface Slot {
|
||||||
|
type: Primitive
|
||||||
|
sharedBy: string[]
|
||||||
|
}
|
||||||
|
const paramInfo = new Map<string, Slot>()
|
||||||
|
for (const fnName of fnNames) {
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
if (!entry) continue
|
||||||
|
const input = entry.ir?.input
|
||||||
|
if (!input || input.length === 0) continue
|
||||||
|
for (const field of input) {
|
||||||
|
let slot = paramInfo.get(field.name)
|
||||||
|
if (!slot) {
|
||||||
|
slot = { type: 'string', sharedBy: [] }
|
||||||
|
paramInfo.set(field.name, slot)
|
||||||
|
}
|
||||||
|
slot.type = annotationToPrimitive(field.shape)
|
||||||
|
slot.sharedBy.push(fnName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
em.open(0, 'context', kdlString(ctxName))
|
||||||
|
// Members alphabetical — canonical order.
|
||||||
|
for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName))
|
||||||
|
for (const paramName of [...paramInfo.keys()].sort()) {
|
||||||
|
const slot = paramInfo.get(paramName)!
|
||||||
|
const required = slot.sharedBy.length === fnNames.length
|
||||||
|
em.open(1, 'param', kdlString(paramName))
|
||||||
|
em.leaf(2, 'type', kdlString(slot.type))
|
||||||
|
em.leaf(2, 'required', kdlBool(required))
|
||||||
|
for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer))
|
||||||
|
em.close(1)
|
||||||
|
}
|
||||||
|
em.close(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Top-level builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Mizan IR (KDL) for every registered function. Byte-equivalent to
|
||||||
|
* the Python `build_ir()` against the same registry.
|
||||||
|
*
|
||||||
|
* `private` and view-path functions are excluded from the function section,
|
||||||
|
* matching ir.py.
|
||||||
|
*/
|
||||||
|
export function buildIr(): string {
|
||||||
|
const functions = getAllFunctions()
|
||||||
|
const contextGroups = getContextGroups()
|
||||||
|
|
||||||
|
// Functions contributing to the type/function sections (skip private + view).
|
||||||
|
const typeFns = new Map<string, FnTypeInfo>()
|
||||||
|
const emitFns: RegistryEntry[] = []
|
||||||
|
for (const [name, entry] of functions) {
|
||||||
|
if (entry.private || entry.viewPath) continue
|
||||||
|
typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) })
|
||||||
|
emitFns.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedTypes = collectNamedTypes(typeFns)
|
||||||
|
|
||||||
|
const em = new Emitter()
|
||||||
|
|
||||||
|
// Types — alphabetical by name (canonical IR ordering).
|
||||||
|
const typeNames = Object.keys(namedTypes).sort()
|
||||||
|
for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName])
|
||||||
|
if (typeNames.length > 0) em.blank()
|
||||||
|
|
||||||
|
// Functions — alphabetical by wire name.
|
||||||
|
emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||||
|
for (const entry of emitFns) emitFunction(em, entry)
|
||||||
|
if (emitFns.length > 0) em.blank()
|
||||||
|
|
||||||
|
// Contexts — alphabetical by name.
|
||||||
|
const ctxNames = Object.keys(contextGroups).sort()
|
||||||
|
for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName])
|
||||||
|
if (ctxNames.length > 0) em.blank()
|
||||||
|
|
||||||
|
return em.intoString()
|
||||||
|
}
|
||||||
17
backends/mizan-ts/src/ir/index.ts
Normal file
17
backends/mizan-ts/src/ir/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Mizan IR (KDL) — the codegen contract.
|
||||||
|
*
|
||||||
|
* `buildIr()` emits KDL byte-identical to the Python `build_ir()` against the
|
||||||
|
* same registry. This is what lets a TypeScript backend feed
|
||||||
|
* `protocol/mizan-codegen`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { buildIr, snakeToCamel } from './build'
|
||||||
|
export type {
|
||||||
|
Primitive,
|
||||||
|
TypeShape,
|
||||||
|
DefaultValue,
|
||||||
|
StructField,
|
||||||
|
NamedType,
|
||||||
|
IrSchema,
|
||||||
|
} from './types'
|
||||||
70
backends/mizan-ts/src/ir/types.ts
Normal file
70
backends/mizan-ts/src/ir/types.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and
|
||||||
|
* `cores/mizan-rust/src/ir.rs` 1:1.
|
||||||
|
*
|
||||||
|
* The IR is the contract. Backends emit it; the codegen consumes it. The
|
||||||
|
* TypeScript side produces byte-equivalent KDL to the Python emitter against
|
||||||
|
* the same function registry.
|
||||||
|
*
|
||||||
|
* TypeScript has no Pydantic to introspect, so the `@client` decorator carries
|
||||||
|
* an explicit IR type schema (input fields + output shape). That schema is the
|
||||||
|
* binding: a TS backend declares its IR types, and `buildIr()` emits the KDL
|
||||||
|
* the codegen reads — exactly as the Rust adapter declares typed `StructField`
|
||||||
|
* / `TypeShape` registrations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Primitive = 'integer' | 'number' | 'boolean' | 'string'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-place type shape — referenced from struct fields, function
|
||||||
|
* inputs/outputs, and alias bodies.
|
||||||
|
*/
|
||||||
|
export type TypeShape =
|
||||||
|
| { kind: 'primitive'; primitive: Primitive }
|
||||||
|
| { kind: 'ref'; name: string }
|
||||||
|
| { kind: 'list'; inner: TypeShape }
|
||||||
|
| { kind: 'optional'; inner: TypeShape }
|
||||||
|
| { kind: 'enum'; variants: string[] }
|
||||||
|
| { kind: 'union'; branches: TypeShape[] }
|
||||||
|
| { kind: 'upload'; maxSize?: number; contentTypes?: string[] }
|
||||||
|
|
||||||
|
export type DefaultValue =
|
||||||
|
| { kind: 'integer'; value: number }
|
||||||
|
| { kind: 'number'; value: number }
|
||||||
|
| { kind: 'boolean'; value: boolean }
|
||||||
|
| { kind: 'string'; value: string }
|
||||||
|
| { kind: 'null' }
|
||||||
|
|
||||||
|
export interface StructField {
|
||||||
|
name: string
|
||||||
|
required: boolean
|
||||||
|
default?: DefaultValue
|
||||||
|
shape: TypeShape
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A named type that appears in the IR's `type "<Name>" { ... }` section. */
|
||||||
|
export type NamedType =
|
||||||
|
| { kind: 'struct'; fields: StructField[] }
|
||||||
|
| { kind: 'alias'; inner: TypeShape }
|
||||||
|
| { kind: 'enum'; variants: string[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IR type schema a `@client` function carries.
|
||||||
|
*
|
||||||
|
* `input` is the ordered list of input fields (already excluding the implicit
|
||||||
|
* request/identity arg). When absent or empty, the function `has-input #false`.
|
||||||
|
*
|
||||||
|
* `output` is the function's return shape: a `ref` to a named struct, a `list`,
|
||||||
|
* an `optional`, or a `primitive`. The emitter derives the canonical
|
||||||
|
* `<camel>Input` / `<camel>Output` names and the struct-vs-alias split exactly
|
||||||
|
* as `_collect_named_types` does.
|
||||||
|
*
|
||||||
|
* `types` resolves every `ref` used in `input`/`output` (and transitively) to
|
||||||
|
* its `NamedType` definition — Python gets this from Pydantic model
|
||||||
|
* introspection; TS declares it explicitly.
|
||||||
|
*/
|
||||||
|
export interface IrSchema {
|
||||||
|
input?: StructField[]
|
||||||
|
output?: TypeShape
|
||||||
|
types?: Record<string, NamedType>
|
||||||
|
}
|
||||||
46
backends/mizan-ts/src/session.ts
Normal file
46
backends/mizan-ts/src/session.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Session / CSRF init endpoint — the AFI-common `GET /api/mizan/session/`.
|
||||||
|
*
|
||||||
|
* Wired at parity with mizan-django / mizan-fastapi / mizan-rust-axum. The CSRF
|
||||||
|
* *token* is a Django session mechanism with no TypeScript-runtime equivalent,
|
||||||
|
* so this returns a null token by default; the endpoint itself is the owed AFI
|
||||||
|
* surface, and a host that mints CSRF tokens can pass one in. A SPA client uses
|
||||||
|
* the response as its session-readiness signal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MizanResponse } from './dispatch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical mount path for the session-init endpoint, relative to the Mizan
|
||||||
|
* mount (`/api/mizan`). A router adapter binds `handleSessionInit` here — the
|
||||||
|
* same `/session/` route Django (`path("session/")`), FastAPI
|
||||||
|
* (`@router.get("/session/")`), and Axum register.
|
||||||
|
*/
|
||||||
|
export const SESSION_INIT_PATH = '/session/'
|
||||||
|
|
||||||
|
/** HTTP method for the session-init route. */
|
||||||
|
export const SESSION_INIT_METHOD = 'GET'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the session-init response. Returns `{ csrfToken }` with `no-store`.
|
||||||
|
* `csrfToken` defaults to null (no Django-style session); a host with its own
|
||||||
|
* CSRF mechanism passes the token to embed.
|
||||||
|
*/
|
||||||
|
export function handleSessionInit(csrfToken: string | null = null): MizanResponse {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { csrfToken },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route descriptor for the session-init endpoint — what a router adapter
|
||||||
|
* registers: `GET /session/` → `handleSessionInit`. Mirrors the
|
||||||
|
* `path("session/", session_init_view)` URL entry the Python adapters declare.
|
||||||
|
*/
|
||||||
|
export const sessionInitRoute = {
|
||||||
|
path: SESSION_INIT_PATH,
|
||||||
|
method: SESSION_INIT_METHOD,
|
||||||
|
handler: () => handleSessionInit(),
|
||||||
|
} as const
|
||||||
78
backends/mizan-ts/src/shapes.ts
Normal file
78
backends/mizan-ts/src/shapes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Shapes — typed query projection.
|
||||||
|
*
|
||||||
|
* AFI-common capability; the binding is per-ORM. Django's binding is
|
||||||
|
* django-readers (select named fields + nested relations from a QuerySet in
|
||||||
|
* one query). The TypeScript binding is the same shape over the data source a
|
||||||
|
* TS backend already has: a `QueryProjection` declares the fields and nested
|
||||||
|
* relations to keep, and `project()` produces records carrying *only* those —
|
||||||
|
* the over-fetch-elimination the Shapes capability exists for, expressed
|
||||||
|
* against plain records rather than a Django QuerySet.
|
||||||
|
*
|
||||||
|
* A projection composes: a relation is itself a `QueryProjection`, so nested
|
||||||
|
* shapes prune recursively (mirrors `Shape._spec` / `_build_pair`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A declarative projection: scalar fields plus nested relation projections. */
|
||||||
|
export interface QueryProjection {
|
||||||
|
/** Scalar field names to keep on each record. */
|
||||||
|
fields: string[]
|
||||||
|
/** Nested relations to keep, each projected by its own `QueryProjection`. */
|
||||||
|
relations?: Record<string, QueryProjection>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Record_ = Record<string, any>
|
||||||
|
|
||||||
|
function projectOne(record: Record_, projection: QueryProjection): Record_ {
|
||||||
|
const out: Record_ = {}
|
||||||
|
for (const f of projection.fields) {
|
||||||
|
if (f in record) out[f] = record[f]
|
||||||
|
}
|
||||||
|
for (const [name, child] of Object.entries(projection.relations ?? {})) {
|
||||||
|
const value = record[name]
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
out[name] = value
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
out[name] = value.map((v) => projectOne(v, child))
|
||||||
|
} else {
|
||||||
|
out[name] = projectOne(value, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a list of records through a `QueryProjection`, keeping only the
|
||||||
|
* declared fields + nested relations. Each output record carries nothing the
|
||||||
|
* projection didn't name — the typed-projection guarantee.
|
||||||
|
*/
|
||||||
|
export function project(records: Record_[], projection: QueryProjection): Record_[] {
|
||||||
|
return records.map((r) => projectOne(r, projection))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a single record. */
|
||||||
|
export function projectRecord(record: Record_, projection: QueryProjection): Record_ {
|
||||||
|
return projectOne(record, projection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable Shape: binds a `QueryProjection` to a name so a `@client` context
|
||||||
|
* function can `Shape.query(source)` and return uniformly-projected records.
|
||||||
|
* The per-ORM source differs; the projection contract does not.
|
||||||
|
*/
|
||||||
|
export class Shape {
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly projection: QueryProjection,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Project a record source through this shape's projection. */
|
||||||
|
query(source: Record_[]): Record_[] {
|
||||||
|
return project(source, this.projection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a single record. */
|
||||||
|
one(record: Record_): Record_ {
|
||||||
|
return projectRecord(record, this.projection)
|
||||||
|
}
|
||||||
|
}
|
||||||
216
backends/mizan-ts/src/ssr.ts
Normal file
216
backends/mizan-ts/src/ssr.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* SSR Bridge — manages a persistent Bun subprocess for React server-rendering.
|
||||||
|
*
|
||||||
|
* TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire
|
||||||
|
* protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with
|
||||||
|
* message-id correlation so concurrent renders don't cross.
|
||||||
|
*
|
||||||
|
* → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } }
|
||||||
|
* ← { "id": 1, "html": "<div>...</div>" }
|
||||||
|
* ← { "id": 1, "error": "..." } (on failure)
|
||||||
|
*
|
||||||
|
* The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file
|
||||||
|
* and calls `renderToString` — no registry. It announces readiness with
|
||||||
|
* `{ "id": 0, "ready": true }`; the bridge waits for that before accepting
|
||||||
|
* renders, and restarts the worker if it exits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
||||||
|
|
||||||
|
export interface SSRBridgeOptions {
|
||||||
|
/** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */
|
||||||
|
worker: string
|
||||||
|
/** Per-render + startup timeout, seconds. Default 5. */
|
||||||
|
timeout?: number
|
||||||
|
/** Runtime to launch the worker. Default 'bun'. */
|
||||||
|
runtime?: string
|
||||||
|
/**
|
||||||
|
* Args passed to the runtime before the worker path. Default `['run']`
|
||||||
|
* (the Bun/`bun run <worker>` convention). Set `[]` for a runtime like
|
||||||
|
* `node` that takes the script path directly.
|
||||||
|
*/
|
||||||
|
runtimeArgs?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderResult {
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (msg: any) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SSRBridge {
|
||||||
|
private readonly worker: string
|
||||||
|
private readonly timeoutMs: number
|
||||||
|
private readonly runtime: string
|
||||||
|
private readonly runtimeArgs: string[]
|
||||||
|
|
||||||
|
private proc: ChildProcessWithoutNullStreams | null = null
|
||||||
|
private counter = 0
|
||||||
|
private buffer = ''
|
||||||
|
private readonly pending = new Map<number, Pending>()
|
||||||
|
private readyPromise: Promise<void> | null = null
|
||||||
|
private readyResolve: (() => void) | null = null
|
||||||
|
private readyReject: ((err: Error) => void) | null = null
|
||||||
|
|
||||||
|
constructor(options: SSRBridgeOptions) {
|
||||||
|
this.worker = options.worker
|
||||||
|
this.timeoutMs = (options.timeout ?? 5) * 1000
|
||||||
|
this.runtime = options.runtime ?? 'bun'
|
||||||
|
this.runtimeArgs = options.runtimeArgs ?? ['run']
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRunning(): Promise<void> {
|
||||||
|
if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) {
|
||||||
|
return this.readyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
this.readyResolve = () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.readyReject = (err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
this.proc = proc
|
||||||
|
|
||||||
|
proc.stdout.setEncoding('utf-8')
|
||||||
|
proc.stdout.on('data', (chunk: string) => this.onStdout(chunk))
|
||||||
|
// Only react to THIS proc's exit — a stale exit event (from a worker we
|
||||||
|
// already replaced) must not null out the freshly-spawned one.
|
||||||
|
proc.on('exit', () => this.onExit(proc))
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
const startTimer = setTimeout(() => {
|
||||||
|
this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`))
|
||||||
|
this.shutdown()
|
||||||
|
}, this.timeoutMs)
|
||||||
|
|
||||||
|
// Clear the start timer once ready settles (either way).
|
||||||
|
this.readyPromise.then(
|
||||||
|
() => clearTimeout(startTimer),
|
||||||
|
() => clearTimeout(startTimer),
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.readyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStdout(chunk: string): void {
|
||||||
|
this.buffer += chunk
|
||||||
|
let nl: number
|
||||||
|
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = this.buffer.slice(0, nl).trim()
|
||||||
|
this.buffer = this.buffer.slice(nl + 1)
|
||||||
|
if (!line) continue
|
||||||
|
let msg: any
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line)
|
||||||
|
} catch {
|
||||||
|
continue // malformed line — ignore, matches the Python reader
|
||||||
|
}
|
||||||
|
this.onMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(msg: any): void {
|
||||||
|
// Ready signal (id=0).
|
||||||
|
if (msg.id === 0 && msg.ready) {
|
||||||
|
this.readyResolve?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = msg.id
|
||||||
|
if (typeof id === 'number' && this.pending.has(id)) {
|
||||||
|
const p = this.pending.get(id)!
|
||||||
|
this.pending.delete(id)
|
||||||
|
clearTimeout(p.timer)
|
||||||
|
p.resolve(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onExit(proc: ChildProcessWithoutNullStreams): void {
|
||||||
|
// Ignore exit events from a worker we've already replaced.
|
||||||
|
if (this.proc !== null && this.proc !== proc) return
|
||||||
|
|
||||||
|
// Fail any in-flight requests; the next call re-spawns a fresh worker.
|
||||||
|
const err = new Error('SSR worker exited')
|
||||||
|
for (const [, p] of this.pending) {
|
||||||
|
clearTimeout(p.timer)
|
||||||
|
p.reject(err)
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
|
this.readyReject?.(err)
|
||||||
|
this.proc = null
|
||||||
|
this.readyPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(method: string, params: Record<string, any>): Promise<any> {
|
||||||
|
const id = ++this.counter
|
||||||
|
const frame = JSON.stringify({ id, method, params }) + '\n'
|
||||||
|
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id)
|
||||||
|
reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`))
|
||||||
|
}, this.timeoutMs)
|
||||||
|
this.pending.set(id, { resolve, reject, timer })
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.proc!.stdin.write(frame)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.pending.delete(id)
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a React component file to HTML. Spawns the worker on first use. */
|
||||||
|
async render(file: string, props: Record<string, any> = {}): Promise<RenderResult> {
|
||||||
|
await this.ensureRunning()
|
||||||
|
const msg = await this.request('render', { file, props })
|
||||||
|
if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`)
|
||||||
|
return { html: msg.html }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health check — resolves true when the worker answers a ping. */
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
await this.ensureRunning()
|
||||||
|
const msg = await this.request('ping', {})
|
||||||
|
return msg.pong === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the Bun subprocess. */
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.proc !== null) {
|
||||||
|
try {
|
||||||
|
this.proc.stdin.end()
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.proc.kill()
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
this.proc = null
|
||||||
|
this.readyPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* MWT / JWT decode — HS256 verification, cross-language parity with
|
* MWT / JWT mint + decode — HS256, cross-language parity with
|
||||||
* cores/mizan-python/src/mizan_core/mwt.py.
|
* `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`.
|
||||||
*
|
*
|
||||||
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
|
* Decode returns null on ANY failure (bad signature, expired, future nbf,
|
||||||
* aud, malformed). Never throws.
|
* wrong aud, malformed) and never throws. Mint is byte-identical to PyJWT's
|
||||||
|
* `jwt.encode(...)`: the JOSE header is serialized with sorted keys, the
|
||||||
|
* payload preserves insertion order, both with `(",", ":")` separators and
|
||||||
|
* base64url-without-padding — so a TS-minted token equals a Python-minted one
|
||||||
|
* for the same claims. `tests/token.test.ts` pins this against the live Python
|
||||||
|
* mint via subprocess.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHmac, timingSafeEqual } from 'crypto'
|
import { createHash, createHmac, timingSafeEqual } from 'crypto'
|
||||||
import type { Identity } from './identity'
|
import type { Identity } from './identity'
|
||||||
|
|
||||||
|
// ─── HS256 JWS serialization (PyJWT byte-parity) ──────────────────────────────
|
||||||
|
|
||||||
|
function base64urlEncode(buf: Buffer | string): string {
|
||||||
|
return Buffer.from(buf).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a JSON object the way PyJWT does — compact `(",", ":")` separators.
|
||||||
|
* `sortKeys` matches PyJWT: the JOSE header is emitted with sorted keys; the
|
||||||
|
* payload preserves the object's own (insertion) order. Mirrors Python's
|
||||||
|
* `json.dumps(obj, separators=(",", ":"), sort_keys=...)`.
|
||||||
|
*/
|
||||||
|
function compactJson(obj: Record<string, unknown>, sortKeys: boolean): string {
|
||||||
|
if (!sortKeys) return JSON.stringify(obj)
|
||||||
|
const sorted: Record<string, unknown> = {}
|
||||||
|
for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]
|
||||||
|
return JSON.stringify(sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an HS256 JWS. `header` extras (e.g. `{kid}`) merge over the base
|
||||||
|
* `{alg, typ}`; the JOSE header is serialized with sorted keys, exactly as
|
||||||
|
* PyJWT's `api_jws.encode`. Returns `header.payload.signature` (base64url).
|
||||||
|
*/
|
||||||
|
export function signHs256(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
secret: string,
|
||||||
|
headerExtras: Record<string, unknown> = {},
|
||||||
|
): string {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT', ...headerExtras }
|
||||||
|
const headerB64 = base64urlEncode(compactJson(header, true))
|
||||||
|
const payloadB64 = base64urlEncode(compactJson(payload, false))
|
||||||
|
const signing = `${headerB64}.${payloadB64}`
|
||||||
|
const sig = createHmac('sha256', secret).update(signing).digest('base64url')
|
||||||
|
return `${signing}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
export interface MwtPayload {
|
export interface MwtPayload {
|
||||||
sub: string
|
sub: string
|
||||||
staff: boolean
|
staff: boolean
|
||||||
@@ -108,3 +150,115 @@ export function identityFromMwt(payload: MwtPayload): Identity {
|
|||||||
id: Number(payload.sub),
|
id: Number(payload.sub),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── MWT mint (byte-parity with mwt.create_mwt) ───────────────────────────────
|
||||||
|
|
||||||
|
/** A user-shaped source for minting. Mirrors the fields create_mwt reads. */
|
||||||
|
export interface MintUser {
|
||||||
|
pk: number | string
|
||||||
|
isStaff?: boolean
|
||||||
|
isSuperuser?: boolean
|
||||||
|
/** All permission strings, in any order (sorted here, as Python does). */
|
||||||
|
permissions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic hash of permission state — byte-identical to
|
||||||
|
* `mwt.compute_permission_key`: SHA-256 over `"{staff}:{super}:{sorted_perms}"`.
|
||||||
|
*/
|
||||||
|
export function computePermissionKey(user: MintUser): string {
|
||||||
|
const perms = [...(user.permissions ?? [])].sort()
|
||||||
|
const staff = user.isStaff ? '1' : '0'
|
||||||
|
const superuser = user.isSuperuser ? '1' : '0'
|
||||||
|
const blob = `${staff}:${superuser}:${perms.join(',')}`
|
||||||
|
return createHash('sha256').update(blob, 'utf-8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an MWT for `user`. Byte-identical to `mwt.create_mwt`: claims in order
|
||||||
|
* `sub, staff, super, pkey, aud, iat, nbf, exp`; `kid` in the JOSE header.
|
||||||
|
*/
|
||||||
|
export function signMwt(
|
||||||
|
user: MintUser,
|
||||||
|
secret: string,
|
||||||
|
options: { ttl?: number; audience?: string; kid?: string; now?: number } = {},
|
||||||
|
): string {
|
||||||
|
const { ttl = 300, audience = 'mizan', kid = 'v1', now = Math.floor(Date.now() / 1000) } = options
|
||||||
|
const payload = {
|
||||||
|
sub: String(user.pk),
|
||||||
|
staff: Boolean(user.isStaff),
|
||||||
|
super: Boolean(user.isSuperuser),
|
||||||
|
pkey: computePermissionKey(user),
|
||||||
|
aud: audience,
|
||||||
|
iat: now,
|
||||||
|
nbf: now,
|
||||||
|
exp: now + ttl,
|
||||||
|
}
|
||||||
|
return signHs256(payload, secret, { kid })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias matching the `mintXxx` naming the protocol-parity surface expects. */
|
||||||
|
export const mintMwt = signMwt
|
||||||
|
|
||||||
|
// ─── JWT access/refresh mint (byte-parity with auth.jwt._mint) ────────────────
|
||||||
|
|
||||||
|
export interface JwtConfig {
|
||||||
|
privateKey: string
|
||||||
|
algorithm?: 'HS256'
|
||||||
|
accessTokenExpiresIn?: number
|
||||||
|
refreshTokenExpiresIn?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtMintClaims {
|
||||||
|
userId: number | string
|
||||||
|
sessionKey: string
|
||||||
|
isStaff?: boolean
|
||||||
|
isSuperuser?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint one HS256 JWT. Byte-identical to `auth.jwt._mint`: claims in order
|
||||||
|
* `sub, sid, staff, super, type, iat, exp`. No custom JOSE header (PyJWT emits
|
||||||
|
* the bare `{alg, typ}` header for `jwt.encode` without `headers=`).
|
||||||
|
*/
|
||||||
|
export function signJwt(
|
||||||
|
claims: JwtMintClaims,
|
||||||
|
tokenType: 'access' | 'refresh',
|
||||||
|
ttl: number,
|
||||||
|
config: JwtConfig,
|
||||||
|
now: number = Math.floor(Date.now() / 1000),
|
||||||
|
): string {
|
||||||
|
const payload = {
|
||||||
|
sub: String(claims.userId),
|
||||||
|
sid: claims.sessionKey,
|
||||||
|
staff: Boolean(claims.isStaff),
|
||||||
|
super: Boolean(claims.isSuperuser),
|
||||||
|
type: tokenType,
|
||||||
|
iat: now,
|
||||||
|
exp: now + ttl,
|
||||||
|
}
|
||||||
|
return signHs256(payload, config.privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccessToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
|
||||||
|
return signJwt(claims, 'access', config.accessTokenExpiresIn ?? 300, config, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRefreshToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
|
||||||
|
return signJwt(claims, 'refresh', config.refreshTokenExpiresIn ?? 604800, config, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtTokenPair {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresIn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint an access+refresh pair. Mirrors `auth.jwt.create_token_pair`. */
|
||||||
|
export function mintJwt(claims: JwtMintClaims, config: JwtConfig, now?: number): JwtTokenPair {
|
||||||
|
return {
|
||||||
|
accessToken: createAccessToken(claims, config, now),
|
||||||
|
refreshToken: createRefreshToken(claims, config, now),
|
||||||
|
expiresIn: config.accessTokenExpiresIn ?? 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AuthPredicate } from './identity'
|
import type { AuthPredicate } from './identity'
|
||||||
|
import type { IrSchema } from './ir/types'
|
||||||
|
|
||||||
export class ReactContext {
|
export class ReactContext {
|
||||||
constructor(public readonly name: string) {
|
constructor(public readonly name: string) {
|
||||||
@@ -18,15 +19,31 @@ export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
|
|||||||
/** Normalized auth requirement as stored on the registry entry. */
|
/** Normalized auth requirement as stored on the registry entry. */
|
||||||
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
|
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
|
||||||
|
|
||||||
|
/** Form role for a forms-binding function (schema / validate / submit). */
|
||||||
|
export type FormRole = 'schema' | 'validate' | 'submit'
|
||||||
|
|
||||||
export interface ClientOptions {
|
export interface ClientOptions {
|
||||||
context?: ReactContext | string
|
context?: ReactContext | string
|
||||||
affects?: AffectsTarget | AffectsTarget[]
|
affects?: AffectsTarget | AffectsTarget[]
|
||||||
|
/** Contexts the mutation's return value merges into (vs. refetch). */
|
||||||
|
merge?: AffectsTarget | AffectsTarget[]
|
||||||
private?: boolean
|
private?: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthOption
|
auth?: AuthOption
|
||||||
|
websocket?: boolean
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
|
/**
|
||||||
|
* IR type schema (input fields + output shape). TypeScript has no Pydantic
|
||||||
|
* to introspect, so the codegen IR is declared here. Without it the
|
||||||
|
* function still dispatches, but `buildIr()` cannot emit its types.
|
||||||
|
*/
|
||||||
|
ir?: IrSchema
|
||||||
|
/** Forms binding: marks this as a form function and names its role. */
|
||||||
|
form?: boolean
|
||||||
|
formName?: string
|
||||||
|
formRole?: FormRole
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParamDef {
|
export interface ParamDef {
|
||||||
@@ -40,14 +57,20 @@ export interface RegistryEntry {
|
|||||||
fn: (...args: any[]) => Promise<any>
|
fn: (...args: any[]) => Promise<any>
|
||||||
context?: string
|
context?: string
|
||||||
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
|
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
|
||||||
|
merge?: string[]
|
||||||
params: ParamDef[]
|
params: ParamDef[]
|
||||||
private: boolean
|
private: boolean
|
||||||
viewPath: boolean
|
viewPath: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthRequirement
|
auth?: AuthRequirement
|
||||||
|
websocket?: boolean
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
|
ir?: IrSchema
|
||||||
|
form?: boolean
|
||||||
|
formName?: string
|
||||||
|
formRole?: FormRole
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestContext {
|
export interface ManifestContext {
|
||||||
|
|||||||
143
backends/mizan-ts/src/upload.ts
Normal file
143
backends/mizan-ts/src/upload.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Mizan Upload — first-class binary input for `@client` functions.
|
||||||
|
*
|
||||||
|
* Mirrors `cores/mizan-python/src/mizan_core/upload.py`. Declaring an
|
||||||
|
* Upload-typed field in a function's `ir.input` makes a call multipart-aware:
|
||||||
|
* the generated client switches to `multipart/form-data`, and dispatch binds
|
||||||
|
* each file part into a uniform `UploadedFile` on the function's args.
|
||||||
|
* Constraints declared via `File` (max size, content types) are enforced at
|
||||||
|
* dispatch, exactly as the Python `validate_upload` enforces them.
|
||||||
|
*
|
||||||
|
* TypeScript has no Pydantic to introspect, so the Upload fields are read from
|
||||||
|
* the function's declared `ir.input` shapes (`{ kind: 'upload', ... }`) rather
|
||||||
|
* than from model metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RegistryEntry } from './types'
|
||||||
|
import type { TypeShape } from './ir/types'
|
||||||
|
|
||||||
|
const SIZE_UNITS: Array<[string, number]> = [
|
||||||
|
['GB', 1024 ** 3],
|
||||||
|
['MB', 1024 ** 2],
|
||||||
|
['KB', 1024],
|
||||||
|
['B', 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Parse a byte count. Accepts a number (bytes) or a string like `"5MB"`. */
|
||||||
|
export function parseSize(value: number | string): number {
|
||||||
|
if (typeof value === 'number') return value
|
||||||
|
const s = value.trim().toUpperCase()
|
||||||
|
for (const [unit, mult] of SIZE_UNITS) {
|
||||||
|
if (s.endsWith(unit)) return Math.trunc(parseFloat(s.slice(0, -unit.length).trim()) * mult)
|
||||||
|
}
|
||||||
|
return Math.trunc(Number(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declarative constraints for an Upload field. */
|
||||||
|
export interface File {
|
||||||
|
maxSize?: number
|
||||||
|
contentTypes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform file handle handed to `@client` functions — adapter-agnostic.
|
||||||
|
* Constructed by dispatch from a multipart `Blob`/`File` part.
|
||||||
|
*/
|
||||||
|
export class UploadedFile {
|
||||||
|
constructor(
|
||||||
|
public readonly filename: string | null,
|
||||||
|
public readonly contentType: string | null,
|
||||||
|
private readonly data: Uint8Array,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.data.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
read(): Uint8Array {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return new TextDecoder().decode(this.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeAllowed(contentType: string | null, allowed: string[]): boolean {
|
||||||
|
if (!contentType) return false
|
||||||
|
for (const ct of allowed) {
|
||||||
|
if (ct === contentType) return true
|
||||||
|
if (ct.endsWith('/*') && contentType.startsWith(ct.slice(0, -1))) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce declared constraints. Returns an error message, or null if ok. */
|
||||||
|
export function validateUpload(file: UploadedFile, spec: File | undefined): string | null {
|
||||||
|
if (!spec) return null
|
||||||
|
if (spec.maxSize !== undefined && file.size > spec.maxSize) {
|
||||||
|
return `file exceeds max size ${spec.maxSize} bytes (got ${file.size})`
|
||||||
|
}
|
||||||
|
if (spec.contentTypes && spec.contentTypes.length > 0 && !contentTypeAllowed(file.contentType, spec.contentTypes)) {
|
||||||
|
return `content-type ${JSON.stringify(file.contentType)} not allowed (expected one of ${JSON.stringify(spec.contentTypes)})`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An Upload field on a function input: name → (isList, spec). */
|
||||||
|
interface UploadField {
|
||||||
|
isList: boolean
|
||||||
|
spec: File | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unwrap Optional/list around an `upload` shape → [isUpload, isList, spec]. */
|
||||||
|
function classifyUpload(shape: TypeShape): { isUpload: boolean; isList: boolean; spec: File | undefined } {
|
||||||
|
let s = shape
|
||||||
|
if (s.kind === 'optional') s = s.inner
|
||||||
|
let isList = false
|
||||||
|
if (s.kind === 'list') {
|
||||||
|
isList = true
|
||||||
|
s = s.inner
|
||||||
|
}
|
||||||
|
if (s.kind === 'upload') {
|
||||||
|
const spec: File = {}
|
||||||
|
if (s.maxSize !== undefined) spec.maxSize = s.maxSize
|
||||||
|
if (s.contentTypes !== undefined) spec.contentTypes = s.contentTypes
|
||||||
|
const hasSpec = s.maxSize !== undefined || s.contentTypes !== undefined
|
||||||
|
return { isUpload: true, isList, spec: hasSpec ? spec : undefined }
|
||||||
|
}
|
||||||
|
return { isUpload: false, isList: false, spec: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map each Upload-typed field of a function's input → (isList, spec). */
|
||||||
|
export function uploadFields(entry: RegistryEntry): Map<string, UploadField> {
|
||||||
|
const out = new Map<string, UploadField>()
|
||||||
|
for (const field of entry.ir?.input ?? []) {
|
||||||
|
const { isUpload, isList, spec } = classifyUpload(field.shape)
|
||||||
|
if (isUpload) out.set(field.name, { isList, spec })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place uploaded files into `args` by field name, enforcing constraints.
|
||||||
|
* Mutates `args` in place. `files` maps a field name to the parts received for
|
||||||
|
* it (a list field receives several). Returns an error message on the first
|
||||||
|
* constraint violation, else null. Mirrors `upload.bind_uploads`.
|
||||||
|
*/
|
||||||
|
export function bindUploads(
|
||||||
|
entry: RegistryEntry,
|
||||||
|
args: Record<string, any>,
|
||||||
|
files: Map<string, UploadedFile[]>,
|
||||||
|
): string | null {
|
||||||
|
for (const [name, { isList, spec }] of uploadFields(entry)) {
|
||||||
|
const bucket = files.get(name) ?? []
|
||||||
|
if (bucket.length === 0) continue
|
||||||
|
for (const f of bucket) {
|
||||||
|
const err = validateUpload(f, spec)
|
||||||
|
if (err !== null) return `${name}: ${err}`
|
||||||
|
}
|
||||||
|
args[name] = isList ? [...bucket] : bucket[0]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
116
backends/mizan-ts/src/websocket.ts
Normal file
116
backends/mizan-ts/src/websocket.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket transport — RPC over a WebSocket connection for
|
||||||
|
* `@client({ websocket: true })` functions.
|
||||||
|
*
|
||||||
|
* Parity with the Django Channels consumer and the Axum WebSocket handler: the
|
||||||
|
* client sends JSON-RPC frames and receives correlated replies. Both the
|
||||||
|
* mutation (`call`) and the bundled context (`fetch`) verbs route through the
|
||||||
|
* *same* dispatch core the HTTP path uses, so invalidation, auth, and caching
|
||||||
|
* behave identically on either transport — only the framing differs.
|
||||||
|
*
|
||||||
|
* Frame protocol (newline-free JSON, one object per WS message):
|
||||||
|
*
|
||||||
|
* → { "id": 1, "type": "call", "fn": "update_profile", "args": { ... } }
|
||||||
|
* ← { "id": 1, "result": { ... }, "invalidate": [ ... ] }
|
||||||
|
*
|
||||||
|
* → { "id": 2, "type": "fetch", "context": "user", "params": { ... } }
|
||||||
|
* ← { "id": 2, "result": { user_profile: { ... }, ... } }
|
||||||
|
*
|
||||||
|
* ← { "id": N, "error": { "code": "...", "message": "..." } } (on failure)
|
||||||
|
*
|
||||||
|
* The `id` echoes back so a client can correlate concurrent in-flight calls
|
||||||
|
* over one socket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { handleContextFetch, handleMutationCall } from './dispatch'
|
||||||
|
import { ANONYMOUS, type Identity } from './identity'
|
||||||
|
|
||||||
|
interface CallFrame {
|
||||||
|
id?: number | string
|
||||||
|
type: 'call'
|
||||||
|
fn: string
|
||||||
|
args?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchFrame {
|
||||||
|
id?: number | string
|
||||||
|
type: 'fetch'
|
||||||
|
context: string
|
||||||
|
params?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MizanWsFrame = CallFrame | FetchFrame
|
||||||
|
|
||||||
|
export interface MizanWsReply {
|
||||||
|
id?: number | string
|
||||||
|
result?: any
|
||||||
|
invalidate?: any
|
||||||
|
error?: { code: string; message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle one inbound WebSocket frame and produce the reply object.
|
||||||
|
*
|
||||||
|
* `raw` is the message payload (string or already-parsed object). Routing is by
|
||||||
|
* the frame `type`; the body of the work is the same dispatch the HTTP handlers
|
||||||
|
* call, so a function exposed over both transports behaves identically.
|
||||||
|
*/
|
||||||
|
export async function handleWebSocketMessage(
|
||||||
|
raw: string | MizanWsFrame,
|
||||||
|
identity: Identity = ANONYMOUS,
|
||||||
|
): Promise<MizanWsReply> {
|
||||||
|
let frame: MizanWsFrame
|
||||||
|
try {
|
||||||
|
frame = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||||
|
} catch {
|
||||||
|
return { error: { code: 'BAD_REQUEST', message: 'Invalid JSON frame' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = (frame as { id?: number | string }).id
|
||||||
|
|
||||||
|
if (frame.type === 'call') {
|
||||||
|
if (!frame.fn) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'fn'" } }
|
||||||
|
const res = await handleMutationCall(frame.fn, frame.args ?? {}, identity)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
|
||||||
|
}
|
||||||
|
const reply: MizanWsReply = { id, result: res.body.result }
|
||||||
|
if (res.body.invalidate !== undefined) reply.invalidate = res.body.invalidate
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === 'fetch') {
|
||||||
|
if (!frame.context) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'context'" } }
|
||||||
|
const res = await handleContextFetch(frame.context, frame.params ?? {}, identity)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
|
||||||
|
}
|
||||||
|
return { id, result: res.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, error: { code: 'BAD_REQUEST', message: `Unknown frame type` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal structural type for a WebSocket-like connection. */
|
||||||
|
export interface WebSocketLike {
|
||||||
|
send(data: string): void
|
||||||
|
addEventListener(type: 'message', listener: (event: { data: any }) => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the Mizan RPC protocol to a `WebSocket`-like connection. Each inbound
|
||||||
|
* message is dispatched via `handleWebSocketMessage` and the reply is sent back
|
||||||
|
* as JSON. `identity` resolves the caller (host wires MWT/JWT decode here).
|
||||||
|
*/
|
||||||
|
export function serveWebSocket(
|
||||||
|
ws: WebSocketLike,
|
||||||
|
identity: Identity = ANONYMOUS,
|
||||||
|
): void {
|
||||||
|
ws.addEventListener('message', async (event) => {
|
||||||
|
const reply = await handleWebSocketMessage(
|
||||||
|
typeof event.data === 'string' ? event.data : String(event.data),
|
||||||
|
identity,
|
||||||
|
)
|
||||||
|
ws.send(JSON.stringify(reply))
|
||||||
|
})
|
||||||
|
}
|
||||||
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
|
||||||
|
/** SSR fixture component — rendered by the Bun worker in the bridge test. */
|
||||||
|
export default function Hello({ name }: { name: string }) {
|
||||||
|
return createElement('div', { className: 'greeting' }, `Hello, ${name}!`)
|
||||||
|
}
|
||||||
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited
|
||||||
|
* JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React
|
||||||
|
* dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess
|
||||||
|
* machinery (ready handshake, id correlation, render reply, ping, error frame)
|
||||||
|
* under plain Node, independent of the real worker's install state.
|
||||||
|
*
|
||||||
|
* `render` echoes the props into a deterministic HTML string so the bridge's
|
||||||
|
* request/response correlation is observable; a file named "*boom*" yields an
|
||||||
|
* error frame to prove the failure path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function respond(msg) {
|
||||||
|
process.stdout.write(JSON.stringify(msg) + '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(msg) {
|
||||||
|
if (msg.method === 'ping') {
|
||||||
|
respond({ id: msg.id, pong: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.method === 'render') {
|
||||||
|
const { file, props } = msg.params ?? {}
|
||||||
|
if (typeof file === 'string' && file.includes('boom')) {
|
||||||
|
respond({ id: msg.id, error: `cannot render ${file}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond({ id: msg.id, html: `<div data-file="${file}">${JSON.stringify(props ?? {})}</div>` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
process.stdin.setEncoding('utf-8')
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
buffer += chunk
|
||||||
|
let nl
|
||||||
|
while ((nl = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = buffer.slice(0, nl).trim()
|
||||||
|
buffer = buffer.slice(nl + 1)
|
||||||
|
if (line) {
|
||||||
|
try {
|
||||||
|
handle(JSON.parse(line))
|
||||||
|
} catch (e) {
|
||||||
|
respond({ id: -1, error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ready handshake — identical to the real worker.
|
||||||
|
respond({ id: 0, ready: true })
|
||||||
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1.
|
||||||
|
*
|
||||||
|
* Each function declares the same IR type schema the Python fixture's Pydantic
|
||||||
|
* Input/Output models imply, so `buildIr()` here emits the same KDL the Python
|
||||||
|
* `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`)
|
||||||
|
* subprocesses the live Python emitter and asserts equality.
|
||||||
|
*
|
||||||
|
* Output structs are declared under their model name (`ProfileOutput`,
|
||||||
|
* `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames
|
||||||
|
* them to the canonical `<camel>Output`, exactly as `_collect_named_types`
|
||||||
|
* renames the Pydantic models.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { client, ReactContext } from '../src'
|
||||||
|
import type { NamedType, StructField } from '../src'
|
||||||
|
|
||||||
|
const intField = (name: string): StructField => ({
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
shape: { kind: 'primitive', primitive: 'integer' },
|
||||||
|
})
|
||||||
|
const strField = (name: string): StructField => ({
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
shape: { kind: 'primitive', primitive: 'string' },
|
||||||
|
})
|
||||||
|
const boolField = (name: string): StructField => ({
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
shape: { kind: 'primitive', primitive: 'boolean' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] }
|
||||||
|
const OrderOutput: NamedType = {
|
||||||
|
kind: 'struct',
|
||||||
|
fields: [intField('id'), intField('user_id'), intField('total')],
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
|
/** Register the AFI fixture functions with the mizan-ts registry. */
|
||||||
|
export function registerFixture(): void {
|
||||||
|
// echo — plain function, typed input + struct output.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
ir: {
|
||||||
|
input: [strField('text')],
|
||||||
|
output: { kind: 'ref', name: 'EchoOutput' },
|
||||||
|
types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function echo(text: string) {
|
||||||
|
return { message: `echo: ${text}` }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// whoami — no input.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
ir: {
|
||||||
|
output: { kind: 'ref', name: 'WhoamiOutput' },
|
||||||
|
types: {
|
||||||
|
WhoamiOutput: {
|
||||||
|
kind: 'struct',
|
||||||
|
fields: [strField('email'), boolField('authenticated')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function whoami() {
|
||||||
|
return { email: 'anon@example.com', authenticated: false }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// user_profile — context member.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
context: UserCtx,
|
||||||
|
ir: {
|
||||||
|
input: [intField('user_id')],
|
||||||
|
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||||
|
types: { ProfileOutput },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function user_profile(user_id: number) {
|
||||||
|
return { user_id, name: 'placeholder' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// user_orders — context member, list output, same param (param elevation).
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
context: UserCtx,
|
||||||
|
ir: {
|
||||||
|
input: [intField('user_id')],
|
||||||
|
output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } },
|
||||||
|
types: { OrderOutput },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function user_orders(_user_id: number) {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// update_profile — mutation affecting the user context.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
affects: UserCtx,
|
||||||
|
ir: {
|
||||||
|
input: [intField('user_id'), strField('name')],
|
||||||
|
output: { kind: 'ref', name: 'StatusOutput' },
|
||||||
|
types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function update_profile(_user_id: number, _name: string) {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// find_user — optional return.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
ir: {
|
||||||
|
input: [intField('user_id')],
|
||||||
|
output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } },
|
||||||
|
types: { ProfileOutput },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function find_user(_user_id: number) {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// rename_user — merge target.
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
merge: UserCtx,
|
||||||
|
ir: {
|
||||||
|
input: [intField('user_id'), strField('name')],
|
||||||
|
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||||
|
types: { ProfileOutput },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async function rename_user(user_id: number, name: string) {
|
||||||
|
return { user_id, name }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
159
backends/mizan-ts/tests/ir.test.ts
Normal file
159
backends/mizan-ts/tests/ir.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python
|
||||||
|
* `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`).
|
||||||
|
*
|
||||||
|
* The IR is the codegen contract. A TypeScript backend can only feed
|
||||||
|
* `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends
|
||||||
|
* emit for the same registry. This test reconstructs the AFI fixture in both
|
||||||
|
* languages, subprocesses the live Python emitter, and asserts byte-equality —
|
||||||
|
* the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { buildIr, clearRegistry } from '../src'
|
||||||
|
import { registerFixture } from './ir-fixture'
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||||
|
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the AFI fixture in Python via `mizan_core` only (no backend
|
||||||
|
* adapter dependency) and emit `build_ir()`. This is the cross-language oracle:
|
||||||
|
* the same registrations the TS fixture makes, run through the reference
|
||||||
|
* emitter.
|
||||||
|
*/
|
||||||
|
const PY_FIXTURE = String.raw`
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core import registry as reg
|
||||||
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
reg.clear_registry()
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class WhoamiOutput(BaseModel):
|
||||||
|
email: str
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
class ProfileOutput(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class OrderOutput(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
class StatusOutput(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
|
||||||
|
@client
|
||||||
|
def echo(request, text: str) -> EchoOutput: ...
|
||||||
|
|
||||||
|
@client
|
||||||
|
def whoami(request) -> WhoamiOutput: ...
|
||||||
|
|
||||||
|
@client(context="user")
|
||||||
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||||
|
|
||||||
|
@client(context="user")
|
||||||
|
def user_orders(request, user_id: int) -> list[OrderOutput]: ...
|
||||||
|
|
||||||
|
@client(affects="user")
|
||||||
|
def update_profile(request, user_id: int, name: str) -> StatusOutput: ...
|
||||||
|
|
||||||
|
@client
|
||||||
|
def find_user(request, user_id: int) -> Optional[ProfileOutput]: ...
|
||||||
|
|
||||||
|
@client(merge="user")
|
||||||
|
def rename_user(request, user_id: int, name: str) -> ProfileOutput: ...
|
||||||
|
|
||||||
|
for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]:
|
||||||
|
reg.register(f, f.__name__)
|
||||||
|
|
||||||
|
sys.stdout.write(build_ir())
|
||||||
|
`
|
||||||
|
|
||||||
|
function pythonBuildIr(): string {
|
||||||
|
return execFileSync(
|
||||||
|
'uv',
|
||||||
|
['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE],
|
||||||
|
{ encoding: 'utf-8' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const UV_AVAILABLE = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||||
|
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
describe('KDL IR — buildIr()', () => {
|
||||||
|
beforeEach(() => clearRegistry())
|
||||||
|
|
||||||
|
test('emits the canonical type / function / context sections', () => {
|
||||||
|
registerFixture()
|
||||||
|
const kdl = buildIr()
|
||||||
|
|
||||||
|
// Types are alphabetical; output structs renamed to <camel>Output.
|
||||||
|
expect(kdl).toContain('type "OrderOutput" {')
|
||||||
|
expect(kdl).toContain('type "echoInput" {')
|
||||||
|
expect(kdl).toContain('type "findUserOutput" {')
|
||||||
|
expect(kdl).toContain('type "userOrdersOutput" {')
|
||||||
|
|
||||||
|
// Functions alphabetical, with transport + context/affects/merge leaves.
|
||||||
|
expect(kdl).toContain('function "echo" {')
|
||||||
|
expect(kdl).toContain(' camel "echo"')
|
||||||
|
expect(kdl).toContain(' has-input #true')
|
||||||
|
expect(kdl).toContain(' output-nullable #true') // find_user
|
||||||
|
expect(kdl).toContain(' affects "user"') // update_profile
|
||||||
|
expect(kdl).toContain(' merge "user"') // rename_user
|
||||||
|
|
||||||
|
// Context section with shared param elevation.
|
||||||
|
expect(kdl).toContain('context "user" {')
|
||||||
|
expect(kdl).toContain(' shared-by "user_orders"')
|
||||||
|
expect(kdl).toContain(' shared-by "user_profile"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('has-input #false for a no-arg function', () => {
|
||||||
|
registerFixture()
|
||||||
|
const kdl = buildIr()
|
||||||
|
const whoami = kdl.slice(kdl.indexOf('function "whoami" {'))
|
||||||
|
expect(whoami).toContain('has-input #false')
|
||||||
|
expect(whoami).not.toContain('input "whoamiInput"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.skipIf(!UV_AVAILABLE)(
|
||||||
|
'byte-identical to the Python build_ir() (cores/mizan-python)',
|
||||||
|
() => {
|
||||||
|
registerFixture()
|
||||||
|
const tsKdl = buildIr()
|
||||||
|
const pyKdl = pythonBuildIr()
|
||||||
|
|
||||||
|
// Line-by-line first so a divergence names the offending line.
|
||||||
|
const tsLines = tsKdl.split('\n')
|
||||||
|
const pyLines = pyKdl.split('\n')
|
||||||
|
const n = Math.max(tsLines.length, pyLines.length)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (tsLines[i] !== pyLines[i]) {
|
||||||
|
throw new Error(
|
||||||
|
`KDL diverges at line ${i + 1}:\n` +
|
||||||
|
` python: ${JSON.stringify(pyLines[i])}\n` +
|
||||||
|
` ts: ${JSON.stringify(tsLines[i])}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(tsKdl).toBe(pyKdl)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Shapes (typed query projection) + Forms (schema / validate / submit) tests.
|
||||||
|
*
|
||||||
|
* Shapes prove over-fetch elimination: the projected record carries only the
|
||||||
|
* declared fields + nested relations, nothing else. Forms prove the three
|
||||||
|
* roles register as dispatchable `@client` functions carrying the IR's
|
||||||
|
* `form`/`form-name`/`form-role` meta, and that validate/submit enforce the
|
||||||
|
* declared field rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import {
|
||||||
|
clearRegistry,
|
||||||
|
getFunction,
|
||||||
|
handleMutationCall,
|
||||||
|
Shape,
|
||||||
|
project,
|
||||||
|
registerForm,
|
||||||
|
formSchema,
|
||||||
|
validateForm,
|
||||||
|
type QueryProjection,
|
||||||
|
} from '../src'
|
||||||
|
|
||||||
|
describe('Shapes — typed query projection', () => {
|
||||||
|
test('keeps only declared scalar fields', () => {
|
||||||
|
const projection: QueryProjection = { fields: ['id', 'name'] }
|
||||||
|
const out = project([{ id: 1, name: 'A', secret: 'x', internal: 42 }], projection)
|
||||||
|
expect(out).toEqual([{ id: 1, name: 'A' }])
|
||||||
|
expect(out[0]).not.toHaveProperty('secret')
|
||||||
|
expect(out[0]).not.toHaveProperty('internal')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prunes nested relations recursively', () => {
|
||||||
|
const projection: QueryProjection = {
|
||||||
|
fields: ['id'],
|
||||||
|
relations: { orders: { fields: ['total'] } },
|
||||||
|
}
|
||||||
|
const out = project(
|
||||||
|
[{ id: 1, name: 'drop', orders: [{ id: 9, total: 100, hidden: true }] }],
|
||||||
|
projection,
|
||||||
|
)
|
||||||
|
expect(out).toEqual([{ id: 1, orders: [{ total: 100 }] }])
|
||||||
|
expect(out[0].orders[0]).not.toHaveProperty('hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles single-object relation + null', () => {
|
||||||
|
const projection: QueryProjection = {
|
||||||
|
fields: ['id'],
|
||||||
|
relations: { profile: { fields: ['bio'] } },
|
||||||
|
}
|
||||||
|
const out = project(
|
||||||
|
[
|
||||||
|
{ id: 1, profile: { bio: 'hi', age: 30 } },
|
||||||
|
{ id: 2, profile: null },
|
||||||
|
],
|
||||||
|
projection,
|
||||||
|
)
|
||||||
|
expect(out[0]).toEqual({ id: 1, profile: { bio: 'hi' } })
|
||||||
|
expect(out[1]).toEqual({ id: 2, profile: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shape.query binds a projection to a source', () => {
|
||||||
|
const UserShape = new Shape('user', { fields: ['id', 'email'] })
|
||||||
|
const out = UserShape.query([{ id: 1, email: 'a@b.c', password: 'nope' }])
|
||||||
|
expect(out).toEqual([{ id: 1, email: 'a@b.c' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Forms — schema / validate / submit', () => {
|
||||||
|
beforeEach(() => clearRegistry())
|
||||||
|
|
||||||
|
const contactForm = {
|
||||||
|
fields: [
|
||||||
|
{ name: 'email', type: 'email', required: true, label: 'Email' },
|
||||||
|
{
|
||||||
|
name: 'age',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
validate: (v: unknown) => (Number(v) < 0 ? 'must be non-negative' : null),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test('formSchema produces field definitions', () => {
|
||||||
|
const schema = formSchema(contactForm)
|
||||||
|
expect(schema.fields).toHaveLength(2)
|
||||||
|
expect(schema.fields[0]).toEqual({
|
||||||
|
name: 'email',
|
||||||
|
type: 'email',
|
||||||
|
required: true,
|
||||||
|
label: 'Email',
|
||||||
|
helpText: '',
|
||||||
|
choices: null,
|
||||||
|
initial: null,
|
||||||
|
})
|
||||||
|
// Default label derived from name when omitted.
|
||||||
|
expect(schema.fields[1].label).toBe('Age')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateForm: required + custom validator', () => {
|
||||||
|
expect(validateForm(contactForm, { email: 'a@b.c' }).valid).toBe(true)
|
||||||
|
expect(validateForm(contactForm, {}).errors.email).toEqual(['This field is required.'])
|
||||||
|
expect(validateForm(contactForm, { email: 'a@b.c', age: -1 }).errors.age).toEqual([
|
||||||
|
'must be non-negative',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registerForm registers schema + validate + submit with form meta', () => {
|
||||||
|
const reg = registerForm(contactForm, 'contact', {
|
||||||
|
submit: async (data) => ({ saved: data.email }),
|
||||||
|
})
|
||||||
|
expect(reg).toEqual({ schema: 'contact-schema', validate: 'contact-validate', submit: 'contact-submit' })
|
||||||
|
|
||||||
|
for (const [wire, role] of [
|
||||||
|
['contact-schema', 'schema'],
|
||||||
|
['contact-validate', 'validate'],
|
||||||
|
['contact-submit', 'submit'],
|
||||||
|
] as const) {
|
||||||
|
const entry = getFunction(wire)
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
expect(entry!.form).toBe(true)
|
||||||
|
expect(entry!.formName).toBe('contact')
|
||||||
|
expect(entry!.formRole).toBe(role)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('schema function dispatches to the field defs', async () => {
|
||||||
|
registerForm(contactForm, 'contact')
|
||||||
|
const r = await handleMutationCall('contact-schema', {})
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
expect(r.body.result.fields).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validate function dispatches and rejects bad data', async () => {
|
||||||
|
registerForm(contactForm, 'contact')
|
||||||
|
const ok = await handleMutationCall('contact-validate', { data: { email: 'a@b.c' } })
|
||||||
|
expect(ok.body.result.valid).toBe(true)
|
||||||
|
|
||||||
|
const bad = await handleMutationCall('contact-validate', { data: {} })
|
||||||
|
expect(bad.body.result.valid).toBe(false)
|
||||||
|
expect(bad.body.result.errors.email).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit validates then runs the handler', async () => {
|
||||||
|
let handled: any = null
|
||||||
|
registerForm(contactForm, 'contact', {
|
||||||
|
submit: async (data) => {
|
||||||
|
handled = data
|
||||||
|
return { id: 7 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ok = await handleMutationCall('contact-submit', { data: { email: 'a@b.c' } })
|
||||||
|
expect(ok.body.result).toEqual({ ok: true, result: { id: 7 } })
|
||||||
|
expect(handled).toEqual({ email: 'a@b.c' })
|
||||||
|
|
||||||
|
const bad = await handleMutationCall('contact-submit', { data: {} })
|
||||||
|
expect(bad.body.result.ok).toBe(false)
|
||||||
|
expect(bad.body.result.errors.email).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit not registered without a handler', () => {
|
||||||
|
const reg = registerForm(contactForm, 'noSubmit')
|
||||||
|
expect(reg.submit).toBeUndefined()
|
||||||
|
expect(getFunction('noSubmit-submit')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* SSR bridge tests — spawn + drive a JSON-RPC worker subprocess.
|
||||||
|
*
|
||||||
|
* The bridge's contract is the newline-delimited JSON-RPC protocol over a
|
||||||
|
* spawned worker (ready handshake, id-correlated render/ping, error frames,
|
||||||
|
* timeout, restart). Two peers exercise it:
|
||||||
|
*
|
||||||
|
* - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always
|
||||||
|
* runs, proving the full subprocess machinery independent of any install;
|
||||||
|
* - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an
|
||||||
|
* actual React component — runs when `bun` + the worker's deps are present.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterEach } from 'bun:test'
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { SSRBridge } from '../src'
|
||||||
|
|
||||||
|
const HERE = import.meta.dir
|
||||||
|
const REPO_ROOT = resolve(HERE, '../../..')
|
||||||
|
const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs')
|
||||||
|
const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx')
|
||||||
|
const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx')
|
||||||
|
|
||||||
|
// The real worker renders an actual React component. bun resolves `react`
|
||||||
|
// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's
|
||||||
|
// own react devDependency (installed alongside this package).
|
||||||
|
const BUN_OK = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync('bun', ['--version'], { stdio: 'ignore' })
|
||||||
|
return existsSync(resolve(HERE, '../node_modules/react/package.json'))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
let bridge: SSRBridge | null = null
|
||||||
|
afterEach(() => {
|
||||||
|
bridge?.shutdown()
|
||||||
|
bridge = null
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SSRBridge — stub worker (Node, no React)', () => {
|
||||||
|
test('waits for ready, then renders with id correlation', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||||
|
const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 })
|
||||||
|
expect(r.html).toBe('<div data-file="/abs/Card.tsx">{"title":"Hi","n":3}</div>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ping health check', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||||
|
expect(await bridge.ping()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('concurrent renders stay correlated', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
bridge.render('/a.tsx', { k: 'a' }),
|
||||||
|
bridge.render('/b.tsx', { k: 'b' }),
|
||||||
|
bridge.render('/c.tsx', { k: 'c' }),
|
||||||
|
])
|
||||||
|
expect(a.html).toContain('"k":"a"')
|
||||||
|
expect(b.html).toContain('"k":"b"')
|
||||||
|
expect(c.html).toContain('"k":"c"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('worker error frame surfaces as a thrown error', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||||
|
await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restarts after the worker exits', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||||
|
const first = await bridge.render('/one.tsx', { k: 1 })
|
||||||
|
expect(first.html).toContain('"k":1')
|
||||||
|
bridge.shutdown() // simulate a crashed/stopped worker
|
||||||
|
const second = await bridge.render('/two.tsx', { k: 2 })
|
||||||
|
expect(second.html).toContain('"k":2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('startup timeout when the worker never signals ready', async () => {
|
||||||
|
// `true` exits immediately without a ready frame → start times out.
|
||||||
|
bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 })
|
||||||
|
await expect(bridge.render('/x.tsx', {})).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SSRBridge — real Bun worker (renderToString)', () => {
|
||||||
|
test.skipIf(!BUN_OK)('renders a React component to HTML', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||||
|
const r = await bridge.render(HELLO_TSX, { name: 'Ryth' })
|
||||||
|
expect(r.html).toContain('Hello, Ryth!')
|
||||||
|
expect(r.html).toContain('class="greeting"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.skipIf(!BUN_OK)('ping on the real worker', async () => {
|
||||||
|
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||||
|
expect(await bridge.ping()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
|
* MWT / JWT token tests — decode round-trip + cross-language byte-parity pins
|
||||||
|
* against the live Python mint (`cores/mizan-python`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac, createHash } from 'crypto'
|
||||||
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
|
import { execFileSync } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import {
|
||||||
|
decodeMwt,
|
||||||
|
decodeJwtBearer,
|
||||||
|
identityFromMwt,
|
||||||
|
signMwt,
|
||||||
|
computePermissionKey,
|
||||||
|
createAccessToken,
|
||||||
|
createRefreshToken,
|
||||||
|
type MintUser,
|
||||||
|
} from '../src'
|
||||||
|
|
||||||
function b64url(buf: Buffer | string): string {
|
function b64url(buf: Buffer | string): string {
|
||||||
return Buffer.from(buf).toString('base64url')
|
return Buffer.from(buf).toString('base64url')
|
||||||
@@ -124,3 +137,117 @@ describe('MWT cross-language pin (Python create_mwt)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Mint: round-trip + cross-language byte-parity ────────────────────────────
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||||
|
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||||
|
|
||||||
|
const UV_AVAILABLE = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||||
|
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a Python snippet against cores/mizan-python and return stdout (trimmed).
|
||||||
|
* `time.time` is pinned so the production mint functions are deterministic.
|
||||||
|
*/
|
||||||
|
function py(snippet: string): string {
|
||||||
|
return execFileSync('uv', ['run', '--project', MIZAN_PYTHON, 'python', '-c', snippet], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MWT mint — round-trip', () => {
|
||||||
|
const SECRET = 'mint-roundtrip-secret'
|
||||||
|
|
||||||
|
test('signMwt produces a token decodeMwt accepts', () => {
|
||||||
|
const user: MintUser = { pk: 7, isStaff: true, isSuperuser: false, permissions: ['a.view', 'a.edit'] }
|
||||||
|
const token = signMwt(user, SECRET, { now: Math.floor(Date.now() / 1000) })
|
||||||
|
const p = decodeMwt(token, SECRET)
|
||||||
|
expect(p).not.toBeNull()
|
||||||
|
expect(p!.sub).toBe('7')
|
||||||
|
expect(p!.staff).toBe(true)
|
||||||
|
expect(p!.super).toBe(false)
|
||||||
|
expect(p!.kid).toBe('v1')
|
||||||
|
expect(p!.aud).toBe('mizan')
|
||||||
|
// pkey is the permission hash, surviving the round-trip.
|
||||||
|
expect(p!.pkey).toBe(computePermissionKey(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('computePermissionKey matches the documented blob hash', () => {
|
||||||
|
const user: MintUser = { pk: 1, isStaff: true, isSuperuser: false, permissions: ['z', 'a'] }
|
||||||
|
// "1:0:a,z" — staff:super:sorted-perms.
|
||||||
|
const expected = createHash('sha256').update('1:0:a,z', 'utf-8').digest('hex')
|
||||||
|
expect(computePermissionKey(user)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MWT mint — cross-language pin (Python create_mwt)', () => {
|
||||||
|
const SECRET = 'pin-mint-secret-mwt'
|
||||||
|
const NOW = 1700000000
|
||||||
|
|
||||||
|
test.skipIf(!UV_AVAILABLE)('TS signMwt byte-identical to Python create_mwt', () => {
|
||||||
|
const user: MintUser = {
|
||||||
|
pk: 42,
|
||||||
|
isStaff: true,
|
||||||
|
isSuperuser: false,
|
||||||
|
permissions: ['app.view_thing', 'app.change_thing'],
|
||||||
|
}
|
||||||
|
const tsToken = signMwt(user, SECRET, { ttl: 300, now: NOW })
|
||||||
|
|
||||||
|
// Drive the REAL create_mwt with time.time pinned to NOW and a
|
||||||
|
// user stub whose get_all_permissions returns the same perms.
|
||||||
|
const pyToken = py(String.raw`
|
||||||
|
import time, sys
|
||||||
|
time.time = lambda: ${NOW}
|
||||||
|
from mizan_core.mwt import create_mwt
|
||||||
|
|
||||||
|
class U:
|
||||||
|
pk = 42
|
||||||
|
is_staff = True
|
||||||
|
is_superuser = False
|
||||||
|
def get_all_permissions(self):
|
||||||
|
return {"app.view_thing", "app.change_thing"}
|
||||||
|
|
||||||
|
sys.stdout.write(create_mwt(U(), ${JSON.stringify(SECRET)}, ttl=300))
|
||||||
|
`)
|
||||||
|
expect(tsToken).toBe(pyToken)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('JWT mint — cross-language pin (Python create_access/refresh_token)', () => {
|
||||||
|
const SECRET = 'pin-mint-secret-jwt'
|
||||||
|
const NOW = 1700000000
|
||||||
|
|
||||||
|
const config = { privateKey: SECRET, accessTokenExpiresIn: 300, refreshTokenExpiresIn: 604800 }
|
||||||
|
const claims = { userId: 42, sessionKey: 'sess-abc', isStaff: true, isSuperuser: false }
|
||||||
|
|
||||||
|
test.skipIf(!UV_AVAILABLE)('TS createAccessToken byte-identical to Python', () => {
|
||||||
|
const tsToken = createAccessToken(claims, config, NOW)
|
||||||
|
const pyToken = py(String.raw`
|
||||||
|
import time, sys
|
||||||
|
time.time = lambda: ${NOW}
|
||||||
|
from mizan_core.auth.jwt import JWTConfig, create_access_token
|
||||||
|
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||||
|
sys.stdout.write(create_access_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||||
|
`)
|
||||||
|
expect(tsToken).toBe(pyToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.skipIf(!UV_AVAILABLE)('TS createRefreshToken byte-identical to Python', () => {
|
||||||
|
const tsToken = createRefreshToken(claims, config, NOW)
|
||||||
|
const pyToken = py(String.raw`
|
||||||
|
import time, sys
|
||||||
|
time.time = lambda: ${NOW}
|
||||||
|
from mizan_core.auth.jwt import JWTConfig, create_refresh_token
|
||||||
|
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||||
|
sys.stdout.write(create_refresh_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||||
|
`)
|
||||||
|
expect(tsToken).toBe(pyToken)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
131
backends/mizan-ts/tests/transport.test.ts
Normal file
131
backends/mizan-ts/tests/transport.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Session-init + WebSocket transport tests.
|
||||||
|
*
|
||||||
|
* session-init returns the `{ csrfToken }` no-store shape at parity with the
|
||||||
|
* Django/FastAPI/Axum session endpoint. The WebSocket transport drives the
|
||||||
|
* SAME dispatch core the HTTP path uses, so a function exposed over WS behaves
|
||||||
|
* identically — invalidation, auth, and not-found all carry through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import {
|
||||||
|
ReactContext,
|
||||||
|
client,
|
||||||
|
clearRegistry,
|
||||||
|
handleSessionInit,
|
||||||
|
sessionInitRoute,
|
||||||
|
SESSION_INIT_PATH,
|
||||||
|
handleWebSocketMessage,
|
||||||
|
serveWebSocket,
|
||||||
|
type Identity,
|
||||||
|
type WebSocketLike,
|
||||||
|
} from '../src'
|
||||||
|
|
||||||
|
describe('session-init', () => {
|
||||||
|
test('returns { csrfToken: null } with no-store', () => {
|
||||||
|
const r = handleSessionInit()
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
expect(r.body).toEqual({ csrfToken: null })
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
expect(r.headers['Content-Type']).toBe('application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('embeds a host-provided CSRF token', () => {
|
||||||
|
const r = handleSessionInit('tok-123')
|
||||||
|
expect(r.body).toEqual({ csrfToken: 'tok-123' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('route descriptor mounts GET /session/ (parity with Django/FastAPI/Axum)', () => {
|
||||||
|
expect(SESSION_INIT_PATH).toBe('/session/')
|
||||||
|
expect(sessionInitRoute.path).toBe('/session/')
|
||||||
|
expect(sessionInitRoute.method).toBe('GET')
|
||||||
|
// The wired handler returns the session shape.
|
||||||
|
expect(sessionInitRoute.handler().body).toEqual({ csrfToken: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WebSocket transport', () => {
|
||||||
|
beforeEach(() => clearRegistry())
|
||||||
|
|
||||||
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
client({ context: UserCtx, websocket: true }, async function user_profile(user_id: number) {
|
||||||
|
return { user_id, name: `user_${user_id}` }
|
||||||
|
})
|
||||||
|
client({ affects: UserCtx, websocket: true }, async function update_profile(user_id: number, name: string) {
|
||||||
|
return { ok: true, user_id, name }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('call frame routes through mutation dispatch + carries invalidation', async () => {
|
||||||
|
setup()
|
||||||
|
const reply = await handleWebSocketMessage({
|
||||||
|
id: 1,
|
||||||
|
type: 'call',
|
||||||
|
fn: 'update_profile',
|
||||||
|
args: { user_id: 5, name: 'X' },
|
||||||
|
})
|
||||||
|
expect(reply.id).toBe(1)
|
||||||
|
expect(reply.result).toEqual({ ok: true, user_id: 5, name: 'X' })
|
||||||
|
expect(reply.invalidate).toBeDefined()
|
||||||
|
expect(reply.invalidate[0].context).toBe('user')
|
||||||
|
expect(reply.invalidate[0].params.user_id).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetch frame routes through context bundle', async () => {
|
||||||
|
setup()
|
||||||
|
const reply = await handleWebSocketMessage({
|
||||||
|
id: 2,
|
||||||
|
type: 'fetch',
|
||||||
|
context: 'user',
|
||||||
|
params: { user_id: '7' },
|
||||||
|
})
|
||||||
|
expect(reply.id).toBe(2)
|
||||||
|
expect(reply.result.user_profile).toEqual({ user_id: '7', name: 'user_7' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown function returns an error frame, not a throw', async () => {
|
||||||
|
const reply = await handleWebSocketMessage({ id: 3, type: 'call', fn: 'nope' })
|
||||||
|
expect(reply.error).toBeDefined()
|
||||||
|
expect(reply.error!.code).toBe('NOT_FOUND')
|
||||||
|
expect(reply.id).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auth enforcement carries over the WS transport', async () => {
|
||||||
|
client({ auth: true, websocket: true }, async function secret() {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
const anon: Identity = { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
|
||||||
|
const reply = await handleWebSocketMessage({ id: 4, type: 'call', fn: 'secret' }, anon)
|
||||||
|
expect(reply.error!.code).toBe('UNAUTHORIZED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed JSON frame → error', async () => {
|
||||||
|
const reply = await handleWebSocketMessage('{not json')
|
||||||
|
expect(reply.error!.code).toBe('BAD_REQUEST')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('serveWebSocket wires a connection and replies as JSON', async () => {
|
||||||
|
setup()
|
||||||
|
const sent: string[] = []
|
||||||
|
let listener: ((e: { data: any }) => void) | null = null
|
||||||
|
const ws: WebSocketLike = {
|
||||||
|
send: (d) => sent.push(d),
|
||||||
|
addEventListener: (_t, l) => {
|
||||||
|
listener = l
|
||||||
|
},
|
||||||
|
}
|
||||||
|
serveWebSocket(ws)
|
||||||
|
expect(listener).not.toBeNull()
|
||||||
|
|
||||||
|
// Drive a message through the wired listener.
|
||||||
|
await listener!({ data: JSON.stringify({ id: 9, type: 'fetch', context: 'user', params: { user_id: '3' } }) })
|
||||||
|
// Give the async handler a tick to resolve + send.
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(sent.length).toBe(1)
|
||||||
|
const reply = JSON.parse(sent[0])
|
||||||
|
expect(reply.id).toBe(9)
|
||||||
|
expect(reply.result.user_profile.name).toBe('user_3')
|
||||||
|
})
|
||||||
|
})
|
||||||
163
backends/mizan-ts/tests/upload.test.ts
Normal file
163
backends/mizan-ts/tests/upload.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Upload tests — multipart File-part binding + constraint enforcement.
|
||||||
|
*
|
||||||
|
* Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts
|
||||||
|
* into the function's Upload-typed inputs, and `File(...)` constraints
|
||||||
|
* (max-size, content-type) reject at dispatch with a 400.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import {
|
||||||
|
client,
|
||||||
|
clearRegistry,
|
||||||
|
handleMultipartCall,
|
||||||
|
parseSize,
|
||||||
|
validateUpload,
|
||||||
|
UploadedFile,
|
||||||
|
type StructField,
|
||||||
|
} from '../src'
|
||||||
|
|
||||||
|
const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => {
|
||||||
|
let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes }
|
||||||
|
if (opts.list) shape = { kind: 'list', inner: shape }
|
||||||
|
if (opts.optional) shape = { kind: 'optional', inner: shape }
|
||||||
|
return { name, required: !opts.optional, shape }
|
||||||
|
}
|
||||||
|
const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } })
|
||||||
|
|
||||||
|
function multipart(fn: string, args: Record<string, any>, files: Record<string, Blob | Blob[]>): FormData {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('fn', fn)
|
||||||
|
form.set('args', JSON.stringify(args))
|
||||||
|
for (const [key, val] of Object.entries(files)) {
|
||||||
|
for (const f of Array.isArray(val) ? val : [val]) form.append(key, f)
|
||||||
|
}
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseSize', () => {
|
||||||
|
test('parses human sizes', () => {
|
||||||
|
expect(parseSize('5MB')).toBe(5 * 1024 * 1024)
|
||||||
|
expect(parseSize('1KB')).toBe(1024)
|
||||||
|
expect(parseSize('2GB')).toBe(2 * 1024 ** 3)
|
||||||
|
expect(parseSize(123)).toBe(123)
|
||||||
|
expect(parseSize('500')).toBe(500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateUpload', () => {
|
||||||
|
test('max-size rejection', () => {
|
||||||
|
const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100))
|
||||||
|
expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size')
|
||||||
|
expect(validateUpload(f, { maxSize: 200 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('content-type allowlist + wildcard', () => {
|
||||||
|
const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1))
|
||||||
|
expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull()
|
||||||
|
expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull()
|
||||||
|
expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('multipart dispatch', () => {
|
||||||
|
beforeEach(() => clearRegistry())
|
||||||
|
|
||||||
|
test('binds a file part into the Upload input', async () => {
|
||||||
|
let received: UploadedFile | null = null
|
||||||
|
client(
|
||||||
|
{
|
||||||
|
affects: 'avatars',
|
||||||
|
ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] },
|
||||||
|
},
|
||||||
|
async function set_avatar(user_id: number, avatar: UploadedFile) {
|
||||||
|
received = avatar
|
||||||
|
return { ok: true, name: avatar.filename, bytes: avatar.size }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = multipart('set_avatar', { user_id: 5 }, {
|
||||||
|
avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }),
|
||||||
|
})
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 })
|
||||||
|
expect(received).not.toBeNull()
|
||||||
|
expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('max-size violation rejects with 400', async () => {
|
||||||
|
client(
|
||||||
|
{ affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } },
|
||||||
|
async function set_avatar(_avatar: UploadedFile) {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const form = multipart('set_avatar', {}, {
|
||||||
|
avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }),
|
||||||
|
})
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(400)
|
||||||
|
expect(r.body.message).toContain('avatar:')
|
||||||
|
expect(r.body.message).toContain('exceeds max size')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('content-type violation rejects with 400', async () => {
|
||||||
|
client(
|
||||||
|
{ affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } },
|
||||||
|
async function set_avatar(_avatar: UploadedFile) {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const form = multipart('set_avatar', {}, {
|
||||||
|
avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }),
|
||||||
|
})
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(400)
|
||||||
|
expect(r.body.message).toContain('not allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('list upload binds multiple parts', async () => {
|
||||||
|
let count = 0
|
||||||
|
client(
|
||||||
|
{ affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } },
|
||||||
|
async function add_photos(photos: UploadedFile[]) {
|
||||||
|
count = photos.length
|
||||||
|
return { ok: true, count: photos.length }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const form = multipart('add_photos', {}, {
|
||||||
|
photos: [
|
||||||
|
new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }),
|
||||||
|
new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
expect(r.body.result.count).toBe(2)
|
||||||
|
expect(count).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing fn → 400', async () => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('args', '{}')
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(400)
|
||||||
|
expect(r.body.message).toContain("'fn'")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalidation still emitted on multipart mutation', async () => {
|
||||||
|
client(
|
||||||
|
{ affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } },
|
||||||
|
async function set_avatar(_user_id: number, _avatar: UploadedFile) {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const form = multipart('set_avatar', { user_id: 9 }, {
|
||||||
|
avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }),
|
||||||
|
})
|
||||||
|
const r = await handleMultipartCall(form)
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('avatars')
|
||||||
|
})
|
||||||
|
})
|
||||||
161
cores/mizan-python/src/mizan_core/manifest.py
Normal file
161
cores/mizan-python/src/mizan_core/manifest.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Edge-manifest derivation — the AFI-common source of truth.
|
||||||
|
|
||||||
|
The Edge manifest is a static JSON mapping contexts to URL patterns, params, and
|
||||||
|
cache/render policy. Mizan Edge reads it at deploy time to drive CDN cache
|
||||||
|
purging: when it receives `X-Mizan-Invalidate: user;user_id=5` it looks up
|
||||||
|
`user` in the manifest, resolves the page routes with the params, and purges
|
||||||
|
both the resolved URLs and the context endpoint.
|
||||||
|
|
||||||
|
The manifest is *derived from the registry* — the same `@client` metadata every
|
||||||
|
adapter populates — so its derivation is AFI-common, not framework-bound. It
|
||||||
|
lives here in the core; each adapter exposes it (a callable, a CLI entry) over
|
||||||
|
its own surface. Django's `export_edge_manifest` command and the FastAPI
|
||||||
|
console entry both call `generate_edge_manifest`; there is one derivation.
|
||||||
|
|
||||||
|
`render_strategy` is computed here too: a context whose params overlap
|
||||||
|
`USER_SCOPED_PARAMS` is `dynamic_cached` (per-user at the edge); one whose
|
||||||
|
params don't is `psr` (one shared pre-rendered artifact, re-rendered on
|
||||||
|
mutation). That single rule is what the `psr` capability checks for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_function, get_all_functions
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"USER_SCOPED_PARAMS",
|
||||||
|
"generate_edge_manifest",
|
||||||
|
"generate_edge_manifest_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# A context is per-user (and so must be `dynamic_cached` at the edge) when any of
|
||||||
|
# its params identifies a user. A context with no such param renders one shared
|
||||||
|
# artifact — `psr`. This set is the entire `render_strategy` decision.
|
||||||
|
USER_SCOPED_PARAMS: frozenset[str] = frozenset({"user_id", "user", "owner_id", "account_id"})
|
||||||
|
|
||||||
|
|
||||||
|
def _input_param_names(fn_cls: Any) -> set[str]:
|
||||||
|
"""The declared input field names of a registered function (empty if none)."""
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
return set(input_cls.model_fields.keys())
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Derive the Edge manifest from the function registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default ``/api/mizan``).
|
||||||
|
view_urls: Optional extra page routes per context for Edge to purge,
|
||||||
|
beyond the routes declared on view-path functions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JSON-serializable manifest: ``{"version", "contexts", "mutations"}``.
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
all_functions = get_all_functions()
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in sorted(groups.items()):
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
page_routes: list[str] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
param_names |= _input_param_names(fn_cls)
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
route = meta.get("route")
|
||||||
|
view_path = meta.get("view_path")
|
||||||
|
|
||||||
|
fn_entry: dict[str, Any] = {
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if view_path else "rpc",
|
||||||
|
}
|
||||||
|
if route:
|
||||||
|
fn_entry["route"] = route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(route)
|
||||||
|
if meta.get("rev"):
|
||||||
|
fn_entry["rev"] = meta["rev"]
|
||||||
|
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||||
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
user_scoped = any(p in USER_SCOPED_PARAMS for p in param_names)
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted(param_names),
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
|
}
|
||||||
|
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
if not meta.get("affects"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||||
|
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||||
|
|
||||||
|
# Auto-scoped params — function params that match a param of an affected
|
||||||
|
# context. These are the keys Edge can resolve to scope the purge.
|
||||||
|
fn_params = _input_param_names(fn_cls)
|
||||||
|
if fn_params:
|
||||||
|
auto_scoped: list[str] = []
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_param_names: set[str] = set()
|
||||||
|
for ctx_fn_name in groups.get(ctx_name, []):
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls is not None:
|
||||||
|
ctx_param_names |= _input_param_names(ctx_fn_cls)
|
||||||
|
for p in fn_params:
|
||||||
|
if p in ctx_param_names and p not in auto_scoped:
|
||||||
|
auto_scoped.append(p)
|
||||||
|
if auto_scoped:
|
||||||
|
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||||
|
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation["route"] = meta["route"]
|
||||||
|
mutation["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
indent: int | None = 2,
|
||||||
|
) -> str:
|
||||||
|
"""JSON-serialize the Edge manifest (keys sorted for deterministic output)."""
|
||||||
|
return json.dumps(
|
||||||
|
generate_edge_manifest(base_url, view_urls), indent=indent, sort_keys=True
|
||||||
|
)
|
||||||
@@ -5,12 +5,11 @@ sub-registry (channels/WebSocket, forms, shapes) to plug into.
|
|||||||
|
|
||||||
This is the framework-agnostic registry. The extension points
|
This is the framework-agnostic registry. The extension points
|
||||||
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
|
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
|
||||||
a binding for each, and registers it here so the unified schema export
|
a binding for each, on its own stack — Django Channels or a native
|
||||||
sees it. Django binds all of them today; the other adapters' unbound
|
WebSocket route; Django Forms or Pydantic; django-readers or the project's
|
||||||
extensions are gaps tracked by the capability-parity suite in
|
ORM. The capability is common; the binding is per-stack. Each adapter wires
|
||||||
`tests/afi/`, not framework-specific features. The binding is per-stack
|
its binding so the unified schema export sees it; an unwired one is a gap on
|
||||||
(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the
|
the capability-parity board (`tests/afi/`), not a framework-specific feature.
|
||||||
capability is common.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
12
cores/mizan-python/src/mizan_core/ssr/__init__.py
Normal file
12
cores/mizan-python/src/mizan_core/ssr/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
mizan_core.ssr — framework-agnostic server-side rendering.
|
||||||
|
|
||||||
|
`SSRBridge` manages a persistent Bun subprocess that renders React components to
|
||||||
|
HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle;
|
||||||
|
adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's
|
||||||
|
`SSRRenderer`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from mizan_core.ssr.bridge import RenderResult, SSRBridge
|
||||||
|
|
||||||
|
__all__ = ["SSRBridge", "RenderResult"]
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
SSR Bridge — manages a persistent Bun subprocess for React rendering.
|
||||||
|
|
||||||
|
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
|
||||||
|
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
|
||||||
|
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
|
||||||
|
render path — so the subprocess lifecycle and wire protocol are authored once.
|
||||||
|
|
||||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ class SSRBridge:
|
|||||||
"""
|
"""
|
||||||
Manages a persistent Bun subprocess for server-side rendering.
|
Manages a persistent Bun subprocess for server-side rendering.
|
||||||
|
|
||||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
Thread-safe. Multiple worker threads can call render() concurrently.
|
||||||
Request-response matching via message IDs.
|
Request-response matching via message IDs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -26,6 +26,15 @@ pub struct FunctionArgs {
|
|||||||
pub merge: Vec<Path>,
|
pub merge: Vec<Path>,
|
||||||
pub websocket: bool,
|
pub websocket: bool,
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
|
/// `auth = "required" | "staff" | "superuser"` (or bare `auth` ⇒
|
||||||
|
/// "required") — the `@client(auth=...)` guard. Bare-true and the string
|
||||||
|
/// `"required"` both mean "must be authenticated".
|
||||||
|
pub auth: Option<String>,
|
||||||
|
/// `form_name = "..."` + `form_role = "schema"|"validate"|"submit"` — the
|
||||||
|
/// Forms binding's per-endpoint metadata, mirroring the Django form
|
||||||
|
/// `_meta` keys. Carried into the IR (`is-form`/`form-name`/`form-role`).
|
||||||
|
pub form_name: Option<String>,
|
||||||
|
pub form_role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FunctionArgs {
|
impl FunctionArgs {
|
||||||
@@ -45,10 +54,16 @@ impl FunctionArgs {
|
|||||||
out.affects = collect_paths(&nv.value)?;
|
out.affects = collect_paths(&nv.value)?;
|
||||||
} else if nv.path.is_ident("merge") {
|
} else if nv.path.is_ident("merge") {
|
||||||
out.merge = collect_paths(&nv.value)?;
|
out.merge = collect_paths(&nv.value)?;
|
||||||
|
} else if nv.path.is_ident("auth") {
|
||||||
|
out.auth = Some(expect_str(&nv.value)?);
|
||||||
|
} else if nv.path.is_ident("form_name") {
|
||||||
|
out.form_name = Some(expect_str(&nv.value)?);
|
||||||
|
} else if nv.path.is_ident("form_role") {
|
||||||
|
out.form_role = Some(expect_str(&nv.value)?);
|
||||||
} else {
|
} else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
nv.path,
|
nv.path,
|
||||||
"unknown attribute key; expected one of: context, affects, merge",
|
"unknown attribute key; expected one of: context, affects, merge, auth, form_name, form_role",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,10 +72,12 @@ impl FunctionArgs {
|
|||||||
out.websocket = true;
|
out.websocket = true;
|
||||||
} else if p.is_ident("private") {
|
} else if p.is_ident("private") {
|
||||||
out.private = true;
|
out.private = true;
|
||||||
|
} else if p.is_ident("auth") {
|
||||||
|
out.auth = Some("required".to_string());
|
||||||
} else {
|
} else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
p,
|
p,
|
||||||
"unknown flag; expected `websocket` or `private`",
|
"unknown flag; expected `websocket`, `private`, or `auth`",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +116,21 @@ fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expect_str(expr: &Expr) -> syn::Result<String> {
|
||||||
|
if let Expr::Lit(syn::ExprLit {
|
||||||
|
lit: syn::Lit::Str(s),
|
||||||
|
..
|
||||||
|
}) = expr
|
||||||
|
{
|
||||||
|
Ok(s.value())
|
||||||
|
} else {
|
||||||
|
Err(syn::Error::new_spanned(
|
||||||
|
expr,
|
||||||
|
"expected a string literal (e.g. `\"staff\"`)",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
||||||
match expr {
|
match expr {
|
||||||
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
||||||
@@ -183,7 +215,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
quote! {
|
quote! {
|
||||||
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
|
// The synthetic Input is only ever *deserialized* (from the call's
|
||||||
|
// JSON args by the dispatch wrapper); it is never serialized, so it
|
||||||
|
// derives `Deserialize` only. Dropping `Serialize` lets binary
|
||||||
|
// field types like `Upload` (deserialize-only) participate.
|
||||||
|
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Deserialize)]
|
||||||
pub struct #input_type_ident {
|
pub struct #input_type_ident {
|
||||||
#(#field_defs)*
|
#(#field_defs)*
|
||||||
}
|
}
|
||||||
@@ -353,6 +389,20 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
let output_nullable = analysis.nullable;
|
let output_nullable = analysis.nullable;
|
||||||
let private = args.private;
|
let private = args.private;
|
||||||
|
|
||||||
|
let auth_value = match &args.auth {
|
||||||
|
Some(a) => quote! { ::std::option::Option::Some(#a) },
|
||||||
|
None => quote! { ::std::option::Option::None },
|
||||||
|
};
|
||||||
|
let is_form = args.form_name.is_some() || args.form_role.is_some();
|
||||||
|
let form_name_value = match &args.form_name {
|
||||||
|
Some(n) => quote! { ::std::option::Option::Some(#n) },
|
||||||
|
None => quote! { ::std::option::Option::None },
|
||||||
|
};
|
||||||
|
let form_role_value = match &args.form_role {
|
||||||
|
Some(r) => quote! { ::std::option::Option::Some(#r) },
|
||||||
|
None => quote! { ::std::option::Option::None },
|
||||||
|
};
|
||||||
|
|
||||||
let dispatch_body = build_dispatch(
|
let dispatch_body = build_dispatch(
|
||||||
&item,
|
&item,
|
||||||
&input_args,
|
&input_args,
|
||||||
@@ -389,6 +439,10 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
||||||
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
||||||
fn private(&self) -> bool { #private }
|
fn private(&self) -> bool { #private }
|
||||||
|
fn auth(&self) -> ::std::option::Option<&'static str> { #auth_value }
|
||||||
|
fn is_form(&self) -> bool { #is_form }
|
||||||
|
fn form_name(&self) -> ::std::option::Option<&'static str> { #form_name_value }
|
||||||
|
fn form_role(&self) -> ::std::option::Option<&'static str> { #form_role_value }
|
||||||
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
||||||
|
|
||||||
fn dispatch<'a>(
|
fn dispatch<'a>(
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
|||||||
if let Some(p) = primitive_of(ty) {
|
if let Some(p) = primitive_of(ty) {
|
||||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
||||||
}
|
}
|
||||||
|
if is_upload(ty) {
|
||||||
|
// An `Upload`-typed field emits the IR `upload` type-child rather than
|
||||||
|
// a `ref`, matching the Python emitter. Constraints (`max-size`,
|
||||||
|
// `content-type`) aren't carried in this baseline — an unconstrained
|
||||||
|
// upload — but the wire/IR shape is the recognized `upload` node.
|
||||||
|
return quote! {
|
||||||
|
::mizan_core::TypeShape::Upload { max_size: ::std::option::Option::None, content_types: &[] }
|
||||||
|
};
|
||||||
|
}
|
||||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
||||||
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
||||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
||||||
@@ -149,6 +158,19 @@ pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
|||||||
type_args.next()
|
type_args.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if `ty` names the `mizan_core::Upload` marker (by its last path
|
||||||
|
/// segment) — the binary file-input type.
|
||||||
|
pub fn is_upload(ty: &Type) -> bool {
|
||||||
|
match ty {
|
||||||
|
Type::Path(TypePath { qself: None, path }) => path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.map(|s| s.ident == "Upload")
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
||||||
/// known primitive scalar.
|
/// known primitive scalar.
|
||||||
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
||||||
|
|||||||
108
cores/mizan-rust/Cargo.lock
generated
108
cores/mizan-rust/Cargo.lock
generated
@@ -13,12 +13,82 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.7"
|
version = "2.0.7"
|
||||||
@@ -34,6 +104,12 @@ 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 = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linkme"
|
name = "linkme"
|
||||||
version = "0.3.36"
|
version = "0.3.36"
|
||||||
@@ -65,11 +141,14 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"hmac",
|
||||||
"indoc",
|
"indoc",
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -149,6 +228,23 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -160,12 +256,24 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
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 = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
mizan-macros = { path = "../mizan-rust-macros" }
|
mizan-macros = { path = "../mizan-rust-macros" }
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
indoc = "2"
|
indoc = "2"
|
||||||
|
|||||||
552
cores/mizan-rust/src/auth.rs
Normal file
552
cores/mizan-rust/src/auth.rs
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
//! JWT + MWT — HS256 mint and verify, byte-pinned to the Python core.
|
||||||
|
//!
|
||||||
|
//! Pinned references:
|
||||||
|
//! * JWT → `cores/mizan-python/src/mizan_core/auth/jwt.py`
|
||||||
|
//! * MWT → `cores/mizan-python/src/mizan_core/mwt.py`
|
||||||
|
//!
|
||||||
|
//! These are RFC 7519 JWTs over HMAC-SHA256. Byte-identical output to PyJWT
|
||||||
|
//! 2.x requires reproducing its exact serialization, which a generic JWT crate
|
||||||
|
//! does not expose:
|
||||||
|
//!
|
||||||
|
//! * the JOSE **header** keys are emitted in **sorted** order with compact
|
||||||
|
//! `(",", ":")` separators — `{"alg":"HS256","typ":"JWT"}`, or with a
|
||||||
|
//! `kid`, `{"alg":"HS256","kid":"v1","typ":"JWT"}`;
|
||||||
|
//! * the **payload** keys are emitted in **insertion** order (PyJWT does not
|
||||||
|
//! sort the claims) with the same compact separators;
|
||||||
|
//! * both segments are base64url-encoded **without padding**.
|
||||||
|
//!
|
||||||
|
//! So a mint here builds each segment's bytes deliberately (sorted header,
|
||||||
|
//! ordered claims) and signs `header.payload`. `tests/token_pin.rs` pins the
|
||||||
|
//! exact tokens against the Python reference for fixed inputs.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Current unix time in seconds — the `now` adapters pass to mint/verify when
|
||||||
|
/// they aren't pinning a fixed clock (tests inject a fixed value for byte
|
||||||
|
/// determinism).
|
||||||
|
pub fn now_unix() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b64url(bytes: &[u8]) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
|
||||||
|
URL_SAFE_NO_PAD.decode(s).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(secret: &str, signing_input: &str) -> String {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC any key length");
|
||||||
|
mac.update(signing_input.as_bytes());
|
||||||
|
b64url(&mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a JOSE header for HS256 with optional `kid`, keys in sorted order
|
||||||
|
/// (`alg` < `kid` < `typ`) and compact separators — byte-identical to PyJWT.
|
||||||
|
fn header_json(kid: Option<&str>) -> String {
|
||||||
|
match kid {
|
||||||
|
Some(kid) => format!(
|
||||||
|
"{{\"alg\":\"HS256\",\"kid\":{},\"typ\":\"JWT\"}}",
|
||||||
|
json_str(kid)
|
||||||
|
),
|
||||||
|
None => "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode one JSON string literal byte-for-byte with PyJWT's serializer,
|
||||||
|
/// which is `json.dumps` with the default `ensure_ascii=True`: short escapes
|
||||||
|
/// for `"`, `\`, `\b\f\n\r\t`, and `\uXXXX` for the rest of the C0 range and
|
||||||
|
/// every non-ASCII code point (surrogate pairs above the BMP).
|
||||||
|
fn json_str(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
'\u{08}' => out.push_str("\\b"),
|
||||||
|
'\u{0c}' => out.push_str("\\f"),
|
||||||
|
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||||
|
let mut buf = [0u16; 2];
|
||||||
|
for unit in c.encode_utf16(&mut buf) {
|
||||||
|
out.push_str(&format!("\\u{unit:04x}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_bool(b: bool) -> &'static str {
|
||||||
|
if b {
|
||||||
|
"true"
|
||||||
|
} else {
|
||||||
|
"false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint `header.payload.signature` from a pre-serialized payload body. The
|
||||||
|
/// payload bytes are authored by the caller so claim ordering is under exact
|
||||||
|
/// control (PyJWT preserves insertion order).
|
||||||
|
fn encode(secret: &str, kid: Option<&str>, payload_json: &str) -> String {
|
||||||
|
let header = b64url(header_json(kid).as_bytes());
|
||||||
|
let payload = b64url(payload_json.as_bytes());
|
||||||
|
let signing_input = format!("{header}.{payload}");
|
||||||
|
let sig = sign(secret, &signing_input);
|
||||||
|
format!("{signing_input}.{sig}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the HS256 signature over `header.payload` and return the decoded
|
||||||
|
/// payload bytes. Constant-time-ish: recompute and compare the signature.
|
||||||
|
fn verify_signature(secret: &str, token: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut parts = token.splitn(3, '.');
|
||||||
|
let header_b64 = parts.next()?;
|
||||||
|
let payload_b64 = parts.next()?;
|
||||||
|
let sig_b64 = parts.next()?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let signing_input = format!("{header_b64}.{payload_b64}");
|
||||||
|
let expected = sign(secret, &signing_input);
|
||||||
|
// base64url of HMAC is fixed-length; a direct compare is adequate here and
|
||||||
|
// matches the reference's PyJWT-side verification semantics.
|
||||||
|
if !ct_eq(expected.as_bytes(), sig_b64.as_bytes()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
b64url_decode(payload_b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut diff = 0u8;
|
||||||
|
for (x, y) in a.iter().zip(b.iter()) {
|
||||||
|
diff |= x ^ y;
|
||||||
|
}
|
||||||
|
diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the `kid` claim from the (unverified) JOSE header — needed before
|
||||||
|
/// signature verification to mirror `decode_mwt`'s `get_unverified_header`.
|
||||||
|
fn unverified_kid(token: &str) -> Option<String> {
|
||||||
|
let header_b64 = token.split('.').next()?;
|
||||||
|
let bytes = b64url_decode(header_b64)?;
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||||
|
v.get("kid")
|
||||||
|
.and_then(|k| k.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JWT ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// JWT signing/verification config — Rust analog of `JWTConfig`. HS256 only
|
||||||
|
/// here (the byte-pinned algorithm); `private_key` doubles as the verify key.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JwtConfig {
|
||||||
|
pub secret: String,
|
||||||
|
pub access_ttl: i64,
|
||||||
|
pub refresh_ttl: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtConfig {
|
||||||
|
pub fn new(secret: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
secret: secret.into(),
|
||||||
|
access_ttl: 300,
|
||||||
|
refresh_ttl: 604_800,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded JWT claims — Rust analog of `TokenPayload`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct JwtPayload {
|
||||||
|
pub sub: String,
|
||||||
|
pub sid: String,
|
||||||
|
pub staff: bool,
|
||||||
|
pub superuser: bool,
|
||||||
|
pub token_type: String,
|
||||||
|
pub iat: i64,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the JWT claims body in PyJWT-insertion order: sub, sid, staff, super,
|
||||||
|
/// type, iat, exp. (Matches `jwt.py::_mint`.)
|
||||||
|
fn jwt_payload_json(
|
||||||
|
sub: &str,
|
||||||
|
sid: &str,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
token_type: &str,
|
||||||
|
iat: i64,
|
||||||
|
exp: i64,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{{\"sub\":{},\"sid\":{},\"staff\":{},\"super\":{},\"type\":{},\"iat\":{},\"exp\":{}}}",
|
||||||
|
json_str(sub),
|
||||||
|
json_str(sid),
|
||||||
|
json_bool(staff),
|
||||||
|
json_bool(superuser),
|
||||||
|
json_str(token_type),
|
||||||
|
iat,
|
||||||
|
exp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn mint_jwt(
|
||||||
|
cfg: &JwtConfig,
|
||||||
|
sub: &str,
|
||||||
|
sid: &str,
|
||||||
|
token_type: &str,
|
||||||
|
ttl: i64,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> String {
|
||||||
|
let payload = jwt_payload_json(sub, sid, staff, superuser, token_type, now, now + ttl);
|
||||||
|
encode(&cfg.secret, None, &payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint an access token. `now` is unix-seconds (injected for determinism).
|
||||||
|
pub fn create_access_token(
|
||||||
|
cfg: &JwtConfig,
|
||||||
|
sub: &str,
|
||||||
|
sid: &str,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> String {
|
||||||
|
mint_jwt(cfg, sub, sid, "access", cfg.access_ttl, staff, superuser, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a refresh token.
|
||||||
|
pub fn create_refresh_token(
|
||||||
|
cfg: &JwtConfig,
|
||||||
|
sub: &str,
|
||||||
|
sid: &str,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> String {
|
||||||
|
mint_jwt(
|
||||||
|
cfg,
|
||||||
|
sub,
|
||||||
|
sid,
|
||||||
|
"refresh",
|
||||||
|
cfg.refresh_ttl,
|
||||||
|
staff,
|
||||||
|
superuser,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode + validate a JWT. `None` on a bad signature, malformed token,
|
||||||
|
/// expiry (against `now`), or a `type` mismatch. Mirrors `decode_token`.
|
||||||
|
pub fn decode_jwt(
|
||||||
|
token: &str,
|
||||||
|
cfg: &JwtConfig,
|
||||||
|
expected_type: Option<&str>,
|
||||||
|
now: i64,
|
||||||
|
) -> Option<JwtPayload> {
|
||||||
|
let payload_bytes = verify_signature(&cfg.secret, token)?;
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||||
|
let exp = v.get("exp")?.as_i64()?;
|
||||||
|
if now >= exp {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token_type = v.get("type")?.as_str()?.to_string();
|
||||||
|
if let Some(want) = expected_type {
|
||||||
|
if token_type != want {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(JwtPayload {
|
||||||
|
sub: v.get("sub")?.as_str()?.to_string(),
|
||||||
|
sid: v.get("sid")?.as_str()?.to_string(),
|
||||||
|
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||||
|
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||||
|
token_type,
|
||||||
|
iat: v.get("iat")?.as_i64()?,
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MWT ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decoded MWT claims — Rust analog of `MWTPayload`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MwtPayload {
|
||||||
|
pub sub: String,
|
||||||
|
pub staff: bool,
|
||||||
|
pub superuser: bool,
|
||||||
|
pub pkey: String,
|
||||||
|
pub kid: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub iat: i64,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the permission-state hash — full SHA-256 hex over
|
||||||
|
/// `"{staff}:{super}:{sorted,comma-joined perms}"`. Matches
|
||||||
|
/// `mwt.py::compute_permission_key` byte-for-byte.
|
||||||
|
pub fn compute_permission_key(staff: bool, superuser: bool, perms: &[String]) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
let mut sorted: Vec<&String> = perms.iter().collect();
|
||||||
|
sorted.sort();
|
||||||
|
let staff_c = if staff { "1" } else { "0" };
|
||||||
|
let super_c = if superuser { "1" } else { "0" };
|
||||||
|
let joined: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
|
||||||
|
let blob = format!("{staff_c}:{super_c}:{}", joined.join(","));
|
||||||
|
let digest = Sha256::digest(blob.as_bytes());
|
||||||
|
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the MWT claims body in `create_mwt` insertion order: sub, staff,
|
||||||
|
/// super, pkey, aud, iat, nbf, exp.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn mwt_payload_json(
|
||||||
|
sub: &str,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
pkey: &str,
|
||||||
|
aud: &str,
|
||||||
|
iat: i64,
|
||||||
|
nbf: i64,
|
||||||
|
exp: i64,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{{\"sub\":{},\"staff\":{},\"super\":{},\"pkey\":{},\"aud\":{},\"iat\":{},\"nbf\":{},\"exp\":{}}}",
|
||||||
|
json_str(sub),
|
||||||
|
json_bool(staff),
|
||||||
|
json_bool(superuser),
|
||||||
|
json_str(pkey),
|
||||||
|
json_str(aud),
|
||||||
|
iat,
|
||||||
|
nbf,
|
||||||
|
exp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint an MWT from already-resolved identity fields. `pkey` is the permission
|
||||||
|
/// hash (see `compute_permission_key`); `now` is unix-seconds.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn create_mwt(
|
||||||
|
secret: &str,
|
||||||
|
sub: &str,
|
||||||
|
staff: bool,
|
||||||
|
superuser: bool,
|
||||||
|
pkey: &str,
|
||||||
|
ttl: i64,
|
||||||
|
audience: &str,
|
||||||
|
kid: &str,
|
||||||
|
now: i64,
|
||||||
|
) -> String {
|
||||||
|
let payload = mwt_payload_json(sub, staff, superuser, pkey, audience, now, now, now + ttl);
|
||||||
|
encode(secret, Some(kid), &payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode + validate an MWT. `None` on bad signature, malformed token, expiry,
|
||||||
|
/// not-yet-valid (`nbf`), or audience mismatch. Mirrors `decode_mwt`.
|
||||||
|
pub fn decode_mwt(token: &str, secret: &str, audience: &str, now: i64) -> Option<MwtPayload> {
|
||||||
|
let kid = unverified_kid(token).unwrap_or_else(|| "v1".to_string());
|
||||||
|
let payload_bytes = verify_signature(secret, token)?;
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||||
|
|
||||||
|
let exp = v.get("exp")?.as_i64()?;
|
||||||
|
if now >= exp {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(nbf) = v.get("nbf").and_then(|n| n.as_i64()) {
|
||||||
|
if now < nbf {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let aud = v.get("aud").and_then(|a| a.as_str()).unwrap_or("");
|
||||||
|
if aud != audience {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(MwtPayload {
|
||||||
|
sub: v.get("sub")?.as_str()?.to_string(),
|
||||||
|
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||||
|
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||||
|
pkey: v
|
||||||
|
.get("pkey")
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
kid,
|
||||||
|
aud: audience.to_string(),
|
||||||
|
iat: v.get("iat")?.as_i64()?,
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Identity + auth-guard enforcement ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// The identity a token resolves to — Rust analog of `Identity`. `None`
|
||||||
|
/// (anonymous) and `Invalid` (a present-but-bad token) are distinct: the
|
||||||
|
/// adapter must REJECT on `Invalid`, never silently downgrade to anonymous.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub user_id: String,
|
||||||
|
pub is_staff: bool,
|
||||||
|
pub is_superuser: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&JwtPayload> for Identity {
|
||||||
|
fn from(p: &JwtPayload) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: p.sub.clone(),
|
||||||
|
is_staff: p.staff,
|
||||||
|
is_superuser: p.superuser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MwtPayload> for Identity {
|
||||||
|
fn from(p: &MwtPayload) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: p.sub.clone(),
|
||||||
|
is_staff: p.staff,
|
||||||
|
is_superuser: p.superuser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of resolving identity from request headers. Mirrors the Python
|
||||||
|
/// `Identity | INVALID | None` contract.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthOutcome {
|
||||||
|
/// A valid token resolved to this identity.
|
||||||
|
Authenticated(Identity),
|
||||||
|
/// No token was offered — the adapter may fall back to session identity.
|
||||||
|
Anonymous,
|
||||||
|
/// A token was present but failed validation — the adapter MUST reject.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth config carried by the adapter — JWT and/or MWT secrets. Either may be
|
||||||
|
/// absent; a token type with no configured secret is ignored. Mirrors
|
||||||
|
/// `AuthConfig`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub jwt: Option<JwtConfig>,
|
||||||
|
pub mwt_secret: Option<String>,
|
||||||
|
pub mwt_audience: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
jwt: None,
|
||||||
|
mwt_secret: None,
|
||||||
|
mwt_audience: "mizan".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve identity from `X-Mizan-Token` (MWT) then `Authorization: Bearer`
|
||||||
|
/// (JWT). Header lookup is case-sensitive on the names the adapter passes in;
|
||||||
|
/// pass both casings or normalize upstream. Mirrors `authenticate`.
|
||||||
|
pub fn authenticate(
|
||||||
|
mwt_header: Option<&str>,
|
||||||
|
bearer_header: Option<&str>,
|
||||||
|
config: &AuthConfig,
|
||||||
|
now: i64,
|
||||||
|
) -> AuthOutcome {
|
||||||
|
if let (Some(mwt), Some(secret)) = (mwt_header, config.mwt_secret.as_deref()) {
|
||||||
|
if !mwt.is_empty() {
|
||||||
|
return match decode_mwt(mwt, secret, &config.mwt_audience, now) {
|
||||||
|
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||||
|
None => AuthOutcome::Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(bearer), Some(jwt_cfg)) = (bearer_header, config.jwt.as_ref()) {
|
||||||
|
if let Some(token) = bearer.strip_prefix("Bearer ") {
|
||||||
|
return match decode_jwt(token, jwt_cfg, Some("access"), now) {
|
||||||
|
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||||
|
None => AuthOutcome::Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthOutcome::Anonymous
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `@client(auth=...)` requirement a function declares. `Callable` carries
|
||||||
|
/// the host's own predicate — the adapter resolves it; the core stays free of
|
||||||
|
/// the native request.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthRequirement {
|
||||||
|
None,
|
||||||
|
Required,
|
||||||
|
Staff,
|
||||||
|
Superuser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRequirement {
|
||||||
|
/// Parse the IR/`FunctionSpec` auth string into a requirement.
|
||||||
|
/// `"required" | "staff" | "superuser"` → the matching variant; anything
|
||||||
|
/// else (including the absence of an `auth=`) → `None`.
|
||||||
|
pub fn from_str_opt(s: Option<&str>) -> Self {
|
||||||
|
match s {
|
||||||
|
Some("required") | Some("true") => AuthRequirement::Required,
|
||||||
|
Some("staff") => AuthRequirement::Staff,
|
||||||
|
Some("superuser") => AuthRequirement::Superuser,
|
||||||
|
_ => AuthRequirement::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce a function's `auth=` against the resolved identity. `Ok(())` to
|
||||||
|
/// proceed; `Err(MizanError)` (`Unauthorized`/`Forbidden`) to reject. Mirrors
|
||||||
|
/// `authguard.enforce_auth`.
|
||||||
|
pub fn enforce_auth(
|
||||||
|
identity: Option<&Identity>,
|
||||||
|
requirement: &AuthRequirement,
|
||||||
|
) -> Result<(), crate::runtime::MizanError> {
|
||||||
|
use crate::runtime::MizanError;
|
||||||
|
if matches!(requirement, AuthRequirement::None) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ident = match identity {
|
||||||
|
Some(i) => i,
|
||||||
|
None => return Err(MizanError::Unauthorized("Authentication required".into())),
|
||||||
|
};
|
||||||
|
match requirement {
|
||||||
|
AuthRequirement::None | AuthRequirement::Required => Ok(()),
|
||||||
|
AuthRequirement::Staff => {
|
||||||
|
if ident.is_staff {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(MizanError::Forbidden("Staff access required".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthRequirement::Superuser => {
|
||||||
|
if ident.is_superuser {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(MizanError::Forbidden("Superuser access required".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
cores/mizan-rust/src/cache.rs
Normal file
272
cores/mizan-rust/src/cache.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
//! Origin-side cache: HMAC-SHA256 key derivation + a pluggable backend.
|
||||||
|
//!
|
||||||
|
//! Byte-pinned to `cores/mizan-python/src/mizan_core/cache/keys.py`. The HMAC
|
||||||
|
//! message is the JSON-canonical form `{"c":ctx,"p":{sorted params},"r":rev}`
|
||||||
|
//! (with optional `"u":user_id`), emitted with Python's `json.dumps(...,
|
||||||
|
//! sort_keys=True, separators=(",", ":"))` byte layout: keys sorted, no
|
||||||
|
//! whitespace. Every Mizan adapter must produce the identical key for
|
||||||
|
//! identical inputs — `tests/cache_keys_pin.rs` pins this against the Python
|
||||||
|
//! reference and the committed cross-language vectors.
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Context prefix for broad purge (SCAN pattern), mirroring Python's
|
||||||
|
/// `CONTEXT_KEY_PREFIX`.
|
||||||
|
pub const CONTEXT_KEY_PREFIX: &str = "ctx:";
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Normalize a param value to its cross-language-stable string form.
|
||||||
|
///
|
||||||
|
/// Python `str(True)` is `"True"` but JS `String(true)` is `"true"`; the
|
||||||
|
/// reference picks the JSON-native spelling. Numbers and strings stringify
|
||||||
|
/// directly. This must match `keys.py::_normalize` exactly.
|
||||||
|
fn normalize(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Bool(true) => "true".to_string(),
|
||||||
|
Value::Bool(false) => "false".to_string(),
|
||||||
|
Value::Null => "null".to_string(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
// Arrays/objects have no scalar param meaning; fall back to the JSON
|
||||||
|
// text, matching Python's `str(v)` catch-all for non-scalars.
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-escape a string into `out` byte-for-byte with Python's
|
||||||
|
/// `json.dumps(..., ensure_ascii=True)`: the short escapes for `"`, `\`,
|
||||||
|
/// `\b\f\n\r\t`, `\uXXXX` for the rest of the C0 control range, and — because
|
||||||
|
/// the reference leaves `ensure_ascii` at its default `True` — `\uXXXX` for
|
||||||
|
/// every non-ASCII code point, encoded as a UTF-16 surrogate pair when the
|
||||||
|
/// code point is above the BMP (e.g. `😀` → `😀`).
|
||||||
|
fn push_json_string(out: &mut String, s: &str) {
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
'\u{08}' => out.push_str("\\b"),
|
||||||
|
'\u{0c}' => out.push_str("\\f"),
|
||||||
|
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||||
|
let mut buf = [0u16; 2];
|
||||||
|
for unit in c.encode_utf16(&mut buf) {
|
||||||
|
out.push_str(&format!("\\u{unit:04x}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the exact HMAC message bytes: `{"c":...,"p":{...},"r":...}` with an
|
||||||
|
/// optional `"u":...`. Keys are emitted in sorted order (c, p, r, u) and the
|
||||||
|
/// `p` object's keys are sorted too — equivalent to `sort_keys=True`.
|
||||||
|
fn canonical_message(
|
||||||
|
context: &str,
|
||||||
|
params: &BTreeMap<String, Value>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
rev: i64,
|
||||||
|
) -> String {
|
||||||
|
let mut msg = String::new();
|
||||||
|
msg.push('{');
|
||||||
|
|
||||||
|
// "c"
|
||||||
|
msg.push_str("\"c\":");
|
||||||
|
push_json_string(&mut msg, context);
|
||||||
|
|
||||||
|
// "p" — object of normalized, sorted params (BTreeMap iterates sorted).
|
||||||
|
msg.push_str(",\"p\":{");
|
||||||
|
for (i, (k, v)) in params.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
msg.push(',');
|
||||||
|
}
|
||||||
|
push_json_string(&mut msg, k);
|
||||||
|
msg.push(':');
|
||||||
|
push_json_string(&mut msg, &normalize(v));
|
||||||
|
}
|
||||||
|
msg.push('}');
|
||||||
|
|
||||||
|
// "r"
|
||||||
|
msg.push_str(",\"r\":");
|
||||||
|
msg.push_str(&rev.to_string());
|
||||||
|
|
||||||
|
// "u" (optional) — sorts after "r".
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
msg.push_str(",\"u\":");
|
||||||
|
push_json_string(&mut msg, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.push('}');
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic HMAC-SHA256 cache key.
|
||||||
|
///
|
||||||
|
/// Returns `ctx:{context}:{hmac_hex}` so broad purge can SCAN by the prefix
|
||||||
|
/// `ctx:{context}:*`. Byte-identical to the Python/TS reference for identical
|
||||||
|
/// inputs.
|
||||||
|
pub fn derive_cache_key(
|
||||||
|
secret: &str,
|
||||||
|
context: &str,
|
||||||
|
params: &BTreeMap<String, Value>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
rev: i64,
|
||||||
|
) -> String {
|
||||||
|
let message = canonical_message(context, params, user_id, rev);
|
||||||
|
let mut mac =
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
||||||
|
mac.update(message.as_bytes());
|
||||||
|
let digest = mac.finalize().into_bytes();
|
||||||
|
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
format!("{CONTEXT_KEY_PREFIX}{context}:{hex}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluggable origin cache store. The HTTP adapter injects a backend (memory
|
||||||
|
/// for tests, Redis in production); dispatch reads/writes through it.
|
||||||
|
pub trait CacheBackend: Send + Sync {
|
||||||
|
fn get(&self, key: &str) -> Option<Vec<u8>>;
|
||||||
|
fn set(&self, key: &str, value: Vec<u8>);
|
||||||
|
fn delete(&self, key: &str);
|
||||||
|
/// Delete every key beginning with `prefix` (broad purge).
|
||||||
|
fn delete_by_prefix(&self, prefix: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory `CacheBackend` for tests and single-process deployments. Mirrors
|
||||||
|
/// the Python `MemoryCache` — a dict guarded by a lock, no persistence.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MemoryCache {
|
||||||
|
store: std::sync::Mutex<BTreeMap<String, Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheBackend for MemoryCache {
|
||||||
|
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||||
|
self.store.lock().unwrap().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&self, key: &str, value: Vec<u8>) {
|
||||||
|
self.store.lock().unwrap().insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, key: &str) {
|
||||||
|
self.store.lock().unwrap().remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_by_prefix(&self, prefix: &str) {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|k, _| !k.starts_with(prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origin-side cache orchestrator — backend + secret injected by the adapter
|
||||||
|
/// (the config seam). Mirrors Python's `CacheOrchestrator`: disabled (a no-op)
|
||||||
|
/// until both a backend and a secret are present.
|
||||||
|
pub struct CacheOrchestrator {
|
||||||
|
backend: Option<std::sync::Arc<dyn CacheBackend>>,
|
||||||
|
secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheOrchestrator {
|
||||||
|
pub fn new(backend: Option<std::sync::Arc<dyn CacheBackend>>, secret: Option<String>) -> Self {
|
||||||
|
Self { backend, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A disabled orchestrator — every op is a no-op. Used by stateless apps.
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: None,
|
||||||
|
secret: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.backend.is_some() && self.secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(
|
||||||
|
&self,
|
||||||
|
context: &str,
|
||||||
|
params: &BTreeMap<String, Value>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
rev: i64,
|
||||||
|
) -> Option<String> {
|
||||||
|
let secret = self.secret.as_deref()?;
|
||||||
|
Some(derive_cache_key(secret, context, params, user_id, rev))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(
|
||||||
|
&self,
|
||||||
|
context: &str,
|
||||||
|
params: &BTreeMap<String, Value>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
rev: i64,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
if !self.enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let backend = self.backend.as_ref()?;
|
||||||
|
let key = self.key(context, params, user_id, rev)?;
|
||||||
|
backend.get(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put(
|
||||||
|
&self,
|
||||||
|
context: &str,
|
||||||
|
params: &BTreeMap<String, Value>,
|
||||||
|
value: Vec<u8>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
rev: i64,
|
||||||
|
) {
|
||||||
|
if !self.enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(backend), Some(key)) =
|
||||||
|
(self.backend.as_ref(), self.key(context, params, user_id, rev))
|
||||||
|
{
|
||||||
|
backend.set(&key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge the cache entries named by an invalidation list. A scoped entry
|
||||||
|
/// (`ScopedContext`) deletes its single derived key; a bare context purges
|
||||||
|
/// by prefix — exactly Python's `CacheOrchestrator.purge`.
|
||||||
|
pub fn purge(&self, invalidate: &[crate::runtime::InvalidationTarget], user_id: Option<&str>) {
|
||||||
|
if !self.enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let backend = match self.backend.as_ref() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
for entry in invalidate {
|
||||||
|
match entry {
|
||||||
|
crate::runtime::InvalidationTarget::Context(ctx)
|
||||||
|
| crate::runtime::InvalidationTarget::Function(ctx) => {
|
||||||
|
backend.delete_by_prefix(&format!("{CONTEXT_KEY_PREFIX}{ctx}:"));
|
||||||
|
}
|
||||||
|
crate::runtime::InvalidationTarget::ScopedContext { context, params } => {
|
||||||
|
let params_tree: BTreeMap<String, Value> =
|
||||||
|
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||||
|
if let Some(key) = self.key(context, ¶ms_tree, user_id, 0) {
|
||||||
|
backend.delete(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ pub enum TypeShape {
|
|||||||
Optional(Box<TypeShape>),
|
Optional(Box<TypeShape>),
|
||||||
Enum(Vec<&'static str>),
|
Enum(Vec<&'static str>),
|
||||||
Union(Vec<TypeShape>),
|
Union(Vec<TypeShape>),
|
||||||
|
/// An `Upload`-typed field — a binary file input. Emits the IR `upload`
|
||||||
|
/// type-child (matching `cores/mizan-python`'s `_emit_upload_node`), with
|
||||||
|
/// optional declarative `max-size` / `content-type` constraints. `None`s
|
||||||
|
/// mean an unconstrained upload.
|
||||||
|
Upload {
|
||||||
|
max_size: Option<i64>,
|
||||||
|
content_types: &'static [&'static str],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ impl<'a> Emitter<'a> {
|
|||||||
}
|
}
|
||||||
self.close(indent);
|
self.close(indent);
|
||||||
}
|
}
|
||||||
|
TypeShape::Upload {
|
||||||
|
max_size,
|
||||||
|
content_types,
|
||||||
|
} => {
|
||||||
|
// Match Python's `_emit_upload_node`: `max-size` is the bare
|
||||||
|
// integer (its `repr`); content-types become nested children;
|
||||||
|
// the unconstrained case is a bare `upload` leaf.
|
||||||
|
let mut header: Vec<String> = vec!["upload".into()];
|
||||||
|
if let Some(ms) = max_size {
|
||||||
|
header.push(format!("max-size={ms}"));
|
||||||
|
}
|
||||||
|
let header_refs: Vec<&str> = header.iter().map(String::as_str).collect();
|
||||||
|
if content_types.is_empty() {
|
||||||
|
self.leaf(indent, &header_refs);
|
||||||
|
} else {
|
||||||
|
self.open(indent, &header_refs);
|
||||||
|
for ct in content_types.iter() {
|
||||||
|
let lit = kdl_string(ct);
|
||||||
|
self.leaf(indent + 1, &["content-type", &lit]);
|
||||||
|
}
|
||||||
|
self.close(indent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +487,7 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
|||||||
walk_shape_refs(b, visit);
|
walk_shape_refs(b, visit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
|
TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,25 +14,43 @@
|
|||||||
//! 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(...)]`.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod cache;
|
||||||
pub mod graph_check;
|
pub mod graph_check;
|
||||||
pub mod ir;
|
pub mod ir;
|
||||||
pub mod kdl;
|
pub mod kdl;
|
||||||
|
pub mod manifest;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod shapes;
|
||||||
|
pub mod ssr;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod upload;
|
||||||
|
|
||||||
|
pub use auth::{
|
||||||
|
authenticate, compute_permission_key, create_access_token, create_mwt, create_refresh_token,
|
||||||
|
decode_jwt, decode_mwt, enforce_auth, now_unix, AuthConfig, AuthOutcome, AuthRequirement,
|
||||||
|
Identity, JwtConfig, JwtPayload, MwtPayload,
|
||||||
|
};
|
||||||
|
pub use upload::Upload;
|
||||||
|
pub use cache::{
|
||||||
|
derive_cache_key, CacheBackend, CacheOrchestrator, MemoryCache, CONTEXT_KEY_PREFIX,
|
||||||
|
};
|
||||||
pub use ir::{
|
pub use ir::{
|
||||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
||||||
};
|
};
|
||||||
pub use kdl::{build_ir, snake_to_camel};
|
pub use kdl::{build_ir, snake_to_camel};
|
||||||
|
pub use manifest::{generate_edge_manifest, generate_edge_manifest_json};
|
||||||
pub use registry::{
|
pub use registry::{
|
||||||
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
||||||
FUNCTIONS, TYPES,
|
FUNCTIONS, TYPES,
|
||||||
};
|
};
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
compute_invalidation, compute_merges, format_invalidate_header, InvalidationTarget,
|
||||||
RequestHandle,
|
MergeEntry, MizanError, RequestHandle,
|
||||||
};
|
};
|
||||||
|
pub use shapes::{QueryProjection, ShapeField};
|
||||||
|
pub use ssr::{SsrBridge, SsrError, WorkerCommand};
|
||||||
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||||
|
|
||||||
// Re-export proc macros so consumers depend on one crate.
|
// Re-export proc macros so consumers depend on one crate.
|
||||||
|
|||||||
190
cores/mizan-rust/src/manifest.rs
Normal file
190
cores/mizan-rust/src/manifest.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
//! Edge manifest — the static JSON that Mizan Edge reads to configure CDN
|
||||||
|
//! cache rules + invalidation routing.
|
||||||
|
//!
|
||||||
|
//! Mirrors `backends/mizan-django/src/mizan/export/__init__.py`'s
|
||||||
|
//! `generate_edge_manifest`: a `{version, contexts, mutations}` document where
|
||||||
|
//! each context carries its functions, endpoints, params, `user_scoped`, and
|
||||||
|
//! `render_strategy` (the PSR axis), and each mutation carries its `affects`
|
||||||
|
//! and `auto_scoped_params`. Keys are emitted alphabetically (the Django
|
||||||
|
//! command serializes with `sort_keys=True`); `to_json_string` matches that.
|
||||||
|
|
||||||
|
use crate::registry::{context_members, CONTEXTS, FUNCTIONS};
|
||||||
|
use crate::traits::FunctionSpec;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Params that imply a user-scoped context → `render_strategy:
|
||||||
|
/// "dynamic_cached"`. Anything else renders as `"psr"`. Matches Python's
|
||||||
|
/// `_USER_SCOPED_PARAMS`.
|
||||||
|
const USER_SCOPED_PARAMS: [&str; 4] = ["user_id", "user", "owner_id", "account_id"];
|
||||||
|
|
||||||
|
/// Build the edge manifest as a `serde_json::Value`. `base_url` is the Mizan
|
||||||
|
/// mount point (default `/api/mizan`).
|
||||||
|
pub fn generate_edge_manifest(base_url: &str) -> Value {
|
||||||
|
let mut contexts = Map::new();
|
||||||
|
|
||||||
|
// Contexts, alphabetical by name (BTreeSet over the registered names).
|
||||||
|
let ctx_names: BTreeSet<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
||||||
|
for ctx_name in &ctx_names {
|
||||||
|
let members = context_members(ctx_name);
|
||||||
|
if members.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut param_names: BTreeSet<&'static str> = BTreeSet::new();
|
||||||
|
let mut functions_meta: Vec<Value> = Vec::new();
|
||||||
|
let mut page_routes: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for fn_spec in &members {
|
||||||
|
for p in fn_spec.input_params() {
|
||||||
|
param_names.insert(p.name);
|
||||||
|
}
|
||||||
|
// The Rust IR has no view-path/route metadata yet; every function
|
||||||
|
// is an RPC path. (`route`/`view_path` land with the view-path
|
||||||
|
// macro extension.)
|
||||||
|
functions_meta.push(json!({ "name": fn_spec.name(), "path": "rpc" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_scoped = param_names
|
||||||
|
.iter()
|
||||||
|
.any(|p| USER_SCOPED_PARAMS.contains(p));
|
||||||
|
|
||||||
|
let mut ctx_entry = Map::new();
|
||||||
|
ctx_entry.insert("functions".into(), Value::Array(functions_meta));
|
||||||
|
ctx_entry.insert(
|
||||||
|
"endpoints".into(),
|
||||||
|
json!([format!("{base_url}/ctx/{ctx_name}/")]),
|
||||||
|
);
|
||||||
|
ctx_entry.insert(
|
||||||
|
"params".into(),
|
||||||
|
Value::Array(
|
||||||
|
param_names
|
||||||
|
.iter()
|
||||||
|
.map(|p| Value::String((*p).to_string()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ctx_entry.insert("user_scoped".into(), Value::Bool(user_scoped));
|
||||||
|
ctx_entry.insert(
|
||||||
|
"render_strategy".into(),
|
||||||
|
Value::String(
|
||||||
|
if user_scoped {
|
||||||
|
"dynamic_cached"
|
||||||
|
} else {
|
||||||
|
"psr"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if !page_routes.is_empty() {
|
||||||
|
page_routes.sort();
|
||||||
|
ctx_entry.insert(
|
||||||
|
"page_routes".into(),
|
||||||
|
Value::Array(page_routes.into_iter().map(Value::String).collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts.insert((*ctx_name).to_string(), Value::Object(ctx_entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations — every non-private function declaring `affects`, alphabetical.
|
||||||
|
let mut fns: Vec<&'static dyn FunctionSpec> = FUNCTIONS.iter().copied().collect();
|
||||||
|
fns.sort_by_key(|f| f.name());
|
||||||
|
|
||||||
|
let mut mutations = Map::new();
|
||||||
|
for fn_spec in &fns {
|
||||||
|
let affected: BTreeSet<&'static str> = fn_spec
|
||||||
|
.affects()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| match a {
|
||||||
|
crate::ir::AffectTarget::Context(name) => Some(*name),
|
||||||
|
crate::ir::AffectTarget::Function { context, .. } => *context,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if affected.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mutation = Map::new();
|
||||||
|
mutation.insert(
|
||||||
|
"affects".into(),
|
||||||
|
Value::Array(
|
||||||
|
affected
|
||||||
|
.iter()
|
||||||
|
.map(|c| Value::String((*c).to_string()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-scoped params: this mutation's params that also name a param of
|
||||||
|
// an affected context.
|
||||||
|
let fn_params: BTreeSet<&'static str> =
|
||||||
|
fn_spec.input_params().iter().map(|p| p.name).collect();
|
||||||
|
let mut auto_scoped: BTreeSet<&'static str> = BTreeSet::new();
|
||||||
|
for ctx in &affected {
|
||||||
|
let mut ctx_params: BTreeSet<&'static str> = BTreeSet::new();
|
||||||
|
for m in context_members(ctx) {
|
||||||
|
for p in m.input_params() {
|
||||||
|
ctx_params.insert(p.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in fn_params.intersection(&ctx_params) {
|
||||||
|
auto_scoped.insert(*p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !auto_scoped.is_empty() {
|
||||||
|
mutation.insert(
|
||||||
|
"auto_scoped_params".into(),
|
||||||
|
Value::Array(
|
||||||
|
auto_scoped
|
||||||
|
.iter()
|
||||||
|
.map(|p| Value::String((*p).to_string()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if fn_spec.private() {
|
||||||
|
mutation.insert("private".into(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
mutations.insert(fn_spec.name().to_string(), Value::Object(mutation));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"version": 1,
|
||||||
|
"contexts": Value::Object(contexts),
|
||||||
|
"mutations": Value::Object(mutations),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-serialize the manifest with sorted keys (the Django command uses
|
||||||
|
/// `json.dumps(..., sort_keys=True)`); `indent` of 0 → compact.
|
||||||
|
pub fn generate_edge_manifest_json(base_url: &str, indent: usize) -> String {
|
||||||
|
let value = generate_edge_manifest(base_url);
|
||||||
|
let sorted = sort_value(&value);
|
||||||
|
if indent == 0 {
|
||||||
|
serde_json::to_string(&sorted).unwrap()
|
||||||
|
} else {
|
||||||
|
serde_json::to_string_pretty(&sorted).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively re-key every object so serialization is sorted-key, matching
|
||||||
|
/// Python's `sort_keys=True`. (serde_json::Map preserves insertion order, so
|
||||||
|
/// we rebuild via BTreeMap ordering.)
|
||||||
|
fn sort_value(v: &Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Object(m) => {
|
||||||
|
let mut keys: Vec<&String> = m.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
let mut out = Map::new();
|
||||||
|
for k in keys {
|
||||||
|
out.insert(k.clone(), sort_value(&m[k]));
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,75 @@ impl InvalidationTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Percent-encode for the `X-Mizan-Invalidate` header, matching Python's
|
||||||
|
/// `urllib.parse.quote(str(v), safe='')`: the RFC 3986 unreserved set
|
||||||
|
/// (`A-Za-z0-9_.-~`) passes through; every other byte (of the UTF-8 encoding)
|
||||||
|
/// becomes `%XX` with **upper-case** hex.
|
||||||
|
fn url_encode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
|
||||||
|
out.push(b as char);
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an invalidation value to a JSON-ish string for header param values.
|
||||||
|
/// Mirrors Python's `str(v)`: a JSON string yields its raw text; numbers and
|
||||||
|
/// booleans their literal spelling (`true`/`false`); other shapes their JSON.
|
||||||
|
fn header_value_str(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
Value::Null => "None".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a list of targets to the `X-Mizan-Invalidate` header value —
|
||||||
|
/// byte-for-byte with `cores/mizan-python`'s `format_invalidate_header`:
|
||||||
|
/// comma-separated contexts, semicolon-separated URL-encoded params per
|
||||||
|
/// context (params sorted by key).
|
||||||
|
///
|
||||||
|
/// `[Context("user")]` → `user`
|
||||||
|
/// `[Context("user"), Context("notifications")]` → `user, notifications`
|
||||||
|
/// `[ScopedContext{user, {user_id:5}}]` → `user;user_id=5`
|
||||||
|
/// `[ScopedContext{search, {q:"hello world"}}]` → `search;q=hello%20world`
|
||||||
|
pub fn format_invalidate_header(targets: &[InvalidationTarget]) -> String {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for t in targets {
|
||||||
|
match t {
|
||||||
|
InvalidationTarget::Context(name) | InvalidationTarget::Function(name) => {
|
||||||
|
parts.push(name.clone());
|
||||||
|
}
|
||||||
|
InvalidationTarget::ScopedContext { context, params } => {
|
||||||
|
if params.is_empty() {
|
||||||
|
parts.push(context.clone());
|
||||||
|
} else {
|
||||||
|
// BTreeMap-sort the keys to match Python's `sorted(params.items())`.
|
||||||
|
let mut keys: Vec<&String> = params.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
let param_str = keys
|
||||||
|
.iter()
|
||||||
|
.map(|k| {
|
||||||
|
let v = ¶ms[*k];
|
||||||
|
format!("{}={}", url_encode(k), url_encode(&header_value_str(v)))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
parts.push(format!("{context};{param_str}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry in the response's `merge` array. Server-resolved slot — the
|
/// One entry in the response's `merge` array. Server-resolved slot — the
|
||||||
/// kernel writes the value into `bundle[slot]` directly.
|
/// kernel writes the value into `bundle[slot]` directly.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
146
cores/mizan-rust/src/shapes.rs
Normal file
146
cores/mizan-rust/src/shapes.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//! Shapes — typed query projection over the registered type graph.
|
||||||
|
//!
|
||||||
|
//! The AFI-common capability is "given the typed shape a function returns,
|
||||||
|
//! derive the field projection a query layer should select" — the same role
|
||||||
|
//! django-readers plays on Django (a `Shape` declares fields + nested shapes,
|
||||||
|
//! and `_spec` is the projection handed to the ORM). The binding is per-ORM;
|
||||||
|
//! the *capability* — deriving the projection from the declared shape — is
|
||||||
|
//! shared, so it lives here in the core and each adapter rides it.
|
||||||
|
//!
|
||||||
|
//! A `QueryProjection` is computed from a registered named type's struct
|
||||||
|
//! shape: scalar fields become leaf selections, `Ref`-to-struct fields become
|
||||||
|
//! nested projections (recursively), lists/optionals unwrap to their element.
|
||||||
|
//! It is the typed, ORM-agnostic answer to "what columns/relations does this
|
||||||
|
//! response need?" — the dead-field-elimination the whole-stack story wants,
|
||||||
|
//! reached from the response type.
|
||||||
|
|
||||||
|
use crate::ir::{NamedType, TypeShape};
|
||||||
|
use crate::registry::TYPES;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// One selected field of a projection: a scalar leaf, or a nested projection
|
||||||
|
/// for a related struct.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ShapeField {
|
||||||
|
/// A scalar/primitive column.
|
||||||
|
Leaf(String),
|
||||||
|
/// A related struct, with its own projection.
|
||||||
|
Nested(String, QueryProjection),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShapeField {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ShapeField::Leaf(n) | ShapeField::Nested(n, _) => n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed, ORM-agnostic field projection derived from a named struct type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct QueryProjection {
|
||||||
|
/// The named type this projects (the struct's IR name).
|
||||||
|
pub type_name: String,
|
||||||
|
pub fields: Vec<ShapeField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryProjection {
|
||||||
|
/// The flat list of scalar leaf field names selected at this level.
|
||||||
|
pub fn leaf_names(&self) -> Vec<&str> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| match f {
|
||||||
|
ShapeField::Leaf(n) => Some(n.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nested relations selected at this level, name → sub-projection.
|
||||||
|
pub fn nested(&self) -> Vec<(&str, &QueryProjection)> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| match f {
|
||||||
|
ShapeField::Nested(n, p) => Some((n.as_str(), p)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the registry's named-type table once (name → shape).
|
||||||
|
fn type_table() -> BTreeMap<&'static str, NamedType> {
|
||||||
|
let mut t = BTreeMap::new();
|
||||||
|
for entry in TYPES {
|
||||||
|
t.insert(entry.name, (entry.shape_fn)());
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a `TypeShape` to the named struct it ultimately references, if any
|
||||||
|
/// — peeling `List`/`Optional`. Returns the referenced type name.
|
||||||
|
fn referenced_struct<'a>(
|
||||||
|
shape: &TypeShape,
|
||||||
|
table: &'a BTreeMap<&'static str, NamedType>,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
match shape {
|
||||||
|
TypeShape::Ref(name) => {
|
||||||
|
// Only treat it as nested if it resolves to a struct.
|
||||||
|
match table.get(name) {
|
||||||
|
Some(NamedType::Struct(_)) => Some(name),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeShape::List(inner) | TypeShape::Optional(inner) => referenced_struct(inner, table),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the projection for a registered named type by its IR name. `None`
|
||||||
|
/// if the name is absent or is not a struct.
|
||||||
|
pub fn project(type_name: &str) -> Option<QueryProjection> {
|
||||||
|
let table = type_table();
|
||||||
|
project_inner(type_name, &table, &mut Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_inner(
|
||||||
|
type_name: &str,
|
||||||
|
table: &BTreeMap<&'static str, NamedType>,
|
||||||
|
stack: &mut Vec<String>,
|
||||||
|
) -> Option<QueryProjection> {
|
||||||
|
let body = table.get(type_name)?;
|
||||||
|
let fields = match body {
|
||||||
|
NamedType::Struct(fields) => fields,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guard against recursive types (self-referential shapes): a name already
|
||||||
|
// on the stack projects to its scalar leaves only, no further descent.
|
||||||
|
let recursing = stack.iter().any(|n| n == type_name);
|
||||||
|
stack.push(type_name.to_string());
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for field in fields {
|
||||||
|
if !recursing {
|
||||||
|
if let Some(nested_name) = referenced_struct(&field.shape, table) {
|
||||||
|
if let Some(sub) = project_inner(nested_name, table, stack) {
|
||||||
|
out.push(ShapeField::Nested(field.name.to_string(), sub));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(ShapeField::Leaf(field.name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.pop();
|
||||||
|
Some(QueryProjection {
|
||||||
|
type_name: type_name.to_string(),
|
||||||
|
fields: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the projection for a function's output type, by function name.
|
||||||
|
pub fn project_function_output(fn_name: &str) -> Option<QueryProjection> {
|
||||||
|
let fn_spec = crate::registry::lookup_function(fn_name)?;
|
||||||
|
project(fn_spec.output_type())
|
||||||
|
}
|
||||||
268
cores/mizan-rust/src/ssr.rs
Normal file
268
cores/mizan-rust/src/ssr.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! SSR bridge — drive a persistent Bun subprocess for React `renderToString`.
|
||||||
|
//!
|
||||||
|
//! Same wire protocol as the Python `SSRBridge`
|
||||||
|
//! (`backends/mizan-django/src/mizan/ssr/bridge.py`): newline-delimited
|
||||||
|
//! JSON-RPC over the worker's stdin/stdout.
|
||||||
|
//!
|
||||||
|
//! → {"id": 1, "method": "render", "params": {"file": "/abs/X.tsx", "props": {...}}}
|
||||||
|
//! ← {"id": 1, "html": "<div>...</div>"}
|
||||||
|
//!
|
||||||
|
//! The worker emits `{"id": 0, "ready": true}` once on startup; `render`
|
||||||
|
//! blocks until that arrives. A background reader thread demultiplexes
|
||||||
|
//! responses by `id` and parks each caller on a per-request condvar. The
|
||||||
|
//! subprocess stays alive across requests and is respawned on the next render
|
||||||
|
//! if it has died. `command` is injected so a test can drive the exact same
|
||||||
|
//! framing/correlation path against a stub worker without Bun installed.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// How the bridge launches its worker. The default is `bun run <worker>`; a
|
||||||
|
/// test injects a stub program that speaks the same JSON-RPC framing.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WorkerCommand {
|
||||||
|
pub program: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerCommand {
|
||||||
|
/// The production launcher: `bun run <worker_path>`.
|
||||||
|
pub fn bun(worker_path: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
program: "bun".to_string(),
|
||||||
|
args: vec!["run".to_string(), worker_path.into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SsrError {
|
||||||
|
Spawn(String),
|
||||||
|
Timeout(String),
|
||||||
|
Render(String),
|
||||||
|
Pipe(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SsrError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SsrError::Spawn(m) => write!(f, "SSR worker spawn failed: {m}"),
|
||||||
|
SsrError::Timeout(m) => write!(f, "SSR render timed out: {m}"),
|
||||||
|
SsrError::Render(m) => write!(f, "SSR render failed: {m}"),
|
||||||
|
SsrError::Pipe(m) => write!(f, "SSR worker pipe broken: {m}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SsrError {}
|
||||||
|
|
||||||
|
/// Shared slot a parked caller waits on. The reader thread fills `result` and
|
||||||
|
/// flips `done`, then notifies.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Slot {
|
||||||
|
done: Mutex<Option<Value>>,
|
||||||
|
cv: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
child: Option<Child>,
|
||||||
|
stdin: Option<ChildStdin>,
|
||||||
|
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
|
||||||
|
ready: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persistent Bun SSR subprocess, thread-safe across concurrent `render`s.
|
||||||
|
pub struct SsrBridge {
|
||||||
|
command: WorkerCommand,
|
||||||
|
timeout: Duration,
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsrBridge {
|
||||||
|
pub fn new(command: WorkerCommand, timeout: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
command,
|
||||||
|
timeout,
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
child: None,
|
||||||
|
stdin: None,
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
ready: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
|
counter: 0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production constructor: `bun run <worker>` with a 5s render timeout.
|
||||||
|
pub fn bun(worker_path: impl Into<String>) -> Self {
|
||||||
|
Self::new(WorkerCommand::bun(worker_path), Duration::from_secs(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_running(&self, inner: &mut Inner) -> Result<(), SsrError> {
|
||||||
|
if let Some(child) = inner.child.as_mut() {
|
||||||
|
if matches!(child.try_wait(), Ok(None)) {
|
||||||
|
return Ok(()); // still alive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*inner.ready.0.lock().unwrap() = false;
|
||||||
|
inner.pending.lock().unwrap().clear();
|
||||||
|
|
||||||
|
let mut child = Command::new(&self.command.program)
|
||||||
|
.args(&self.command.args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| SsrError::Spawn(e.to_string()))?;
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| SsrError::Spawn("no stdout".into()))?;
|
||||||
|
inner.stdin = Some(
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| SsrError::Spawn("no stdin".into()))?,
|
||||||
|
);
|
||||||
|
inner.child = Some(child);
|
||||||
|
|
||||||
|
let pending = inner.pending.clone();
|
||||||
|
let ready = inner.ready.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("mizan-ssr-reader".to_string())
|
||||||
|
.spawn(move || Self::read_loop(stdout, pending, ready))
|
||||||
|
.map_err(|e| SsrError::Spawn(e.to_string()))?;
|
||||||
|
|
||||||
|
// Block until the worker signals readiness.
|
||||||
|
let (lock, cv) = &*inner.ready;
|
||||||
|
let mut is_ready = lock.lock().unwrap();
|
||||||
|
while !*is_ready {
|
||||||
|
let (g, timed_out) = cv.wait_timeout(is_ready, self.timeout).unwrap();
|
||||||
|
is_ready = g;
|
||||||
|
if timed_out.timed_out() && !*is_ready {
|
||||||
|
return Err(SsrError::Timeout("worker failed to start".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_loop(
|
||||||
|
stdout: std::process::ChildStdout,
|
||||||
|
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
|
||||||
|
ready: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
) {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let msg: Value = match serde_json::from_str(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue, // malformed line; skip, matching Python
|
||||||
|
};
|
||||||
|
let id = msg.get("id").and_then(|v| v.as_u64());
|
||||||
|
|
||||||
|
// Ready signal: {"id": 0, "ready": true}.
|
||||||
|
if id == Some(0) && msg.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||||
|
let (lock, cv) = &*ready;
|
||||||
|
*lock.lock().unwrap() = true;
|
||||||
|
cv.notify_all();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = id {
|
||||||
|
let slot = pending.lock().unwrap().remove(&id);
|
||||||
|
if let Some(slot) = slot {
|
||||||
|
*slot.done.lock().unwrap() = Some(msg);
|
||||||
|
slot.cv.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `file` (an absolute `.tsx`/`.jsx` path) with `props`, returning
|
||||||
|
/// the HTML string. Spawns the worker on first use; respawns if it died.
|
||||||
|
pub fn render(&self, file: &str, props: Value) -> Result<String, SsrError> {
|
||||||
|
let (id, stdin_taken, slot) = {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
self.ensure_running(&mut inner)?;
|
||||||
|
inner.counter += 1;
|
||||||
|
let id = inner.counter;
|
||||||
|
let slot = Arc::new(Slot::default());
|
||||||
|
inner.pending.lock().unwrap().insert(id, slot.clone());
|
||||||
|
|
||||||
|
let request = json!({
|
||||||
|
"id": id,
|
||||||
|
"method": "render",
|
||||||
|
"params": {"file": file, "props": props},
|
||||||
|
});
|
||||||
|
let mut line = serde_json::to_string(&request).unwrap();
|
||||||
|
line.push('\n');
|
||||||
|
|
||||||
|
let write_res = inner
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| SsrError::Pipe("no stdin".into()))
|
||||||
|
.and_then(|w| {
|
||||||
|
w.write_all(line.as_bytes())
|
||||||
|
.and_then(|_| w.flush())
|
||||||
|
.map_err(|e| SsrError::Pipe(e.to_string()))
|
||||||
|
});
|
||||||
|
(id, write_res, slot)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = stdin_taken {
|
||||||
|
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Park on the slot until the reader fills it or we time out.
|
||||||
|
let mut done = slot.done.lock().unwrap();
|
||||||
|
while done.is_none() {
|
||||||
|
let (g, timed_out) = slot.cv.wait_timeout(done, self.timeout).unwrap();
|
||||||
|
done = g;
|
||||||
|
if timed_out.timed_out() && done.is_none() {
|
||||||
|
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
|
||||||
|
return Err(SsrError::Timeout(format!("render of {file:?}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let msg = done.take().unwrap();
|
||||||
|
drop(done);
|
||||||
|
|
||||||
|
if let Some(err) = msg.get("error").and_then(|e| e.as_str()) {
|
||||||
|
return Err(SsrError::Render(err.to_string()));
|
||||||
|
}
|
||||||
|
match msg.get("html").and_then(|h| h.as_str()) {
|
||||||
|
Some(html) => Ok(html.to_string()),
|
||||||
|
None => Err(SsrError::Render("response missing `html`".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the subprocess. Idempotent; called from `Drop`.
|
||||||
|
pub fn shutdown(&self) {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
inner.stdin = None; // close stdin → worker sees EOF
|
||||||
|
if let Some(mut child) = inner.child.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SsrBridge {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ pub trait FunctionSpec: Send + Sync {
|
|||||||
fn private(&self) -> bool {
|
fn private(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
/// The `@client(auth=...)` requirement, as the IR string form: `None`
|
||||||
|
/// (no guard), `"required"`, `"staff"`, or `"superuser"`. The dispatch
|
||||||
|
/// core resolves this into an `AuthRequirement` and rejects accordingly.
|
||||||
|
fn auth(&self) -> Option<&'static str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
fn is_form(&self) -> bool {
|
fn is_form(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
72
cores/mizan-rust/src/upload.rs
Normal file
72
cores/mizan-rust/src/upload.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
//! Upload — first-class binary input for `#[mizan::client]` functions.
|
||||||
|
//!
|
||||||
|
//! Rust analog of `cores/mizan-python/src/mizan_core/upload.py`. An adapter
|
||||||
|
//! parses a multipart file part and binds it into the function's typed input
|
||||||
|
//! as the JSON shape `Upload` deserializes:
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! {"filename": "a.png", "content_type": "image/png", "data_b64": "...", "size": 12}
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Declaring an `Upload`-typed parameter makes a function multipart-aware end
|
||||||
|
//! to end (the generated client switches the call to `multipart/form-data`;
|
||||||
|
//! each adapter binds the part). `Upload` is `Deserialize`, so it drops into a
|
||||||
|
//! `#[mizan(...)]` input struct like any other field and the dispatch
|
||||||
|
//! wrapper's `serde_json::from_value` validates it.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::de::{self, Deserializer};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A bound, decoded upload handed to a `#[mizan::client]` function. The bytes
|
||||||
|
/// are eagerly decoded from the adapter's base64 transport form.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Upload {
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Upload {
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
self.data.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bytes(&self) -> &[u8] {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the upload to `path`.
|
||||||
|
pub fn save(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||||
|
std::fs::write(path, &self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire form an adapter encodes a file part into. Kept separate from
|
||||||
|
/// `Upload` so the public handle exposes decoded bytes, not base64.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UploadWire {
|
||||||
|
#[serde(default)]
|
||||||
|
filename: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
content_type: Option<String>,
|
||||||
|
data_b64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Upload {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let wire = UploadWire::deserialize(deserializer)?;
|
||||||
|
let data = STANDARD
|
||||||
|
.decode(wire.data_b64.as_bytes())
|
||||||
|
.map_err(|e| de::Error::custom(format!("invalid base64 upload data: {e}")))?;
|
||||||
|
Ok(Upload {
|
||||||
|
filename: wire.filename,
|
||||||
|
content_type: wire.content_type,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
120
cores/mizan-rust/tests/cache_keys_pin.rs
Normal file
120
cores/mizan-rust/tests/cache_keys_pin.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//! Cross-language pin: Rust `derive_cache_key` must be byte-identical to the
|
||||||
|
//! Python reference (`cores/mizan-python/.../cache/keys.py`) and to the
|
||||||
|
//! committed cross-language vectors that `tests/afi` and `mizan-ts` also pin.
|
||||||
|
//!
|
||||||
|
//! The Python reference is the oracle: a subprocess mints the key with fixed
|
||||||
|
//! inputs and the Rust output must match exactly. `never if backend == X` —
|
||||||
|
//! one spec, pinned both ways.
|
||||||
|
|
||||||
|
use mizan_core::derive_cache_key;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// The `tests/afi` dir, whose venv has `mizan_core` + PyJWT installed.
|
||||||
|
fn afi_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../tests/afi")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("tests/afi exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the Python reference via `uv run python -c <code>` in tests/afi and
|
||||||
|
/// return its single stdout line, trimmed.
|
||||||
|
fn py(code: &str) -> String {
|
||||||
|
let out = Command::new("uv")
|
||||||
|
.args(["run", "python", "-c", code])
|
||||||
|
.current_dir(afi_dir())
|
||||||
|
.output()
|
||||||
|
.expect("invoke uv run python");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_vectors_match() {
|
||||||
|
// The exact pins committed in cores/mizan-python/tests/test_keys.py and
|
||||||
|
// backends/mizan-ts/tests — the canonical cross-language anchor.
|
||||||
|
let secret = "test-pin-secret-that-is-32bytes!";
|
||||||
|
|
||||||
|
let public = derive_cache_key(secret, "user", &tree(&[("user_id", json!("5"))]), None, 0);
|
||||||
|
assert_eq!(
|
||||||
|
public,
|
||||||
|
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6"
|
||||||
|
);
|
||||||
|
|
||||||
|
let scoped = derive_cache_key(
|
||||||
|
secret,
|
||||||
|
"user",
|
||||||
|
&tree(&[("user_id", json!("5"))]),
|
||||||
|
Some("5"),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scoped,
|
||||||
|
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_python_reference_across_inputs() {
|
||||||
|
// A spread of shapes: multi-param (order-independence), numeric vs string,
|
||||||
|
// bool/null normalization, user-scoped, nonzero rev.
|
||||||
|
let cases: Vec<(&str, BTreeMap<String, Value>, Option<&str>, i64)> = vec![
|
||||||
|
("user", tree(&[("user_id", json!("5"))]), None, 0),
|
||||||
|
("user", tree(&[("user_id", json!("5"))]), Some("5"), 0),
|
||||||
|
("user", tree(&[("user_id", json!("5"))]), Some("5"), 3),
|
||||||
|
(
|
||||||
|
"search",
|
||||||
|
tree(&[("q", json!("hello world")), ("page", json!(2))]),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"flags",
|
||||||
|
tree(&[("on", json!(true)), ("off", json!(false)), ("nil", json!(null))]),
|
||||||
|
Some("42"),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
("empty", tree(&[]), None, 0),
|
||||||
|
(
|
||||||
|
"unicode",
|
||||||
|
tree(&[("name", json!("café—ñ"))]),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (ctx, params, uid, rev) in cases {
|
||||||
|
let rust = derive_cache_key("pin-secret-xyz", ctx, ¶ms, uid, rev);
|
||||||
|
|
||||||
|
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
|
||||||
|
let params_json = serde_json::to_string(
|
||||||
|
¶ms.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<serde_json::Map<_, _>>(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let uid_arg = match uid {
|
||||||
|
Some(u) => format!("'{u}'"),
|
||||||
|
None => "None".to_string(),
|
||||||
|
};
|
||||||
|
let code = format!(
|
||||||
|
"import json; from mizan_core.cache.keys import derive_cache_key; \
|
||||||
|
print(derive_cache_key('pin-secret-xyz', {ctx:?}, json.loads(r'''{params_json}'''), {uid_arg}, {rev}))",
|
||||||
|
);
|
||||||
|
let expected = py(&code);
|
||||||
|
assert_eq!(
|
||||||
|
rust, expected,
|
||||||
|
"cache-key mismatch for ctx={ctx} params={params_json} uid={uid:?} rev={rev}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
cores/mizan-rust/tests/invalidate_header_pin.rs
Normal file
90
cores/mizan-rust/tests/invalidate_header_pin.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//! Cross-language pin: Rust `format_invalidate_header` must be byte-identical
|
||||||
|
//! to `cores/mizan-python/.../invalidation.py::format_invalidate_header`.
|
||||||
|
//!
|
||||||
|
//! The `X-Mizan-Invalidate` header is co-equal with the JSON body channel in
|
||||||
|
//! the spec; Edge parses it to purge. The Python reference is the oracle.
|
||||||
|
|
||||||
|
use mizan_core::{format_invalidate_header, InvalidationTarget};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn afi_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../tests/afi")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("tests/afi exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn py_header(json_list: &str) -> String {
|
||||||
|
let code = format!(
|
||||||
|
"import json; from mizan_core.invalidation import format_invalidate_header; \
|
||||||
|
print(format_invalidate_header(json.loads(r'''{json_list}''')))",
|
||||||
|
);
|
||||||
|
let out = Command::new("uv")
|
||||||
|
.args(["run", "python", "-c", &code])
|
||||||
|
.current_dir(afi_dir())
|
||||||
|
.output()
|
||||||
|
.expect("invoke uv run python");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"python reference failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
// Trim the trailing newline only — the header value itself may be empty.
|
||||||
|
let s = String::from_utf8(out.stdout).unwrap();
|
||||||
|
s.strip_suffix('\n').unwrap_or(&s).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scoped(ctx: &str, params: &[(&str, serde_json::Value)]) -> InvalidationTarget {
|
||||||
|
InvalidationTarget::ScopedContext {
|
||||||
|
context: ctx.to_string(),
|
||||||
|
params: params.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_python_reference() {
|
||||||
|
let cases: Vec<(Vec<InvalidationTarget>, &str)> = vec![
|
||||||
|
(vec![InvalidationTarget::Context("user".into())], r#"["user"]"#),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
InvalidationTarget::Context("user".into()),
|
||||||
|
InvalidationTarget::Context("notifications".into()),
|
||||||
|
],
|
||||||
|
r#"["user", "notifications"]"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![scoped("user", &[("user_id", json!(5))])],
|
||||||
|
r#"[{"context": "user", "params": {"user_id": 5}}]"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![scoped("search", &[("q", json!("hello world"))])],
|
||||||
|
r#"[{"context": "search", "params": {"q": "hello world"}}]"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
// Multiple params → sorted by key, semicolon-joined.
|
||||||
|
vec![scoped("u", &[("b", json!("2")), ("a", json!("1"))])],
|
||||||
|
r#"[{"context": "u", "params": {"b": "2", "a": "1"}}]"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
// Special chars that must percent-encode: &, =, /, space, unicode.
|
||||||
|
vec![scoped("c", &[("k", json!("a&b=c/d e—ñ"))])],
|
||||||
|
r#"[{"context": "c", "params": {"k": "a&b=c/d e—ñ"}}]"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
// Mixed bare + scoped.
|
||||||
|
vec![
|
||||||
|
scoped("user", &[("user_id", json!(5))]),
|
||||||
|
InvalidationTarget::Context("notifications".into()),
|
||||||
|
],
|
||||||
|
r#"[{"context": "user", "params": {"user_id": 5}}, "notifications"]"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (targets, json_list) in cases {
|
||||||
|
let rust = format_invalidate_header(&targets);
|
||||||
|
let expected = py_header(json_list);
|
||||||
|
assert_eq!(rust, expected, "header mismatch for {json_list}");
|
||||||
|
}
|
||||||
|
}
|
||||||
89
cores/mizan-rust/tests/shapes_manifest.rs
Normal file
89
cores/mizan-rust/tests/shapes_manifest.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! Behavior tests for the Shapes projection + edge-manifest derivation,
|
||||||
|
//! driven off a small registered fixture (same graph the AFI fixture uses:
|
||||||
|
//! a nested struct, a user context with a shared `user_id` param, and an
|
||||||
|
//! `affects` mutation).
|
||||||
|
|
||||||
|
use mizan_core as mizan;
|
||||||
|
use mizan_core::prelude::*;
|
||||||
|
use mizan_core::{generate_edge_manifest, shapes, RequestHandle};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Address {
|
||||||
|
pub city: String,
|
||||||
|
pub zip: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Person {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub address: Address,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Ok {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::context("people")]
|
||||||
|
pub struct PeopleCtx;
|
||||||
|
|
||||||
|
#[mizan::client(context = PeopleCtx)]
|
||||||
|
pub async fn person(_req: &RequestHandle<'_>, user_id: i64) -> Person {
|
||||||
|
Person {
|
||||||
|
user_id,
|
||||||
|
name: "x".into(),
|
||||||
|
address: Address {
|
||||||
|
city: "c".into(),
|
||||||
|
zip: "z".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client(affects = PeopleCtx)]
|
||||||
|
pub async fn rename_person(_req: &RequestHandle<'_>, user_id: i64, _name: String) -> Ok {
|
||||||
|
let _ = user_id;
|
||||||
|
Ok { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shapes_projection_descends_nested_structs() {
|
||||||
|
let proj = shapes::project_function_output("person").expect("projects");
|
||||||
|
assert_eq!(proj.type_name, "personOutput");
|
||||||
|
// Scalar leaves at the top level.
|
||||||
|
let leaves = proj.leaf_names();
|
||||||
|
assert!(leaves.contains(&"user_id"));
|
||||||
|
assert!(leaves.contains(&"name"));
|
||||||
|
// `address` is a nested struct → a sub-projection, not a leaf.
|
||||||
|
assert!(!leaves.contains(&"address"));
|
||||||
|
let nested = proj.nested();
|
||||||
|
assert_eq!(nested.len(), 1);
|
||||||
|
let (name, sub) = nested[0];
|
||||||
|
assert_eq!(name, "address");
|
||||||
|
let sub_leaves = sub.leaf_names();
|
||||||
|
assert!(sub_leaves.contains(&"city") && sub_leaves.contains(&"zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_manifest_has_context_render_strategy_and_mutation() {
|
||||||
|
let m = generate_edge_manifest("/api/mizan");
|
||||||
|
|
||||||
|
// Context: user-scoped (has `user_id`) → render_strategy dynamic_cached.
|
||||||
|
let people = &m["contexts"]["people"];
|
||||||
|
assert_eq!(people["user_scoped"], serde_json::json!(true));
|
||||||
|
assert_eq!(people["render_strategy"], serde_json::json!("dynamic_cached"));
|
||||||
|
assert_eq!(
|
||||||
|
people["endpoints"],
|
||||||
|
serde_json::json!(["/api/mizan/ctx/people/"])
|
||||||
|
);
|
||||||
|
assert_eq!(people["params"], serde_json::json!(["user_id"]));
|
||||||
|
|
||||||
|
// Mutation: rename_person affects people, auto-scopes user_id.
|
||||||
|
let mutation = &m["mutations"]["rename_person"];
|
||||||
|
assert_eq!(mutation["affects"], serde_json::json!(["people"]));
|
||||||
|
assert_eq!(
|
||||||
|
mutation["auto_scoped_params"],
|
||||||
|
serde_json::json!(["user_id"])
|
||||||
|
);
|
||||||
|
}
|
||||||
105
cores/mizan-rust/tests/ssr_bridge.rs
Normal file
105
cores/mizan-rust/tests/ssr_bridge.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! Behavior test for the SSR bridge's framing + request/response correlation.
|
||||||
|
//!
|
||||||
|
//! Bun isn't required (it isn't installed in CI): a stub worker speaking the
|
||||||
|
//! exact same newline-delimited JSON-RPC protocol stands in. The stub emits
|
||||||
|
//! the `{"id":0,"ready":true}` handshake, then for each `render` request
|
||||||
|
//! echoes back `{"id":N,"html":"<rendered:FILE props=PROPS>"}` — exercising
|
||||||
|
//! the ready-gate, the per-request id correlation, and the html extraction
|
||||||
|
//! that the real Bun worker drives.
|
||||||
|
|
||||||
|
use mizan_core::{SsrBridge, WorkerCommand};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A tiny Python stub that speaks the SSR worker protocol. Written to a temp
|
||||||
|
/// file and launched via `python3 <file>`.
|
||||||
|
const STUB: &str = r#"
|
||||||
|
import sys, json
|
||||||
|
# Handshake: announce readiness exactly as the Bun worker does.
|
||||||
|
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
msg = json.loads(line)
|
||||||
|
mid = msg.get("id")
|
||||||
|
if msg.get("method") == "render":
|
||||||
|
p = msg["params"]
|
||||||
|
# A sentinel file name forces the worker-error branch.
|
||||||
|
if p["file"] == "/boom.tsx":
|
||||||
|
sys.stdout.write(json.dumps({"id": mid, "error": "render exploded"}) + "\n")
|
||||||
|
else:
|
||||||
|
html = "<rendered:%s props=%s>" % (p["file"], json.dumps(p["props"], sort_keys=True))
|
||||||
|
sys.stdout.write(json.dumps({"id": mid, "html": html}) + "\n")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(json.dumps({"id": mid, "error": "unknown method"}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn write_stub() -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("mizan_ssr_stub_{}.py", std::process::id()));
|
||||||
|
let mut f = std::fs::File::create(&path).unwrap();
|
||||||
|
f.write_all(STUB.as_bytes()).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_drives_worker_protocol() {
|
||||||
|
let stub = write_stub();
|
||||||
|
let bridge = SsrBridge::new(
|
||||||
|
WorkerCommand {
|
||||||
|
program: "python3".to_string(),
|
||||||
|
args: vec![stub.to_string_lossy().to_string()],
|
||||||
|
},
|
||||||
|
Duration::from_secs(5),
|
||||||
|
);
|
||||||
|
|
||||||
|
// First render — spawns the worker, waits for the ready handshake.
|
||||||
|
let html = bridge
|
||||||
|
.render("/abs/Hello.tsx", json!({"name": "World"}))
|
||||||
|
.expect("first render succeeds");
|
||||||
|
assert_eq!(
|
||||||
|
html,
|
||||||
|
r#"<rendered:/abs/Hello.tsx props={"name": "World"}>"#
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second render reuses the same subprocess; id correlation must keep the
|
||||||
|
// responses matched to their requests.
|
||||||
|
let html2 = bridge
|
||||||
|
.render("/abs/Other.tsx", json!({"a": 1, "b": 2}))
|
||||||
|
.expect("second render succeeds");
|
||||||
|
assert_eq!(
|
||||||
|
html2,
|
||||||
|
r#"<rendered:/abs/Other.tsx props={"a": 1, "b": 2}>"#
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.shutdown();
|
||||||
|
let _ = std::fs::remove_file(&stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_propagates_worker_error() {
|
||||||
|
let stub = write_stub();
|
||||||
|
let bridge = SsrBridge::new(
|
||||||
|
WorkerCommand {
|
||||||
|
program: "python3".to_string(),
|
||||||
|
args: vec![stub.to_string_lossy().to_string()],
|
||||||
|
},
|
||||||
|
Duration::from_secs(5),
|
||||||
|
);
|
||||||
|
// The sentinel file makes the stub return an `error` frame; the bridge
|
||||||
|
// must surface it as `SsrError::Render`, not a successful empty render.
|
||||||
|
let err = bridge
|
||||||
|
.render("/boom.tsx", json!({}))
|
||||||
|
.expect_err("worker error propagates");
|
||||||
|
assert!(matches!(err, mizan_core::SsrError::Render(_)));
|
||||||
|
assert!(err.to_string().contains("render exploded"));
|
||||||
|
|
||||||
|
// A subsequent good render on the same worker still succeeds.
|
||||||
|
assert!(bridge.render("/ok.tsx", json!({})).is_ok());
|
||||||
|
bridge.shutdown();
|
||||||
|
let _ = std::fs::remove_file(&stub);
|
||||||
|
}
|
||||||
153
cores/mizan-rust/tests/token_pin.rs
Normal file
153
cores/mizan-rust/tests/token_pin.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//! Cross-language pin: Rust HS256 JWT + MWT must be byte-identical to the
|
||||||
|
//! Python core (`auth/jwt.py`, `mwt.py`, both PyJWT-backed).
|
||||||
|
//!
|
||||||
|
//! Byte-identity is the whole point — Edge and the origin cache key on these
|
||||||
|
//! tokens, so a one-byte divergence is a cache-key spoof surface. The Python
|
||||||
|
//! reference is the oracle: it mints with fixed claims + a fixed `iat`/`exp`
|
||||||
|
//! (we pin `now` on both sides) and the Rust token must match exactly. We also
|
||||||
|
//! prove round-trip: Rust decodes a Python-minted token and vice-versa.
|
||||||
|
|
||||||
|
use mizan_core::{
|
||||||
|
create_access_token, create_mwt, create_refresh_token, decode_jwt, decode_mwt,
|
||||||
|
compute_permission_key, JwtConfig,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn afi_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../tests/afi")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("tests/afi exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn py(code: &str) -> String {
|
||||||
|
let out = Command::new("uv")
|
||||||
|
.args(["run", "python", "-c", code])
|
||||||
|
.current_dir(afi_dir())
|
||||||
|
.output()
|
||||||
|
.expect("invoke uv run python");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: i64 = 1_700_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_access_token_matches_python() {
|
||||||
|
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||||
|
let rust = create_access_token(&cfg, "42", "sess-abc", true, false, NOW);
|
||||||
|
|
||||||
|
// Python: freeze time to NOW, mint an access token with the same claims.
|
||||||
|
let code = format!(
|
||||||
|
"import time; from unittest import mock; \
|
||||||
|
from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||||
|
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||||
|
orig = time.time; \
|
||||||
|
time.time = lambda: {NOW}; \
|
||||||
|
print(create_access_token('42', 'sess-abc', cfg, is_staff=True, is_superuser=False)); \
|
||||||
|
time.time = orig",
|
||||||
|
);
|
||||||
|
let expected = py(&code);
|
||||||
|
assert_eq!(rust, expected, "JWT access-token byte mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_refresh_token_matches_python() {
|
||||||
|
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||||
|
let rust = create_refresh_token(&cfg, "7", "sid-9", false, true, NOW);
|
||||||
|
let code = format!(
|
||||||
|
"import time; from mizan_core.auth.jwt import JWTConfig, create_refresh_token; \
|
||||||
|
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||||
|
time.time = lambda: {NOW}; \
|
||||||
|
print(create_refresh_token('7', 'sid-9', cfg, is_staff=False, is_superuser=True))",
|
||||||
|
);
|
||||||
|
assert_eq!(rust, py(&code), "JWT refresh-token byte mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_roundtrip_decode_python_minted() {
|
||||||
|
// A Python-minted access token must decode in Rust with matching claims.
|
||||||
|
let code = format!(
|
||||||
|
"import time; from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||||
|
cfg = JWTConfig(private_key='rt-secret', public_key='rt-secret'); \
|
||||||
|
time.time = lambda: {NOW}; \
|
||||||
|
print(create_access_token('99', 'sess-x', cfg, is_staff=False, is_superuser=True))",
|
||||||
|
);
|
||||||
|
let token = py(&code);
|
||||||
|
let cfg = JwtConfig::new("rt-secret");
|
||||||
|
let payload = decode_jwt(&token, &cfg, Some("access"), NOW + 10).expect("decodes");
|
||||||
|
assert_eq!(payload.sub, "99");
|
||||||
|
assert_eq!(payload.sid, "sess-x");
|
||||||
|
assert!(payload.superuser);
|
||||||
|
assert!(!payload.staff);
|
||||||
|
// Wrong secret → None; expired → None.
|
||||||
|
assert!(decode_jwt(&token, &JwtConfig::new("nope"), None, NOW + 10).is_none());
|
||||||
|
assert!(decode_jwt(&token, &cfg, Some("access"), NOW + 10_000).is_none());
|
||||||
|
// Type mismatch → None.
|
||||||
|
assert!(decode_jwt(&token, &cfg, Some("refresh"), NOW + 10).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_key_matches_python() {
|
||||||
|
let perms = vec!["app.add_thing".to_string(), "app.view_thing".to_string()];
|
||||||
|
let rust = compute_permission_key(true, false, &perms);
|
||||||
|
let code =
|
||||||
|
"from mizan_core.mwt import compute_permission_key; \
|
||||||
|
from unittest.mock import MagicMock; \
|
||||||
|
u = MagicMock(); u.is_staff=True; u.is_superuser=False; \
|
||||||
|
u.get_all_permissions = MagicMock(return_value={'app.view_thing','app.add_thing'}); \
|
||||||
|
print(compute_permission_key(u))";
|
||||||
|
assert_eq!(rust, py(code), "pkey byte mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mwt_matches_python() {
|
||||||
|
// Build the same pkey on both sides, then mint with frozen time + fixed
|
||||||
|
// kid/audience and compare bytes.
|
||||||
|
let perms = vec!["app.view_thing".to_string()];
|
||||||
|
let pkey = compute_permission_key(false, false, &perms);
|
||||||
|
let rust = create_mwt("mwt-pin-secret", "5", false, false, &pkey, 300, "mizan", "v1", NOW);
|
||||||
|
|
||||||
|
let code = format!(
|
||||||
|
"import time; from unittest.mock import MagicMock; \
|
||||||
|
from mizan_core.mwt import create_mwt; \
|
||||||
|
u = MagicMock(); u.pk=5; u.is_staff=False; u.is_superuser=False; \
|
||||||
|
u.get_all_permissions = MagicMock(return_value={{'app.view_thing'}}); \
|
||||||
|
time.time = lambda: {NOW}; \
|
||||||
|
print(create_mwt(u, 'mwt-pin-secret', ttl=300, audience='mizan', kid='v1'))",
|
||||||
|
);
|
||||||
|
assert_eq!(rust, py(&code), "MWT byte mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mwt_roundtrip_and_rejections() {
|
||||||
|
let pkey = compute_permission_key(true, true, &[]);
|
||||||
|
let token = create_mwt("rt-mwt", "13", true, true, &pkey, 300, "mizan", "v1", NOW);
|
||||||
|
let p = decode_mwt(&token, "rt-mwt", "mizan", NOW + 5).expect("decodes");
|
||||||
|
assert_eq!(p.sub, "13");
|
||||||
|
assert!(p.staff && p.superuser);
|
||||||
|
assert_eq!(p.kid, "v1");
|
||||||
|
assert_eq!(p.pkey.len(), 64);
|
||||||
|
// Wrong secret, wrong audience, expired → None.
|
||||||
|
assert!(decode_mwt(&token, "wrong", "mizan", NOW + 5).is_none());
|
||||||
|
assert!(decode_mwt(&token, "rt-mwt", "other", NOW + 5).is_none());
|
||||||
|
assert!(decode_mwt(&token, "rt-mwt", "mizan", NOW + 10_000).is_none());
|
||||||
|
|
||||||
|
// And a Python-minted MWT decodes in Rust.
|
||||||
|
let code = format!(
|
||||||
|
"import time; from unittest.mock import MagicMock; from mizan_core.mwt import create_mwt; \
|
||||||
|
u = MagicMock(); u.pk=21; u.is_staff=True; u.is_superuser=False; \
|
||||||
|
u.get_all_permissions = MagicMock(return_value=set()); \
|
||||||
|
time.time = lambda: {NOW}; print(create_mwt(u, 'rt-mwt', ttl=300, audience='mizan', kid='v1'))",
|
||||||
|
);
|
||||||
|
let py_token = py(&code);
|
||||||
|
let pp = decode_mwt(&py_token, "rt-mwt", "mizan", NOW + 5).expect("py mwt decodes in rust");
|
||||||
|
assert_eq!(pp.sub, "21");
|
||||||
|
assert!(pp.staff && !pp.superuser);
|
||||||
|
}
|
||||||
@@ -294,8 +294,11 @@ def _probe_websocket(a: Adapter) -> ProbeResult:
|
|||||||
|
|
||||||
|
|
||||||
def _probe_ssr_bridge(a: Adapter) -> ProbeResult:
|
def _probe_ssr_bridge(a: Adapter) -> ProbeResult:
|
||||||
if a.id == "django":
|
# Uniform, location-independent: the SSR subprocess bridge is single-sourced
|
||||||
return _wired(_has_path(a.id, "ssr", "bridge.py"), "Bun SSR subprocess bridge")
|
# (Python adapters ride `mizan_core.ssr.SSRBridge`); a capability "pass" means
|
||||||
|
# the ADAPTER invokes it — references the bridge / its renderer — over its own
|
||||||
|
# surface, not that a `bridge.py` lives at a fixed path. So the check is the
|
||||||
|
# same for every adapter: an invocation of the SSR renderer in adapter source.
|
||||||
return _wired(_hit(_adapter(a), r"SSRBridge|renderToString|ssr_bridge"), "SSR bridge (subprocess renderer)")
|
return _wired(_hit(_adapter(a), r"SSRBridge|renderToString|ssr_bridge"), "SSR bridge (subprocess renderer)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
303
tests/afi/rust_app/Cargo.lock
generated
303
tests/afi/rust_app/Cargo.lock
generated
@@ -39,6 +39,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -50,6 +51,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -57,8 +59,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -86,12 +90,33 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -104,6 +129,51 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -138,6 +208,23 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -151,17 +238,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -323,7 +442,10 @@ name = "mizan-axum"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
|
"futures-util",
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
|
"multer",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -336,10 +458,13 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"hmac",
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -352,6 +477,23 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -393,6 +535,15 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -411,6 +562,36 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -504,6 +685,28 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -536,6 +739,18 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -553,6 +768,26 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
@@ -581,6 +816,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -645,12 +892,48 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
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 = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -672,6 +955,26 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
Reference in New Issue
Block a user