From 6c5f6f1fba938a78cd7c8c5669ea4a7693f5586a Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 4 Jun 2026 13:44:35 -0400 Subject: [PATCH] =?UTF-8?q?AFI=20parity:=20close=20all=2035=20gaps=20?= =?UTF-8?q?=E2=80=94=20every=20adapter=20wires=20every=20AFI-common=20capa?= =?UTF-8?q?bility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 28 +- .../mizan-django/src/mizan/export/__init__.py | 148 +---- .../mizan-django/src/mizan/ssr/backend.py | 2 +- backends/mizan-fastapi/pyproject.toml | 4 + .../src/mizan_fastapi/__init__.py | 52 +- .../src/mizan_fastapi/forms/__init__.py | 245 ++++++++ .../src/mizan_fastapi/forms/schemas.py | 77 +++ .../src/mizan_fastapi/manifest.py | 98 ++++ .../mizan-fastapi/src/mizan_fastapi/router.py | 106 +++- .../mizan-fastapi/src/mizan_fastapi/shapes.py | 307 ++++++++++ .../mizan-fastapi/src/mizan_fastapi/ssr.py | 80 +++ .../mizan-fastapi/tests/test_edge_manifest.py | 167 ++++++ backends/mizan-fastapi/tests/test_forms.py | 145 +++++ backends/mizan-fastapi/tests/test_shapes.py | 269 +++++++++ backends/mizan-fastapi/tests/test_ssr.py | 138 +++++ .../mizan-fastapi/tests/test_websocket.py | 145 +++++ backends/mizan-rust-axum/Cargo.lock | 312 ++++++++++ backends/mizan-rust-axum/Cargo.toml | 10 +- backends/mizan-rust-axum/src/forms.rs | 89 +++ backends/mizan-rust-axum/src/handlers.rs | 435 ++++++++++++-- backends/mizan-rust-axum/src/lib.rs | 74 ++- backends/mizan-rust-axum/src/ssr.rs | 50 ++ backends/mizan-rust-axum/src/state.rs | 106 ++++ backends/mizan-rust-axum/src/ws.rs | 174 ++++++ backends/mizan-rust-axum/tests/behavior.rs | 422 +++++++++++++ backends/mizan-tauri/Cargo.lock | 101 ++-- backends/mizan-tauri/Cargo.toml | 5 + backends/mizan-tauri/src/lib.rs | 503 +++++++++++++--- backends/mizan-tauri/src/ssr.rs | 67 +++ backends/mizan-tauri/tests/behavior.rs | 370 ++++++++++++ backends/mizan-ts/bun.lock | 16 + backends/mizan-ts/package.json | 6 +- backends/mizan-ts/src/decorator.ts | 94 ++- backends/mizan-ts/src/dispatch.ts | 66 ++- backends/mizan-ts/src/forms.ts | 170 ++++++ backends/mizan-ts/src/index.ts | 48 +- backends/mizan-ts/src/ir/build.ts | 409 +++++++++++++ backends/mizan-ts/src/ir/index.ts | 17 + backends/mizan-ts/src/ir/types.ts | 70 +++ backends/mizan-ts/src/session.ts | 46 ++ backends/mizan-ts/src/shapes.ts | 78 +++ backends/mizan-ts/src/ssr.ts | 216 +++++++ backends/mizan-ts/src/token.ts | 164 +++++- backends/mizan-ts/src/types.ts | 23 + backends/mizan-ts/src/upload.ts | 143 +++++ backends/mizan-ts/src/websocket.ts | 116 ++++ backends/mizan-ts/tests/fixtures/Hello.tsx | 6 + .../mizan-ts/tests/fixtures/stub-worker.mjs | 53 ++ backends/mizan-ts/tests/ir-fixture.ts | 149 +++++ backends/mizan-ts/tests/ir.test.ts | 159 +++++ backends/mizan-ts/tests/shapes-forms.test.ts | 167 ++++++ backends/mizan-ts/tests/ssr.test.ts | 101 ++++ backends/mizan-ts/tests/token.test.ts | 133 ++++- backends/mizan-ts/tests/transport.test.ts | 131 +++++ backends/mizan-ts/tests/upload.test.ts | 163 ++++++ cores/mizan-python/src/mizan_core/manifest.py | 161 +++++ cores/mizan-python/src/mizan_core/registry.py | 11 +- .../src/mizan_core/ssr/__init__.py | 12 + .../src/mizan_core}/ssr/bridge.py | 9 +- cores/mizan-rust-macros/src/function.rs | 60 +- cores/mizan-rust-macros/src/shape.rs | 22 + cores/mizan-rust/Cargo.lock | 108 ++++ cores/mizan-rust/Cargo.toml | 3 + cores/mizan-rust/src/auth.rs | 552 ++++++++++++++++++ cores/mizan-rust/src/cache.rs | 272 +++++++++ cores/mizan-rust/src/ir.rs | 8 + cores/mizan-rust/src/kdl.rs | 25 +- cores/mizan-rust/src/lib.rs | 22 +- cores/mizan-rust/src/manifest.rs | 190 ++++++ cores/mizan-rust/src/runtime.rs | 69 +++ cores/mizan-rust/src/shapes.rs | 146 +++++ cores/mizan-rust/src/ssr.rs | 268 +++++++++ cores/mizan-rust/src/traits.rs | 6 + cores/mizan-rust/src/upload.rs | 72 +++ cores/mizan-rust/tests/cache_keys_pin.rs | 120 ++++ .../mizan-rust/tests/invalidate_header_pin.rs | 90 +++ cores/mizan-rust/tests/shapes_manifest.rs | 89 +++ cores/mizan-rust/tests/ssr_bridge.rs | 105 ++++ cores/mizan-rust/tests/token_pin.rs | 153 +++++ tests/afi/probes.py | 7 +- tests/afi/rust_app/Cargo.lock | 303 ++++++++++ 81 files changed, 9893 insertions(+), 463 deletions(-) create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/manifest.py create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/shapes.py create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/ssr.py create mode 100644 backends/mizan-fastapi/tests/test_edge_manifest.py create mode 100644 backends/mizan-fastapi/tests/test_forms.py create mode 100644 backends/mizan-fastapi/tests/test_shapes.py create mode 100644 backends/mizan-fastapi/tests/test_ssr.py create mode 100644 backends/mizan-fastapi/tests/test_websocket.py create mode 100644 backends/mizan-rust-axum/src/forms.rs create mode 100644 backends/mizan-rust-axum/src/ssr.rs create mode 100644 backends/mizan-rust-axum/src/state.rs create mode 100644 backends/mizan-rust-axum/src/ws.rs create mode 100644 backends/mizan-rust-axum/tests/behavior.rs create mode 100644 backends/mizan-tauri/src/ssr.rs create mode 100644 backends/mizan-tauri/tests/behavior.rs create mode 100644 backends/mizan-ts/src/forms.ts create mode 100644 backends/mizan-ts/src/ir/build.ts create mode 100644 backends/mizan-ts/src/ir/index.ts create mode 100644 backends/mizan-ts/src/ir/types.ts create mode 100644 backends/mizan-ts/src/session.ts create mode 100644 backends/mizan-ts/src/shapes.ts create mode 100644 backends/mizan-ts/src/ssr.ts create mode 100644 backends/mizan-ts/src/upload.ts create mode 100644 backends/mizan-ts/src/websocket.ts create mode 100644 backends/mizan-ts/tests/fixtures/Hello.tsx create mode 100644 backends/mizan-ts/tests/fixtures/stub-worker.mjs create mode 100644 backends/mizan-ts/tests/ir-fixture.ts create mode 100644 backends/mizan-ts/tests/ir.test.ts create mode 100644 backends/mizan-ts/tests/shapes-forms.test.ts create mode 100644 backends/mizan-ts/tests/ssr.test.ts create mode 100644 backends/mizan-ts/tests/transport.test.ts create mode 100644 backends/mizan-ts/tests/upload.test.ts create mode 100644 cores/mizan-python/src/mizan_core/manifest.py create mode 100644 cores/mizan-python/src/mizan_core/ssr/__init__.py rename {backends/mizan-django/src/mizan => cores/mizan-python/src/mizan_core}/ssr/bridge.py (92%) create mode 100644 cores/mizan-rust/src/auth.rs create mode 100644 cores/mizan-rust/src/cache.rs create mode 100644 cores/mizan-rust/src/manifest.rs create mode 100644 cores/mizan-rust/src/shapes.rs create mode 100644 cores/mizan-rust/src/ssr.rs create mode 100644 cores/mizan-rust/src/upload.rs create mode 100644 cores/mizan-rust/tests/cache_keys_pin.rs create mode 100644 cores/mizan-rust/tests/invalidate_header_pin.rs create mode 100644 cores/mizan-rust/tests/shapes_manifest.rs create mode 100644 cores/mizan-rust/tests/ssr_bridge.rs create mode 100644 cores/mizan-rust/tests/token_pin.rs diff --git a/README.md b/README.md index 2f855d8..088813a 100644 --- a/README.md +++ b/README.md @@ -60,32 +60,32 @@ Every capability below is **AFI-common**: each adapter owes a binding, and a ❌ | RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ | -| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ❌ | — | ✅ | +| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ✅ | — | ✅ | | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ | | Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ | -| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ❌ | -| File uploads (`Upload` type) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ✅ | +| File uploads (`Upload` type) | ✅ | ✅ | ✅ | ✅ | ✅ | ### Edge, cache & enforcement | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ◑ | ◑ | ✅ | -| Origin-side HMAC cache | ✅ | ✅ | ❌ | ❌ | ✅ | -| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ | -| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ | -| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ❌ | +| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Origin-side HMAC cache | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edge manifest export | ✅ | ✅ | ✅ | — | ✅ | +| PSR (`render_strategy` in manifest) | ✅ | ✅ | ✅ | — | ✅ | +| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ✅ | ### Extension points | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| WebSocket transport (`websocket=` declared) | ✅ | ❌ | ◑ | ❌ | ❌ | -| SSR bridge (subprocess renderer) | ✅ | ❌ | ❌ | ❌ | ❌ | -| JWT auth (access / refresh) | ✅ | ✅ | ❌ | ❌ | ◑ | -| MWT (edge identity token) | ✅ | ✅ | ❌ | — | ◑ | -| Typed query projection (Shapes) | ✅ | ❌ | ❌ | ❌ | ❌ | -| Forms (schema / validate / submit) | ✅ | ❌ | ◑ | ◑ | ❌ | +| WebSocket transport (`websocket=` declared) | ✅ | ✅ | ✅ | ✅ | ✅ | +| SSR bridge (subprocess renderer) | ✅ | ✅ | ✅ | ✅ | ✅ | +| JWT auth (access / refresh) | ✅ | ✅ | ✅ | ✅ | ✅ | +| MWT (edge identity token) | ✅ | ✅ | ✅ | — | ✅ | +| Typed query projection (Shapes) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Forms (schema / validate / submit) | ✅ | ✅ | ✅ | ✅ | ✅ | **Notes** diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index 230b7d0..3829f9d 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -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 -patterns and params, consumed by Mizan Edge at deploy time for CDN -cache invalidation. Independent from the Mizan IR; the IR drives -codegen, the manifest drives CDN purging. +The manifest derivation is AFI-common and lives in `mizan_core.manifest`; +Django exposes it through `python manage.py export_edge_manifest` and this +re-export. The manifest maps contexts to URL patterns and params, consumed by +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: from mizan.export import generate_edge_manifest, generate_edge_manifest_json @@ -12,145 +13,10 @@ Usage: from __future__ import annotations -import json -import re -from typing import Any - -from mizan_core.registry import get_context_groups, get_registry +from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json __all__ = [ "generate_edge_manifest", "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) diff --git a/backends/mizan-django/src/mizan/ssr/backend.py b/backends/mizan-django/src/mizan/ssr/backend.py index f9f3ac3..29bad1a 100644 --- a/backends/mizan-django/src/mizan/ssr/backend.py +++ b/backends/mizan-django/src/mizan/ssr/backend.py @@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist from django.template.backends.base import BaseEngine from django.utils.safestring import mark_safe -from .bridge import SSRBridge +from mizan_core.ssr import SSRBridge class MizanTemplate: diff --git a/backends/mizan-fastapi/pyproject.toml b/backends/mizan-fastapi/pyproject.toml index e9f6d8f..fa5894a 100644 --- a/backends/mizan-fastapi/pyproject.toml +++ b/backends/mizan-fastapi/pyproject.toml @@ -9,8 +9,12 @@ dependencies = [ "fastapi>=0.110", "pydantic>=2.0", "python-multipart>=0.0.9", + "sqlalchemy>=2.0", ] +[project.scripts] +mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index 26f6276..e28c5a7 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -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 Django adapter. -WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities -this adapter does not wire yet — open gaps on the capability-parity board -(`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 -generated typed client through one decorator, so an adapter that defers to -the native primitive isn't yet a complete AFI adapter. The SSR bridge in -particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django -coupling) and is mountable here directly. +The full AFI-common surface is wired here over FastAPI-native primitives, +each riding the shared core: + +- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)` + functions through the same `mizan_core.dispatch` as `POST /call/`. +- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared + `mizan_core.ssr.SSRBridge` Bun subprocess. +- 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: from fastapi import FastAPI @@ -42,11 +48,30 @@ from .executor import ( compute_invalidation, 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 .auth import MizanAuthMiddleware, mizan_auth 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 +# 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__ = [ "Upload", "File", @@ -60,6 +85,17 @@ __all__ = [ "mizan_validation_handler", "execute_function", "compute_invalidation", + "edge_manifest", + "generate_edge_manifest", + "render_strategies", + "SSRRenderer", + "shapes", + "forms", + "Shape", + "Diff", + "NestedDiff", + "mizanForm", + "FormConfig", "ErrorCode", "MizanError", "NotFound", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py new file mode 100644 index 0000000..e53cbf3 --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py b/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py new file mode 100644 index 0000000..124869e --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/manifest.py b/backends/mizan-fastapi/src/mizan_fastapi/manifest.py new file mode 100644 index 0000000..c439b7d --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/manifest.py @@ -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()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index 5c288f0..db9155c 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -17,7 +17,7 @@ from __future__ import annotations import json from typing import Any -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, Response 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.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.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) +# ─── 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": "", "fn": "", "args": {...}} + ← {"id": "", "ok": true, "data": , "invalidate": [...], "merge"?: [...]} + ← {"id": "", "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 ────────────────────────────────────────────────────── diff --git a/backends/mizan-fastapi/src/mizan_fastapi/shapes.py b/backends/mizan-fastapi/src/mizan_fastapi/shapes.py new file mode 100644 index 0000000..825cd8f --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/shapes.py @@ -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] diff --git a/backends/mizan-fastapi/src/mizan_fastapi/ssr.py b/backends/mizan-fastapi/src/mizan_fastapi/ssr.py new file mode 100644 index 0000000..ed630ef --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/ssr.py @@ -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'
{result.html}
' + f"" + ) + + 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() diff --git a/backends/mizan-fastapi/tests/test_edge_manifest.py b/backends/mizan-fastapi/tests/test_edge_manifest.py new file mode 100644 index 0000000..3c98a24 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_edge_manifest.py @@ -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//") + 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//"] + fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page") + assert fn_entry["path"] == "view" + assert fn_entry["route"] == "/profile//" + + +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 ` 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" diff --git a/backends/mizan-fastapi/tests/test_forms.py b/backends/mizan-fastapi/tests/test_forms.py new file mode 100644 index 0000000..fa58cb1 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_forms.py @@ -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 diff --git a/backends/mizan-fastapi/tests/test_shapes.py b/backends/mizan-fastapi/tests/test_shapes.py new file mode 100644 index 0000000..9129c97 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_shapes.py @@ -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 diff --git a/backends/mizan-fastapi/tests/test_ssr.py b/backends/mizan-fastapi/tests/test_ssr.py new file mode 100644 index 0000000..eacc252 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_ssr.py @@ -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 = "

" + props.get("name", "") + "

" + 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 ` invocation for `python `. + 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 == "

World

" + + +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) == ("

A

", "

B

") + + +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 '

Mizan

' 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() diff --git a/backends/mizan-fastapi/tests/test_websocket.py b/backends/mizan-fastapi/tests/test_websocket.py new file mode 100644 index 0000000..7a503d9 --- /dev/null +++ b/backends/mizan-fastapi/tests/test_websocket.py @@ -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" diff --git a/backends/mizan-rust-axum/Cargo.lock b/backends/mizan-rust-axum/Cargo.lock index 59bfc10..4a29ea1 100644 --- a/backends/mizan-rust-axum/Cargo.lock +++ b/backends/mizan-rust-axum/Cargo.lock @@ -27,6 +27,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "base64", "bytes", "futures-util", "http", @@ -38,6 +39,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -45,8 +47,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -74,18 +78,90 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "form_urlencoded" version = "1.2.2" @@ -110,6 +186,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-task" version = "0.3.32" @@ -123,17 +216,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "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]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -286,10 +411,15 @@ name = "mizan-axum" version = "0.1.0" dependencies = [ "axum", + "base64", + "futures-util", + "http-body-util", "mizan-core", + "multer", "serde", "serde_json", "tokio", + "tokio-tungstenite", "tower", "tower-http", ] @@ -299,10 +429,13 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "hmac", "linkme", "mizan-macros", "serde", "serde_json", + "sha2", ] [[package]] @@ -315,6 +448,23 @@ dependencies = [ "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]] name = "once_cell" version = "1.21.4" @@ -333,6 +483,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro2" version = "1.0.106" @@ -351,6 +510,36 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" @@ -429,6 +618,28 @@ dependencies = [ "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]] name = "slab" version = "0.4.12" @@ -451,6 +662,18 @@ dependencies = [ "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]] name = "syn" version = "2.0.117" @@ -468,12 +691,33 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -493,6 +737,18 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" @@ -557,12 +813,48 @@ dependencies = [ "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]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -584,6 +876,26 @@ dependencies = [ "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]] name = "zmij" version = "1.0.21" diff --git a/backends/mizan-rust-axum/Cargo.toml b/backends/mizan-rust-axum/Cargo.toml index 62b89ad..f974c58 100644 --- a/backends/mizan-rust-axum/Cargo.toml +++ b/backends/mizan-rust-axum/Cargo.toml @@ -7,9 +7,17 @@ license = "Elastic-2.0" [dependencies] mizan-core = { path = "../../cores/mizan-rust" } -axum = "0.7" +axum = { version = "0.7", features = ["ws", "multipart"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower = "0.5" 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" diff --git a/backends/mizan-rust-axum/src/forms.rs b/backends/mizan-rust-axum/src/forms.rs new file mode 100644 index 0000000..10ef9f5 --- /dev/null +++ b/backends/mizan-rust-axum/src/forms.rs @@ -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 { + 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>, + Path(form_name): Path, + Json(args): Json, +) -> Result { + 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>, + Path(form_name): Path, + Json(args): Json, +) -> Result { + 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>, + Path(form_name): Path, + Json(args): Json, +) -> Result { + dispatch_role(&state, &form_name, "submit", args).await +} diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs index c639fd7..06abc32 100644 --- a/backends/mizan-rust-axum/src/handlers.rs +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -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::http::{header, HeaderValue, StatusCode}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; 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, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::any::Any; use std::collections::BTreeMap; use std::sync::Arc; use crate::errors::ApiError; - -/// 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; +use crate::state::MizanState; /// Body for POST /call/. Matches the Python `CallBody` shape. #[derive(Debug, Deserialize)] @@ -33,9 +30,7 @@ pub struct CallBody { impl CallBody { fn resolved_name(&self) -> Option<&str> { - self.function_name - .as_deref() - .or(self.fn_.as_deref()) + self.function_name.as_deref().or(self.fn_.as_deref()) } } @@ -54,44 +49,210 @@ fn no_store(json: Value) -> Response { 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, 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 { + 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( - State(app_state): State, - Json(body): Json, + State(state): State>, + headers: HeaderMap, + body: axum::body::Body, ) -> Result { - let fn_name = body - .resolved_name() - .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? + let identity = identity_from_headers(&headers, &state)?; + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") .to_string(); - let fn_spec = lookup_function(&fn_name) - .ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?; + let (fn_name, args) = if content_type.starts_with("multipart/form-data") { + parse_multipart(&content_type, body).await? + } else { + parse_json_call(body).await? + }; - let req = RequestHandle::from_dyn(app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?; + let fn_spec = lookup_function(&fn_name).ok_or_else(|| { + ApiError(MizanError::NotFound(format!( + "function {fn_name:?} not registered" + ))) + })?; + reject_if_private(fn_spec)?; + guard(fn_spec, identity.as_ref())?; - let invalidate: Vec = compute_invalidation(fn_spec, &body.args) - .iter() - .map(InvalidationTarget::to_json) - .collect(); - let merges = compute_merges(fn_spec, &body.args, &result); + let req = RequestHandle::from_dyn(state.app_state.as_ref()); + let result = fn_spec + .dispatch(req, Value::Object(args.clone())) + .await + .map_err(ApiError)?; + + let targets = compute_invalidation(fn_spec, &args); + let invalidate: Vec = targets.iter().map(InvalidationTarget::to_json).collect(); + let merges = compute_merges(fn_spec, &args, &result); let merge_payload: Option> = if merges.is_empty() { None } else { 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 { result, invalidate, 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), 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), 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 = None; + let mut args: Map = Map::new(); + let mut files: BTreeMap> = 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, content_type: Option, 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( - State(app_state): State, + State(state): State>, + headers: HeaderMap, Path(context_name): Path, Query(params): Query>, ) -> Result { @@ -101,6 +262,8 @@ pub async fn context_fetch( )))); } + let identity = identity_from_headers(&headers, &state)?; + let members: Vec<&dyn FunctionSpec> = FUNCTIONS .iter() .copied() @@ -112,22 +275,130 @@ pub async fn context_fetch( )))); } - // Convert query params (all-string values) to the JSON arg map. Numeric - // params get parsed via the per-function input_params primitive table. + // Origin cache: the canonical-JSON bundle body is keyed by (context, + // params, user, rev). The Rust IR carries no per-fn rev yet → rev 0. + let cache_params: BTreeMap = 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(); for fn_spec in &members { + guard(*fn_spec, identity.as_ref())?; let args = coerce_query_args(*fn_spec, ¶ms); - let req = RequestHandle::from_dyn(app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?; + let req = RequestHandle::from_dyn(state.app_state.as_ref()); + let result = fn_spec + .dispatch(req, Value::Object(args)) + .await + .map_err(ApiError)?; bundled.insert(fn_spec.name().to_string(), result); } - 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 -/// function's declared input_params. Strings that don't parse stay as -/// strings — the dispatch wrapper will raise ValidationFailed downstream. +/// Canonical JSON bytes for the cache body — sorted keys, matching Python's +/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible. +fn canonical_bytes(v: &Value) -> Vec { + 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 { + 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, 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_spec: &dyn FunctionSpec, params: &BTreeMap, @@ -137,28 +408,88 @@ fn coerce_query_args( if let Some(raw) = params.get(ip.name) { let parsed = match ip.primitive { mizan_core::Primitive::Integer => raw.parse::().ok().map(Value::from), - mizan_core::Primitive::Number => raw.parse::().ok().and_then(|v| { - serde_json::Number::from_f64(v).map(Value::Number) - }), + mizan_core::Primitive::Number => raw + .parse::() + .ok() + .and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)), mizan_core::Primitive::Boolean => raw.parse::().ok().map(Value::from), mizan_core::Primitive::String => Some(Value::from(raw.clone())), }; - if let Some(v) = parsed { - out.insert(ip.name.into(), v); - } else { - out.insert(ip.name.into(), Value::from(raw.clone())); - } + out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone()))); } } out } /// GET /session/ — the AFI-common session-init endpoint, wired at parity with -/// mizan-django and mizan-fastapi. The CSRF *token* is a Django session -/// mechanism with no Rust equivalent, so this returns a null token; the endpoint -/// itself is owed and present, and readiness-probe consumers get a well-formed -/// response. +/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session +/// mechanism; the endpoint here returns a null token and serves as the +/// readiness probe the wire-parity harness uses. pub async fn session_init() -> Response { - let body = serde_json::json!({ "csrfToken": null }); - no_store(body) + no_store(serde_json::json!({ "csrfToken": null })) +} + +/// 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>) -> 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>, + Path(context_name): Path, +) -> Result { + 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) -> Result { + 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 }) } diff --git a/backends/mizan-rust-axum/src/lib.rs b/backends/mizan-rust-axum/src/lib.rs index df370fd..29f2214 100644 --- a/backends/mizan-rust-axum/src/lib.rs +++ b/backends/mizan-rust-axum/src/lib.rs @@ -1,58 +1,80 @@ -//! 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: //! ```ignore //! use axum::Router; -//! use mizan_axum::router; +//! use mizan_axum::{router, MizanState}; //! //! #[tokio::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(); //! axum::serve(listener, app).await.unwrap(); //! } //! ``` //! //! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`): -//! * `GET /session/` — session-init probe (placeholder CSRF token) -//! * `POST /call/` — RPC dispatch with invalidate+merge response -//! * `GET /ctx/:name/` — bundled context fetch +//! * `GET /session/` — session-init probe (placeholder CSRF token) +//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate +//! * `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 forms; mod handlers; +mod ssr; +mod state; +mod ws; pub use errors::ApiError; -pub use handlers::{ - context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse, -}; +pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse}; +pub use ssr::{ssr_render, SsrRequest}; +pub use state::{AppStateAny, MizanState, MizanStateBuilder}; use axum::routing::{get, post}; use axum::Router; use std::any::Any; use std::sync::Arc; -/// Build the Mizan router with user-supplied app state. The state is -/// type-erased into an `Arc` and threaded into every -/// dispatch via `RequestHandle`. Handlers downcast to their concrete state -/// type. -/// -/// Mount under a prefix: -/// `Router::new().nest("/api/mizan", router(my_state))`. -pub fn router(state: S) -> Router -where - S: Any + Send + Sync + 'static, -{ - let state: AppStateAny = Arc::new(state); +/// Build the Mizan router with a fully-configured [`MizanState`] (app state + +/// auth + cache + optional SSR worker). Mount under a prefix: +/// `Router::new().nest("/api/mizan", router(state))`. +pub fn router(state: Arc) -> Router { Router::new() .route("/session/", get(handlers::session_init)) .route("/call/", post(handlers::function_call)) .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) } -/// Router variant for callers that have no app state to thread — the -/// dispatch path receives a unit-typed handle. Used by the AFI fixture -/// and other stateless test apps. -pub fn router_stateless() -> Router { - router(()) +/// Router variant for the common case of just an app state, no auth/cache. +pub fn router_with_state(app_state: S) -> Router +where + S: Any + Send + Sync + 'static, +{ + 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()) } diff --git a/backends/mizan-rust-axum/src/ssr.rs b/backends/mizan-rust-axum/src/ssr.rs new file mode 100644 index 0000000..373869d --- /dev/null +++ b/backends/mizan-rust-axum/src/ssr.rs @@ -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>, + Json(req): Json, +) -> Result { + 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) +} diff --git a/backends/mizan-rust-axum/src/state.rs b/backends/mizan-rust-axum/src/state.rs new file mode 100644 index 0000000..7ac4536 --- /dev/null +++ b/backends/mizan-rust-axum/src/state.rs @@ -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` +//! 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; + +/// 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, + pub(crate) ssr_bridge: OnceLock, +} + +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, +} + +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(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) -> 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) -> Self { + self.ssr_worker = Some(worker_path.into()); + self + } + + pub fn build(self) -> Arc { + 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(), + }) + } +} diff --git a/backends/mizan-rust-axum/src/ws.rs b/backends/mizan-rust-axum/src/ws.rs new file mode 100644 index 0000000..452708c --- /dev/null +++ b/backends/mizan-rust-axum/src/ws.rs @@ -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>, +) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(mut socket: WebSocket, state: Arc) { + 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 { + 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 = 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 { + 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() }, + }) +} diff --git a/backends/mizan-rust-axum/tests/behavior.rs b/backends/mizan-rust-axum/tests/behavior.rs new file mode 100644 index 0000000..8c9e7c5 --- /dev/null +++ b/backends/mizan-rust-axum/tests/behavior.rs @@ -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, +} + +#[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 = 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(); +} diff --git a/backends/mizan-tauri/Cargo.lock b/backends/mizan-tauri/Cargo.lock index 084a2f8..5b3904d 100644 --- a/backends/mizan-tauri/Cargo.lock +++ b/backends/mizan-tauri/Cargo.lock @@ -558,6 +558,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1228,6 +1229,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1545,7 +1555,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-sys", "log", "thiserror 1.0.69", "walkdir", @@ -1554,37 +1564,15 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -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", -] +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1682,9 +1670,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "libc", ] @@ -1788,10 +1776,13 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", + "hmac", "linkme", "mizan-macros", "serde", "serde_json", + "sha2", ] [[package]] @@ -1808,10 +1799,12 @@ dependencies = [ name = "mizan-tauri" version = "0.1.0" dependencies = [ + "base64 0.22.1", "mizan-core", "serde", "serde_json", "tauri", + "tokio", ] [[package]] @@ -1842,7 +1835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.1", - "jni-sys 0.3.1", + "jni-sys", "log", "ndk-sys", "num_enum", @@ -1856,7 +1849,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys 0.3.1", + "jni-sys", ] [[package]] @@ -2467,9 +2460,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", @@ -2908,6 +2901,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3360,9 +3359,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "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]] name = "tokio-util" version = "0.7.18" @@ -3785,9 +3796,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -3798,9 +3809,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -3808,9 +3819,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3818,9 +3829,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -3831,9 +3842,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -3887,9 +3898,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/backends/mizan-tauri/Cargo.toml b/backends/mizan-tauri/Cargo.toml index 9278ece..ee25e98 100644 --- a/backends/mizan-tauri/Cargo.toml +++ b/backends/mizan-tauri/Cargo.toml @@ -10,3 +10,8 @@ mizan-core = { path = "../../cores/mizan-rust" } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +tauri = { version = "2", features = ["test"] } +tokio = { version = "1", features = ["rt", "macros"] } +base64 = "0.22" diff --git a/backends/mizan-tauri/src/lib.rs b/backends/mizan-tauri/src/lib.rs index e56c0f2..8ee617a 100644 --- a/backends/mizan-tauri/src/lib.rs +++ b/backends/mizan-tauri/src/lib.rs @@ -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: //! @@ -9,79 +10,137 @@ //! .expect("error while running tauri application"); //! ``` //! -//! The plugin exposes a single command `mizan_invoke` (full Tauri name -//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport` -//! sends call/fetch envelopes to it; the dispatch routes through -//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same -//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum) -//! consumes. There is no per-function tauri::command; the registry IS -//! the dispatch table. +//! The plugin exposes commands reachable from the JS-side +//! `@mizan/tauri-transport`: //! -//! 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` carries the push stream instead. +//! +//! Wire envelope (the `mizan_invoke` payload's `envelope` field): //! //! ```json -//! { "op": "call", "fn": "list_sessions", "args": {} } -//! { "op": "fetch", "context": "session", "params": {} } +//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? } +//! { "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 -//! mizan-rust-axum: +//! Response shapes mirror the HTTP adapter: //! -//! * `call` → `{ result, invalidate, merge? }` -//! * `fetch` → `{ : , ... }` (a flat bundle) +//! * `call` → `{ result, invalidate, merge? }` +//! * `fetch` → `{ : , ... }` (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 -//! `Result`, which Tauri serializes into the JS-side `Promise.reject`. -//! The TS-side transport re-wraps it into a `MizanError` so consumers -//! see one error surface regardless of transport. +//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token` +//! equivalent) or a `Bearer `; it is resolved through the shared +//! `authenticate` and enforced against each function's `auth=` requirement. +//! 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::{ - compute_invalidation, compute_merges, lookup_context, lookup_function, - FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, + authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context, + lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator, + FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use tauri::ipc::Channel; use tauri::{ plugin::{Builder, TauriPlugin}, - Runtime, + Manager, Runtime, }; -/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())` -/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch -/// command is reachable from JS as `plugin:mizan|mizan_invoke`. +/// The Mizan config Tauri manages: auth (token → identity) + the origin cache. +/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the +/// 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() -> TauriPlugin { Builder::::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::().is_none() { + app.manage(MizanTauriConfig::default()); + } + Ok(()) + }) .build() } // === Wire envelope === -/// One Mizan request. The JS-side transport sends `{ envelope: ... }`; -/// Tauri's serde deserializer pulls this struct out of the `envelope` -/// field of the invoke payload. +/// One Mizan request. Tauri's serde deserializer pulls this out of the +/// `envelope` field of the invoke payload. #[derive(Debug, Deserialize)] #[serde(tag = "op")] pub enum Envelope { #[serde(rename = "call")] Call { - /// Wire-level function name — registered name on the Rust side. #[serde(rename = "fn")] function_name: String, #[serde(default)] args: Map, + /// Optional auth token (MWT, or `Bearer `) — the IPC analogue of + /// the HTTP `X-Mizan-Token` / `Authorization` headers. + #[serde(default)] + token: Option, }, #[serde(rename = "fetch")] Fetch { context: String, #[serde(default)] params: Map, + #[serde(default)] + token: Option, + }, + #[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 -/// `{"code", "message", "details?"}` shape; the TS-side transport reads -/// this and constructs a `MizanError`. +/// `{"code", "message", "details?"}` shape. #[derive(Debug, Serialize)] pub struct ErrorPayload { pub code: &'static str, @@ -105,110 +164,336 @@ impl From for ErrorPayload { } } -// === Dispatch === +// === Auth === -/// The single Mizan dispatch command. Registered on the plugin's invoke -/// handler — the consumer never wires it directly. -/// -/// `app: AppHandle` is auto-injected by Tauri; the function body borrows -/// it into a `RequestHandle` so `#[mizan::client]` functions can -/// `req.downcast::()` for app-managed state or event -/// emission. Stateless functions ignore the handle. +/// Resolve identity from an envelope `token`. An MWT is tried first (raw +/// token), then a `Bearer `. A present-but-invalid token rejects (the +/// `INVALID`-sentinel contract); absent → anonymous. +fn identity_from_token( + token: Option<&str>, + config: &MizanTauriConfig, +) -> Result, 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::()` for managed state or event emission. #[tauri::command] async fn mizan_invoke( app: tauri::AppHandle, envelope: Envelope, ) -> Result { + 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( + app: &tauri::AppHandle, + envelope: Envelope, +) -> Result { + // 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::(); + let default; + let cfg: &MizanTauriConfig = match managed.as_ref() { + Some(state) => state.inner(), + None => { + default = MizanTauriConfig::default(); + &default + } + }; match envelope { Envelope::Call { function_name, args, - } => handle_call(&app, &function_name, args).await, - Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await, + token, + } => 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( app: &tauri::AppHandle, + cfg: &MizanTauriConfig, fn_name: &str, - args: Map, -) -> Result { - let fn_spec = lookup_function(fn_name).ok_or_else(|| { - ErrorPayload::from(MizanError::NotFound(format!( - "function {fn_name:?} not registered" - ))) - })?; + mut args: Map, + token: Option<&str>, +) -> Result { + let identity = identity_from_token(token, cfg)?; + + 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 result = fn_spec - .dispatch(req, Value::Object(args.clone())) - .await - .map_err(ErrorPayload::from)?; + let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?; - let invalidate: Vec = compute_invalidation(fn_spec, &args) - .iter() - .map(InvalidationTarget::to_json) - .collect(); + let targets = compute_invalidation(fn_spec, &args); + let invalidate: Vec = targets.iter().map(InvalidationTarget::to_json).collect(); let merges = compute_merges(fn_spec, &args, &result); - let merge_payload: Option> = if merges.is_empty() { - None - } else { - Some(merges.iter().map(MergeEntry::to_json).collect()) - }; - let mut payload = json!({ - "result": result, - "invalidate": invalidate, - }); - if let Some(merge) = merge_payload { - payload - .as_object_mut() - .expect("payload is a JSON object") - .insert("merge".into(), Value::Array(merge)); + // Purge the origin cache for everything this mutation invalidated. + if !targets.is_empty() { + let uid = identity.as_ref().map(|i| i.user_id.clone()); + cfg.cache.purge(&targets, uid.as_deref()); + } + + let mut payload = json!({ "result": result, "invalidate": invalidate }); + if !merges.is_empty() { + payload.as_object_mut().unwrap().insert( + "merge".into(), + Value::Array(merges.iter().map(MergeEntry::to_json).collect()), + ); } Ok(payload) } async fn handle_fetch( app: &tauri::AppHandle, + cfg: &MizanTauriConfig, context_name: &str, params: Map, -) -> Result { - if lookup_context(context_name).is_none() { - return Err(ErrorPayload::from(MizanError::NotFound(format!( - "context {context_name:?} not registered" - )))); - } + token: Option<&str>, +) -> Result { + let identity = identity_from_token(token, cfg)?; + if lookup_context(context_name).is_none() { + return Err(MizanError::NotFound(format!( + "context {context_name:?} not registered" + ))); + } let members: Vec<&dyn FunctionSpec> = FUNCTIONS .iter() .copied() .filter(|f| f.context() == Some(context_name)) .collect(); if members.is_empty() { - return Err(ErrorPayload::from(MizanError::NotFound(format!( + return Err(MizanError::NotFound(format!( "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 = 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::(&cached) { + return Ok(v); + } } let mut bundled = Map::new(); for fn_spec in &members { + guard(*fn_spec, identity.as_ref())?; let args = filter_args(*fn_spec, ¶ms); let req = RequestHandle::new(app); - let result = fn_spec - .dispatch(req, Value::Object(args)) - .await - .map_err(ErrorPayload::from)?; + let result = fn_spec.dispatch(req, Value::Object(args)).await?; 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 -/// input. The HTTP/axum adapter coerces string-typed query params to -/// JSON primitives in the equivalent step; the Tauri arg channel already -/// carries typed JSON, so the filter is sufficient on its own. +/// `shape` op — the typed query projection for a function's output, derived by +/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding). +fn handle_shape(fn_name: &str) -> Result { + 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( + app: &tauri::AppHandle, + form_name: &str, + role: &str, + args: Value, +) -> Result { + 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( + app: &tauri::AppHandle, + form_name: &str, + role: &str, + args: Value, +) -> Result { + 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( + app: &tauri::AppHandle, + form_name: &str, +) -> Result { + dispatch_form_role(app, form_name, "schema", Value::Null).await +} + +async fn form_validate( + app: &tauri::AppHandle, + form_name: &str, + args: Value, +) -> Result { + dispatch_form_role(app, form_name, "validate", args).await +} + +async fn form_submit( + app: &tauri::AppHandle, + form_name: &str, + args: Value, +) -> Result { + 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, +} + +/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]` +/// function. A desktop shell has no WebSocket; a Tauri `Channel` 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( + app: tauri::AppHandle, + function_name: String, + args: Map, + on_event: Channel, +) -> 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( + app: &tauri::AppHandle, + function_name: &str, + args: Map, + on_event: Channel, +) -> 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) -> Map { let mut out = Map::new(); for ip in fn_spec.input_params() { @@ -218,3 +503,45 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map) -> Map | [, ...] }`) 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, +) -> 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(()) +} diff --git a/backends/mizan-tauri/src/ssr.rs b/backends/mizan-tauri/src/ssr.rs new file mode 100644 index 0000000..161285d --- /dev/null +++ b/backends/mizan-tauri/src/ssr.rs @@ -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: "
...
" } + +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, +} + +impl MizanSsr { + /// Build an SSR state that launches `bun run ` on first render. + pub fn new(worker_path: impl Into) -> 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( + app: tauri::AppHandle, + file: String, + props: Option, +) -> Result { + let state = app.try_state::().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 }) +} diff --git a/backends/mizan-tauri/tests/behavior.rs b/backends/mizan-tauri/tests/behavior.rs new file mode 100644 index 0000000..d1fa040 --- /dev/null +++ b/backends/mizan-tauri/tests/behavior.rs @@ -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 { + 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 = 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>> = Arc::new(Mutex::new(Vec::new())); + let sink = captured.clone(); + let channel: Channel = 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 = 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 { + pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() +} diff --git a/backends/mizan-ts/bun.lock b/backends/mizan-ts/bun.lock index 5b0c283..1286743 100644 --- a/backends/mizan-ts/bun.lock +++ b/backends/mizan-ts/bun.lock @@ -5,15 +5,31 @@ "": { "name": "@mizan/ts", "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", "bun-types": "latest", + "react": "^19", + "react-dom": "^19", }, }, }, "packages": { "@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=="], + "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=="], } } diff --git a/backends/mizan-ts/package.json b/backends/mizan-ts/package.json index 59bac8c..6d1f29b 100644 --- a/backends/mizan-ts/package.json +++ b/backends/mizan-ts/package.json @@ -8,7 +8,11 @@ "test": "bun test" }, "devDependencies": { - "bun-types": "latest" + "@types/react": "^19", + "@types/react-dom": "^19", + "bun-types": "latest", + "react": "^19", + "react-dom": "^19" }, "license": "Elastic-2.0" } diff --git a/backends/mizan-ts/src/decorator.ts b/backends/mizan-ts/src/decorator.ts index 9758ff7..133d11d 100644 --- a/backends/mizan-ts/src/decorator.ts +++ b/backends/mizan-ts/src/decorator.ts @@ -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' function resolveContext(ctx: ReactContext | string | undefined): string | undefined { @@ -21,6 +21,12 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi 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. * 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. * @@ -85,69 +121,19 @@ export function client Promise>( */ export function client(options: ClientOptions): MethodDecorator -export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any { +export function client(optionsOrFn: ClientOptions, fn?: Function): any { // Function wrapper form: client(options, fn) if (fn && typeof fn === 'function') { const options = optionsOrFn as ClientOptions - 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 params = extractParams(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) + register(buildEntry(options, name, fn)) return fn } // Decorator form: @client(options) const options = optionsOrFn as ClientOptions return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) { - const originalMethod = 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) + register(buildEntry(options, propertyKey, descriptor.value)) return descriptor } } diff --git a/backends/mizan-ts/src/dispatch.ts b/backends/mizan-ts/src/dispatch.ts index 48c7f62..d94e098 100644 --- a/backends/mizan-ts/src/dispatch.ts +++ b/backends/mizan-ts/src/dispatch.ts @@ -10,6 +10,7 @@ import { resolveInvalidation, formatInvalidateHeader } from './invalidation' import { getCache, cacheGet, cachePut, cachePurge } from './cache' import { ANONYMOUS, type Identity } from './identity' import type { AuthRequirement } from './types' +import { UploadedFile, bindUploads } from './upload' 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( 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 { + 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 + 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() + 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) +} diff --git a/backends/mizan-ts/src/forms.ts b/backends/mizan-ts/src/forms.ts new file mode 100644 index 0000000..c83472f --- /dev/null +++ b/backends/mizan-ts/src/forms.ts @@ -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 — `-schema`, + * `-validate`, and (when a submit handler is given) `-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 | 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 +} + +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): FormValidationOutput { + const errors: Record = {} + + 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) => unknown | Promise + +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 + * `-schema`, `-validate`, `-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) { + 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) { + 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 +} diff --git a/backends/mizan-ts/src/index.ts b/backends/mizan-ts/src/index.ts index 50b147d..ed29850 100644 --- a/backends/mizan-ts/src/index.ts +++ b/backends/mizan-ts/src/index.ts @@ -1,23 +1,63 @@ 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 type { Identity, AuthPredicate } from './identity' -export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token' -export type { MwtPayload } from './token' +export { + 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 { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry' -export { handleContextFetch, handleMutationCall } from './dispatch' +export { handleContextFetch, handleMutationCall, handleMultipartCall } 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 { 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 type { CacheBackend } from './cache' export { setCacheSecret } from './dispatch' diff --git a/backends/mizan-ts/src/ir/build.ts b/backends/mizan-ts/src/ir/build.ts new file mode 100644 index 0000000..3e39a15 --- /dev/null +++ b/backends/mizan-ts/src/ir/build.ts @@ -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): 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 `Input` / `Output` rename) + * - output wrapper aliases (`Output = list[T]` / primitive / renamed + * model) so the consumer has one named type to reference. + */ +function collectNamedTypes(fns: Map): Record { + const seen: Record = {} + + function visitModel(name: string, types: Record): 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): void { + for (const ref of refsIn(shape)) visitModel(ref, types) + } + + for (const { schema, camel } of fns.values()) { + const types = schema.types ?? {} + + // Input — named `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') { + // or Optional[] — emit the model under the canonical + // output name (rename). Python renames the Pydantic model to + // `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() + 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() + 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() +} diff --git a/backends/mizan-ts/src/ir/index.ts b/backends/mizan-ts/src/ir/index.ts new file mode 100644 index 0000000..8eccf90 --- /dev/null +++ b/backends/mizan-ts/src/ir/index.ts @@ -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' diff --git a/backends/mizan-ts/src/ir/types.ts b/backends/mizan-ts/src/ir/types.ts new file mode 100644 index 0000000..0df5295 --- /dev/null +++ b/backends/mizan-ts/src/ir/types.ts @@ -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 "" { ... }` 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 + * `Input` / `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 +} diff --git a/backends/mizan-ts/src/session.ts b/backends/mizan-ts/src/session.ts new file mode 100644 index 0000000..5fe0ff0 --- /dev/null +++ b/backends/mizan-ts/src/session.ts @@ -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 diff --git a/backends/mizan-ts/src/shapes.ts b/backends/mizan-ts/src/shapes.ts new file mode 100644 index 0000000..091ab86 --- /dev/null +++ b/backends/mizan-ts/src/shapes.ts @@ -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 +} + +type Record_ = Record + +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) + } +} diff --git a/backends/mizan-ts/src/ssr.ts b/backends/mizan-ts/src/ssr.ts new file mode 100644 index 0000000..cb91c66 --- /dev/null +++ b/backends/mizan-ts/src/ssr.ts @@ -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": "
...
" } + * ← { "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 ` 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 +} + +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() + private readyPromise: Promise | 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 { + if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) { + return this.readyPromise + } + + let settled = false + this.readyPromise = new Promise((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): Promise { + const id = ++this.counter + const frame = JSON.stringify({ id, method, params }) + '\n' + + return new Promise((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 = {}): Promise { + 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 { + 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 + } + } +} diff --git a/backends/mizan-ts/src/token.ts b/backends/mizan-ts/src/token.ts index 5289ab6..e69d099 100644 --- a/backends/mizan-ts/src/token.ts +++ b/backends/mizan-ts/src/token.ts @@ -1,14 +1,56 @@ /** - * MWT / JWT decode — HS256 verification, cross-language parity with - * cores/mizan-python/src/mizan_core/mwt.py. + * MWT / JWT mint + decode — HS256, cross-language parity with + * `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`. * - * Returns null on ANY failure (bad signature, expired, future nbf, wrong - * aud, malformed). Never throws. + * Decode returns null on ANY failure (bad signature, expired, future nbf, + * 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' +// ─── 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, sortKeys: boolean): string { + if (!sortKeys) return JSON.stringify(obj) + const sorted: Record = {} + 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, + secret: string, + headerExtras: Record = {}, +): 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 { sub: string staff: boolean @@ -108,3 +150,115 @@ export function identityFromMwt(payload: MwtPayload): Identity { 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, + } +} diff --git a/backends/mizan-ts/src/types.ts b/backends/mizan-ts/src/types.ts index 17309e8..c0509c2 100644 --- a/backends/mizan-ts/src/types.ts +++ b/backends/mizan-ts/src/types.ts @@ -3,6 +3,7 @@ */ import type { AuthPredicate } from './identity' +import type { IrSchema } from './ir/types' export class ReactContext { 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. */ 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 { context?: ReactContext | string affects?: AffectsTarget | AffectsTarget[] + /** Contexts the mutation's return value merges into (vs. refetch). */ + merge?: AffectsTarget | AffectsTarget[] private?: boolean route?: string methods?: string[] auth?: AuthOption + websocket?: boolean rev?: number 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 { @@ -40,14 +57,20 @@ export interface RegistryEntry { fn: (...args: any[]) => Promise context?: string affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }> + merge?: string[] params: ParamDef[] private: boolean viewPath: boolean route?: string methods?: string[] auth?: AuthRequirement + websocket?: boolean rev?: number cache?: number | false + ir?: IrSchema + form?: boolean + formName?: string + formRole?: FormRole } export interface ManifestContext { diff --git a/backends/mizan-ts/src/upload.ts b/backends/mizan-ts/src/upload.ts new file mode 100644 index 0000000..afbfdca --- /dev/null +++ b/backends/mizan-ts/src/upload.ts @@ -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 { + const out = new Map() + 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, + files: Map, +): 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 +} diff --git a/backends/mizan-ts/src/websocket.ts b/backends/mizan-ts/src/websocket.ts new file mode 100644 index 0000000..f389291 --- /dev/null +++ b/backends/mizan-ts/src/websocket.ts @@ -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 +} + +interface FetchFrame { + id?: number | string + type: 'fetch' + context: string + params?: Record +} + +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 { + 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)) + }) +} diff --git a/backends/mizan-ts/tests/fixtures/Hello.tsx b/backends/mizan-ts/tests/fixtures/Hello.tsx new file mode 100644 index 0000000..8af1359 --- /dev/null +++ b/backends/mizan-ts/tests/fixtures/Hello.tsx @@ -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}!`) +} diff --git a/backends/mizan-ts/tests/fixtures/stub-worker.mjs b/backends/mizan-ts/tests/fixtures/stub-worker.mjs new file mode 100644 index 0000000..4d8adcb --- /dev/null +++ b/backends/mizan-ts/tests/fixtures/stub-worker.mjs @@ -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: `
${JSON.stringify(props ?? {})}
` }) + 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 }) diff --git a/backends/mizan-ts/tests/ir-fixture.ts b/backends/mizan-ts/tests/ir-fixture.ts new file mode 100644 index 0000000..1871bf3 --- /dev/null +++ b/backends/mizan-ts/tests/ir-fixture.ts @@ -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 `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 } + }, + ) +} diff --git a/backends/mizan-ts/tests/ir.test.ts b/backends/mizan-ts/tests/ir.test.ts new file mode 100644 index 0000000..ca6446e --- /dev/null +++ b/backends/mizan-ts/tests/ir.test.ts @@ -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 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) + }, + ) +}) diff --git a/backends/mizan-ts/tests/shapes-forms.test.ts b/backends/mizan-ts/tests/shapes-forms.test.ts new file mode 100644 index 0000000..b078406 --- /dev/null +++ b/backends/mizan-ts/tests/shapes-forms.test.ts @@ -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() + }) +}) diff --git a/backends/mizan-ts/tests/ssr.test.ts b/backends/mizan-ts/tests/ssr.test.ts new file mode 100644 index 0000000..2fd9c98 --- /dev/null +++ b/backends/mizan-ts/tests/ssr.test.ts @@ -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('
{"title":"Hi","n":3}
') + }) + + 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) + }) +}) diff --git a/backends/mizan-ts/tests/token.test.ts b/backends/mizan-ts/tests/token.test.ts index 2f7f115..6f03cc4 100644 --- a/backends/mizan-ts/tests/token.test.ts +++ b/backends/mizan-ts/tests/token.test.ts @@ -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 { createHmac } from 'crypto' -import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src' +import { createHmac, createHash } from 'crypto' +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 { 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) + }) +}) diff --git a/backends/mizan-ts/tests/transport.test.ts b/backends/mizan-ts/tests/transport.test.ts new file mode 100644 index 0000000..52e9c8c --- /dev/null +++ b/backends/mizan-ts/tests/transport.test.ts @@ -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') + }) +}) diff --git a/backends/mizan-ts/tests/upload.test.ts b/backends/mizan-ts/tests/upload.test.ts new file mode 100644 index 0000000..8d93ddb --- /dev/null +++ b/backends/mizan-ts/tests/upload.test.ts @@ -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, files: Record): 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') + }) +}) diff --git a/cores/mizan-python/src/mizan_core/manifest.py b/cores/mizan-python/src/mizan_core/manifest.py new file mode 100644 index 0000000..c0d4527 --- /dev/null +++ b/cores/mizan-python/src/mizan_core/manifest.py @@ -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 + ) diff --git a/cores/mizan-python/src/mizan_core/registry.py b/cores/mizan-python/src/mizan_core/registry.py index 71b4852..7e7ebf5 100644 --- a/cores/mizan-python/src/mizan_core/registry.py +++ b/cores/mizan-python/src/mizan_core/registry.py @@ -5,12 +5,11 @@ sub-registry (channels/WebSocket, forms, shapes) to plug into. This is the framework-agnostic registry. The extension points (channels, forms, websockets, shapes) are AFI-common: every adapter owes -a binding for each, and registers it here so the unified schema export -sees it. Django binds all of them today; the other adapters' unbound -extensions are gaps tracked by the capability-parity suite in -`tests/afi/`, not framework-specific features. The binding is per-stack -(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the -capability is common. +a binding for each, on its own stack — Django Channels or a native +WebSocket route; Django Forms or Pydantic; django-readers or the project's +ORM. The capability is common; the binding is per-stack. Each adapter wires +its binding so the unified schema export sees it; an unwired one is a gap on +the capability-parity board (`tests/afi/`), not a framework-specific feature. """ from __future__ import annotations diff --git a/cores/mizan-python/src/mizan_core/ssr/__init__.py b/cores/mizan-python/src/mizan_core/ssr/__init__.py new file mode 100644 index 0000000..d1f7e42 --- /dev/null +++ b/cores/mizan-python/src/mizan_core/ssr/__init__.py @@ -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"] diff --git a/backends/mizan-django/src/mizan/ssr/bridge.py b/cores/mizan-python/src/mizan_core/ssr/bridge.py similarity index 92% rename from backends/mizan-django/src/mizan/ssr/bridge.py rename to cores/mizan-python/src/mizan_core/ssr/bridge.py index 78d1084..745759b 100644 --- a/backends/mizan-django/src/mizan/ssr/bridge.py +++ b/cores/mizan-python/src/mizan_core/ssr/bridge.py @@ -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. @@ -33,7 +38,7 @@ class SSRBridge: """ 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. """ diff --git a/cores/mizan-rust-macros/src/function.rs b/cores/mizan-rust-macros/src/function.rs index 45fa73f..6247b82 100644 --- a/cores/mizan-rust-macros/src/function.rs +++ b/cores/mizan-rust-macros/src/function.rs @@ -26,6 +26,15 @@ pub struct FunctionArgs { pub merge: Vec, pub websocket: 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, + /// `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, + pub form_role: Option, } impl FunctionArgs { @@ -45,10 +54,16 @@ impl FunctionArgs { out.affects = collect_paths(&nv.value)?; } else if nv.path.is_ident("merge") { 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 { return Err(syn::Error::new_spanned( 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; } else if p.is_ident("private") { out.private = true; + } else if p.is_ident("auth") { + out.auth = Some("required".to_string()); } else { return Err(syn::Error::new_spanned( 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 { } } +fn expect_str(expr: &Expr) -> syn::Result { + 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> { match expr { Expr::Path(_) => Ok(vec![expect_path(expr)?]), @@ -183,7 +215,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { }); } 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 { #(#field_defs)* } @@ -353,6 +389,20 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { let output_nullable = analysis.nullable; 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( &item, &input_args, @@ -389,6 +439,10 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { fn merge(&self) -> &'static [&'static str] { #merge_static } fn transport(&self) -> ::mizan_core::Transport { #transport_value } 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 dispatch<'a>( diff --git a/cores/mizan-rust-macros/src/shape.rs b/cores/mizan-rust-macros/src/shape.rs index a3b94a6..35756b0 100644 --- a/cores/mizan-rust-macros/src/shape.rs +++ b/cores/mizan-rust-macros/src/shape.rs @@ -105,6 +105,15 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream { if let Some(p) = primitive_of(ty) { 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. // The Ref name comes from `::TYPE_NAME` (associated const). 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_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 /// known primitive scalar. pub fn primitive_of(ty: &Type) -> Option { diff --git a/cores/mizan-rust/Cargo.lock b/cores/mizan-rust/Cargo.lock index a2db134..469a603 100644 --- a/cores/mizan-rust/Cargo.lock +++ b/cores/mizan-rust/Cargo.lock @@ -13,12 +13,82 @@ dependencies = [ "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]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indoc" version = "2.0.7" @@ -34,6 +104,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "linkme" version = "0.3.36" @@ -65,11 +141,14 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "hmac", "indoc", "linkme", "mizan-macros", "serde", "serde_json", + "sha2", ] [[package]] @@ -149,6 +228,23 @@ dependencies = [ "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]] name = "syn" version = "2.0.117" @@ -160,12 +256,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "zmij" version = "1.0.21" diff --git a/cores/mizan-rust/Cargo.toml b/cores/mizan-rust/Cargo.toml index 9ee85bb..ae3e5ec 100644 --- a/cores/mizan-rust/Cargo.toml +++ b/cores/mizan-rust/Cargo.toml @@ -11,6 +11,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" mizan-macros = { path = "../mizan-rust-macros" } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" [dev-dependencies] indoc = "2" diff --git a/cores/mizan-rust/src/auth.rs b/cores/mizan-rust/src/auth.rs new file mode 100644 index 0000000..b65dedf --- /dev/null +++ b/cores/mizan-rust/src/auth.rs @@ -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; + +/// 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> { + 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> { + 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 { + 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) -> 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 { + 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 { + 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, + pub mwt_secret: Option, + 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())) + } + } + } +} diff --git a/cores/mizan-rust/src/cache.rs b/cores/mizan-rust/src/cache.rs new file mode 100644 index 0000000..0fad3bb --- /dev/null +++ b/cores/mizan-rust/src/cache.rs @@ -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; + +/// 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, + 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, + 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>; + fn set(&self, key: &str, value: Vec); + 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>>, +} + +impl MemoryCache { + pub fn new() -> Self { + Self::default() + } +} + +impl CacheBackend for MemoryCache { + fn get(&self, key: &str) -> Option> { + self.store.lock().unwrap().get(key).cloned() + } + + fn set(&self, key: &str, value: Vec) { + 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>, + secret: Option, +} + +impl CacheOrchestrator { + pub fn new(backend: Option>, secret: Option) -> 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, + user_id: Option<&str>, + rev: i64, + ) -> Option { + let secret = self.secret.as_deref()?; + Some(derive_cache_key(secret, context, params, user_id, rev)) + } + + pub fn get( + &self, + context: &str, + params: &BTreeMap, + user_id: Option<&str>, + rev: i64, + ) -> Option> { + 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, + value: Vec, + 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 = + 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); + } + } + } + } + } +} diff --git a/cores/mizan-rust/src/ir.rs b/cores/mizan-rust/src/ir.rs index 108ab52..c64316c 100644 --- a/cores/mizan-rust/src/ir.rs +++ b/cores/mizan-rust/src/ir.rs @@ -26,6 +26,14 @@ pub enum TypeShape { Optional(Box), Enum(Vec<&'static str>), Union(Vec), + /// 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, + content_types: &'static [&'static str], + }, } #[derive(Debug, Clone, Copy)] diff --git a/cores/mizan-rust/src/kdl.rs b/cores/mizan-rust/src/kdl.rs index c67dcd1..ee07ff3 100644 --- a/cores/mizan-rust/src/kdl.rs +++ b/cores/mizan-rust/src/kdl.rs @@ -160,6 +160,29 @@ impl<'a> Emitter<'a> { } 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 = 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(shape: &TypeShape, visit: &mut F) { walk_shape_refs(b, visit); } } - TypeShape::Primitive(_) | TypeShape::Enum(_) => {} + TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {} } } diff --git a/cores/mizan-rust/src/lib.rs b/cores/mizan-rust/src/lib.rs index ae0ab97..bc84c3d 100644 --- a/cores/mizan-rust/src/lib.rs +++ b/cores/mizan-rust/src/lib.rs @@ -14,25 +14,43 @@ //! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at //! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`. +pub mod auth; +pub mod cache; pub mod graph_check; pub mod ir; pub mod kdl; +pub mod manifest; pub mod registry; pub mod runtime; +pub mod shapes; +pub mod ssr; 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::{ AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, }; pub use kdl::{build_ir, snake_to_camel}; +pub use manifest::{generate_edge_manifest, generate_edge_manifest_json}; pub use registry::{ context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS, FUNCTIONS, TYPES, }; pub use runtime::{ - compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError, - RequestHandle, + compute_invalidation, compute_merges, format_invalidate_header, InvalidationTarget, + MergeEntry, MizanError, RequestHandle, }; +pub use shapes::{QueryProjection, ShapeField}; +pub use ssr::{SsrBridge, SsrError, WorkerCommand}; pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; // Re-export proc macros so consumers depend on one crate. diff --git a/cores/mizan-rust/src/manifest.rs b/cores/mizan-rust/src/manifest.rs new file mode 100644 index 0000000..895d367 --- /dev/null +++ b/cores/mizan-rust/src/manifest.rs @@ -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 = Vec::new(); + let mut page_routes: Vec = 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(), + } +} diff --git a/cores/mizan-rust/src/runtime.rs b/cores/mizan-rust/src/runtime.rs index c1a01fd..5b388b7 100644 --- a/cores/mizan-rust/src/runtime.rs +++ b/cores/mizan-rust/src/runtime.rs @@ -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 = 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::>() + .join(";"); + parts.push(format!("{context};{param_str}")); + } + } + } + } + parts.join(", ") +} + /// One entry in the response's `merge` array. Server-resolved slot — the /// kernel writes the value into `bundle[slot]` directly. #[derive(Debug, Clone)] diff --git a/cores/mizan-rust/src/shapes.rs b/cores/mizan-rust/src/shapes.rs new file mode 100644 index 0000000..14885bf --- /dev/null +++ b/cores/mizan-rust/src/shapes.rs @@ -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, +} + +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 { + 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, +) -> Option { + 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 { + let fn_spec = crate::registry::lookup_function(fn_name)?; + project(fn_spec.output_type()) +} diff --git a/cores/mizan-rust/src/ssr.rs b/cores/mizan-rust/src/ssr.rs new file mode 100644 index 0000000..7c1d53a --- /dev/null +++ b/cores/mizan-rust/src/ssr.rs @@ -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": "
...
"} +//! +//! 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 `; a +/// test injects a stub program that speaks the same JSON-RPC framing. +#[derive(Clone)] +pub struct WorkerCommand { + pub program: String, + pub args: Vec, +} + +impl WorkerCommand { + /// The production launcher: `bun run `. + pub fn bun(worker_path: impl Into) -> 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>, + cv: Condvar, +} + +struct Inner { + child: Option, + stdin: Option, + pending: Arc>>>, + ready: Arc<(Mutex, Condvar)>, + counter: u64, +} + +/// A persistent Bun SSR subprocess, thread-safe across concurrent `render`s. +pub struct SsrBridge { + command: WorkerCommand, + timeout: Duration, + inner: Mutex, +} + +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 ` with a 5s render timeout. + pub fn bun(worker_path: impl Into) -> 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>>>, + ready: Arc<(Mutex, 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 { + 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(); + } +} diff --git a/cores/mizan-rust/src/traits.rs b/cores/mizan-rust/src/traits.rs index 38f8b65..c6e2e36 100644 --- a/cores/mizan-rust/src/traits.rs +++ b/cores/mizan-rust/src/traits.rs @@ -53,6 +53,12 @@ pub trait FunctionSpec: Send + Sync { fn private(&self) -> bool { 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 { false } diff --git a/cores/mizan-rust/src/upload.rs b/cores/mizan-rust/src/upload.rs new file mode 100644 index 0000000..d49e1a2 --- /dev/null +++ b/cores/mizan-rust/src/upload.rs @@ -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, + pub content_type: Option, + data: Vec, +} + +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::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, + #[serde(default)] + content_type: Option, + data_b64: String, +} + +impl<'de> Deserialize<'de> for Upload { + fn deserialize(deserializer: D) -> Result + 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, + }) + } +} diff --git a/cores/mizan-rust/tests/cache_keys_pin.rs b/cores/mizan-rust/tests/cache_keys_pin.rs new file mode 100644 index 0000000..c272aa1 --- /dev/null +++ b/cores/mizan-rust/tests/cache_keys_pin.rs @@ -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 ` 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 { + 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, 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::>(), + ) + .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}", + ); + } +} diff --git a/cores/mizan-rust/tests/invalidate_header_pin.rs b/cores/mizan-rust/tests/invalidate_header_pin.rs new file mode 100644 index 0000000..82ac8cf --- /dev/null +++ b/cores/mizan-rust/tests/invalidate_header_pin.rs @@ -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, &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}"); + } +} diff --git a/cores/mizan-rust/tests/shapes_manifest.rs b/cores/mizan-rust/tests/shapes_manifest.rs new file mode 100644 index 0000000..de1585f --- /dev/null +++ b/cores/mizan-rust/tests/shapes_manifest.rs @@ -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"]) + ); +} diff --git a/cores/mizan-rust/tests/ssr_bridge.rs b/cores/mizan-rust/tests/ssr_bridge.rs new file mode 100644 index 0000000..383854c --- /dev/null +++ b/cores/mizan-rust/tests/ssr_bridge.rs @@ -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":""}` — 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 `. +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 = "" % (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#""# + ); + + // 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#""# + ); + + 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); +} diff --git a/cores/mizan-rust/tests/token_pin.rs b/cores/mizan-rust/tests/token_pin.rs new file mode 100644 index 0000000..2fc764a --- /dev/null +++ b/cores/mizan-rust/tests/token_pin.rs @@ -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); +} diff --git a/tests/afi/probes.py b/tests/afi/probes.py index 0d74bab..7c42f7b 100644 --- a/tests/afi/probes.py +++ b/tests/afi/probes.py @@ -294,8 +294,11 @@ def _probe_websocket(a: Adapter) -> ProbeResult: def _probe_ssr_bridge(a: Adapter) -> ProbeResult: - if a.id == "django": - return _wired(_has_path(a.id, "ssr", "bridge.py"), "Bun SSR subprocess bridge") + # Uniform, location-independent: the SSR subprocess bridge is single-sourced + # (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)") diff --git a/tests/afi/rust_app/Cargo.lock b/tests/afi/rust_app/Cargo.lock index 52d5ef5..0c54564 100644 --- a/tests/afi/rust_app/Cargo.lock +++ b/tests/afi/rust_app/Cargo.lock @@ -39,6 +39,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "base64", "bytes", "futures-util", "http", @@ -50,6 +51,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -57,8 +59,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -86,12 +90,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bytes" version = "1.11.1" @@ -104,6 +129,51 @@ 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]] name = "errno" version = "0.3.14" @@ -138,6 +208,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-task" version = "0.3.32" @@ -151,17 +238,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "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]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -323,7 +442,10 @@ name = "mizan-axum" version = "0.1.0" dependencies = [ "axum", + "base64", + "futures-util", "mizan-core", + "multer", "serde", "serde_json", "tokio", @@ -336,10 +458,13 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "hmac", "linkme", "mizan-macros", "serde", "serde_json", + "sha2", ] [[package]] @@ -352,6 +477,23 @@ dependencies = [ "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]] name = "once_cell" version = "1.21.4" @@ -393,6 +535,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro2" version = "1.0.106" @@ -411,6 +562,36 @@ dependencies = [ "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]] name = "redox_syscall" version = "0.5.18" @@ -504,6 +685,28 @@ dependencies = [ "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]] name = "signal-hook-registry" version = "1.4.8" @@ -536,6 +739,18 @@ dependencies = [ "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]] name = "syn" version = "2.0.117" @@ -553,6 +768,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "tokio" version = "1.52.3" @@ -581,6 +816,18 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" @@ -645,12 +892,48 @@ dependencies = [ "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]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -672,6 +955,26 @@ dependencies = [ "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]] name = "zmij" version = "1.0.21"