Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
name: Publish Django package to Gitea registry
|
||||
name: Publish Django package to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish React package to Gitea registry
|
||||
name: Publish React package to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,6 +34,3 @@ examples/django-react-site/harness/test-results/
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Agent worktrees (transient scratch — never tracked)
|
||||
.claude/worktrees/
|
||||
|
||||
472
CLAUDE.md
Normal file
472
CLAUDE.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Mizan — Technical Reference
|
||||
|
||||
## What Mizan Is
|
||||
|
||||
Mizan is an Application Framework Interface (AFI). One decorator on a server function. Typed client generated. Invalidation automatic. Caching protocol-driven. SSR via subprocess.
|
||||
|
||||
Django + React ships first. The protocol is language-agnostic (proven by mizan-ts).
|
||||
|
||||
---
|
||||
|
||||
## Package Layout
|
||||
|
||||
Tree organized by role. Per-framework adapters wrap a single shared kernel; codegen targets the adapter.
|
||||
|
||||
```
|
||||
backends/ server protocol adapters
|
||||
mizan-django/ Django adapter
|
||||
mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope)
|
||||
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
|
||||
mizan-rust-axum/ Rust/Axum adapter (server-side substrate; three-way parity)
|
||||
mizan-tauri/ Tauri-as-Mizan-backend substrate
|
||||
frontends/ client kernel + per-framework adapters + transports
|
||||
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
|
||||
mizan-react/ React contexts + hooks over the kernel
|
||||
mizan-vue/ Vue composables over the kernel (codegen target; runtime package unimplemented)
|
||||
mizan-svelte/ Svelte stores over the kernel (codegen target; runtime package unimplemented)
|
||||
mizan-rust/ Rust kernel (PyO3 bridge; consumed by the Rust codegen's python target)
|
||||
mizan-tauri-transport/ Tauri IPC transport for the kernel
|
||||
mizan-webview-transport/ VSCode-webview transport for the kernel
|
||||
mizan-webview-channels/ webview channel transport
|
||||
cores/ shared language-level primitives
|
||||
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
|
||||
mizan-rust/ shared Rust primitives (IR, KDL, registry, graph-check)
|
||||
mizan-rust-macros/ proc-macros for the Rust backend/kernel
|
||||
protocol/ protocol-level tooling
|
||||
mizan-codegen/ the codegen — a Rust binary; reads KDL IR, emits typed clients per target
|
||||
mizan-generate/ thin npm launcher around the compiled mizan-codegen binary
|
||||
workers/ runtime workers / bridges
|
||||
mizan-ssr/ Bun subprocess used by the Django template backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Three Protocols
|
||||
|
||||
### 1. RPC Protocol (Anti-REST)
|
||||
|
||||
No resources. No CRUD. Functions in, results out.
|
||||
|
||||
**Context fetch (reads):**
|
||||
```
|
||||
GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
||||
|
||||
200 OK
|
||||
Cache-Control: no-store
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"user_profile": {"name": "Ryth", "email": "ryth@example.com"},
|
||||
"user_orders": [{"id": 1, "total": 100}]
|
||||
}
|
||||
```
|
||||
|
||||
All functions sharing a context name are bundled into one response. Keys are function names. Values are return values.
|
||||
|
||||
**Mutation call (writes):**
|
||||
```
|
||||
POST /api/mizan/call/
|
||||
Content-Type: application/json
|
||||
|
||||
{"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}
|
||||
|
||||
200 OK
|
||||
Cache-Control: no-store
|
||||
X-Mizan-Invalidate: user;user_id=5
|
||||
|
||||
{
|
||||
"result": {"ok": true},
|
||||
"invalidate": [{"context": "user", "params": {"user_id": 5}}]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Invalidation-on-Mutation Protocol
|
||||
|
||||
Two transports for the same signal. Both are first-class.
|
||||
|
||||
**Transport 1 — JSON body** (for RPC/SPA clients):
|
||||
```json
|
||||
{"result": {...}, "invalidate": ["user"]}
|
||||
{"result": {...}, "invalidate": [{"context": "user", "params": {"user_id": 5}}]}
|
||||
```
|
||||
|
||||
**Transport 2 — HTTP header** (for Edge, htmx, view-path functions):
|
||||
```
|
||||
X-Mizan-Invalidate: user
|
||||
X-Mizan-Invalidate: user;user_id=5
|
||||
X-Mizan-Invalidate: user;user_id=5, notifications
|
||||
```
|
||||
|
||||
Format: comma-separated contexts, semicolon-separated URL-encoded params per context.
|
||||
|
||||
**Three-tier auto-scoping** (no developer annotation needed):
|
||||
1. **Argument name matching:** mutation has `user_id` param, context has `user_id` param → scoped automatically
|
||||
2. **Auth inference:** Edge-side concern (reads JWT/MWT to extract user identity)
|
||||
3. **Broad fallback:** invalidate all instances of the context
|
||||
|
||||
**Return-type branching** determines which transport:
|
||||
- Function returns data (dict, BaseModel) → RPC path → JSON body + header
|
||||
- Function returns HttpResponse (redirect, HTML) → View path → header only
|
||||
|
||||
### 3. Frontend-Agnostic Rendering (SSR + PSR)
|
||||
|
||||
**SSR** — Django template backend integration. `render(request, 'ProfilePage', props)` calls a persistent Bun subprocess that runs `renderToString`.
|
||||
|
||||
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
|
||||
|
||||
**The Bun worker protocol** — JSON-RPC over stdin/stdout:
|
||||
```
|
||||
→ {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {"userId": 5}}}
|
||||
← {"id": 1, "html": "<div>...</div>"}
|
||||
```
|
||||
|
||||
Worker stays alive across requests. Django's `SSRBridge` manages the subprocess lifecycle with thread-safe request correlation via message IDs.
|
||||
|
||||
---
|
||||
|
||||
## The @client Decorator — Full API
|
||||
|
||||
```python
|
||||
from mizan import client, ReactContext, GlobalContext
|
||||
|
||||
UserContext = ReactContext('user')
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `context` | `ReactContext \| str \| False` | `False` | Named context for grouping. `False` = standalone function. |
|
||||
| `affects` | `ReactContext \| str \| list` | `None` | What this mutation invalidates. Mutually exclusive with `context`. |
|
||||
| `private` | `bool` | `False` | Not client-callable. No RPC endpoint. No codegen. Still in invalidation graph. |
|
||||
| `route` | `str \| None` | `None` | Mizan-owned URL pattern for view-path functions. |
|
||||
| `methods` | `list[str] \| None` | `None` | HTTP methods for route. Default: `['GET']` for context, `['POST']` for mutation. |
|
||||
| `auth` | `bool \| str \| callable \| None` | `None` | Auth requirement: `True`, `'staff'`, `'superuser'`, or `callable(request) -> bool`. |
|
||||
| `websocket` | `bool` | `False` | Enable WebSocket RPC transport. |
|
||||
| `rev` | `int` | `0` | Cache revision. Increment to bust cached entries on deploy. |
|
||||
| `cache` | `int \| False` | (default) | Cache TTL hint. `False` = never cache. Integer = TTL seconds. |
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
```python
|
||||
# Global context — auto-mounted at root, SSR-hydrated
|
||||
@client(context=GlobalContext)
|
||||
def current_user(request) -> UserShape:
|
||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
||||
|
||||
# Named context — bundled GET, generates typed hooks
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> UserShape:
|
||||
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_orders(request, user_id: int) -> list[OrderShape]:
|
||||
return OrderShape.query(lambda qs: qs.filter(user_id=user_id))
|
||||
|
||||
# Mutation — auto-scoped invalidation (user_id matches)
|
||||
@client(affects=UserContext)
|
||||
def update_profile(request, user_id: int, name: str) -> dict:
|
||||
request.user.name = name
|
||||
request.user.save()
|
||||
return {"ok": True}
|
||||
|
||||
# Function-level affects — only user_profile refetches
|
||||
@client(affects='user_profile')
|
||||
def update_name(request, user_id: int, name: str) -> dict:
|
||||
...
|
||||
|
||||
# View-path context — registered in invalidation graph, no codegen
|
||||
@client(context=UserContext, route='/profile/<user_id>/')
|
||||
def profile_page(request, user_id: int) -> HttpResponse:
|
||||
return render(request, 'profile.html', {...})
|
||||
|
||||
# View-path mutation — invalidation via header on the redirect
|
||||
@client(affects=UserContext, route='/profile/<user_id>/update/', methods=['POST'])
|
||||
def update_profile_view(request, user_id: int) -> HttpResponse:
|
||||
form = ProfileForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(f'/profile/{user_id}/')
|
||||
|
||||
# Private webhook — not client-callable, emits invalidation
|
||||
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
||||
def stripe_webhook(request) -> HttpResponse:
|
||||
event = json.loads(request.body)
|
||||
process_stripe_event(event)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
# Auth guards
|
||||
@client(auth=True)
|
||||
def secret(request) -> dict: ...
|
||||
|
||||
@client(auth='staff')
|
||||
def admin_action(request) -> dict: ...
|
||||
|
||||
@client(auth=lambda req: req.user.email.endswith('@company.com'))
|
||||
def internal_tool(request) -> dict: ...
|
||||
```
|
||||
|
||||
### _meta Dict Structure
|
||||
|
||||
After decoration, the function class has `_meta` with these possible keys:
|
||||
|
||||
```python
|
||||
{
|
||||
"context": "user", # context name string (if context=)
|
||||
"affects": [ # normalized affects targets (if affects=)
|
||||
{"type": "context", "name": "user"},
|
||||
{"type": "function", "name": "user_profile", "context": "user"},
|
||||
],
|
||||
"private": True, # if private=True
|
||||
"route": "/webhooks/stripe/", # if route=
|
||||
"methods": ["POST"], # if route= (defaults applied)
|
||||
"view_path": True, # if return type is HttpResponse
|
||||
"websocket": True, # if websocket=True
|
||||
"auth": "required", # "required" | "staff" | "superuser" | callable
|
||||
"rev": 3, # if rev=
|
||||
"cache": 60, # if cache=
|
||||
"form": True, # if form function
|
||||
"form_name": "contact", # form name
|
||||
"form_role": "schema", # "schema" | "validate" | "submit"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache System
|
||||
|
||||
### Required Settings
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
MIZAN_CACHE_SECRET = "your-32-byte-hmac-signing-key" # Required for cache
|
||||
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0" # Required for cache
|
||||
```
|
||||
|
||||
Both must be set. If either is missing, caching is disabled with a warning.
|
||||
|
||||
### HMAC Key Derivation
|
||||
|
||||
Cache keys are derived from HMAC-SHA256 over a JSON-canonical form:
|
||||
|
||||
```python
|
||||
derive_cache_key(secret, context, params, user_id=None, rev=0) -> str
|
||||
```
|
||||
|
||||
**Canonical form** (the HMAC message):
|
||||
```json
|
||||
{"c":"user","p":{"user_id":"5"},"r":0}
|
||||
```
|
||||
With optional `"u":"5"` for user-scoped entries.
|
||||
|
||||
- `c` = context name
|
||||
- `p` = sorted params dict (all values stringified)
|
||||
- `r` = revision number
|
||||
- `u` = user ID (for auth-scoped cache entries)
|
||||
|
||||
**Key format:** `ctx:{context}:{hmac_hex}`
|
||||
- Example: `ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6`
|
||||
|
||||
**Cross-language conformance:** The TypeScript adapter (`mizan-ts/src/cache/keys.ts`) produces identical keys for identical inputs. Pin tests verify this.
|
||||
|
||||
### Cache Operations
|
||||
|
||||
```python
|
||||
from mizan.cache import cache_get, cache_put, cache_purge
|
||||
|
||||
# Store
|
||||
cache_put(secret, backend, "user", {"user_id": "5"}, b'{"name":"Ryth"}')
|
||||
|
||||
# Retrieve
|
||||
data = cache_get(secret, backend, "user", {"user_id": "5"})
|
||||
|
||||
# Scoped purge (recomputes HMAC, deletes one key)
|
||||
cache_purge(backend, "user", params={"user_id": "5"}, secret=secret)
|
||||
|
||||
# Broad purge (SCAN by prefix "ctx:user:*")
|
||||
cache_purge(backend, "user")
|
||||
```
|
||||
|
||||
### Backends
|
||||
|
||||
**MemoryCache** — dict-based, for testing. No persistence.
|
||||
|
||||
**RedisCache** — production backend.
|
||||
- Connection pooling (50 max connections)
|
||||
- 24h default TTL safety net
|
||||
- Key prefix: `mizan:` (configurable)
|
||||
- `delete_by_prefix` uses Redis SCAN (1000 keys per batch)
|
||||
- `delete` uses UNLINK (non-blocking)
|
||||
|
||||
### Cache Integration in Dispatch
|
||||
|
||||
`context_fetch_view` checks origin-side cache before executing functions. On cache miss, executes functions and stores the result. On mutation, purges affected cache entries based on the invalidation targets.
|
||||
|
||||
All HTTP responses emit `Cache-Control: no-store`. Origin-side caching is internal — the HTTP layer never caches at the CDN. Edge caching is managed by Mizan Edge (closed-source Cloudflare Workers) which uses the manifest and MWT tokens.
|
||||
|
||||
---
|
||||
|
||||
## MWT (Mizan Web Token) and JWT
|
||||
|
||||
### Two Token Systems
|
||||
|
||||
**JWT** — standard user authentication tokens. Access + refresh pair. Session-tied for revocation.
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
JWT_PRIVATE_KEY = "your-secret-key" # Required
|
||||
JWT_ALGORITHM = "HS256" # Default, or RS256 for asymmetric
|
||||
JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
|
||||
JWT_REFRESH_TOKEN_EXPIRES_IN = 604800 # 7 days
|
||||
JWT_VALIDATE_SESSION = True # Check session exists on use
|
||||
```
|
||||
|
||||
JWT claims: `sub` (user ID), `sid` (session key), `staff`, `super`, `type` (access/refresh), `iat`, `exp`.
|
||||
|
||||
Session validation: on every JWT use, checks that the session still exists. Logging out destroys the session → immediately revokes all tokens tied to it.
|
||||
|
||||
**MWT** — Mizan Web Token. Protocol-owned identity for Edge cache keying. Separate secret from JWT and cache.
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
MIZAN_MWT_SECRET = "your-mwt-signing-key" # Separate from JWT_PRIVATE_KEY
|
||||
MIZAN_MWT_TTL = 300 # 5 minutes
|
||||
```
|
||||
|
||||
MWT is used by Mizan Edge to derive user-scoped cache keys without exposing the cache secret to the client. The MWT carries claims that Edge needs (user identity, permissions) in a short-lived token that travels on a custom header (`X-Mizan-Token`).
|
||||
|
||||
### Secret Separation
|
||||
|
||||
Three independent secrets, each with its own blast radius:
|
||||
|
||||
| Secret | Setting | Purpose | Compromise Impact |
|
||||
|--------|---------|---------|-------------------|
|
||||
| JWT secret | `JWT_PRIVATE_KEY` | User auth tokens | Auth bypass |
|
||||
| Cache secret | `MIZAN_CACHE_SECRET` | HMAC cache keys | Cache poisoning |
|
||||
| MWT secret | `MIZAN_MWT_SECRET` | Edge identity tokens | Cache key spoofing |
|
||||
|
||||
---
|
||||
|
||||
## SSR Implementation
|
||||
|
||||
### Django Template Backend
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'OPTIONS': {
|
||||
'worker_path': 'frontend/ssr-worker.tsx',
|
||||
'timeout': 5,
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Usage in Views
|
||||
|
||||
```python
|
||||
from django.shortcuts import render
|
||||
|
||||
def profile_page(request, user_id):
|
||||
profile = get_user_profile(user_id)
|
||||
return render(request, 'ProfilePage', {'profile': profile})
|
||||
```
|
||||
|
||||
`render()` calls `MizanTemplates.get_template('ProfilePage')` which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC to the Bun worker.
|
||||
|
||||
### SSR Bridge (bridge.py)
|
||||
|
||||
- Spawns `bun run <worker_path>` on first render
|
||||
- Persistent subprocess — stays alive across requests
|
||||
- JSON-RPC over stdin/stdout with message ID correlation
|
||||
- Thread-safe: multiple Django workers can call `render()` concurrently
|
||||
- Auto-restarts on crash
|
||||
- Waits for `{"id": 0, "ready": true}` before accepting requests
|
||||
|
||||
### Bun Worker (worker.tsx)
|
||||
|
||||
- Reads newline-delimited JSON from stdin
|
||||
- Component registry: `registerComponent('ProfilePage', ProfilePage)`
|
||||
- Calls `renderToString(createElement(Component, props))`
|
||||
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
|
||||
- Health check: `{"method": "ping"}` → `{"pong": true}`
|
||||
|
||||
---
|
||||
|
||||
## Edge Manifest
|
||||
|
||||
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
|
||||
|
||||
```json
|
||||
{
|
||||
"contexts": {
|
||||
"user": {
|
||||
"functions": [
|
||||
{"name": "user_profile", "path": "rpc"},
|
||||
{"name": "profile_page", "path": "view", "route": "/profile/<user_id>/"}
|
||||
],
|
||||
"endpoints": ["/api/mizan/ctx/user/"],
|
||||
"params": ["user_id"],
|
||||
"user_scoped": true,
|
||||
"render_strategy": "dynamic_cached",
|
||||
"page_routes": ["/profile/<user_id>/"]
|
||||
}
|
||||
},
|
||||
"mutations": {
|
||||
"update_profile": {
|
||||
"affects": ["user"],
|
||||
"auto_scoped_params": ["user_id"]
|
||||
},
|
||||
"stripe_webhook": {
|
||||
"affects": ["subscription"],
|
||||
"private": true,
|
||||
"route": "/webhooks/stripe/",
|
||||
"methods": ["POST"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**render_strategy**: `"psr"` (no user-scoped params) or `"dynamic_cached"` (user-scoped). Derived automatically from whether params overlap with `{user_id, user, owner_id, account_id}`.
|
||||
|
||||
---
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```python
|
||||
# mizan/urls.py
|
||||
urlpatterns = [
|
||||
path("session/", session_init_view), # GET — CSRF cookie
|
||||
path("call/", function_call_view), # POST — RPC dispatch
|
||||
path("ctx/<str:context_name>/", context_fetch_view), # GET — bundled context fetch
|
||||
]
|
||||
```
|
||||
|
||||
Mounted at `/api/mizan/` by convention:
|
||||
```python
|
||||
urlpatterns = [
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codegen — Current State
|
||||
|
||||
The codegen is a **Rust binary**, `protocol/mizan-codegen/` (crate `mizan-codegen`). `protocol/mizan-generate/` is a thin npm launcher (`bin/launcher.mjs`) that shells out to the compiled binary. The IR is **KDL** — each backend emits KDL describing its functions/contexts; the binary reads it (`src/fetch.rs`, `src/ir.rs`) and emits per-target output from Askama templates (`templates/`, dispatched in `src/emit/`).
|
||||
|
||||
Two layers, same as before: a framework-agnostic protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types) and a per-framework adapter layer that subscribes to the `mizan-base` kernel.
|
||||
|
||||
**Targets** (`src/emit/`, each byte-checked by a `*_parity.rs` test):
|
||||
|
||||
- `react` — function/context hooks over `useSyncExternalStore`, plus the full wrapper layer: the `MizanContext` root provider (calls `configure()`, mounts the global context), `useMizan()` imperative escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`.
|
||||
- `vue`, `svelte` — composables / `readable` stores. Byte-parity-tested, but no runtime adapter package or live-backend example exercises them yet (the `mizan-vue`/`mizan-svelte` packages are unimplemented stubs).
|
||||
- `channels` — WebSocket transport hooks.
|
||||
- `stage1` — the framework-agnostic protocol files.
|
||||
- `python`, `rust` — typed clients for the Python (PyO3) and Rust frontends.
|
||||
|
||||
The pre-kernel `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) still ships and is imported by the desktop example; it coexists with the generated `MizanContext`. Forms (`mizan-react/src/forms.ts`) are hand-written and consume the pre-kernel provider — a form codegen target wired to `mizanCall` is still owed. See `ISSUES.md`.
|
||||
|
||||
The SSR pipeline is independent of the codegen — the Bun worker resolves a component by **file path** (`import(file)` + `renderToString`), not via a registry.
|
||||
@@ -11,9 +11,8 @@ no longer exist.
|
||||
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||
- [ ] **Upload dispatch not wired for Rust/Axum + Tauri.** The `Upload` type is first-class end to end — IR (`upload` KDL node), codegen (TS `File`; the Rust target lowers it to `Vec<u8>`), kernel (auto-multipart), and dispatch+constraint binding on Django and FastAPI. The Rust/Axum and Tauri *adapters* have no upload concept at dispatch — they don't bind multipart file parts yet.
|
||||
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
||||
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: cross-language stringification of un-normalized value types, and no thundering-herd / single-flight protection.
|
||||
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||
|
||||
## Resolved this pass
|
||||
|
||||
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: install test test-core test-django test-fastapi test-react test-afi parity-table parity-check test-integration docker-up docker-down clean
|
||||
.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
|
||||
|
||||
CORE = cores/mizan-python
|
||||
DJANGO = backends/mizan-django
|
||||
@@ -30,24 +30,11 @@ test-fastapi:
|
||||
test-react:
|
||||
cd $(REACT) && npm test
|
||||
|
||||
# AFI conformance — two gates, substrate-level, not e2e:
|
||||
# test_codegen_parity.py — Django/FastAPI/Rust emit byte-identical KDL IR.
|
||||
# test_capability_parity.py — every (capability, applicable adapter) pair is
|
||||
# probed for its wiring. RED on every unwired gap
|
||||
# by design: that board is the owed work, itemized.
|
||||
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
|
||||
# schemas for the same @client fixture. Substrate-level gate, not e2e.
|
||||
test-afi:
|
||||
cd $(AFI) && uv run pytest
|
||||
|
||||
# Regenerate the README parity table from the live conformance probes. The table
|
||||
# is generated output — never hand-edited.
|
||||
parity-table:
|
||||
cd $(AFI) && uv run python parity_table.py --write
|
||||
|
||||
# CI gate: the committed README parity table matches what the probes report.
|
||||
# Fails on any hand-edit, the same forcing function as the codegen byte-parity.
|
||||
parity-check:
|
||||
cd $(AFI) && uv run python parity_table.py --check
|
||||
|
||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||
|
||||
test-integration: docker-up
|
||||
|
||||
116
README.md
116
README.md
@@ -33,86 +33,86 @@ reference implementation; per-adapter support is inventoried below.
|
||||
|
||||
## Backend adapters
|
||||
|
||||
Every adapter implements the same AFI wire protocol. The matrix below is **generated**
|
||||
from the conformance probes in [`tests/afi/`](tests/afi/) by `make parity-table` — it is
|
||||
output, not prose. A cell goes `✅` only when that adapter wires the capability into its
|
||||
own dispatch surface; it cannot be set to "supported" or "Django-only" by editing this
|
||||
file (a hand-edit fails `python tests/afi/parity_table.py --check` in CI, the same
|
||||
forcing function the codegen byte-parity tests use).
|
||||
Every adapter implements the same AFI wire protocol. The matrix below inventories
|
||||
support per adapter, grouped to separate protocol guarantees from Django-specific
|
||||
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
|
||||
when that adapter wires the capability into its own dispatch surface, not merely that a
|
||||
shared core primitive exists.
|
||||
|
||||
Every capability in the matrix is **AFI-common** — each adapter owes a binding, and a
|
||||
`❌` is a gap on the owed-work board, never a "this framework doesn't do that." The line
|
||||
between AFI-common and genuinely backend-bound lives in
|
||||
[`tests/afi/manifest.py`](tests/afi/manifest.py): what sits *outside* the matrix by
|
||||
design is the `allauth` integration (a Django-ecosystem package) and the per-stack
|
||||
*bindings* of common capabilities (`django-readers` is Django's Shapes binding; Django
|
||||
Forms is Django's Forms binding) — the capability is common; the binding is not.
|
||||
|
||||
<!-- MIZAN:PARITY:START — generated by tests/afi/parity_table.py; do not edit by hand -->
|
||||
Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · — not applicable to this adapter's transport
|
||||
|
||||
Every capability below is **AFI-common**: each adapter owes a binding, and a ❌ is a gap on the owed-work board (`tests/afi/`), never a category. Backend-specific *bindings* of common capabilities (django-readers for Shapes, Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are out of this matrix by design — see `tests/afi/manifest.py` for the line.
|
||||
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
|
||||
|
||||
### Protocol core
|
||||
|
||||
The surface every Mizan adapter implements.
|
||||
|
||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|
|
||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
|
||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 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) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||
|
||||
### 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 | ✅ | ✅ | ✅ | — | ✅ |
|
||||
|
||||
### Extension points
|
||||
Protocol transports and guarantees co-equal with the body channel in the spec.
|
||||
|
||||
| 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) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
||||
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
||||
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
||||
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
||||
|
||||
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
|
||||
> it — do not rely on `auth=` for access control on those adapters.
|
||||
|
||||
### Stack extensions (Django)
|
||||
|
||||
Django ecosystem features Mizan wraps. Other adapters provide these only where the
|
||||
target stack calls for them.
|
||||
|
||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|
|
||||
| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ |
|
||||
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
|
||||
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
|
||||
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
|
||||
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
|
||||
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**Notes**
|
||||
|
||||
- **Invalidation — `X-Mizan-Invalidate` header** — The header channel is co-equal with the body channel in the spec. IPC transports carry invalidation in the response envelope instead.
|
||||
- **Edge manifest export** — The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge.
|
||||
- **MWT (edge identity token)** — MWT exists to key an edge cache; without an edge there is nothing to key.
|
||||
- **Typed query projection (Shapes)** — The capability is AFI-common; the binding is per-ORM (django-readers on Django, the project's ORM elsewhere).
|
||||
- **Forms (schema / validate / submit)** — The capability is AFI-common; the binding is per-framework (Django Forms on Django, Pydantic-or-equivalent elsewhere).
|
||||
<!-- MIZAN:PARITY:END -->
|
||||
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||
Invalidation rides in the JSON response body; there is no header channel.
|
||||
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
|
||||
WebSocket handler yet.
|
||||
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
|
||||
4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
||||
adapter carries typed input/output through the KDL IR; the projection primitive
|
||||
itself is Django-only.
|
||||
5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
|
||||
enforce them. Rust/Axum has no enforcement either.
|
||||
6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
||||
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
||||
rather than fetching over HTTP.
|
||||
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||
parity; CSRF is Django-only.
|
||||
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||
codegen source — it demonstrates the cache + invalidation protocol is
|
||||
language-agnostic.
|
||||
|
||||
## Conformance
|
||||
|
||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/), at
|
||||
two layers:
|
||||
|
||||
- **IR-shape parity** (`test_codegen_parity.py`) — Django, FastAPI, and the Rust adapter
|
||||
emit byte-identical KDL for the same registered fixture. The IR is the contract; the
|
||||
language that wrote the backend is irrelevant to the codegen-facing artifact.
|
||||
- **Capability parity** (`test_capability_parity.py`) — every `(capability, applicable
|
||||
adapter)` pair declared in `manifest.py` is probed for its actual wiring (`probes.py`).
|
||||
A gap is a **red test that names the owed binding**, not a footnote. The suite is
|
||||
intentionally red wherever a capability is unwired: that redness is the owed-work
|
||||
board, itemized and loud, and a gap turns green by being *wired*, never by being
|
||||
*described*. This is the per-capability gate the roadmap previously deferred.
|
||||
|
||||
The generated table above is rendered from the capability layer, and the `--check`
|
||||
diff keeps the README honest to the probes on every CI run.
|
||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
|
||||
currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
|
||||
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
|
||||
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
||||
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
||||
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
||||
- [ ] **Cache hardening** — thundering-herd / single-flight protection, and pinning cross-language stringification of un-normalized value types (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
||||
- [ ] **Cache hardening** — purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
||||
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +89,6 @@ from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||
from mizan_core.upload import File, Upload, UploadedFile
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
@@ -165,10 +164,6 @@ __all__ = [
|
||||
"GlobalContext",
|
||||
"ServerFunction",
|
||||
"ComposedContext",
|
||||
# File uploads
|
||||
"Upload",
|
||||
"File",
|
||||
"UploadedFile",
|
||||
# Setup
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Cache Module — Known Issues
|
||||
|
||||
Open issues against the current cache implementation. The cache uses
|
||||
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key;
|
||||
broad purge is a prefix SCAN+UNLINK), so there are no index/sub-index races to
|
||||
track. Resolved items are removed once their fix lands.
|
||||
Open issues against the current cache implementation. Resolved items are
|
||||
removed once their fix lands.
|
||||
|
||||
## Correctness
|
||||
|
||||
### Purge race condition (non-atomic index operations)
|
||||
`cache_purge` reads the index and deletes as separate operations. A
|
||||
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||
|
||||
### Cross-language stringification divergence
|
||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||
@@ -15,6 +19,22 @@ TypeScript HMAC keys can still diverge on an un-normalized type.
|
||||
|
||||
## Performance / Operability
|
||||
|
||||
### Broad purge leaves per-param sub-indexes
|
||||
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||
sub-indexes — a slow Redis memory leak.
|
||||
|
||||
### No thundering-herd protection
|
||||
Concurrent cold misses on the same key all execute and write. No
|
||||
single-flight / request-coalescing.
|
||||
|
||||
## API shape
|
||||
|
||||
### cache_get / cache_put argument inconsistency
|
||||
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||
inputs from module globals — two access patterns for one concern.
|
||||
|
||||
## Coverage
|
||||
|
||||
### RedisCache lacks test coverage
|
||||
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||
|
||||
@@ -29,10 +29,6 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
from mizan_core.registry import get_function, get_context_groups
|
||||
from mizan_core.upload import UploadedFile, bind_uploads
|
||||
from mizan_core import invalidation as _core_inval
|
||||
from mizan_core.authguard import enforce_auth as _core_enforce_auth
|
||||
from mizan_core.errors import MizanError as _CoreMizanError
|
||||
from mizan.setup.settings import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -116,14 +112,53 @@ def _check_auth_requirement(
|
||||
Django User (from session). Either way, no additional DB query is made
|
||||
for the built-in checks. Custom callables may query DB if they choose.
|
||||
"""
|
||||
# Evaluation lives in the shared core (mizan_core.authguard); the callable
|
||||
# path receives the native Django request. Core raises; we render to the
|
||||
# Django-shim FunctionError shape the executor expects.
|
||||
try:
|
||||
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
|
||||
if auth_requirement is None:
|
||||
return None
|
||||
except _CoreMizanError as e:
|
||||
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Handle callable auth
|
||||
if callable(auth_requirement):
|
||||
try:
|
||||
result = auth_requirement(request)
|
||||
if result:
|
||||
return None # Authorized
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Access denied",
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Custom error message from the callable
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Access denied",
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Superuser access required",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
@@ -162,6 +197,51 @@ def _purge_cache_for_invalidation(
|
||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Determine whether an affects target is a context name or function name.
|
||||
|
||||
Returns:
|
||||
("context", "user", None) — full context invalidation
|
||||
("function", "user_profile", "user") — function within context
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
|
||||
# Check if it's a context name directly
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
|
||||
# Check if it's a function name within a context
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
|
||||
# Not a context or context function — treat as context name anyway
|
||||
# (it might be a non-context function or an as-yet-unregistered context)
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _get_context_param_names(context_name: str) -> set[str]:
|
||||
"""
|
||||
Get the set of parameter names used by functions in a context.
|
||||
|
||||
Returns the union of all Input field names across context functions.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
param_names: set[str] = set()
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
param_names.update(input_cls.model_fields.keys())
|
||||
|
||||
return param_names
|
||||
|
||||
|
||||
def _resolve_invalidation(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
@@ -180,7 +260,49 @@ def _resolve_invalidation(
|
||||
Returns a list suitable for both JSON body and header serialization.
|
||||
Returns None if no invalidation needed.
|
||||
"""
|
||||
return _core_inval.resolve_invalidation(view_class, input_data)
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
affects = meta.get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
result = []
|
||||
seen = set()
|
||||
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
# Function-level: use the function name as the invalidation key
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_name in seen:
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
# Resolve the context this target belongs to (for param lookup)
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
# Tier 1: argument name matching
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _get_context_param_names(ctx_for_params)
|
||||
matched = {
|
||||
k: v for k, v in input_data.items()
|
||||
if k in context_params
|
||||
}
|
||||
if matched:
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
# Tier 3: broad fallback
|
||||
result.append(target_name)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _resolve_merges(
|
||||
@@ -199,12 +321,94 @@ def _resolve_merges(
|
||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||
Entries whose slot can't be uniquely resolved are dropped.
|
||||
"""
|
||||
return _core_inval.resolve_merges(view_class, input_data, result_data)
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
targets = meta.get("merge") or []
|
||||
if not targets:
|
||||
return None
|
||||
|
||||
mutation_output = getattr(view_class, "Output", None)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for ctx_name in targets:
|
||||
if ctx_name in seen:
|
||||
continue
|
||||
seen.add(ctx_name)
|
||||
|
||||
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
|
||||
if slot is None:
|
||||
continue
|
||||
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||
if input_data:
|
||||
context_params = _get_context_param_names(ctx_name)
|
||||
matched = {
|
||||
k: v for k, v in input_data.items()
|
||||
if k in context_params
|
||||
}
|
||||
if matched:
|
||||
entry["params"] = matched
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
||||
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core)."""
|
||||
return _core_inval.format_invalidate_header(invalidate)
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
matches: list[str] = []
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and type_matcher(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
Param values are URL-encoded to prevent delimiter collisions.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}]
|
||||
→ "search;q=hello%20world"
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
parts = []
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params", {})
|
||||
if params:
|
||||
param_str = ";".join(
|
||||
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||
for k, v in sorted(params.items())
|
||||
)
|
||||
parts.append(f"{ctx};{param_str}")
|
||||
else:
|
||||
parts.append(ctx)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def execute_function(
|
||||
@@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
is_multipart = content_type.startswith("multipart/form-data")
|
||||
|
||||
if is_multipart:
|
||||
# Multipart carries two shapes: a form submission (Django Form path) or
|
||||
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
|
||||
# Multipart form data - used by form submit functions
|
||||
fn_name = request.POST.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Missing 'fn' field",
|
||||
).to_response()
|
||||
|
||||
fn_class = get_function(fn_name)
|
||||
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
|
||||
# Get form data (excluding 'fn')
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
if is_form_fn:
|
||||
# Form submit — POST fields + FILES handed to Django Form validation.
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
else:
|
||||
# Upload RPC — the `args` JSON part carries the non-file fields; the
|
||||
# file parts bind into the Input's Upload fields (constraints enforced).
|
||||
raw_args = request.POST.get("args")
|
||||
try:
|
||||
input_data = json.loads(raw_args) if raw_args else {}
|
||||
except json.JSONDecodeError:
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Invalid JSON in 'args' field",
|
||||
).to_response()
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
files = {
|
||||
field: [
|
||||
UploadedFile(f.name, f.content_type, f.read())
|
||||
for f in request.FILES.getlist(field)
|
||||
]
|
||||
for field in request.FILES
|
||||
}
|
||||
err = bind_uploads(input_cls, input_data, files)
|
||||
if err is not None:
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message=err,
|
||||
).to_response()
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator (Django adapter surface).
|
||||
Mizan Edge Manifest Generator.
|
||||
|
||||
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.
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
@@ -13,10 +12,145 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
|
||||
|
||||
__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)
|
||||
|
||||
@@ -1,79 +1,245 @@
|
||||
"""
|
||||
JWT tokens — the Django adapter over the shared core (`mizan_core.auth.jwt`).
|
||||
JWT Token Creation and Validation
|
||||
|
||||
The token logic (mint/decode/refresh, `JWTUser`, `TokenPair`, `TokenPayload`)
|
||||
lives in the core; this module binds it to Django settings and keeps the
|
||||
session-revocation check (`validate_session`), which is Django-session-specific.
|
||||
Uses PyJWT directly - no allauth dependency.
|
||||
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from mizan_core.auth import jwt as _core_jwt
|
||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload
|
||||
import jwt
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
from .settings import get_settings
|
||||
|
||||
__all__ = [
|
||||
"TokenPair",
|
||||
"TokenPayload",
|
||||
"JWTUser",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"create_token_pair",
|
||||
"decode_token",
|
||||
"validate_session",
|
||||
"refresh_tokens",
|
||||
]
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
"""Access and refresh token pair."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _config() -> JWTConfig:
|
||||
s = get_settings()
|
||||
return JWTConfig(
|
||||
private_key=s.private_key,
|
||||
public_key=s.public_key,
|
||||
algorithm=s.algorithm,
|
||||
access_token_expires_in=s.access_token_expires_in,
|
||||
refresh_token_expires_in=s.refresh_token_expires_in,
|
||||
class TokenPayload(NamedTuple):
|
||||
"""Decoded token payload."""
|
||||
user_id: int | str
|
||||
session_key: str
|
||||
token_type: str
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
exp: int
|
||||
iat: int
|
||||
|
||||
|
||||
class JWTUser:
|
||||
"""
|
||||
Minimal user object created from JWT claims.
|
||||
|
||||
Used as request.user for JWT-authenticated requests.
|
||||
No database query required - all data comes from the token.
|
||||
|
||||
If you need the full User object with all fields, query explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
"""
|
||||
|
||||
def __init__(self, payload: TokenPayload):
|
||||
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
|
||||
self.pk = self.id
|
||||
self.is_staff = payload.is_staff
|
||||
self.is_superuser = payload.is_superuser
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True # Assumed active if they have a valid token
|
||||
|
||||
def __str__(self):
|
||||
return f"JWTUser(id={self.id})"
|
||||
|
||||
def __repr__(self):
|
||||
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "access"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + settings.access_token_expires_in,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.private_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
||||
return _core_jwt.create_access_token(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def create_refresh_token(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a longer-lived refresh token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "refresh"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": "refresh",
|
||||
"iat": now,
|
||||
"exp": now + settings.refresh_token_expires_in,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.private_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
||||
return _core_jwt.create_refresh_token(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def create_token_pair(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> TokenPair:
|
||||
"""Create both access and refresh tokens."""
|
||||
settings = get_settings()
|
||||
return TokenPair(
|
||||
access_token=create_access_token(
|
||||
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||
),
|
||||
refresh_token=create_refresh_token(
|
||||
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||
),
|
||||
expires_in=settings.access_token_expires_in,
|
||||
)
|
||||
|
||||
|
||||
def create_token_pair(user_id, session_key, *, is_staff=False, is_superuser=False) -> TokenPair:
|
||||
return _core_jwt.create_token_pair(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Returns None if:
|
||||
- Token is invalid or expired
|
||||
- Token type doesn't match expected_type (if specified)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||
return _core_jwt.decode_token(token, _config(), expected_type=expected_type)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_key,
|
||||
algorithms=[settings.algorithm],
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
# Validate token type if specified
|
||||
if expected_type and payload.get("type") != expected_type:
|
||||
return None
|
||||
|
||||
return TokenPayload(
|
||||
user_id=payload["sub"],
|
||||
session_key=payload["sid"],
|
||||
token_type=payload["type"],
|
||||
is_staff=payload.get("staff", False),
|
||||
is_superuser=payload.get("super", False),
|
||||
exp=payload["exp"],
|
||||
iat=payload["iat"],
|
||||
)
|
||||
|
||||
|
||||
def validate_session(session_key: str) -> bool:
|
||||
"""Immediate-logout revocation: is this Django session still alive?
|
||||
"""
|
||||
Check if a session is still valid (exists and not expired).
|
||||
|
||||
Honors `JWT_VALIDATE_SESSION` — when disabled, always True. This is the one
|
||||
Django-session-bound piece; the core's `refresh_tokens` takes it as an
|
||||
injected `session_validator`.
|
||||
This is the key to immediate logout revocation - if the session
|
||||
is destroyed, tokens tied to it become invalid.
|
||||
"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
if not get_settings().validate_session:
|
||||
jwt_settings = get_settings()
|
||||
|
||||
if not jwt_settings.validate_session:
|
||||
return True
|
||||
|
||||
# Use the configured session engine
|
||||
engine = import_module(django_settings.SESSION_ENGINE)
|
||||
session = engine.SessionStore(session_key=session_key)
|
||||
SessionStore = engine.SessionStore
|
||||
|
||||
# Try to load the session
|
||||
session = SessionStore(session_key=session_key)
|
||||
|
||||
# Check if session exists and is not empty
|
||||
# exists() is more reliable than checking load() result
|
||||
return session.exists(session_key)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||
return _core_jwt.refresh_tokens(refresh_token, _config(), session_validator=validate_session)
|
||||
"""
|
||||
Use a refresh token to obtain new tokens.
|
||||
|
||||
Returns None if:
|
||||
- Refresh token is invalid or expired
|
||||
- Associated session no longer exists
|
||||
"""
|
||||
payload = decode_token(refresh_token, expected_type="refresh")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Validate the session still exists
|
||||
if not validate_session(payload.session_key):
|
||||
return None
|
||||
|
||||
# Issue new token pair with same claims
|
||||
return create_token_pair(
|
||||
payload.user_id,
|
||||
payload.session_key,
|
||||
is_staff=payload.is_staff,
|
||||
is_superuser=payload.is_superuser,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
from .bridge import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
"""
|
||||
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.
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
|
||||
@@ -38,7 +33,7 @@ class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple worker threads can call render() concurrently.
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
"""
|
||||
|
||||
@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
|
||||
|
||||
def test_jwt_expired_with_session(self):
|
||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||
# Create token with past expiration by mocking time (minting lives in the core now)
|
||||
with patch("mizan_core.auth.jwt.time.time", return_value=0):
|
||||
# Create token with past expiration by mocking time
|
||||
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||
tokens = create_token_pair(
|
||||
self.user.pk,
|
||||
self.session_key,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Upload dispatch — multipart RPC binds files into Upload fields and enforces
|
||||
the declarative `File(...)` constraints."""
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan import Upload, File
|
||||
from mizan.client import client
|
||||
from mizan.client.executor import function_call_view
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
|
||||
class AvatarOut(BaseModel):
|
||||
ok: bool
|
||||
size: int
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class UploadDispatchTests(TestCase):
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def _register(self):
|
||||
@client
|
||||
def set_avatar(
|
||||
request: HttpRequest,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
|
||||
) -> AvatarOut:
|
||||
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
|
||||
|
||||
register(set_avatar, "set_avatar")
|
||||
|
||||
def _post(self, args, files):
|
||||
data = {"fn": "set_avatar", "args": json.dumps(args), **files}
|
||||
request = self.factory.post("/api/mizan/call/", data) # multipart
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
return function_call_view(request)
|
||||
|
||||
def test_upload_binds_and_executes(self):
|
||||
self._register()
|
||||
png = SimpleUploadedFile("a.png", b"\x89PNG" + b"x" * 100, content_type="image/png")
|
||||
resp = self._post({"user_id": 5}, {"avatar": png})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = json.loads(resp.content)
|
||||
self.assertTrue(data["result"]["ok"])
|
||||
self.assertEqual(data["result"]["name"], "a.png")
|
||||
self.assertEqual(data["result"]["size"], 104)
|
||||
|
||||
def test_max_size_rejected(self):
|
||||
self._register()
|
||||
big = SimpleUploadedFile("b.png", b"x" * (2 * 1024 * 1024), content_type="image/png")
|
||||
resp = self._post({"user_id": 5}, {"avatar": big})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("max size", resp.content.decode())
|
||||
|
||||
def test_content_type_rejected(self):
|
||||
self._register()
|
||||
gif = SimpleUploadedFile("c.gif", b"GIF89a", content_type="image/gif")
|
||||
resp = self._post({"user_id": 5}, {"avatar": gif})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("content-type", resp.content.decode())
|
||||
@@ -8,13 +8,8 @@ dependencies = [
|
||||
"mizan-core",
|
||||
"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",
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
|
||||
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
|
||||
SSR frameworks).
|
||||
|
||||
Usage:
|
||||
from fastapi import FastAPI
|
||||
@@ -48,54 +34,14 @@ 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",
|
||||
"UploadedFile",
|
||||
"mizan_auth",
|
||||
"MizanAuthMiddleware",
|
||||
"MizanConfig",
|
||||
"from_env",
|
||||
"router",
|
||||
"mizan_exception_handler",
|
||||
"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",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
Built-in identity for FastAPI — Django-equivalent automatic `request.state.user`.
|
||||
|
||||
Opt in via `Depends(mizan_auth())` on a route/router, or mount `MizanAuthMiddleware`
|
||||
app-wide. Both decode a bearer-JWT (`Authorization: Bearer`) or MWT (`X-Mizan-Token`)
|
||||
via the shared core and set `request.state.user`. A present-but-invalid token is
|
||||
rejected (401) rather than silently downgraded — the `INVALID` sentinel contract.
|
||||
|
||||
If you'd rather resolve identity yourself, set `request.state.user` upstream and skip
|
||||
these; dispatch reads it directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from mizan_core.auth import INVALID, authenticate
|
||||
from mizan_core.errors import Unauthorized
|
||||
|
||||
from .config import get_config
|
||||
|
||||
|
||||
def _resolve(request: Request) -> None:
|
||||
ident = authenticate(request.headers, get_config(request).auth)
|
||||
if ident is INVALID:
|
||||
raise Unauthorized("Invalid or expired token")
|
||||
if ident is not None:
|
||||
request.state.user = ident
|
||||
|
||||
|
||||
def mizan_auth() -> Callable:
|
||||
"""FastAPI dependency that populates `request.state.user` from a token."""
|
||||
async def _dep(request: Request) -> None:
|
||||
_resolve(request)
|
||||
return _dep
|
||||
|
||||
|
||||
class MizanAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""App-wide variant of `mizan_auth` — resolves identity on every request."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
try:
|
||||
_resolve(request)
|
||||
except Unauthorized:
|
||||
from .router import _no_store
|
||||
from mizan_core.errors import ErrorCode
|
||||
return _no_store(
|
||||
{"error": {"code": ErrorCode.UNAUTHORIZED.value, "message": "Invalid or expired token"}},
|
||||
status_code=401,
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""
|
||||
FastAPI configuration — the "no settings.py" seam.
|
||||
|
||||
Builds the shared core's `AuthConfig` (JWT + MWT) and a `CacheOrchestrator`
|
||||
from environment variables, overridable per-app via `app.state.mizan_config`.
|
||||
|
||||
Env:
|
||||
MIZAN_CACHE_SECRET HMAC cache signing key (enables origin cache)
|
||||
MIZAN_CACHE_REDIS_URL Redis URL (else in-memory cache)
|
||||
MIZAN_MWT_SECRET MWT signing key
|
||||
MIZAN_MWT_AUDIENCE MWT audience (default "mizan")
|
||||
JWT_PRIVATE_KEY JWT signing key (enables bearer-JWT auth)
|
||||
JWT_PUBLIC_KEY JWT verify key (default: private key, HS256)
|
||||
JWT_ALGORITHM default "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRES_IN / JWT_REFRESH_TOKEN_EXPIRES_IN
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mizan_core.auth import AuthConfig, JWTConfig
|
||||
from mizan_core.cache.backend import CacheBackend, MemoryCache
|
||||
from mizan_core.dispatch import CacheOrchestrator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MizanConfig:
|
||||
auth: AuthConfig
|
||||
cache: CacheOrchestrator
|
||||
|
||||
|
||||
def _cache_backend(secret: str | None, redis_url: str | None) -> CacheBackend | None:
|
||||
if not secret:
|
||||
return None
|
||||
if redis_url:
|
||||
from mizan_core.cache.backend import RedisCache
|
||||
return RedisCache(redis_url)
|
||||
return MemoryCache()
|
||||
|
||||
|
||||
def _jwt_config() -> JWTConfig | None:
|
||||
key = os.getenv("JWT_PRIVATE_KEY")
|
||||
if not key:
|
||||
return None
|
||||
return JWTConfig(
|
||||
private_key=key,
|
||||
public_key=os.getenv("JWT_PUBLIC_KEY", key),
|
||||
algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
|
||||
access_token_expires_in=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES_IN", "300")),
|
||||
refresh_token_expires_in=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES_IN", "604800")),
|
||||
)
|
||||
|
||||
|
||||
def from_env() -> MizanConfig:
|
||||
secret = os.getenv("MIZAN_CACHE_SECRET")
|
||||
backend = _cache_backend(secret, os.getenv("MIZAN_CACHE_REDIS_URL"))
|
||||
auth = AuthConfig(
|
||||
jwt=_jwt_config(),
|
||||
mwt_secret=os.getenv("MIZAN_MWT_SECRET"),
|
||||
mwt_audience=os.getenv("MIZAN_MWT_AUDIENCE", "mizan"),
|
||||
)
|
||||
return MizanConfig(auth=auth, cache=CacheOrchestrator(backend, secret))
|
||||
|
||||
|
||||
def get_config(request) -> MizanConfig:
|
||||
"""Per-app config: `app.state.mizan_config` if set, else built from env (cached)."""
|
||||
app = getattr(request, "app", None)
|
||||
state = getattr(app, "state", None)
|
||||
override = getattr(state, "mizan_config", None) if state is not None else None
|
||||
if override is not None:
|
||||
return override
|
||||
global _DEFAULT
|
||||
if _DEFAULT is None:
|
||||
_DEFAULT = from_env()
|
||||
return _DEFAULT
|
||||
|
||||
|
||||
_DEFAULT: MizanConfig | None = None
|
||||
@@ -1,69 +1,263 @@
|
||||
"""
|
||||
Dispatch — a thin shim over the shared core (`mizan_core.dispatch`).
|
||||
RPC dispatch — looks up registered functions, validates input against the
|
||||
function's Pydantic Input model, executes, and returns the serialized result.
|
||||
|
||||
The protocol machinery (auth, validation, execution, invalidation, merge, cache)
|
||||
lives in `mizan_core`; this module re-exports the canonical error taxonomy and
|
||||
keeps backward-compatible helpers. The router drives `dispatch_call` /
|
||||
`dispatch_context` directly to get invalidation + origin cache.
|
||||
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||
let them propagate to your own handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
||||
from mizan_core.errors import (
|
||||
BadRequest,
|
||||
ErrorCode,
|
||||
Forbidden,
|
||||
InternalError,
|
||||
MizanError,
|
||||
NotFound,
|
||||
NotImplementedYet,
|
||||
Unauthorized,
|
||||
ValidationFailed,
|
||||
)
|
||||
from mizan_core.invalidation import resolve_invalidation, resolve_merges
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
__all__ = [
|
||||
"ErrorCode",
|
||||
"MizanError",
|
||||
"NotFound",
|
||||
"BadRequest",
|
||||
"ValidationFailed",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotImplementedYet",
|
||||
"InternalError",
|
||||
"compute_invalidation",
|
||||
"compute_merges",
|
||||
"execute_function",
|
||||
]
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
|
||||
_NO_CACHE = CacheOrchestrator(None, None)
|
||||
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
BAD_REQUEST = "BAD_REQUEST"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
FORBIDDEN = "FORBIDDEN"
|
||||
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
|
||||
|
||||
_STATUS = {
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
ErrorCode.BAD_REQUEST: 400,
|
||||
ErrorCode.VALIDATION_ERROR: 422,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.FORBIDDEN: 403,
|
||||
ErrorCode.NOT_IMPLEMENTED: 501,
|
||||
ErrorCode.INTERNAL_ERROR: 500,
|
||||
}
|
||||
|
||||
|
||||
class MizanError(Exception):
|
||||
"""Base for protocol-level dispatch errors."""
|
||||
|
||||
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
||||
|
||||
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
return _STATUS[self.code]
|
||||
|
||||
|
||||
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
|
||||
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
|
||||
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
||||
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
|
||||
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
|
||||
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
||||
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
||||
|
||||
|
||||
# ─── Auth ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _user(request: Any) -> Any:
|
||||
return getattr(getattr(request, "state", None), "user", None)
|
||||
|
||||
|
||||
def _is_authenticated(user: Any) -> bool:
|
||||
return bool(user) and getattr(user, "is_authenticated", True)
|
||||
|
||||
|
||||
def _enforce_auth(request: Any, requirement: Any) -> None:
|
||||
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
|
||||
if requirement is None:
|
||||
return
|
||||
|
||||
user = _user(request)
|
||||
|
||||
match requirement:
|
||||
case True | "required":
|
||||
if not _is_authenticated(user):
|
||||
raise Unauthorized("Authentication required")
|
||||
case "staff":
|
||||
if not _is_authenticated(user):
|
||||
raise Unauthorized("Authentication required")
|
||||
if not getattr(user, "is_staff", False):
|
||||
raise Forbidden("Staff access required")
|
||||
case "superuser":
|
||||
if not _is_authenticated(user):
|
||||
raise Unauthorized("Authentication required")
|
||||
if not getattr(user, "is_superuser", False):
|
||||
raise Forbidden("Superuser access required")
|
||||
case f if callable(f):
|
||||
if not f(request):
|
||||
raise Forbidden("Permission denied")
|
||||
case other:
|
||||
raise InternalError(f"Unknown auth requirement: {other!r}")
|
||||
|
||||
|
||||
# ─── Input validation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
||||
"""Validate input_data against the function's Input model. Returns the instance or None."""
|
||||
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
||||
return None
|
||||
|
||||
fields = input_cls.model_fields
|
||||
required = [name for name, f in fields.items() if f.is_required()]
|
||||
|
||||
if not input_data:
|
||||
if required:
|
||||
raise ValidationFailed(
|
||||
"Input validation failed",
|
||||
details={"fields": {name: ["Field required"] for name in required}},
|
||||
)
|
||||
return input_cls()
|
||||
|
||||
if not isinstance(input_data, dict):
|
||||
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||
|
||||
try:
|
||||
return input_cls(**input_data)
|
||||
except ValidationError as e:
|
||||
raise ValidationFailed(
|
||||
"Input validation failed",
|
||||
details={"errors": e.errors()},
|
||||
) from e
|
||||
|
||||
|
||||
# ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_function(fn_name: str) -> Any:
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
raise NotFound("Function not found")
|
||||
if getattr(view_class, "_meta", {}).get("private"):
|
||||
raise Forbidden("Function is not client-callable")
|
||||
return view_class
|
||||
|
||||
|
||||
def _serialize(result: Any) -> Any:
|
||||
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
|
||||
# (and nested shapes) come out wire-ready without a per-shape branch here.
|
||||
return jsonable_encoder(result)
|
||||
|
||||
|
||||
async def execute_function(
|
||||
request: Any,
|
||||
fn_name: str,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
|
||||
|
||||
Awaits `view.acall` — async handlers run on the loop, sync handlers run
|
||||
in the default threadpool, both via the same entrypoint.
|
||||
"""
|
||||
view_class = _resolve_function(fn_name)
|
||||
_enforce_auth(request, view_class._meta.get("auth"))
|
||||
|
||||
view = view_class(request)
|
||||
validated = _validate_input(view.Input, input_data)
|
||||
|
||||
try:
|
||||
result = await view.acall(validated)
|
||||
except NotImplementedError as e:
|
||||
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||
except MizanError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from e
|
||||
|
||||
return _serialize(result)
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
||||
"""`@client(affects=...)` → invalidation list (empty when none). Shared core."""
|
||||
return resolve_invalidation(view_class, input_data) or []
|
||||
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
||||
return [_invalidation_target(target, input_data or {}) for target in affects]
|
||||
|
||||
|
||||
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
||||
"""`@client(merge=...)` → merge list (empty when none). Shared core."""
|
||||
return resolve_merges(view_class, input_data, result) or []
|
||||
"""Build the `merge` list from @client(merge=...) metadata.
|
||||
|
||||
|
||||
async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any:
|
||||
"""Dispatch a function and return its serialized result (auth enforced via core).
|
||||
|
||||
Backward-compat entry point; the router uses `dispatch_call` directly to also
|
||||
capture invalidation/merge and run the origin cache.
|
||||
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||
function inside the context bundle the value lands in. The slot is
|
||||
resolved server-side via `types_match_for_merge` so the kernel does
|
||||
no shape inference — the server has the schema, type-checked routing
|
||||
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||
with a warning; the consumer falls back to refetch via `affects`.
|
||||
"""
|
||||
identity = getattr(getattr(request, "state", None), "user", None)
|
||||
res = await dispatch_call(
|
||||
DispatchRequest(identity=identity, args=input_data, native_request=request),
|
||||
fn_name,
|
||||
_NO_CACHE,
|
||||
)
|
||||
return res.data
|
||||
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||
if not targets:
|
||||
return []
|
||||
mutation_output = getattr(view_class, "Output", None)
|
||||
out: list[dict[str, Any]] = []
|
||||
for ctx_name in targets:
|
||||
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||
if slot is None:
|
||||
continue
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
||||
scoped = _scoped_params(ctx_name, input_data or {})
|
||||
if scoped:
|
||||
entry["params"] = scoped
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||
"""Find the unique function-name slot whose return type matches the mutation's output.
|
||||
|
||||
Returns None on no match or ambiguous match (multiple candidates).
|
||||
"""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
matches: list[str] = []
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Match input args against the context's declared Input field names."""
|
||||
fn_names = get_context_groups().get(context_name, [])
|
||||
declared: set[str] = set()
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
declared.update(input_cls.model_fields.keys())
|
||||
return {k: v for k, v in input_data.items() if k in declared}
|
||||
|
||||
|
||||
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||
match target.get("type"):
|
||||
case "context":
|
||||
name = target["name"]
|
||||
scoped = _scoped_params(name, input_data)
|
||||
return {"context": name, "params": scoped} if scoped else name
|
||||
case "function":
|
||||
return {"function": target["name"]}
|
||||
case _:
|
||||
return target
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
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())
|
||||
@@ -14,22 +14,23 @@ FastAPI router exposing Mizan's HTTP endpoints:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from starlette.datastructures import UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mizan_core.auth import INVALID, authenticate
|
||||
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
||||
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
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
from .config import MizanConfig, get_config
|
||||
from .executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
compute_invalidation,
|
||||
compute_merges,
|
||||
execute_function,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,12 +45,11 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
||||
|
||||
@router.get("/session/")
|
||||
async def session_init() -> JSONResponse:
|
||||
"""Session-init endpoint. AFI-common; wired here at parity with mizan-django.
|
||||
"""Session-init probe. Parity with mizan-django's session endpoint.
|
||||
|
||||
The endpoint itself is the AFI-common surface. The CSRF *token* is a Django
|
||||
session mechanism with no FastAPI equivalent, so this returns a null token —
|
||||
the difference is in the token's backing mechanism, not in whether the
|
||||
endpoint is owed. The wire-parity harness uses it as its readiness probe.
|
||||
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||
null token so the response shape stays uniform across backends. The
|
||||
wire-parity harness uses this endpoint as its readiness probe.
|
||||
"""
|
||||
return _no_store({"csrfToken": None})
|
||||
|
||||
@@ -59,197 +59,29 @@ class CallBody(BaseModel):
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
|
||||
"""Read a call request, JSON or multipart. Returns `(fn, args)`.
|
||||
|
||||
Multipart carries the non-file fields in a JSON `args` part and each file as
|
||||
its own part; the file parts bind into the Input's Upload fields with the
|
||||
declarative `File(...)` constraints enforced.
|
||||
"""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
fn = form.get("fn")
|
||||
if not isinstance(fn, str) or not fn:
|
||||
raise BadRequest("Missing 'fn' field")
|
||||
raw_args = form.get("args")
|
||||
try:
|
||||
args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest("Invalid JSON in 'args' field")
|
||||
|
||||
fn_class = get_function(fn)
|
||||
input_cls = getattr(fn_class, "Input", None) if fn_class else None
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
files: dict[str, list[UploadedFile]] = {}
|
||||
for key in set(form.keys()):
|
||||
wrapped = [
|
||||
UploadedFile(p.filename, p.content_type, await p.read())
|
||||
for p in form.getlist(key)
|
||||
if isinstance(p, UploadFile)
|
||||
]
|
||||
if wrapped:
|
||||
files[key] = wrapped
|
||||
err = bind_uploads(input_cls, args, files)
|
||||
if err is not None:
|
||||
raise BadRequest(err)
|
||||
return fn, args
|
||||
|
||||
try:
|
||||
body = CallBody(**(await request.json()))
|
||||
except (ValueError, ValidationError):
|
||||
raise BadRequest("Invalid request body")
|
||||
return body.fn, body.args
|
||||
|
||||
|
||||
def _identity(request: Request, cfg: MizanConfig):
|
||||
"""Identity for dispatch: a host-set `request.state.user`, else a token decode.
|
||||
|
||||
A present-but-invalid token rejects (401); no token → None (anonymous).
|
||||
"""
|
||||
existing = getattr(getattr(request, "state", None), "user", None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
ident = authenticate(request.headers, cfg.auth)
|
||||
if ident is INVALID:
|
||||
raise Unauthorized("Invalid or expired token")
|
||||
return ident
|
||||
|
||||
|
||||
@router.post("/call/")
|
||||
async def function_call(request: Request) -> JSONResponse:
|
||||
"""RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with
|
||||
the `X-Mizan-Invalidate` header alongside the body."""
|
||||
cfg = get_config(request)
|
||||
fn, args = await _parse_call(request)
|
||||
res = await dispatch_call(
|
||||
DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request),
|
||||
fn, cfg.cache,
|
||||
)
|
||||
payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []}
|
||||
if res.merge:
|
||||
payload["merge"] = res.merge
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if res.invalidate_header:
|
||||
headers["X-Mizan-Invalidate"] = res.invalidate_header
|
||||
return JSONResponse(payload, headers=headers)
|
||||
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||
fn_class = get_function(body.fn)
|
||||
result = await execute_function(request, body.fn, body.args)
|
||||
invalidate = compute_invalidation(fn_class, body.args)
|
||||
merges = compute_merges(fn_class, body.args, result)
|
||||
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||
if merges:
|
||||
payload["merge"] = merges
|
||||
return _no_store(payload)
|
||||
|
||||
|
||||
@router.get("/ctx/{context_name}/")
|
||||
async def context_fetch(context_name: str, request: Request) -> Response:
|
||||
"""Bundled context fetch — origin-cached. `{function_name: result, ...}`."""
|
||||
cfg = get_config(request)
|
||||
res = await dispatch_context(
|
||||
DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params),
|
||||
native_request=request),
|
||||
context_name, cfg.cache,
|
||||
)
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if res.cache_status:
|
||||
headers["X-Mizan-Cache"] = res.cache_status
|
||||
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
||||
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||
fn_names = get_context_groups().get(context_name)
|
||||
if not fn_names:
|
||||
raise NotFound(f"Context '{context_name}' not found")
|
||||
|
||||
|
||||
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
|
||||
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
|
||||
token decode from the handshake headers. A present-but-invalid token rejects.
|
||||
|
||||
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
|
||||
identically over either transport.
|
||||
"""
|
||||
existing = getattr(getattr(websocket, "state", None), "user", None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
ident = authenticate(websocket.headers, cfg.auth)
|
||||
if ident is INVALID:
|
||||
raise Unauthorized("Invalid or expired token")
|
||||
return ident
|
||||
|
||||
|
||||
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
|
||||
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
|
||||
if exc.details:
|
||||
err["details"] = exc.details
|
||||
return {"id": request_id, "ok": False, "error": err}
|
||||
|
||||
|
||||
@router.websocket("/ws/")
|
||||
async def websocket_rpc(websocket: WebSocket) -> None:
|
||||
"""WebSocket RPC transport for `@client(websocket=True)` functions.
|
||||
|
||||
Frame protocol (parity with mizan-django's Channels consumer):
|
||||
|
||||
→ {"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
|
||||
← {"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
|
||||
← {"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
|
||||
|
||||
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
|
||||
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
|
||||
and origin-cache purge are identical across transports. Only functions that
|
||||
declared `websocket=True` are callable here; an HTTP-only function returns a
|
||||
`FORBIDDEN` frame rather than executing.
|
||||
"""
|
||||
cfg = get_config(websocket)
|
||||
await websocket.accept()
|
||||
try:
|
||||
identity = _ws_identity(websocket, cfg)
|
||||
except Unauthorized as exc:
|
||||
await websocket.send_json(_error_frame(None, exc))
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = await websocket.receive_json()
|
||||
await _handle_ws_rpc(websocket, content, identity, cfg)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
|
||||
"""Dispatch one WS RPC frame through the shared dispatch core."""
|
||||
if content.get("action") != "rpc":
|
||||
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
|
||||
return
|
||||
|
||||
request_id = content.get("id")
|
||||
fn_name = content.get("fn")
|
||||
args = content.get("args", {})
|
||||
|
||||
if not fn_name:
|
||||
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
|
||||
return
|
||||
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
|
||||
return
|
||||
if not getattr(fn_class, "_meta", {}).get("websocket"):
|
||||
await websocket.send_json(
|
||||
_error_frame(
|
||||
request_id,
|
||||
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
res = await dispatch_call(
|
||||
DispatchRequest(identity=identity, args=args, native_request=websocket),
|
||||
fn_name, cfg.cache,
|
||||
)
|
||||
except MizanError as exc:
|
||||
await websocket.send_json(_error_frame(request_id, exc))
|
||||
return
|
||||
|
||||
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
|
||||
"invalidate": res.invalidate or []}
|
||||
if res.merge:
|
||||
frame["merge"] = res.merge
|
||||
await websocket.send_json(frame)
|
||||
params = dict(request.query_params)
|
||||
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||
return _no_store(bundled)
|
||||
|
||||
|
||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
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]
|
||||
@@ -1,80 +0,0 @@
|
||||
"""
|
||||
SSR render path — FastAPI adapter surface over the shared Bun bridge.
|
||||
|
||||
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
|
||||
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
|
||||
backend, so instead of Django's `MizanTemplates` veneer this exposes an
|
||||
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
|
||||
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
|
||||
markup plus the hydration payload the client reads on mount.
|
||||
|
||||
Usage:
|
||||
from mizan_fastapi.ssr import SSRRenderer
|
||||
|
||||
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
|
||||
|
||||
@app.get("/profile/{user_id}")
|
||||
async def profile(user_id: int):
|
||||
return ssr.render("components/Profile.tsx", {"user_id": user_id})
|
||||
|
||||
`render` resolves the template name to an absolute file path against `dirs`
|
||||
(parity with Django's `DIRS`), then renders the component's default export. The
|
||||
hydration wrapping matches the Django backend byte-for-byte so the same client
|
||||
bundle hydrates either server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
|
||||
|
||||
class SSRRenderer:
|
||||
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
|
||||
|
||||
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
|
||||
serializes worker I/O); a single renderer can be shared across the app.
|
||||
"""
|
||||
|
||||
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
|
||||
self._dirs = list(dirs or [])
|
||||
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
|
||||
|
||||
def _resolve(self, template_name: str) -> str:
|
||||
"""Resolve a template name to an absolute file path against `dirs`.
|
||||
|
||||
An already-absolute, existing path is used directly; otherwise each `dirs`
|
||||
entry is tried in order (parity with Django's `DIRS` resolution).
|
||||
"""
|
||||
if os.path.isabs(template_name) and os.path.isfile(template_name):
|
||||
return template_name
|
||||
for dir_path in self._dirs:
|
||||
candidate = os.path.join(dir_path, template_name)
|
||||
if os.path.isfile(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
raise FileNotFoundError(
|
||||
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
|
||||
)
|
||||
|
||||
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
|
||||
"""Render the component to an HTML string (markup + hydration script)."""
|
||||
props = dict(props or {})
|
||||
result = self._bridge.render(self._resolve(template_name), props)
|
||||
hydration_json = json.dumps(props, sort_keys=True, default=str)
|
||||
return (
|
||||
f'<div id="mizan-root">{result.html}</div>'
|
||||
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
|
||||
)
|
||||
|
||||
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
|
||||
"""Render the component and return a FastAPI `HTMLResponse`."""
|
||||
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the underlying Bun subprocess."""
|
||||
self._bridge.shutdown()
|
||||
@@ -1,167 +0,0 @@
|
||||
"""
|
||||
Edge-manifest + PSR behavior — the genuine capability behind the
|
||||
`edge_manifest` and `psr` probes.
|
||||
|
||||
Proves the FastAPI adapter emits the manifest the spec defines (contexts,
|
||||
mutations, params, user_scoped, render_strategy, page_routes) by deriving it from
|
||||
a real registry, and that `render_strategy` falls out of the user-scoped-param
|
||||
rule: a context whose params overlap {user_id, user, owner_id, account_id} is
|
||||
`dynamic_cached`, otherwise `psr`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import Response
|
||||
|
||||
import mizan_fastapi # registers the Starlette Response base for view-path detection
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
from mizan_fastapi import edge_manifest, generate_edge_manifest
|
||||
from mizan_fastapi.manifest import render_strategies
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
clear_registry()
|
||||
yield
|
||||
clear_registry()
|
||||
|
||||
|
||||
def _register(fn, name):
|
||||
register(fn, name)
|
||||
|
||||
|
||||
def test_user_scoped_context_is_dynamic_cached():
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
_register(user_profile, "user_profile")
|
||||
|
||||
manifest = edge_manifest()
|
||||
ctx = manifest["contexts"]["user"]
|
||||
assert ctx["user_scoped"] is True
|
||||
assert ctx["render_strategy"] == "dynamic_cached"
|
||||
assert ctx["params"] == ["user_id"]
|
||||
assert ctx["endpoints"] == ["/api/mizan/ctx/user/"]
|
||||
|
||||
|
||||
def test_non_user_scoped_context_is_psr():
|
||||
@client(context="catalog")
|
||||
def catalog_items(request, category: str) -> list[dict]:
|
||||
return [{"category": category}]
|
||||
|
||||
_register(catalog_items, "catalog_items")
|
||||
|
||||
ctx = edge_manifest()["contexts"]["catalog"]
|
||||
assert ctx["user_scoped"] is False
|
||||
assert ctx["render_strategy"] == "psr"
|
||||
|
||||
|
||||
def test_render_strategies_maps_each_context():
|
||||
@client(context="user")
|
||||
def me(request, user_id: int) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
@client(context="catalog")
|
||||
def items(request) -> list[dict]:
|
||||
return []
|
||||
|
||||
_register(me, "me")
|
||||
_register(items, "items")
|
||||
|
||||
strategies = render_strategies()
|
||||
assert strategies == {"user": "dynamic_cached", "catalog": "psr"}
|
||||
|
||||
|
||||
def test_mutation_records_affects_and_auto_scope():
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
@client(affects="user")
|
||||
def rename(request, user_id: int, name: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
_register(user_profile, "user_profile")
|
||||
_register(rename, "rename")
|
||||
|
||||
mutation = edge_manifest()["mutations"]["rename"]
|
||||
assert mutation["affects"] == ["user"]
|
||||
# user_id matches the context's param → auto-scoped
|
||||
assert mutation["auto_scoped_params"] == ["user_id"]
|
||||
|
||||
|
||||
def test_private_and_route_mutation_carried():
|
||||
@client(affects="subscription", private=True, route="/webhooks/stripe/", methods=["POST"])
|
||||
def stripe_webhook(request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
@client(context="subscription")
|
||||
def subscription(request, user_id: int) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
_register(stripe_webhook, "stripe_webhook")
|
||||
_register(subscription, "subscription")
|
||||
|
||||
mutation = edge_manifest()["mutations"]["stripe_webhook"]
|
||||
assert mutation["private"] is True
|
||||
assert mutation["route"] == "/webhooks/stripe/"
|
||||
assert mutation["methods"] == ["POST"]
|
||||
|
||||
|
||||
def test_view_path_function_records_route_and_page_routes():
|
||||
@client(context="profile", route="/profile/<user_id>/")
|
||||
def profile_page(request, user_id: int) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
_register(profile_page, "profile_page")
|
||||
|
||||
ctx = edge_manifest()["contexts"]["profile"]
|
||||
assert ctx["page_routes"] == ["/profile/<user_id>/"]
|
||||
fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page")
|
||||
assert fn_entry["path"] == "view"
|
||||
assert fn_entry["route"] == "/profile/<user_id>/"
|
||||
|
||||
|
||||
def test_fastapi_manifest_matches_core_derivation():
|
||||
"""The adapter callable is a thin pass-through to the shared core derivation."""
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
_register(user_profile, "user_profile")
|
||||
|
||||
assert edge_manifest() == generate_edge_manifest(base_url="/api/mizan")
|
||||
|
||||
|
||||
def test_cli_entry_emits_manifest_json(tmp_path):
|
||||
"""`mizan-fastapi-edge-manifest <module>` imports the module then prints JSON."""
|
||||
app_module = tmp_path / "manifest_app.py"
|
||||
app_module.write_text(
|
||||
"from mizan_core.client.function import client\n"
|
||||
"from mizan_core.registry import register\n"
|
||||
"@client(context='user')\n"
|
||||
"def user_profile(request, user_id: int) -> dict:\n"
|
||||
" return {'id': user_id}\n"
|
||||
"register(user_profile, 'user_profile')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mizan_fastapi.manifest", "manifest_app", "--indent", "0"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
manifest = json.loads(result.stdout)
|
||||
assert manifest["contexts"]["user"]["render_strategy"] == "dynamic_cached"
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,98 +0,0 @@
|
||||
"""FastAPI parity with Django: X-Mizan-Invalidate header, origin cache, token auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.auth import AuthConfig, JWTConfig, create_access_token
|
||||
from mizan_core.cache.backend import MemoryCache
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.dispatch import CacheOrchestrator
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan_fastapi import (
|
||||
MizanAuthMiddleware,
|
||||
MizanConfig,
|
||||
MizanError,
|
||||
mizan_auth,
|
||||
mizan_exception_handler,
|
||||
router as mizan_router,
|
||||
)
|
||||
|
||||
|
||||
class Out(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
SECRET = "x" * 32
|
||||
JWT = JWTConfig(private_key=SECRET, public_key=SECRET)
|
||||
|
||||
|
||||
def _app(*, with_cache=False, with_auth_dep=False) -> FastAPI:
|
||||
clear_registry()
|
||||
|
||||
UserCtx = "user"
|
||||
|
||||
@client(context=UserCtx)
|
||||
def user_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(affects=UserCtx)
|
||||
def update_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(auth=True)
|
||||
def whoami(request) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(update_profile, "update_profile")
|
||||
register(whoami, "whoami")
|
||||
|
||||
app = FastAPI()
|
||||
cache = CacheOrchestrator(MemoryCache(), SECRET) if with_cache else CacheOrchestrator(None, None)
|
||||
app.state.mizan_config = MizanConfig(auth=AuthConfig(jwt=JWT), cache=cache)
|
||||
deps = [Depends(mizan_auth())] if with_auth_dep else []
|
||||
app.include_router(mizan_router, prefix="/api/mizan", dependencies=deps)
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
return app
|
||||
|
||||
|
||||
def test_mutation_emits_invalidate_header():
|
||||
c = TestClient(_app())
|
||||
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 5}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["invalidate"] == [{"context": "user", "params": {"user_id": 5}}]
|
||||
assert r.headers["X-Mizan-Invalidate"] == "user;user_id=5"
|
||||
|
||||
|
||||
def test_origin_cache_hit_miss():
|
||||
c = TestClient(_app(with_cache=True))
|
||||
r1 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
|
||||
assert r1.status_code == 200 and r1.headers["X-Mizan-Cache"] == "MISS"
|
||||
r2 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
|
||||
assert r2.headers["X-Mizan-Cache"] == "HIT"
|
||||
assert r1.content == r2.content
|
||||
|
||||
|
||||
def test_auth_required_rejects_anonymous():
|
||||
c = TestClient(_app())
|
||||
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_auth_required_passes_with_bearer_jwt():
|
||||
c = TestClient(_app(with_auth_dep=True))
|
||||
tok = create_access_token("7", "sess", JWT, is_staff=True)
|
||||
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}},
|
||||
headers={"Authorization": f"Bearer {tok}"})
|
||||
assert r.status_code == 200 and r.json()["result"] == {"ok": True}
|
||||
|
||||
|
||||
def test_invalid_bearer_token_rejected():
|
||||
c = TestClient(_app())
|
||||
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 1}},
|
||||
headers={"Authorization": "Bearer not-a-real-token"})
|
||||
assert r.status_code == 401
|
||||
@@ -1,269 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,138 +0,0 @@
|
||||
"""
|
||||
SSR behavior — the genuine capability behind the `ssr_bridge` probe.
|
||||
|
||||
The SSR subprocess lifecycle + JSON-RPC protocol live in the shared
|
||||
`mizan_core.ssr.SSRBridge`; the FastAPI `SSRRenderer` resolves a component path
|
||||
against `dirs`, drives the bridge, and wraps the result with the hydration script
|
||||
the client reads on mount.
|
||||
|
||||
Bun is not assumed present in CI, so the bridge is driven against a stand-in
|
||||
worker that speaks the SAME newline-delimited JSON-RPC protocol (ready signal +
|
||||
`render` → `{id, html}`). That exercises the real bridge code path (spawn,
|
||||
message-ID correlation, threaded reader) — only the renderer binary is swapped.
|
||||
The path-resolution and hydration-wrapping are tested directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
from mizan_fastapi.ssr import SSRRenderer
|
||||
|
||||
|
||||
# A Python stand-in for the Bun worker: emits the ready signal, then for each
|
||||
# render request echoes a deterministic HTML fragment built from the props.
|
||||
_FAKE_WORKER = textwrap.dedent(
|
||||
"""
|
||||
import json, sys
|
||||
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\\n"); sys.stdout.flush()
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "render":
|
||||
props = msg["params"]["props"]
|
||||
html = "<p>" + props.get("name", "") + "</p>"
|
||||
sys.stdout.write(json.dumps({"id": msg["id"], "html": html}) + "\\n")
|
||||
sys.stdout.flush()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worker(tmp_path):
|
||||
worker = tmp_path / "fake_worker.py"
|
||||
worker.write_text(_FAKE_WORKER, encoding="utf-8")
|
||||
return str(worker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def python_bridge(fake_worker, monkeypatch):
|
||||
"""An `SSRBridge` whose subprocess is python (not bun), driving the fake worker."""
|
||||
import subprocess
|
||||
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
# Swap the `bun run <worker>` invocation for `python <worker>`.
|
||||
if cmd[:2] == ["bun", "run"]:
|
||||
cmd = [sys.executable, cmd[2]]
|
||||
return real_popen(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
bridge = SSRBridge(worker_path=fake_worker, timeout=5.0)
|
||||
yield bridge
|
||||
bridge.shutdown()
|
||||
|
||||
|
||||
def test_bridge_round_trips_render(python_bridge):
|
||||
result = python_bridge.render("/abs/Hello.tsx", {"name": "World"})
|
||||
assert result.html == "<p>World</p>"
|
||||
|
||||
|
||||
def test_bridge_correlates_concurrent_renders(python_bridge):
|
||||
# Two renders on the persistent subprocess return their own results.
|
||||
a = python_bridge.render("/abs/A.tsx", {"name": "A"})
|
||||
b = python_bridge.render("/abs/B.tsx", {"name": "B"})
|
||||
assert (a.html, b.html) == ("<p>A</p>", "<p>B</p>")
|
||||
|
||||
|
||||
def test_renderer_resolves_against_dirs_and_wraps_hydration(fake_worker, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
real_popen = subprocess.Popen
|
||||
monkeypatch.setattr(
|
||||
subprocess, "Popen",
|
||||
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
||||
)
|
||||
|
||||
components = tmp_path / "frontend"
|
||||
components.mkdir()
|
||||
(components / "Hello.tsx").write_text("export default () => null", encoding="utf-8")
|
||||
|
||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
||||
try:
|
||||
html = renderer.render_to_string("Hello.tsx", {"name": "Mizan"})
|
||||
finally:
|
||||
renderer.shutdown()
|
||||
|
||||
assert '<div id="mizan-root"><p>Mizan</p></div>' in html
|
||||
assert 'window.__MIZAN_SSR_DATA__={"name": "Mizan"}' in html
|
||||
|
||||
|
||||
def test_renderer_returns_html_response(fake_worker, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
real_popen = subprocess.Popen
|
||||
monkeypatch.setattr(
|
||||
subprocess, "Popen",
|
||||
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
||||
)
|
||||
|
||||
components = tmp_path / "frontend"
|
||||
components.mkdir()
|
||||
(components / "Card.tsx").write_text("export default () => null", encoding="utf-8")
|
||||
|
||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
||||
try:
|
||||
response = renderer.render("Card.tsx", {"name": "x"})
|
||||
finally:
|
||||
renderer.shutdown()
|
||||
|
||||
assert isinstance(response, HTMLResponse)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_renderer_raises_on_missing_component(fake_worker, tmp_path):
|
||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(tmp_path)])
|
||||
try:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
renderer.render_to_string("Nope.tsx", {})
|
||||
finally:
|
||||
renderer.shutdown()
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Upload dispatch over FastAPI multipart — files bind into Upload fields and
|
||||
the declarative `File(...)` constraints are enforced."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
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 File, MizanError, Upload, mizan_exception_handler, router as mizan_router
|
||||
|
||||
|
||||
class AvatarOut(BaseModel):
|
||||
ok: bool
|
||||
size: int
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
clear_registry()
|
||||
|
||||
@client
|
||||
def set_avatar(
|
||||
request,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
|
||||
) -> AvatarOut:
|
||||
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
|
||||
|
||||
register(set_avatar, "set_avatar")
|
||||
|
||||
fastapi_app = FastAPI()
|
||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
return fastapi_app
|
||||
|
||||
|
||||
def _post(test_client: TestClient, args: dict, file_tuple: tuple):
|
||||
return test_client.post(
|
||||
"/api/mizan/call/",
|
||||
data={"fn": "set_avatar", "args": json.dumps(args)},
|
||||
files={"avatar": file_tuple},
|
||||
)
|
||||
|
||||
|
||||
def test_upload_binds_and_executes(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("a.png", b"\x89PNG" + b"x" * 100, "image/png"))
|
||||
assert resp.status_code == 200, resp.text
|
||||
result = resp.json()["result"]
|
||||
assert result["ok"] is True
|
||||
assert result["name"] == "a.png"
|
||||
assert result["size"] == 104
|
||||
|
||||
|
||||
def test_max_size_rejected(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("b.png", b"x" * (2 * 1024 * 1024), "image/png"))
|
||||
assert resp.status_code == 400
|
||||
assert "max size" in resp.text
|
||||
|
||||
|
||||
def test_content_type_rejected(app):
|
||||
resp = _post(TestClient(app), {"user_id": 5}, ("c.gif", b"GIF89a", "image/gif"))
|
||||
assert resp.status_code == 400
|
||||
assert "content-type" in resp.text
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
WebSocket RPC behavior — the genuine capability behind the `websocket` probe.
|
||||
|
||||
Proves the `/ws/` route dispatches `@client(websocket=True)` functions through
|
||||
the SAME `mizan_core.dispatch` core as `POST /call/`: input validation, the
|
||||
`{result, invalidate, merge}` envelope, `auth=` enforcement, and the
|
||||
websocket=True gate that rejects HTTP-only functions. The frame protocol matches
|
||||
mizan-django's Channels consumer (`action:"rpc"` → `{id, ok, data|error}`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan_fastapi import (
|
||||
MizanError,
|
||||
mizan_exception_handler,
|
||||
mizan_validation_handler,
|
||||
router as mizan_router,
|
||||
)
|
||||
|
||||
|
||||
class EchoOut(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
clear_registry()
|
||||
|
||||
@client(websocket=True)
|
||||
def ws_echo(request, text: str) -> EchoOut:
|
||||
return EchoOut(message=f"ws: {text}")
|
||||
|
||||
@client(websocket=True)
|
||||
def ws_add(request, a: int, b: int) -> dict:
|
||||
return {"total": a + b}
|
||||
|
||||
@client(websocket=True, affects="user")
|
||||
def ws_update(request, user_id: int) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@client(websocket=True, auth=True)
|
||||
def ws_secret(request) -> dict:
|
||||
return {"secret": True}
|
||||
|
||||
@client # HTTP-only — must be rejected over WS
|
||||
def http_only(request) -> dict:
|
||||
return {"http": True}
|
||||
|
||||
for fn, name in (
|
||||
(ws_echo, "ws_echo"), (ws_add, "ws_add"), (ws_update, "ws_update"),
|
||||
(ws_secret, "ws_secret"), (http_only, "http_only"),
|
||||
):
|
||||
register(fn, name)
|
||||
|
||||
fastapi_app = FastAPI()
|
||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||
yield fastapi_app
|
||||
clear_registry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_ws_rpc_dispatches_and_returns_data(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "1", "fn": "ws_echo", "args": {"text": "hi"}})
|
||||
frame = ws.receive_json()
|
||||
assert frame == {"id": "1", "ok": True, "data": {"message": "ws: hi"}, "invalidate": []}
|
||||
|
||||
|
||||
def test_ws_rpc_validates_input_through_core(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "2", "fn": "ws_add", "args": {"a": "nope", "b": 3}})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is False
|
||||
assert frame["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
def test_ws_rpc_carries_invalidation(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "3", "fn": "ws_update", "args": {"user_id": 5}})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is True
|
||||
assert "user" in frame["invalidate"]
|
||||
|
||||
|
||||
def test_http_only_function_is_forbidden_over_ws(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "4", "fn": "http_only", "args": {}})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is False
|
||||
assert frame["error"]["code"] == "FORBIDDEN"
|
||||
|
||||
|
||||
def test_unknown_function_over_ws_is_not_found(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "5", "fn": "ghost", "args": {}})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is False
|
||||
assert frame["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_auth_required_function_rejects_anonymous_over_ws(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "6", "fn": "ws_secret", "args": {}})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is False
|
||||
assert frame["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
def test_missing_fn_field_is_bad_request(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "7"})
|
||||
frame = ws.receive_json()
|
||||
assert frame["ok"] is False
|
||||
assert frame["error"]["code"] == "BAD_REQUEST"
|
||||
|
||||
|
||||
def test_unknown_action_errors(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "bogus"})
|
||||
frame = ws.receive_json()
|
||||
assert "error" in frame
|
||||
|
||||
|
||||
def test_multiple_calls_on_one_connection(http):
|
||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
||||
ws.send_json({"action": "rpc", "id": "a", "fn": "ws_echo", "args": {"text": "1"}})
|
||||
first = ws.receive_json()
|
||||
ws.send_json({"action": "rpc", "id": "b", "fn": "ws_echo", "args": {"text": "2"}})
|
||||
second = ws.receive_json()
|
||||
assert first["data"]["message"] == "ws: 1"
|
||||
assert second["data"]["message"] == "ws: 2"
|
||||
312
backends/mizan-rust-axum/Cargo.lock
generated
312
backends/mizan-rust-axum/Cargo.lock
generated
@@ -27,7 +27,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -39,7 +38,6 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
@@ -47,10 +45,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -78,90 +74,18 @@ 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"
|
||||
@@ -186,23 +110,6 @@ 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"
|
||||
@@ -216,49 +123,17 @@ 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"
|
||||
@@ -411,15 +286,10 @@ 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",
|
||||
]
|
||||
@@ -429,13 +299,10 @@ name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"hmac",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -448,23 +315,6 @@ 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"
|
||||
@@ -483,15 +333,6 @@ 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"
|
||||
@@ -510,36 +351,6 @@ 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"
|
||||
@@ -618,28 +429,6 @@ 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"
|
||||
@@ -662,18 +451,6 @@ 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"
|
||||
@@ -691,33 +468,12 @@ 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",
|
||||
@@ -737,18 +493,6 @@ 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"
|
||||
@@ -813,48 +557,12 @@ 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"
|
||||
@@ -876,26 +584,6 @@ 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"
|
||||
|
||||
@@ -7,17 +7,9 @@ license = "Elastic-2.0"
|
||||
|
||||
[dependencies]
|
||||
mizan-core = { path = "../../cores/mizan-rust" }
|
||||
axum = { version = "0.7", features = ["ws", "multipart"] }
|
||||
axum = "0.7"
|
||||
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"
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Forms endpoints — schema / validate / submit over the registered form
|
||||
//! functions. The Forms capability is AFI-common; the binding is
|
||||
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
|
||||
//! form_role=…)]` function here). A form is the set of registered functions
|
||||
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
|
||||
//! own route that dispatches the function whose `(form_name, form_role)`
|
||||
//! matches.
|
||||
//!
|
||||
//! POST /form/:form_name/schema/
|
||||
//! POST /form/:form_name/validate/
|
||||
//! POST /form/:form_name/submit/
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// Find the registered form function with this `(form_name, form_role)`.
|
||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||
}
|
||||
|
||||
/// Dispatch the form function for `(form_name, role)`. Shared by the three
|
||||
/// role routes below.
|
||||
async fn dispatch_role(
|
||||
state: &MizanState,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Response, ApiError> {
|
||||
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"no form {form_name:?} with role {role:?}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let args_value = match args {
|
||||
Value::Object(_) | Value::Null => args,
|
||||
other => Value::Object({
|
||||
let mut m = Map::new();
|
||||
m.insert("data".into(), other);
|
||||
m
|
||||
}),
|
||||
};
|
||||
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
|
||||
|
||||
let mut resp = (StatusCode::OK, Json(result)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
|
||||
pub async fn form_schema(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "schema", args).await
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/validate/ — validate submitted data without committing.
|
||||
pub async fn form_validate(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "validate", args).await
|
||||
}
|
||||
|
||||
/// POST /form/:form_name/submit/ — validate and commit the form.
|
||||
pub async fn form_submit(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(form_name): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
dispatch_role(&state, &form_name, "submit", args).await
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
|
||||
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{
|
||||
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
|
||||
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
|
||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||
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;
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// Type-erased application state threaded into every `dispatch()` call via
|
||||
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -30,7 +33,9 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,210 +54,44 @@ fn no_store(json: Value) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
|
||||
/// through the shared `authenticate`. A present-but-invalid token rejects with
|
||||
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
|
||||
pub(crate) fn identity_from_headers(
|
||||
headers: &HeaderMap,
|
||||
state: &MizanState,
|
||||
) -> Result<Option<Identity>, ApiError> {
|
||||
let mwt = headers
|
||||
.get("X-Mizan-Token")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let bearer = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
|
||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||
AuthOutcome::Anonymous => Ok(None),
|
||||
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
|
||||
"Invalid or expired token".into(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce a function's `@client(auth=...)` against the resolved identity.
|
||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
|
||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||
enforce_auth(identity, &req).map_err(ApiError)
|
||||
}
|
||||
|
||||
/// Reject a client call into a `private` function (no RPC endpoint).
|
||||
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
|
||||
if fn_spec.private() {
|
||||
return Err(ApiError(MizanError::Forbidden(
|
||||
"Function is not client-callable".into(),
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uid_str(identity: Option<&Identity>) -> Option<String> {
|
||||
identity.map(|i| i.user_id.clone())
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
|
||||
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
|
||||
/// invalidated contexts.
|
||||
/// POST /call/ — RPC dispatch.
|
||||
pub async fn function_call(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Body,
|
||||
State(app_state): State<AppStateAny>,
|
||||
Json(body): Json<CallBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let identity = identity_from_headers(&headers, &state)?;
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
let fn_name = body
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
|
||||
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 fn_spec = lookup_function(&fn_name)
|
||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||
|
||||
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 req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||
|
||||
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<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = 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,
|
||||
};
|
||||
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)
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}
|
||||
|
||||
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
|
||||
let call: CallBody = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
|
||||
let fn_name = call
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
Ok((fn_name, call.args))
|
||||
}
|
||||
|
||||
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
|
||||
/// Each file part binds into the matching Upload-typed input field as a
|
||||
/// base64-carrying value the `mizan_core::Upload` field deserializes.
|
||||
async fn parse_multipart(
|
||||
content_type: &str,
|
||||
body: axum::body::Body,
|
||||
) -> Result<(String, Map<String, Value>), ApiError> {
|
||||
let boundary = multer::parse_boundary(content_type)
|
||||
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
|
||||
let stream = body.into_data_stream();
|
||||
let mut mp = multer::Multipart::new(stream, boundary);
|
||||
|
||||
let mut fn_name: Option<String> = None;
|
||||
let mut args: Map<String, Value> = Map::new();
|
||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
|
||||
while let Some(field) = mp
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
let filename = field.file_name().map(|s| s.to_string());
|
||||
let part_content_type = field.content_type().map(|s| s.to_string());
|
||||
|
||||
if filename.is_some() {
|
||||
// A file part → the JSON shape `mizan_core::Upload` deserializes
|
||||
// (filename, content_type, base64 bytes).
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
|
||||
files.entry(name).or_default().push(uploaded_file_json(
|
||||
filename,
|
||||
part_content_type,
|
||||
&data,
|
||||
));
|
||||
} else {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
|
||||
if name == "fn" {
|
||||
fn_name = Some(text);
|
||||
} else if name == "args" {
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
|
||||
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
|
||||
})?;
|
||||
if let Value::Object(m) = parsed {
|
||||
args = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind file parts into args by field name (single vs list).
|
||||
for (field_name, parts) in files {
|
||||
if parts.len() == 1 {
|
||||
args.insert(field_name, parts.into_iter().next().unwrap());
|
||||
} else {
|
||||
args.insert(field_name, Value::Array(parts));
|
||||
}
|
||||
}
|
||||
|
||||
let fn_name =
|
||||
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
|
||||
Ok((fn_name, args))
|
||||
}
|
||||
|
||||
/// Encode a received file part as the JSON shape an `Upload` field expects.
|
||||
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
serde_json::json!({
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"data_b64": STANDARD.encode(data),
|
||||
"size": data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
pub async fn context_fetch(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
headers: HeaderMap,
|
||||
State(app_state): State<AppStateAny>,
|
||||
Path(context_name): Path<String>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
@@ -262,8 +101,6 @@ pub async fn context_fetch(
|
||||
))));
|
||||
}
|
||||
|
||||
let identity = identity_from_headers(&headers, &state)?;
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -275,130 +112,22 @@ pub async fn context_fetch(
|
||||
))));
|
||||
}
|
||||
|
||||
// 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<String, Value> = params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect();
|
||||
let uid = uid_str(identity.as_ref());
|
||||
|
||||
if let Some(cached) = state
|
||||
.cache
|
||||
.get(&context_name, &cache_params, uid.as_deref(), 0)
|
||||
{
|
||||
return Ok(cached_response(cached, "HIT"));
|
||||
}
|
||||
|
||||
// Enforce auth per member (the bundle is only as open as its strictest fn).
|
||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||
// params get parsed via the per-function input_params primitive table.
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
guard(*fn_spec, identity.as_ref())?;
|
||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
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))
|
||||
Ok(no_store(Value::Object(bundled)))
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
fn sort(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
let mut keys: Vec<&String> = m.keys().collect();
|
||||
keys.sort();
|
||||
let mut out = Map::new();
|
||||
for k in keys {
|
||||
out.insert(k.clone(), sort(&m[k]));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
// Python's default separators add a space after ':' and ','. Match that so
|
||||
// a Rust-written cache body and a Python-written one are byte-equal.
|
||||
let sorted = sort(v);
|
||||
python_json(&sorted)
|
||||
}
|
||||
|
||||
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
|
||||
/// (`", "` and `": "`).
|
||||
fn python_json(v: &Value) -> Vec<u8> {
|
||||
let compact = serde_json::to_string(v).unwrap();
|
||||
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
|
||||
// This is a structural transform on the already-sorted value, so the
|
||||
// bytes match `json.dumps` for the JSON value space Mizan returns.
|
||||
let spaced = respace(&compact);
|
||||
spaced.into_bytes()
|
||||
}
|
||||
|
||||
/// Insert the spaces Python's default `json.dumps` uses after structural
|
||||
/// `,`/`:` — but only outside string literals.
|
||||
fn respace(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + s.len() / 8);
|
||||
let mut in_str = false;
|
||||
let mut escaped = false;
|
||||
for c in s.chars() {
|
||||
if in_str {
|
||||
out.push(c);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => {
|
||||
in_str = true;
|
||||
out.push(c);
|
||||
}
|
||||
',' => out.push_str(", "),
|
||||
':' => out.push_str(": "),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
|
||||
let mut resp = (StatusCode::OK, body).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
if !cache_status.is_empty() {
|
||||
if let Ok(v) = HeaderValue::from_str(cache_status) {
|
||||
h.insert("X-Mizan-Cache", v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Coerce string-valued query params into typed JSON via the function's
|
||||
/// declared input_params.
|
||||
/// 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.
|
||||
fn coerce_query_args(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
params: &BTreeMap<String, String>,
|
||||
@@ -408,88 +137,26 @@ fn coerce_query_args(
|
||||
if let Some(raw) = params.get(ip.name) {
|
||||
let parsed = match ip.primitive {
|
||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||
mizan_core::Primitive::Number => raw
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
|
||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||
serde_json::Number::from_f64(v).map(Value::Number)
|
||||
}),
|
||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||
};
|
||||
out.insert(ip.name.into(), parsed.unwrap_or_else(|| 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
|
||||
}
|
||||
|
||||
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
||||
/// 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.
|
||||
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||
/// readiness-probe consumers see a well-formed response.
|
||||
pub async fn session_init() -> Response {
|
||||
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<Arc<MizanState>>) -> Response {
|
||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||
no_store(manifest)
|
||||
}
|
||||
|
||||
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
|
||||
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
|
||||
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
|
||||
/// re-renders. This is the adapter telling Edge *how* to cache each context —
|
||||
/// the PSR half of the manifest, addressable per-context.
|
||||
pub async fn psr_descriptor(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Path(context_name): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
||||
let ctx = manifest
|
||||
.get("contexts")
|
||||
.and_then(|c| c.get(&context_name))
|
||||
.ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not in manifest"
|
||||
)))
|
||||
})?;
|
||||
let render_strategy = ctx
|
||||
.get("render_strategy")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_routes = ctx
|
||||
.get("page_routes")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
||||
Ok(no_store(serde_json::json!({
|
||||
"context": context_name,
|
||||
"render_strategy": render_strategy,
|
||||
"page_routes": page_routes,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
|
||||
/// output, derived from the registered type graph by `mizan_core::shapes`.
|
||||
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
|
||||
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
|
||||
ApiError(MizanError::NotFound(format!(
|
||||
"no shape projection for {fn_name:?}"
|
||||
)))
|
||||
})?;
|
||||
Ok(no_store(projection_to_json(&proj)))
|
||||
}
|
||||
|
||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||
let mut fields = Vec::new();
|
||||
for f in &proj.fields {
|
||||
match f {
|
||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||
shapes::ShapeField::Nested(n, sub) => {
|
||||
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "type": proj.type_name, "fields": fields })
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
}
|
||||
|
||||
@@ -1,80 +1,58 @@
|
||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
|
||||
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
|
||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```ignore
|
||||
//! use axum::Router;
|
||||
//! use mizan_axum::{router, MizanState};
|
||||
//! use mizan_axum::router;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let state = MizanState::builder()
|
||||
//! .app_state(MyState { /* ... */ })
|
||||
//! .build();
|
||||
//! let app = Router::new().nest("/api/mizan", router(state));
|
||||
//! let app = Router::new().nest("/api/mizan", router());
|
||||
//! 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 (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
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
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, CallBody, CallResponse};
|
||||
pub use ssr::{ssr_render, SsrRequest};
|
||||
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
|
||||
pub use handlers::{
|
||||
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
||||
};
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 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<MizanState>) -> Router {
|
||||
/// Build the Mizan router with user-supplied app state. The state is
|
||||
/// type-erased into an `Arc<dyn Any + Send + Sync>` 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<S>(state: S) -> Router
|
||||
where
|
||||
S: Any + Send + Sync + 'static,
|
||||
{
|
||||
let state: AppStateAny = Arc::new(state);
|
||||
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 the common case of just an app state, no auth/cache.
|
||||
pub fn router_with_state<S>(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.
|
||||
/// 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(MizanState::builder().build())
|
||||
router(())
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
|
||||
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
|
||||
//! `SSRBridge`). The bridge spawns on first render and stays alive.
|
||||
//!
|
||||
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use mizan_core::MizanError;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::MizanState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SsrRequest {
|
||||
pub file: String,
|
||||
#[serde(default)]
|
||||
pub props: Value,
|
||||
}
|
||||
|
||||
/// POST /ssr/ — render a component file via the Bun SSR worker.
|
||||
pub async fn ssr_render(
|
||||
State(state): State<Arc<MizanState>>,
|
||||
Json(req): Json<SsrRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let bridge = state.ssr().ok_or_else(|| {
|
||||
ApiError(MizanError::NotImplementedYet(
|
||||
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
|
||||
))
|
||||
})?;
|
||||
let props = if req.props.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
req.props
|
||||
};
|
||||
let html = bridge
|
||||
.render(&req.file, props)
|
||||
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
|
||||
|
||||
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Router state — the Mizan config (auth + origin cache) threaded alongside
|
||||
//! the user's type-erased app state.
|
||||
//!
|
||||
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
|
||||
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
|
||||
//! their concrete type — unchanged from the pre-AFI router). `auth` and
|
||||
//! `cache` are the AFI-common config the handlers read for enforcement and
|
||||
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
|
||||
|
||||
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
|
||||
use std::any::Any;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
|
||||
pub struct MizanState {
|
||||
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
|
||||
pub app_state: AppStateAny,
|
||||
/// JWT/MWT auth config (token → identity resolution + enforcement).
|
||||
pub auth: AuthConfig,
|
||||
/// Origin-side HMAC cache orchestrator (disabled by default).
|
||||
pub cache: CacheOrchestrator,
|
||||
/// Mizan API mount point, used by the edge-manifest endpoint.
|
||||
pub base_url: String,
|
||||
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
|
||||
pub(crate) ssr_worker: Option<String>,
|
||||
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
|
||||
}
|
||||
|
||||
impl MizanState {
|
||||
pub fn builder() -> MizanStateBuilder {
|
||||
MizanStateBuilder::default()
|
||||
}
|
||||
|
||||
/// The SSR bridge, spawned on first use. `None` if no worker was set.
|
||||
pub fn ssr(&self) -> Option<&SsrBridge> {
|
||||
let worker = self.ssr_worker.as_ref()?;
|
||||
Some(
|
||||
self.ssr_bridge
|
||||
.get_or_init(|| SsrBridge::bun(worker.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
|
||||
/// disabled, `/api/mizan` base URL, no SSR worker.
|
||||
pub struct MizanStateBuilder {
|
||||
app_state: AppStateAny,
|
||||
auth: AuthConfig,
|
||||
cache: CacheOrchestrator,
|
||||
base_url: String,
|
||||
ssr_worker: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MizanStateBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_state: Arc::new(()),
|
||||
auth: AuthConfig::new(),
|
||||
cache: CacheOrchestrator::disabled(),
|
||||
base_url: "/api/mizan".to_string(),
|
||||
ssr_worker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MizanStateBuilder {
|
||||
/// Set the consumer's app state (threaded into dispatch).
|
||||
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
|
||||
self.app_state = Arc::new(state);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn auth(mut self, auth: AuthConfig) -> Self {
|
||||
self.auth = auth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
|
||||
self.cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the Bun SSR worker path; the bridge spawns on first render.
|
||||
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
|
||||
self.ssr_worker = Some(worker_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Arc<MizanState> {
|
||||
Arc::new(MizanState {
|
||||
app_state: self.app_state,
|
||||
auth: self.auth,
|
||||
cache: self.cache,
|
||||
base_url: self.base_url,
|
||||
ssr_worker: self.ssr_worker,
|
||||
ssr_bridge: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
|
||||
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
|
||||
//! that dispatches call/fetch frames through the same `mizan-core` registry
|
||||
//! the HTTP path uses. A call frame naming a non-websocket function is
|
||||
//! rejected, so the transport boundary the IR declares is enforced.
|
||||
//!
|
||||
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
|
||||
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
|
||||
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
|
||||
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
|
||||
//! ← {"id": 2, "data": {fnName: result, ...}}
|
||||
//! ← {"id": N, "error": {"code": ..., "message": ...}}
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::MizanState;
|
||||
|
||||
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<MizanState>>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
|
||||
while let Some(Ok(msg)) = socket.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
|
||||
};
|
||||
let reply = handle_frame(&state, &text).await;
|
||||
if socket
|
||||
.send(Message::Text(reply.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_frame(state: &MizanState, text: &str) -> Value {
|
||||
let frame: Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
|
||||
};
|
||||
let id = frame.get("id").cloned().unwrap_or(Value::Null);
|
||||
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
|
||||
|
||||
match op {
|
||||
"call" => match dispatch_ws_call(state, &frame).await {
|
||||
Ok(v) => with_id(id, v),
|
||||
Err(e) => err_frame(id, &e),
|
||||
},
|
||||
"fetch" => match dispatch_ws_fetch(state, &frame).await {
|
||||
Ok(v) => with_id(id, json!({ "data": v })),
|
||||
Err(e) => err_frame(id, &e),
|
||||
},
|
||||
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||
let fn_name = frame
|
||||
.get("fn")
|
||||
.and_then(|f| f.as_str())
|
||||
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
|
||||
let args = frame
|
||||
.get("args")
|
||||
.and_then(|a| a.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let fn_spec =
|
||||
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
|
||||
if fn_spec.private() {
|
||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||
}
|
||||
// The WS transport only carries functions that opted into it.
|
||||
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"function {fn_name:?} is not exposed over the WebSocket transport"
|
||||
)));
|
||||
}
|
||||
enforce_anon_guard(fn_spec)?;
|
||||
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||
|
||||
let targets = compute_invalidation(fn_spec, &args);
|
||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
|
||||
let mut out = Map::new();
|
||||
out.insert("result".into(), result);
|
||||
out.insert("invalidate".into(), Value::Array(invalidate));
|
||||
if !merges.is_empty() {
|
||||
out.insert(
|
||||
"merge".into(),
|
||||
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
||||
let ctx = frame
|
||||
.get("context")
|
||||
.and_then(|c| c.as_str())
|
||||
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
|
||||
if lookup_context(ctx).is_none() {
|
||||
return Err(MizanError::NotFound(format!("context {ctx:?}")));
|
||||
}
|
||||
let params = frame
|
||||
.get("params")
|
||||
.and_then(|p| p.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(ctx))
|
||||
.collect();
|
||||
|
||||
let mut bundle = Map::new();
|
||||
for fn_spec in &members {
|
||||
enforce_anon_guard(*fn_spec)?;
|
||||
let mut args = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
args.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
||||
bundle.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Ok(Value::Object(bundle))
|
||||
}
|
||||
|
||||
/// Enforce a function's auth guard for the WS transport. The WS upgrade
|
||||
/// carries no per-frame identity in this baseline, so a guarded function is
|
||||
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
|
||||
/// applied with an anonymous identity.
|
||||
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
|
||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
||||
mizan_core::enforce_auth(None, &req)
|
||||
}
|
||||
|
||||
fn with_id(id: Value, mut body: Value) -> Value {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert("id".into(), id);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn err_frame(id: Value, e: &MizanError) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"error": { "code": e.code(), "message": e.message() },
|
||||
})
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
//! Runtime behavior tests for the axum adapter — the conformance ceiling that
|
||||
//! the source-presence probes set the floor for. Each AFI-common HTTP cell is
|
||||
//! driven end to end through the real router (`tower::ServiceExt::oneshot`,
|
||||
//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs
|
||||
//! against a real bound port.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::{
|
||||
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
||||
};
|
||||
use mizan_axum::{router, MizanState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// ─── Fixture: the functions these tests dispatch ────────────────────────────
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Ok {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Secret {
|
||||
pub flag: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct UploadEcho {
|
||||
pub filename: String,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SchemaOut {
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[mizan::context("bprofile")]
|
||||
pub struct BProfileCtx;
|
||||
|
||||
#[mizan::client(context = BProfileCtx)]
|
||||
pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
|
||||
Profile {
|
||||
user_id,
|
||||
name: format!("user-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(affects = BProfileCtx)]
|
||||
pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok {
|
||||
let _ = (user_id, name);
|
||||
Ok { ok: true }
|
||||
}
|
||||
|
||||
#[mizan::client(auth = "staff")]
|
||||
pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret {
|
||||
Secret {
|
||||
flag: "top-secret".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(websocket)]
|
||||
pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok {
|
||||
let _ = n;
|
||||
Ok { ok: true }
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho {
|
||||
let _ = user_id;
|
||||
UploadEcho {
|
||||
filename: avatar.filename.clone().unwrap_or_default(),
|
||||
size: avatar.size() as i64,
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(form_name = "contact", form_role = "submit")]
|
||||
pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok {
|
||||
let _ = name;
|
||||
Ok { ok: true }
|
||||
}
|
||||
|
||||
#[mizan::client(form_name = "contact", form_role = "schema")]
|
||||
pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut {
|
||||
SchemaOut {
|
||||
fields: vec!["name".into()],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn stateless_app() -> axum::Router {
|
||||
router(MizanState::builder().build())
|
||||
}
|
||||
|
||||
async fn body_json(resp: axum::response::Response) -> Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/call/")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"fn": fn_name, "args": args}).to_string()))
|
||||
.unwrap();
|
||||
app.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
// ─── invalidate_header + invalidate_body + rpc_call ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_emits_invalidate_body_and_header() {
|
||||
let app = stateless_app();
|
||||
let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// The header is co-equal with the body channel: scoped to user_id=7.
|
||||
let header = resp
|
||||
.headers()
|
||||
.get("X-Mizan-Invalidate")
|
||||
.expect("X-Mizan-Invalidate present")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(header, "bprofile;user_id=7");
|
||||
assert_eq!(
|
||||
resp.headers().get("cache-control").unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["result"], json!({"ok": true}));
|
||||
// Body invalidate entry is the scoped object form.
|
||||
assert_eq!(
|
||||
body["invalidate"],
|
||||
json!([{"context": "bprofile", "params": {"user_id": 7}}])
|
||||
);
|
||||
}
|
||||
|
||||
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_guard_rejects_anonymous_and_admits_staff() {
|
||||
// No auth config + a staff-guarded fn → anonymous is rejected 401.
|
||||
let app = stateless_app();
|
||||
let resp = post_call(&app, "b_secret", json!({})).await;
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// With a JWT config + a staff token, the same call is admitted. Mint at
|
||||
// the real clock so the token is unexpired when the handler verifies it.
|
||||
let cfg = JwtConfig::new("beh-secret");
|
||||
let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix());
|
||||
let auth = AuthConfig {
|
||||
jwt: Some(cfg),
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".into(),
|
||||
};
|
||||
let app = router(MizanState::builder().auth(auth).build());
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/call/")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["result"], json!({"flag": "top-secret"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_guard_forbids_non_staff_token() {
|
||||
// A valid but non-staff token → 403 on a staff-guarded fn.
|
||||
let cfg = JwtConfig::new("beh-secret");
|
||||
let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix());
|
||||
let auth = AuthConfig {
|
||||
jwt: Some(cfg),
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".into(),
|
||||
};
|
||||
let app = router(MizanState::builder().auth(auth).build());
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/call/")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_token_is_rejected_not_downgraded() {
|
||||
// A present-but-bad bearer rejects (401) even on an unguarded context —
|
||||
// the INVALID-sentinel contract.
|
||||
let auth = AuthConfig {
|
||||
jwt: Some(JwtConfig::new("beh-secret")),
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".into(),
|
||||
};
|
||||
let app = router(MizanState::builder().auth(auth).build());
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/ctx/bprofile/?user_id=1")
|
||||
.header("authorization", "Bearer not-a-real-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ─── origin_cache ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn context_fetch_uses_origin_cache() {
|
||||
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
||||
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into()));
|
||||
let app = router(MizanState::builder().cache(cache).build());
|
||||
|
||||
// First fetch: MISS, populates the cache.
|
||||
let req = Request::builder()
|
||||
.uri("/ctx/bprofile/?user_id=3")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
||||
let first = body_json(resp).await;
|
||||
assert_eq!(first["b_user_profile"]["user_id"], json!(3));
|
||||
|
||||
// Second fetch: HIT, served from cache.
|
||||
let req = Request::builder()
|
||||
.uri("/ctx/bprofile/?user_id=3")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT");
|
||||
let second = body_json(resp).await;
|
||||
assert_eq!(first, second);
|
||||
|
||||
// A mutation scoped to user_id=3 purges that key → next fetch MISSes.
|
||||
let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await;
|
||||
let req = Request::builder()
|
||||
.uri("/ctx/bprofile/?user_id=3")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
||||
}
|
||||
|
||||
// ─── upload ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_upload_binds_into_input() {
|
||||
let app = stateless_app();
|
||||
let boundary = "----mizanbeh";
|
||||
let file_bytes = b"PNGDATA-0123456789";
|
||||
let body = format!(
|
||||
"--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\
|
||||
--{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\
|
||||
--{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
|
||||
Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n",
|
||||
b = boundary,
|
||||
data = String::from_utf8_lossy(file_bytes),
|
||||
);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/call/")
|
||||
.header(
|
||||
"content-type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["result"]["filename"], json!("a.png"));
|
||||
assert_eq!(body["result"]["size"], json!(file_bytes.len()));
|
||||
}
|
||||
|
||||
// ─── edge_manifest + psr ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_and_psr_descriptor() {
|
||||
let app = stateless_app();
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/manifest/")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await;
|
||||
// bprofile is user-scoped (user_id) → dynamic_cached.
|
||||
assert_eq!(
|
||||
manifest["contexts"]["bprofile"]["render_strategy"],
|
||||
json!("dynamic_cached")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["mutations"]["b_update_profile"]["affects"],
|
||||
json!(["bprofile"])
|
||||
);
|
||||
|
||||
// Per-context PSR descriptor.
|
||||
let req = Request::builder()
|
||||
.uri("/psr/bprofile/")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let psr = body_json(app.oneshot(req).await.unwrap()).await;
|
||||
assert_eq!(psr["render_strategy"], json!("dynamic_cached"));
|
||||
}
|
||||
|
||||
// ─── shapes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn shape_projection_endpoint() {
|
||||
let app = stateless_app();
|
||||
let req = Request::builder()
|
||||
.uri("/shape/b_user_profile/")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
// Output type name is camelCased by the macro (`b_user_profile` →
|
||||
// `bUserProfile`), suffixed `Output`.
|
||||
assert_eq!(body["type"], json!("bUserProfileOutput"));
|
||||
let fields = body["fields"].as_array().unwrap();
|
||||
assert!(fields.contains(&json!("user_id")));
|
||||
assert!(fields.contains(&json!("name")));
|
||||
}
|
||||
|
||||
// ─── forms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn forms_schema_and_submit_routes() {
|
||||
let app = stateless_app();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/form/contact/schema/")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["fields"], json!(["name"]));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/form/contact/submit/")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"name": "Ada"}).to_string()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await, json!({"ok": true}));
|
||||
}
|
||||
|
||||
// ─── websocket ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_dispatches_and_rejects_non_ws_fn() {
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
// Bind a real socket — the WS upgrade needs an actual connection.
|
||||
let app = stateless_app();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let url = format!("ws://{addr}/ws/");
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
|
||||
|
||||
// A websocket-declared fn dispatches.
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = socket.next().await.unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
||||
assert_eq!(v["id"], json!(1));
|
||||
assert_eq!(v["result"], json!({"ok": true}));
|
||||
|
||||
// A non-websocket fn over WS is rejected (transport boundary enforced).
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = socket.next().await.unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
||||
assert_eq!(v["id"], json!(2));
|
||||
assert!(v["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("WebSocket transport"));
|
||||
|
||||
server.abort();
|
||||
}
|
||||
101
backends/mizan-tauri/Cargo.lock
generated
101
backends/mizan-tauri/Cargo.lock
generated
@@ -558,7 +558,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1229,15 +1228,6 @@ 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"
|
||||
@@ -1555,7 +1545,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -1564,15 +1554,37 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1670,9 +1682,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -1776,13 +1788,10 @@ name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"hmac",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1799,12 +1808,10 @@ dependencies = [
|
||||
name = "mizan-tauri"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"mizan-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1835,7 +1842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
@@ -1849,7 +1856,7 @@ version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2460,9 +2467,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -2901,12 +2908,6 @@ 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"
|
||||
@@ -3359,21 +3360,9 @@ 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"
|
||||
@@ -3796,9 +3785,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3809,9 +3798,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.72"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -3819,9 +3808,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.122"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3829,9 +3818,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.122"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -3842,9 +3831,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.122"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3898,9 +3887,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -10,8 +10,3 @@ 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"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
|
||||
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//!
|
||||
@@ -10,137 +9,79 @@
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes commands reachable from the JS-side
|
||||
//! `@mizan/tauri-transport`:
|
||||
//! 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.
|
||||
//!
|
||||
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
|
||||
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
|
||||
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
|
||||
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
|
||||
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
|
||||
//! Tauri `Channel<T>` carries the push stream instead.
|
||||
//!
|
||||
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
|
||||
//! Wire envelope:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "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": {} }
|
||||
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror the HTTP adapter:
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//! * `shape` → `{ type, fields }`
|
||||
//! * `form` → the form function's result
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
|
||||
//! equivalent) or a `Bearer <jwt>`; 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};
|
||||
//! 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.
|
||||
|
||||
use mizan_core::{
|
||||
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,
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
Runtime,
|
||||
};
|
||||
|
||||
/// 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`.
|
||||
/// 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`.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::<R>::new("mizan")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
mizan_invoke,
|
||||
mizan_subscribe,
|
||||
ssr::ssr_render
|
||||
])
|
||||
.setup(|app, _api| {
|
||||
if app.try_state::<MizanTauriConfig>().is_none() {
|
||||
app.manage(MizanTauriConfig::default());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||
.build()
|
||||
}
|
||||
|
||||
// === Wire envelope ===
|
||||
|
||||
/// One Mizan request. Tauri's serde deserializer pulls this out of the
|
||||
/// `envelope` field of the invoke payload.
|
||||
/// 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.
|
||||
#[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<String, Value>,
|
||||
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
|
||||
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
#[serde(rename = "fetch")]
|
||||
Fetch {
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
params: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
#[serde(rename = "shape")]
|
||||
Shape {
|
||||
#[serde(rename = "fn")]
|
||||
function_name: String,
|
||||
},
|
||||
#[serde(rename = "form")]
|
||||
Form {
|
||||
form: String,
|
||||
role: String,
|
||||
#[serde(default)]
|
||||
args: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape.
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
@@ -164,336 +105,110 @@ impl From<MizanError> for ErrorPayload {
|
||||
}
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
// === Dispatch ===
|
||||
|
||||
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
|
||||
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
|
||||
/// `INVALID`-sentinel contract); absent → anonymous.
|
||||
fn identity_from_token(
|
||||
token: Option<&str>,
|
||||
config: &MizanTauriConfig,
|
||||
) -> Result<Option<Identity>, MizanError> {
|
||||
let (mwt, bearer) = match token {
|
||||
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
|
||||
Some(t) => (Some(t), None),
|
||||
None => (None, None),
|
||||
};
|
||||
match authenticate(mwt, bearer, &config.auth, now_unix()) {
|
||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
||||
AuthOutcome::Anonymous => Ok(None),
|
||||
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
|
||||
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
|
||||
}
|
||||
|
||||
// === Dispatch commands ===
|
||||
|
||||
/// The single Mizan request/response command. Tauri auto-injects `app`; the
|
||||
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
|
||||
/// 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::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
|
||||
}
|
||||
|
||||
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
|
||||
/// response (or a `MizanError`). This is the programmatic entry point the
|
||||
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
|
||||
/// tests) can drive the Mizan protocol without the IPC serialization layer.
|
||||
pub async fn dispatch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, MizanError> {
|
||||
// Read the managed config (lifetime-bound to `app`, which outlives this
|
||||
// dispatch); fall back to a default if none was registered. The `State`
|
||||
// guard is held across the awaits below.
|
||||
let managed = app.try_state::<MizanTauriConfig>();
|
||||
let default;
|
||||
let cfg: &MizanTauriConfig = match managed.as_ref() {
|
||||
Some(state) => state.inner(),
|
||||
None => {
|
||||
default = MizanTauriConfig::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
match envelope {
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
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,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
cfg: &MizanTauriConfig,
|
||||
fn_name: &str,
|
||||
mut args: Map<String, Value>,
|
||||
token: Option<&str>,
|
||||
) -> Result<Value, MizanError> {
|
||||
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)?;
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||
ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let targets = compute_invalidation(fn_spec, &args);
|
||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
// 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()),
|
||||
);
|
||||
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));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn handle_fetch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
cfg: &MizanTauriConfig,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
token: Option<&str>,
|
||||
) -> Result<Value, MizanError> {
|
||||
let identity = identity_from_token(token, cfg)?;
|
||||
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(MizanError::NotFound(format!(
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
)));
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(MizanError::NotFound(format!(
|
||||
return Err(ErrorPayload::from(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<String, Value> = params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
||||
|
||||
if let Some(cached) = cfg
|
||||
.cache
|
||||
.get(context_name, &cache_params, uid.as_deref(), 0)
|
||||
{
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
|
||||
return Ok(v);
|
||||
}
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
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?;
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
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)
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// `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<Value, MizanError> {
|
||||
let proj = shapes::project_function_output(fn_name)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
|
||||
Ok(projection_to_json(&proj))
|
||||
}
|
||||
|
||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
||||
let mut fields = Vec::new();
|
||||
for f in &proj.fields {
|
||||
match f {
|
||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
||||
shapes::ShapeField::Nested(n, sub) => {
|
||||
fields.push(json!({ n.clone(): projection_to_json(sub) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "type": proj.type_name, "fields": fields })
|
||||
}
|
||||
|
||||
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
|
||||
/// Forms binding). `form_validate` / `form_submit` map to the registered
|
||||
/// function whose `(form_name, form_role)` matches.
|
||||
async fn handle_form<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
match role {
|
||||
"schema" => form_schema(app, form_name).await,
|
||||
"validate" => form_validate(app, form_name, args).await,
|
||||
"submit" => form_submit(app, form_name, args).await,
|
||||
other => Err(MizanError::BadRequest(format!(
|
||||
"unknown form role {other:?} (expected schema|validate|submit)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
||||
FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
||||
}
|
||||
|
||||
async fn dispatch_form_role<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
role: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
let fn_spec = lookup_form_fn(form_name, role)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
|
||||
let args_value = match args {
|
||||
Value::Object(_) | Value::Null => args,
|
||||
other => json!({ "data": other }),
|
||||
};
|
||||
let req = RequestHandle::new(app);
|
||||
fn_spec.dispatch(req, args_value).await
|
||||
}
|
||||
|
||||
async fn form_schema<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "schema", Value::Null).await
|
||||
}
|
||||
|
||||
async fn form_validate<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "validate", args).await
|
||||
}
|
||||
|
||||
async fn form_submit<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
form_name: &str,
|
||||
args: Value,
|
||||
) -> Result<Value, MizanError> {
|
||||
dispatch_form_role(app, form_name, "submit", args).await
|
||||
}
|
||||
|
||||
// === WebSocket-equivalent: IPC subscription channel ===
|
||||
|
||||
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SubscriptionFrame {
|
||||
pub result: Value,
|
||||
pub invalidate: Vec<Value>,
|
||||
}
|
||||
|
||||
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
|
||||
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
|
||||
/// the push stream instead — the IPC transport's co-equal of the HTTP
|
||||
/// WebSocket. The initial dispatch result is emitted immediately on the
|
||||
/// channel; subsequent server-side pushes use the same `on_event` channel.
|
||||
#[tauri::command]
|
||||
async fn mizan_subscribe<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
function_name: String,
|
||||
args: Map<String, Value>,
|
||||
on_event: Channel<SubscriptionFrame>,
|
||||
) -> Result<(), ErrorPayload> {
|
||||
subscribe(&app, &function_name, args, on_event)
|
||||
.await
|
||||
.map_err(ErrorPayload::from)
|
||||
}
|
||||
|
||||
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
|
||||
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
|
||||
/// wraps — exposed for embedders and behavior tests.
|
||||
pub async fn subscribe<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
function_name: &str,
|
||||
args: Map<String, Value>,
|
||||
on_event: Channel<SubscriptionFrame>,
|
||||
) -> Result<(), MizanError> {
|
||||
let fn_spec = lookup_function(function_name)
|
||||
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
|
||||
if fn_spec.private() {
|
||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
||||
}
|
||||
// Only `#[mizan(websocket)]` functions are exposed over the subscription
|
||||
// channel — the same transport boundary the HTTP WebSocket enforces.
|
||||
if !matches!(
|
||||
fn_spec.transport(),
|
||||
mizan_core::Transport::Websocket | mizan_core::Transport::Both
|
||||
) {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"function {function_name:?} is not exposed over the subscription transport"
|
||||
)));
|
||||
}
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
||||
let invalidate = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
|
||||
on_event
|
||||
.send(SubscriptionFrame { result, invalidate })
|
||||
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as input.
|
||||
/// 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.
|
||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
@@ -503,45 +218,3 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bind file parts carried in the IPC envelope into the call args.
|
||||
///
|
||||
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
|
||||
/// JSON object `{filename, content_type, data_b64}` (the JS transport
|
||||
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
|
||||
/// deserializes, so for a single file the arg is already in place. This binder
|
||||
/// performs the one transform IPC needs: a top-level `_files` map
|
||||
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
|
||||
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
|
||||
/// also validates that anything presenting as a file carries `data_b64`,
|
||||
/// surfacing a clear error before the typed `Upload` deserialize runs.
|
||||
fn bind_uploads(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
args: &mut Map<String, Value>,
|
||||
) -> Result<(), MizanError> {
|
||||
if let Some(Value::Object(files)) = args.remove("_files") {
|
||||
for (field, parts) in files {
|
||||
args.insert(field, parts);
|
||||
}
|
||||
}
|
||||
|
||||
// The set of param names this function declares — only validate args that
|
||||
// could land in a typed field.
|
||||
let declared: std::collections::HashSet<&str> =
|
||||
fn_spec.input_params().iter().map(|p| p.name).collect();
|
||||
for (name, value) in args.iter() {
|
||||
if !declared.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Value::Object(obj) = value {
|
||||
let looks_like_file =
|
||||
obj.contains_key("filename") || obj.contains_key("content_type");
|
||||
if looks_like_file && !obj.contains_key("data_b64") {
|
||||
return Err(MizanError::BadRequest(format!(
|
||||
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//! SSR over the IPC transport — drive the Bun renderer through the shared
|
||||
//! `mizan_core::SsrBridge` (the same newline-delimited JSON-RPC protocol the
|
||||
//! Django/FastAPI/axum adapters use). A desktop shell renders React the same
|
||||
//! way the server does: spawn the Bun worker once, drive `renderToString`
|
||||
//! through it, keep it alive.
|
||||
//!
|
||||
//! Exposed as a Tauri command + a managed `MizanSsr` holding the bridge:
|
||||
//!
|
||||
//! invoke('plugin:mizan|ssr_render', { file: '/abs/X.tsx', props: {...} })
|
||||
//! → { html: "<div>...</div>" }
|
||||
|
||||
use mizan_core::SsrBridge;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Manager, Runtime};
|
||||
|
||||
use crate::ErrorPayload;
|
||||
|
||||
/// Managed SSR state — holds the persistent Bun bridge. Register it with
|
||||
/// `app.manage(MizanSsr::new("path/to/worker.tsx"))` to enable `ssr_render`.
|
||||
pub struct MizanSsr {
|
||||
bridge: Arc<SsrBridge>,
|
||||
}
|
||||
|
||||
impl MizanSsr {
|
||||
/// Build an SSR state that launches `bun run <worker_path>` on first render.
|
||||
pub fn new(worker_path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
bridge: Arc::new(SsrBridge::bun(worker_path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared `mizan_core` SSR bridge backing this state — the persistent
|
||||
/// Bun subprocess that runs `renderToString` over JSON-RPC. Exposed so a
|
||||
/// consumer can render directly (e.g. PSR re-render on mutation) without
|
||||
/// going through the `ssr_render` IPC command.
|
||||
pub fn ssr_bridge(&self) -> &SsrBridge {
|
||||
&self.bridge
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SsrResult {
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// `ssr_render` — render a component file to HTML via the Bun SSR worker.
|
||||
/// Requires a managed `MizanSsr` (else returns a NOT_IMPLEMENTED error).
|
||||
#[tauri::command]
|
||||
pub async fn ssr_render<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
file: String,
|
||||
props: Option<Value>,
|
||||
) -> Result<SsrResult, ErrorPayload> {
|
||||
let state = app.try_state::<MizanSsr>().ok_or_else(|| {
|
||||
ErrorPayload::from(mizan_core::MizanError::NotImplementedYet(
|
||||
"no SSR worker configured (app.manage(MizanSsr::new(...)))".into(),
|
||||
))
|
||||
})?;
|
||||
let bridge = state.bridge.clone();
|
||||
let props = props.unwrap_or_else(|| serde_json::json!({}));
|
||||
let html = bridge
|
||||
.render(&file, props)
|
||||
.map_err(|e| ErrorPayload::from(mizan_core::MizanError::InternalError(e.to_string())))?;
|
||||
Ok(SsrResult { html })
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling
|
||||
//! over the source-presence probes. Each IPC-applicable cell is driven through
|
||||
//! the real dispatch path against a mock Tauri `AppHandle`
|
||||
//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel
|
||||
//! frames. The IPC serialization boundary is exercised by Tauri's own
|
||||
//! `get_ipc_response` machinery in integration; here we drive `dispatch` /
|
||||
//! `subscribe` (the programmatic entry points the commands wrap) so the
|
||||
//! protocol logic — auth, cache, upload binding, shapes, forms, subscription —
|
||||
//! is asserted directly.
|
||||
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::{
|
||||
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
||||
};
|
||||
use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::test::mock_app;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
// ─── Fixture functions (auto-registered via linkme at link time) ────────────
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TProfile {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TOk {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TSecret {
|
||||
pub flag: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TUploadEcho {
|
||||
pub filename: String,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
#[mizan::context("tprofile")]
|
||||
pub struct TProfileCtx;
|
||||
|
||||
#[mizan::client(context = TProfileCtx)]
|
||||
pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile {
|
||||
TProfile {
|
||||
user_id,
|
||||
name: format!("user-{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(affects = TProfileCtx)]
|
||||
pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk {
|
||||
let _ = (user_id, name);
|
||||
TOk { ok: true }
|
||||
}
|
||||
|
||||
#[mizan::client(auth = "staff")]
|
||||
pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret {
|
||||
TSecret {
|
||||
flag: "ipc-secret".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(websocket)]
|
||||
pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk {
|
||||
let _ = room;
|
||||
TOk { ok: true }
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho {
|
||||
let _ = user_id;
|
||||
TUploadEcho {
|
||||
filename: avatar.filename.clone().unwrap_or_default(),
|
||||
size: avatar.size() as i64,
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(form_name = "tcontact", form_role = "submit")]
|
||||
pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk {
|
||||
let _ = name;
|
||||
TOk { ok: true }
|
||||
}
|
||||
|
||||
// ─── Harness ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a mock app with the given Mizan config managed.
|
||||
fn app_with(config: MizanTauriConfig) -> AppHandle<tauri::test::MockRuntime> {
|
||||
let app = mock_app();
|
||||
let handle = app.handle().clone();
|
||||
handle.manage(config);
|
||||
// Leak the app so its `AppHandle` stays valid for the test body; the
|
||||
// process tears down at test end.
|
||||
std::mem::forget(app);
|
||||
handle
|
||||
}
|
||||
|
||||
fn rt() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ─── rpc_call + invalidate_body ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn call_returns_result_and_invalidate() {
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
rt().block_on(async {
|
||||
let env = Envelope::Call {
|
||||
function_name: "t_update_profile".into(),
|
||||
args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]),
|
||||
token: None,
|
||||
};
|
||||
let resp = dispatch(&handle, env).await.unwrap();
|
||||
assert_eq!(resp["result"], json!({"ok": true}));
|
||||
// IPC carries invalidation in the envelope (no header channel).
|
||||
assert_eq!(
|
||||
resp["invalidate"],
|
||||
json!([{"context": "tprofile", "params": {"user_id": 7}}])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auth_guard_over_ipc() {
|
||||
rt().block_on(async {
|
||||
// No auth config → anonymous → staff-guarded fn rejected.
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
let err = dispatch(
|
||||
&handle,
|
||||
Envelope::Call {
|
||||
function_name: "t_secret".into(),
|
||||
args: Map::new(),
|
||||
token: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
||||
|
||||
// Staff JWT on the envelope token → admitted.
|
||||
let cfg = JwtConfig::new("ipc-secret");
|
||||
let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix());
|
||||
let config = MizanTauriConfig {
|
||||
auth: AuthConfig {
|
||||
jwt: Some(cfg),
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".into(),
|
||||
},
|
||||
cache: CacheOrchestrator::disabled(),
|
||||
};
|
||||
let handle = app_with(config);
|
||||
let resp = dispatch(
|
||||
&handle,
|
||||
Envelope::Call {
|
||||
function_name: "t_secret".into(),
|
||||
args: Map::new(),
|
||||
token: Some(format!("Bearer {token}")),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["result"]["flag"], json!("ipc-secret"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_rejected_over_ipc() {
|
||||
rt().block_on(async {
|
||||
let config = MizanTauriConfig {
|
||||
auth: AuthConfig {
|
||||
jwt: Some(JwtConfig::new("ipc-secret")),
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".into(),
|
||||
},
|
||||
cache: CacheOrchestrator::disabled(),
|
||||
};
|
||||
let handle = app_with(config);
|
||||
let err = dispatch(
|
||||
&handle,
|
||||
Envelope::Fetch {
|
||||
context: "tprofile".into(),
|
||||
params: obj(&[("user_id", json!(1))]),
|
||||
token: Some("Bearer garbage".into()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── origin_cache ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fetch_uses_origin_cache() {
|
||||
rt().block_on(async {
|
||||
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
||||
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into()));
|
||||
let config = MizanTauriConfig {
|
||||
auth: AuthConfig::new(),
|
||||
cache,
|
||||
};
|
||||
let handle = app_with(config);
|
||||
|
||||
let fetch = || Envelope::Fetch {
|
||||
context: "tprofile".into(),
|
||||
params: obj(&[("user_id", json!(3))]),
|
||||
token: None,
|
||||
};
|
||||
|
||||
let first = dispatch(&handle, fetch()).await.unwrap();
|
||||
assert_eq!(first["t_user_profile"]["user_id"], json!(3));
|
||||
|
||||
// The cache now holds the bundle — confirm a key exists under the
|
||||
// context prefix (proves the put happened).
|
||||
let key = mizan::derive_cache_key(
|
||||
"ipc-cache-secret",
|
||||
"tprofile",
|
||||
&std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]),
|
||||
None,
|
||||
0,
|
||||
);
|
||||
assert!(backend.get(&key).is_some(), "fetch populated the origin cache");
|
||||
|
||||
// Second fetch returns the same bundle (served from cache).
|
||||
let second = dispatch(&handle, fetch()).await.unwrap();
|
||||
assert_eq!(first, second);
|
||||
|
||||
// A scoped mutation purges the key.
|
||||
let _ = dispatch(
|
||||
&handle,
|
||||
Envelope::Call {
|
||||
function_name: "t_update_profile".into(),
|
||||
args: obj(&[("user_id", json!(3)), ("name", json!("New"))]),
|
||||
token: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(backend.get(&key).is_none(), "mutation purged the cache key");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── upload ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn upload_binds_from_envelope() {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
rt().block_on(async {
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
let data = b"IPC-FILE-BYTES";
|
||||
let file = json!({
|
||||
"filename": "a.png",
|
||||
"content_type": "image/png",
|
||||
"data_b64": STANDARD.encode(data),
|
||||
});
|
||||
let resp = dispatch(
|
||||
&handle,
|
||||
Envelope::Call {
|
||||
function_name: "t_set_avatar".into(),
|
||||
args: obj(&[("user_id", json!(9)), ("avatar", file)]),
|
||||
token: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["result"]["filename"], json!("a.png"));
|
||||
assert_eq!(resp["result"]["size"], json!(data.len()));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── shapes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn shape_op_projects_output() {
|
||||
rt().block_on(async {
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
let resp = dispatch(
|
||||
&handle,
|
||||
Envelope::Shape {
|
||||
function_name: "t_user_profile".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["type"], json!("tUserProfileOutput"));
|
||||
let fields = resp["fields"].as_array().unwrap();
|
||||
assert!(fields.contains(&json!("user_id")));
|
||||
assert!(fields.contains(&json!("name")));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── forms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn form_submit_op() {
|
||||
rt().block_on(async {
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
let resp = dispatch(
|
||||
&handle,
|
||||
Envelope::Form {
|
||||
form: "tcontact".into(),
|
||||
role: "submit".into(),
|
||||
args: json!({"name": "Ada"}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp, json!({"ok": true}));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── websocket-equivalent: subscription channel ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn subscription_pushes_frame_and_rejects_non_ws_fn() {
|
||||
rt().block_on(async {
|
||||
let handle = app_with(MizanTauriConfig::default());
|
||||
|
||||
// A websocket-declared fn pushes a frame on the channel.
|
||||
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
let channel: Channel<SubscriptionFrame> = Channel::new(move |body| {
|
||||
// The channel serializes the SubscriptionFrame to JSON; read it
|
||||
// back as a generic Value.
|
||||
let v: Value = body.deserialize().unwrap_or(Value::Null);
|
||||
sink.lock().unwrap().push(v);
|
||||
Ok(())
|
||||
});
|
||||
subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let frames = captured.lock().unwrap();
|
||||
assert_eq!(frames.len(), 1, "subscription pushed exactly one frame");
|
||||
assert_eq!(frames[0]["result"], json!({"ok": true}));
|
||||
|
||||
// A non-websocket fn over the subscription transport is rejected.
|
||||
let reject_channel: Channel<SubscriptionFrame> = Channel::new(|_| Ok(()));
|
||||
let err = subscribe(
|
||||
&handle,
|
||||
"t_user_profile",
|
||||
obj(&[("user_id", json!(1))]),
|
||||
reject_channel,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.message().contains("subscription transport"));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
}
|
||||
@@ -5,31 +5,15 @@
|
||||
"": {
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"bun-types": "latest",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"license": "Elastic-2.0"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||
import { register } from './registry'
|
||||
|
||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||
@@ -21,25 +21,6 @@ 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,
|
||||
* 'staff'/'superuser' pass through, anything else throws at decoration time.
|
||||
*/
|
||||
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
|
||||
if (auth === undefined) return undefined
|
||||
if (auth === true) return 'required'
|
||||
if (typeof auth === 'function') return auth
|
||||
if (auth === 'staff' || auth === 'superuser') return auth
|
||||
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
|
||||
}
|
||||
|
||||
function normalizeAffects(
|
||||
affects: ClientOptions['affects'],
|
||||
): RegistryEntry['affects'] | undefined {
|
||||
@@ -71,36 +52,6 @@ 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.
|
||||
*
|
||||
@@ -121,19 +72,69 @@ export function client<T extends (...args: any[]) => Promise<any>>(
|
||||
*/
|
||||
export function client(options: ClientOptions): MethodDecorator
|
||||
|
||||
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
||||
export function client(optionsOrFn: ClientOptions | 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'
|
||||
register(buildEntry(options, name, fn))
|
||||
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: options.auth,
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
|
||||
register(entry)
|
||||
return fn
|
||||
}
|
||||
|
||||
// Decorator form: @client(options)
|
||||
const options = optionsOrFn as ClientOptions
|
||||
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
register(buildEntry(options, propertyKey, descriptor.value))
|
||||
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: options.auth,
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
|
||||
register(entry)
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
import { getFunction, getContextGroups } from './registry'
|
||||
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
|
||||
|
||||
@@ -25,54 +22,6 @@ export interface MizanResponse {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface AuthDenial {
|
||||
status: 401 | 403
|
||||
code: 'UNAUTHORIZED' | 'FORBIDDEN'
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `identity` satisfies the stored `auth` requirement.
|
||||
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
|
||||
* on failure, or null when access is allowed.
|
||||
*/
|
||||
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
|
||||
if (auth === undefined) return null
|
||||
|
||||
// Callable runs first — before the authentication gate.
|
||||
if (typeof auth === 'function') {
|
||||
try {
|
||||
return auth(identity)
|
||||
? null
|
||||
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
|
||||
} catch (e: any) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!identity.isAuthenticated) {
|
||||
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
|
||||
}
|
||||
|
||||
if (auth === 'staff' && !identity.isStaff) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
|
||||
}
|
||||
|
||||
if (auth === 'superuser' && !identity.isSuperuser) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function authDenialResponse(denial: AuthDenial): MizanResponse {
|
||||
return {
|
||||
status: denial.status,
|
||||
body: { error: true, code: denial.code, message: denial.message },
|
||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /api/mizan/ctx/:contextName/
|
||||
*
|
||||
@@ -81,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse {
|
||||
export async function handleContextFetch(
|
||||
contextName: string,
|
||||
params: Record<string, string>,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanResponse> {
|
||||
const groups = getContextGroups()
|
||||
const fnNames = groups[contextName]
|
||||
@@ -94,15 +42,6 @@ export async function handleContextFetch(
|
||||
}
|
||||
}
|
||||
|
||||
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
|
||||
// leak to an unauthorized caller. Any denial short-circuits, uncached.
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (!entry) continue
|
||||
const denial = checkAuth(entry.auth, identity)
|
||||
if (denial) return authDenialResponse(denial)
|
||||
}
|
||||
|
||||
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||
let effectiveRev = 0
|
||||
for (const fnName of fnNames) {
|
||||
@@ -187,15 +126,13 @@ export async function handleContextFetch(
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /api/mizan/call/ — JSON body form.
|
||||
* Handle POST /api/mizan/call/
|
||||
*
|
||||
* Dispatches to a named function. Returns result + invalidation. The multipart
|
||||
* form (`handleMultipartCall`) binds file parts first, then routes here.
|
||||
* Dispatches to a named function. Returns result + invalidation.
|
||||
*/
|
||||
export async function handleMutationCall(
|
||||
fnName: string,
|
||||
args: Record<string, any>,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanResponse> {
|
||||
const entry = getFunction(fnName)
|
||||
|
||||
@@ -216,10 +153,6 @@ export async function handleMutationCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Auth enforcement — after private rejection, before execution.
|
||||
const denial = checkAuth(entry.auth, identity)
|
||||
if (denial) return authDenialResponse(denial)
|
||||
|
||||
try {
|
||||
const argValues = entry.params.map(p => args[p.name])
|
||||
const result = await entry.fn(...argValues)
|
||||
@@ -274,63 +207,3 @@ export async function handleMutationCall(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function badRequest(message: string): MizanResponse {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: true, code: 'BAD_REQUEST', message },
|
||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /api/mizan/call/ — multipart/form-data form.
|
||||
*
|
||||
* Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields
|
||||
* arrive in a JSON `args` part, and each file part binds into the function's
|
||||
* Upload-typed inputs (by field name) with declared `File(...)` constraints
|
||||
* enforced. After binding, execution is identical to the JSON path.
|
||||
*
|
||||
* A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other
|
||||
* parts that share an Upload field name are accepted too.
|
||||
*/
|
||||
export async function handleMultipartCall(
|
||||
form: FormData,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanResponse> {
|
||||
const fnRaw = form.get('fn')
|
||||
if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field")
|
||||
const fnName = fnRaw
|
||||
|
||||
const argsRaw = form.get('args')
|
||||
let args: Record<string, any>
|
||||
try {
|
||||
args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {}
|
||||
} catch {
|
||||
return badRequest("Invalid JSON in 'args' field")
|
||||
}
|
||||
if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object")
|
||||
|
||||
const entry = getFunction(fnName)
|
||||
if (entry) {
|
||||
// Collect file parts by field name into UploadedFile buckets.
|
||||
const files = new Map<string, UploadedFile[]>()
|
||||
for (const key of new Set(form.keys())) {
|
||||
if (key === 'fn' || key === 'args') continue
|
||||
const bucket: UploadedFile[] = []
|
||||
for (const part of form.getAll(key)) {
|
||||
if (part instanceof Blob) {
|
||||
const data = new Uint8Array(await part.arrayBuffer())
|
||||
const filename = part instanceof File ? part.name : null
|
||||
bucket.push(new UploadedFile(filename, part.type || null, data))
|
||||
}
|
||||
}
|
||||
if (bucket.length > 0) files.set(key, bucket)
|
||||
}
|
||||
|
||||
const err = bindUploads(entry, args, files)
|
||||
if (err !== null) return badRequest(err)
|
||||
}
|
||||
|
||||
return handleMutationCall(fnName, args, identity)
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Forms — schema / validate / submit, AFI-common.
|
||||
*
|
||||
* The binding is per-framework (Django Forms on Django; the project's form
|
||||
* layer elsewhere). The TypeScript binding registers the same three `@client`
|
||||
* functions `create_form_functions` registers, carrying the same
|
||||
* `{ form, form_name, form_role }` meta the IR reads — `<name>-schema`,
|
||||
* `<name>-validate`, and (when a submit handler is given) `<name>-submit`.
|
||||
*
|
||||
* schema → { fields: FieldSchema[] } — field definitions
|
||||
* validate → { valid: boolean, errors: {field: [..]} } — per-field validation
|
||||
* submit → the handler's return value — validate-then-handle
|
||||
*
|
||||
* A `FormField` declares its type/required/label and an optional `validate`
|
||||
* predicate; `validateForm` runs every field's validator over the submitted
|
||||
* data, mirroring Django's `form.is_valid()` / `form.errors`.
|
||||
*/
|
||||
|
||||
import { client } from './decorator'
|
||||
import type { FormRole } from './types'
|
||||
|
||||
export interface FormField {
|
||||
name: string
|
||||
type?: string
|
||||
required?: boolean
|
||||
label?: string
|
||||
helpText?: string
|
||||
choices?: Array<{ value: string; label: string }>
|
||||
initial?: unknown
|
||||
/**
|
||||
* Field validator. Return an error message (or array of messages) to
|
||||
* reject, or null/undefined to accept. Required-ness is enforced before
|
||||
* the validator runs.
|
||||
*/
|
||||
validate?: (value: unknown, data: Record<string, unknown>) => string | string[] | null | undefined
|
||||
}
|
||||
|
||||
export interface FormDefinition {
|
||||
fields: FormField[]
|
||||
}
|
||||
|
||||
export interface FieldSchema {
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
label: string
|
||||
helpText: string
|
||||
choices: Array<{ value: string; label: string }> | null
|
||||
initial: unknown
|
||||
}
|
||||
|
||||
export interface FormSchemaOutput {
|
||||
fields: FieldSchema[]
|
||||
}
|
||||
|
||||
export interface FormValidationOutput {
|
||||
valid: boolean
|
||||
errors: Record<string, string[]>
|
||||
}
|
||||
|
||||
function titleize(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Build the field-definition schema for a form. Mirrors `build_form_schema`. */
|
||||
export function formSchema(def: FormDefinition): FormSchemaOutput {
|
||||
return {
|
||||
fields: def.fields.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type ?? 'text',
|
||||
required: f.required ?? true,
|
||||
label: f.label ?? titleize(f.name),
|
||||
helpText: f.helpText ?? '',
|
||||
choices: f.choices ?? null,
|
||||
initial: f.initial ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted `data` against a form. Required fields missing/empty and
|
||||
* any field whose `validate` returns a message produce per-field errors.
|
||||
* Mirrors Django's `is_valid()` → `{valid, errors}`.
|
||||
*/
|
||||
export function validateForm(def: FormDefinition, data: Record<string, unknown>): FormValidationOutput {
|
||||
const errors: Record<string, string[]> = {}
|
||||
|
||||
for (const field of def.fields) {
|
||||
const value = data[field.name]
|
||||
const missing = value === undefined || value === null || value === ''
|
||||
if ((field.required ?? true) && missing) {
|
||||
errors[field.name] = ['This field is required.']
|
||||
continue
|
||||
}
|
||||
if (missing) continue
|
||||
const result = field.validate?.(value, data)
|
||||
if (result !== null && result !== undefined) {
|
||||
errors[field.name] = Array.isArray(result) ? result : [result]
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
/** A submit handler runs after validation passes. */
|
||||
export type FormSubmitHandler = (data: Record<string, unknown>) => unknown | Promise<unknown>
|
||||
|
||||
export interface FormRegistration {
|
||||
schema: string
|
||||
validate: string
|
||||
submit?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a form's schema / validate / submit functions with the registry.
|
||||
*
|
||||
* Equivalent to Python's `register_form`: three `@client` functions named
|
||||
* `<name>-schema`, `<name>-validate`, `<name>-submit`, each carrying
|
||||
* `{ form, formName, formRole }` so the IR emits `is-form`/`form-name`/
|
||||
* `form-role`. `submit` is registered only when a handler is supplied.
|
||||
*
|
||||
* Returns the registered wire names.
|
||||
*/
|
||||
export function registerForm(
|
||||
def: FormDefinition,
|
||||
name: string,
|
||||
options: { submit?: FormSubmitHandler } = {},
|
||||
): FormRegistration {
|
||||
const role = (r: FormRole) => ({ form: true, formName: name, formRole: r })
|
||||
|
||||
const schemaName = `${name}-schema`
|
||||
const validateName = `${name}-validate`
|
||||
const submitName = `${name}-submit`
|
||||
|
||||
// schema — returns the field definitions.
|
||||
const schemaFn = async function () {
|
||||
return formSchema(def)
|
||||
}
|
||||
Object.defineProperty(schemaFn, 'name', { value: schemaName })
|
||||
client(role('schema'), schemaFn)
|
||||
|
||||
// validate — runs per-field validation over the submitted data.
|
||||
const validateFn = async function (data: Record<string, unknown>) {
|
||||
return validateForm(def, data)
|
||||
}
|
||||
Object.defineProperty(validateFn, 'name', { value: validateName })
|
||||
client(role('validate'), validateFn)
|
||||
|
||||
const registration: FormRegistration = { schema: schemaName, validate: validateName }
|
||||
|
||||
// submit — validate, then hand off. Registered only with a handler.
|
||||
if (options.submit) {
|
||||
const handler = options.submit
|
||||
const submitFn = async function (data: Record<string, unknown>) {
|
||||
const validation = validateForm(def, data)
|
||||
if (!validation.valid) {
|
||||
return { ok: false, errors: validation.errors }
|
||||
}
|
||||
const result = await handler(data)
|
||||
return { ok: true, result }
|
||||
}
|
||||
Object.defineProperty(submitFn, 'name', { value: submitName })
|
||||
client(role('submit'), submitFn)
|
||||
registration.submit = submitName
|
||||
}
|
||||
|
||||
return registration
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Identity abstraction — the request-bound caller identity.
|
||||
*
|
||||
* Framework-agnostic. Adapters construct an Identity (from MWT, JWT,
|
||||
* session, etc.) and pass it into dispatch. ANONYMOUS is the default.
|
||||
*/
|
||||
|
||||
export interface Identity {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
isSuperuser: boolean
|
||||
id: number | string | null
|
||||
}
|
||||
|
||||
export const ANONYMOUS: Identity = {
|
||||
isAuthenticated: false,
|
||||
isStaff: false,
|
||||
isSuperuser: false,
|
||||
id: null,
|
||||
}
|
||||
|
||||
export type AuthPredicate = (identity: Identity) => boolean
|
||||
@@ -1,63 +1,17 @@
|
||||
export { ReactContext } 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,
|
||||
signHs256,
|
||||
signMwt,
|
||||
mintMwt,
|
||||
computePermissionKey,
|
||||
signJwt,
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
mintJwt,
|
||||
} from './token'
|
||||
export type { MwtPayload, MintUser, JwtConfig, JwtMintClaims, JwtTokenPair } from './token'
|
||||
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
|
||||
|
||||
export { client } from './decorator'
|
||||
|
||||
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
||||
|
||||
export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch'
|
||||
export { handleContextFetch, handleMutationCall } 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'
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
/**
|
||||
* KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
||||
*
|
||||
* The Python emitter is the spec; this is a second implementation under the
|
||||
* same contract. `buildIr()` walks the registry, resolves the canonical named
|
||||
* types each function references (`_collect_named_types`), and emits KDL the
|
||||
* Rust codegen consumes. Any divergence is a bug here, not a contract change —
|
||||
* `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`.
|
||||
*/
|
||||
|
||||
import { getAllFunctions, getContextGroups, getFunction } from '../registry'
|
||||
import type { RegistryEntry } from '../types'
|
||||
import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types'
|
||||
|
||||
const INDENT = ' '
|
||||
|
||||
// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ────────────────────────────
|
||||
|
||||
function kdlString(s: string): string {
|
||||
const escaped = s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
function kdlBool(b: boolean): string {
|
||||
return b ? '#true' : '#false'
|
||||
}
|
||||
|
||||
function kdlDefault(v: DefaultValue): string {
|
||||
switch (v.kind) {
|
||||
case 'null':
|
||||
return '#null'
|
||||
case 'boolean':
|
||||
return kdlBool(v.value)
|
||||
case 'integer':
|
||||
return String(v.value)
|
||||
case 'number':
|
||||
// Match Python's repr(float): whole-number floats render as "1.0".
|
||||
return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value)
|
||||
case 'string':
|
||||
return kdlString(v.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */
|
||||
export function snakeToCamel(name: string): string {
|
||||
const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_')
|
||||
return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
function primitiveName(p: Primitive): string {
|
||||
return p
|
||||
}
|
||||
|
||||
// ─── Emitter ──────────────────────────────────────────────────────────────
|
||||
|
||||
class Emitter {
|
||||
lines: string[] = []
|
||||
|
||||
private prefix(indent: number): string {
|
||||
return INDENT.repeat(indent)
|
||||
}
|
||||
|
||||
leaf(indent: number, ...parts: string[]): void {
|
||||
this.lines.push(this.prefix(indent) + parts.join(' '))
|
||||
}
|
||||
|
||||
open(indent: number, ...parts: string[]): void {
|
||||
this.lines.push(this.prefix(indent) + parts.join(' ') + ' {')
|
||||
}
|
||||
|
||||
close(indent: number): void {
|
||||
this.lines.push(this.prefix(indent) + '}')
|
||||
}
|
||||
|
||||
blank(): void {
|
||||
this.lines.push('')
|
||||
}
|
||||
|
||||
emitTypeChild(indent: number, shape: TypeShape): void {
|
||||
switch (shape.kind) {
|
||||
case 'primitive':
|
||||
this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive)))
|
||||
return
|
||||
case 'ref':
|
||||
this.leaf(indent, 'ref', kdlString(shape.name))
|
||||
return
|
||||
case 'list':
|
||||
this.open(indent, 'list')
|
||||
this.emitTypeChild(indent + 1, shape.inner)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'optional':
|
||||
this.open(indent, 'optional')
|
||||
this.emitTypeChild(indent + 1, shape.inner)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'enum':
|
||||
this.leaf(indent, 'enum', ...shape.variants.map(kdlString))
|
||||
return
|
||||
case 'union':
|
||||
this.open(indent, 'union')
|
||||
for (const b of shape.branches) this.emitTypeChild(indent + 1, b)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'upload':
|
||||
this.emitUpload(indent, shape)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpload(indent: number, shape: Extract<TypeShape, { kind: 'upload' }>): void {
|
||||
const props: string[] = []
|
||||
if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`)
|
||||
if (shape.contentTypes && shape.contentTypes.length > 0) {
|
||||
this.open(indent, 'upload', ...props)
|
||||
for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct))
|
||||
this.close(indent)
|
||||
} else {
|
||||
this.leaf(indent, 'upload', ...props)
|
||||
}
|
||||
}
|
||||
|
||||
emitNamedType(indent: number, name: string, body: NamedType): void {
|
||||
this.open(indent, 'type', kdlString(name))
|
||||
if (body.kind === 'struct') {
|
||||
this.open(indent + 1, 'struct')
|
||||
for (const field of body.fields) this.emitStructField(indent + 2, field)
|
||||
this.close(indent + 1)
|
||||
} else if (body.kind === 'alias') {
|
||||
this.open(indent + 1, 'alias')
|
||||
this.emitTypeChild(indent + 2, body.inner)
|
||||
this.close(indent + 1)
|
||||
} else {
|
||||
this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString))
|
||||
}
|
||||
this.close(indent)
|
||||
}
|
||||
|
||||
emitStructField(indent: number, field: StructField): void {
|
||||
const header: string[] = ['field', kdlString(field.name)]
|
||||
if (!field.required) {
|
||||
header.push(`required=${kdlBool(false)}`)
|
||||
if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`)
|
||||
}
|
||||
this.open(indent, ...header)
|
||||
this.emitTypeChild(indent + 1, field.shape)
|
||||
this.close(indent)
|
||||
}
|
||||
|
||||
intoString(): string {
|
||||
const lines = [...this.lines]
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ─────────────
|
||||
|
||||
/** Strip Optional[T] → [inner, isOptional]. */
|
||||
function stripOptional(shape: TypeShape): [TypeShape, boolean] {
|
||||
if (shape.kind === 'optional') return [shape.inner, true]
|
||||
return [shape, false]
|
||||
}
|
||||
|
||||
/** list element type, or null. */
|
||||
function listElement(shape: TypeShape): TypeShape | null {
|
||||
if (shape.kind === 'list') return shape.inner
|
||||
return null
|
||||
}
|
||||
|
||||
/** All ref names reachable inside a shape. */
|
||||
function refsIn(shape: TypeShape): string[] {
|
||||
switch (shape.kind) {
|
||||
case 'ref':
|
||||
return [shape.name]
|
||||
case 'list':
|
||||
case 'optional':
|
||||
return refsIn(shape.inner)
|
||||
case 'union':
|
||||
return shape.branches.flatMap(refsIn)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** All ref names a NamedType body references. */
|
||||
function refsInBody(body: NamedType): string[] {
|
||||
if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape))
|
||||
if (body.kind === 'alias') return refsIn(body.inner)
|
||||
return []
|
||||
}
|
||||
|
||||
interface FnTypeInfo {
|
||||
schema: import('./types').IrSchema
|
||||
camel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* First pass: collect every named type the IR's `function` section references,
|
||||
* keyed by emitted name. Two kinds, exactly as `_collect_named_types`:
|
||||
* - structs visited anywhere in input/output traversal (under their ref name,
|
||||
* and under the canonical `<camel>Input` / `<camel>Output` rename)
|
||||
* - output wrapper aliases (`<camel>Output = list[T]` / primitive / renamed
|
||||
* model) so the consumer has one named type to reference.
|
||||
*/
|
||||
function collectNamedTypes(fns: Map<string, FnTypeInfo>): Record<string, NamedType> {
|
||||
const seen: Record<string, NamedType> = {}
|
||||
|
||||
function visitModel(name: string, types: Record<string, NamedType>): void {
|
||||
if (name in seen) return
|
||||
const body = types[name]
|
||||
if (body === undefined) {
|
||||
throw new Error(
|
||||
`IR schema references type "${name}" but no definition was provided in the function's \`types\`.`,
|
||||
)
|
||||
}
|
||||
seen[name] = body
|
||||
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||
}
|
||||
|
||||
function visitShape(shape: TypeShape, types: Record<string, NamedType>): void {
|
||||
for (const ref of refsIn(shape)) visitModel(ref, types)
|
||||
}
|
||||
|
||||
for (const { schema, camel } of fns.values()) {
|
||||
const types = schema.types ?? {}
|
||||
|
||||
// Input — named `<camel>Input`, emitted as a struct.
|
||||
if (schema.input && schema.input.length > 0) {
|
||||
const inputName = `${camel}Input`
|
||||
if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input }
|
||||
// Visit nested refs in the input fields.
|
||||
for (const field of schema.input) visitShape(field.shape, types)
|
||||
}
|
||||
|
||||
// Output.
|
||||
if (schema.output === undefined) continue
|
||||
const outputName = `${camel}Output`
|
||||
const [inner] = stripOptional(schema.output)
|
||||
const elem = listElement(inner)
|
||||
|
||||
if (elem !== null) {
|
||||
// list[T] (possibly Optional) — list alias. Visit element type.
|
||||
visitShape(schema.output, types)
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
} else if (inner.kind === 'ref') {
|
||||
// <Model> or Optional[<Model>] — emit the model under the canonical
|
||||
// output name (rename). Python renames the Pydantic model to
|
||||
// `<camel>Output`; we emit the referenced struct under that name.
|
||||
const refName = inner.name
|
||||
const body = types[refName]
|
||||
if (body === undefined) {
|
||||
throw new Error(
|
||||
`IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`,
|
||||
)
|
||||
}
|
||||
if (body.kind === 'struct') {
|
||||
// Emit the struct under the canonical output name (the rename),
|
||||
// and visit its nested refs.
|
||||
if (!(outputName in seen)) {
|
||||
seen[outputName] = body
|
||||
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||
}
|
||||
} else {
|
||||
// Non-struct named type referenced as output — emit under its
|
||||
// own name plus a canonical alias.
|
||||
visitModel(refName, types)
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
}
|
||||
} else {
|
||||
// Primitive-wrapped output (`result: int`) — alias.
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
}
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
// ─── Function / context emission ──────────────────────────────────────────
|
||||
|
||||
function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } {
|
||||
const camel = snakeToCamel(entry.name)
|
||||
const canonical = `${camel}Output`
|
||||
const schema = entry.ir
|
||||
if (!schema || schema.output === undefined) return { name: canonical, nullable: false }
|
||||
const [, nullable] = stripOptional(schema.output)
|
||||
return { name: canonical, nullable }
|
||||
}
|
||||
|
||||
function emitFunction(em: Emitter, entry: RegistryEntry): void {
|
||||
const camel = snakeToCamel(entry.name)
|
||||
const schema = entry.ir ?? {}
|
||||
const hasInput = !!(schema.input && schema.input.length > 0)
|
||||
const { name: outputName, nullable } = resolveOutput(entry)
|
||||
|
||||
em.open(0, 'function', kdlString(entry.name))
|
||||
em.leaf(1, 'camel', kdlString(camel))
|
||||
em.leaf(1, 'has-input', kdlBool(hasInput))
|
||||
if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`))
|
||||
em.leaf(1, 'output', kdlString(outputName))
|
||||
if (nullable) em.leaf(1, 'output-nullable', kdlBool(true))
|
||||
em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http'))
|
||||
if (entry.context) em.leaf(1, 'context', kdlString(entry.context))
|
||||
// Only context-typed affects make it into the KDL (matches ir.py).
|
||||
for (const a of entry.affects ?? []) {
|
||||
if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name))
|
||||
}
|
||||
for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m))
|
||||
if (entry.form) {
|
||||
em.leaf(1, 'is-form', kdlBool(true))
|
||||
if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName))
|
||||
if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole))
|
||||
}
|
||||
em.close(0)
|
||||
}
|
||||
|
||||
function annotationToPrimitive(shape: TypeShape | undefined): Primitive {
|
||||
if (shape === undefined) return 'string'
|
||||
const [inner] = stripOptional(shape)
|
||||
if (inner.kind === 'primitive') return inner.primitive
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void {
|
||||
// Collect param info across every function in the context.
|
||||
interface Slot {
|
||||
type: Primitive
|
||||
sharedBy: string[]
|
||||
}
|
||||
const paramInfo = new Map<string, Slot>()
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (!entry) continue
|
||||
const input = entry.ir?.input
|
||||
if (!input || input.length === 0) continue
|
||||
for (const field of input) {
|
||||
let slot = paramInfo.get(field.name)
|
||||
if (!slot) {
|
||||
slot = { type: 'string', sharedBy: [] }
|
||||
paramInfo.set(field.name, slot)
|
||||
}
|
||||
slot.type = annotationToPrimitive(field.shape)
|
||||
slot.sharedBy.push(fnName)
|
||||
}
|
||||
}
|
||||
|
||||
em.open(0, 'context', kdlString(ctxName))
|
||||
// Members alphabetical — canonical order.
|
||||
for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName))
|
||||
for (const paramName of [...paramInfo.keys()].sort()) {
|
||||
const slot = paramInfo.get(paramName)!
|
||||
const required = slot.sharedBy.length === fnNames.length
|
||||
em.open(1, 'param', kdlString(paramName))
|
||||
em.leaf(2, 'type', kdlString(slot.type))
|
||||
em.leaf(2, 'required', kdlBool(required))
|
||||
for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer))
|
||||
em.close(1)
|
||||
}
|
||||
em.close(0)
|
||||
}
|
||||
|
||||
// ─── Top-level builder ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the Mizan IR (KDL) for every registered function. Byte-equivalent to
|
||||
* the Python `build_ir()` against the same registry.
|
||||
*
|
||||
* `private` and view-path functions are excluded from the function section,
|
||||
* matching ir.py.
|
||||
*/
|
||||
export function buildIr(): string {
|
||||
const functions = getAllFunctions()
|
||||
const contextGroups = getContextGroups()
|
||||
|
||||
// Functions contributing to the type/function sections (skip private + view).
|
||||
const typeFns = new Map<string, FnTypeInfo>()
|
||||
const emitFns: RegistryEntry[] = []
|
||||
for (const [name, entry] of functions) {
|
||||
if (entry.private || entry.viewPath) continue
|
||||
typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) })
|
||||
emitFns.push(entry)
|
||||
}
|
||||
|
||||
const namedTypes = collectNamedTypes(typeFns)
|
||||
|
||||
const em = new Emitter()
|
||||
|
||||
// Types — alphabetical by name (canonical IR ordering).
|
||||
const typeNames = Object.keys(namedTypes).sort()
|
||||
for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName])
|
||||
if (typeNames.length > 0) em.blank()
|
||||
|
||||
// Functions — alphabetical by wire name.
|
||||
emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
for (const entry of emitFns) emitFunction(em, entry)
|
||||
if (emitFns.length > 0) em.blank()
|
||||
|
||||
// Contexts — alphabetical by name.
|
||||
const ctxNames = Object.keys(contextGroups).sort()
|
||||
for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName])
|
||||
if (ctxNames.length > 0) em.blank()
|
||||
|
||||
return em.intoString()
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and
|
||||
* `cores/mizan-rust/src/ir.rs` 1:1.
|
||||
*
|
||||
* The IR is the contract. Backends emit it; the codegen consumes it. The
|
||||
* TypeScript side produces byte-equivalent KDL to the Python emitter against
|
||||
* the same function registry.
|
||||
*
|
||||
* TypeScript has no Pydantic to introspect, so the `@client` decorator carries
|
||||
* an explicit IR type schema (input fields + output shape). That schema is the
|
||||
* binding: a TS backend declares its IR types, and `buildIr()` emits the KDL
|
||||
* the codegen reads — exactly as the Rust adapter declares typed `StructField`
|
||||
* / `TypeShape` registrations.
|
||||
*/
|
||||
|
||||
export type Primitive = 'integer' | 'number' | 'boolean' | 'string'
|
||||
|
||||
/**
|
||||
* An in-place type shape — referenced from struct fields, function
|
||||
* inputs/outputs, and alias bodies.
|
||||
*/
|
||||
export type TypeShape =
|
||||
| { kind: 'primitive'; primitive: Primitive }
|
||||
| { kind: 'ref'; name: string }
|
||||
| { kind: 'list'; inner: TypeShape }
|
||||
| { kind: 'optional'; inner: TypeShape }
|
||||
| { kind: 'enum'; variants: string[] }
|
||||
| { kind: 'union'; branches: TypeShape[] }
|
||||
| { kind: 'upload'; maxSize?: number; contentTypes?: string[] }
|
||||
|
||||
export type DefaultValue =
|
||||
| { kind: 'integer'; value: number }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'null' }
|
||||
|
||||
export interface StructField {
|
||||
name: string
|
||||
required: boolean
|
||||
default?: DefaultValue
|
||||
shape: TypeShape
|
||||
}
|
||||
|
||||
/** A named type that appears in the IR's `type "<Name>" { ... }` section. */
|
||||
export type NamedType =
|
||||
| { kind: 'struct'; fields: StructField[] }
|
||||
| { kind: 'alias'; inner: TypeShape }
|
||||
| { kind: 'enum'; variants: string[] }
|
||||
|
||||
/**
|
||||
* The IR type schema a `@client` function carries.
|
||||
*
|
||||
* `input` is the ordered list of input fields (already excluding the implicit
|
||||
* request/identity arg). When absent or empty, the function `has-input #false`.
|
||||
*
|
||||
* `output` is the function's return shape: a `ref` to a named struct, a `list`,
|
||||
* an `optional`, or a `primitive`. The emitter derives the canonical
|
||||
* `<camel>Input` / `<camel>Output` names and the struct-vs-alias split exactly
|
||||
* as `_collect_named_types` does.
|
||||
*
|
||||
* `types` resolves every `ref` used in `input`/`output` (and transitively) to
|
||||
* its `NamedType` definition — Python gets this from Pydantic model
|
||||
* introspection; TS declares it explicitly.
|
||||
*/
|
||||
export interface IrSchema {
|
||||
input?: StructField[]
|
||||
output?: TypeShape
|
||||
types?: Record<string, NamedType>
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Shapes — typed query projection.
|
||||
*
|
||||
* AFI-common capability; the binding is per-ORM. Django's binding is
|
||||
* django-readers (select named fields + nested relations from a QuerySet in
|
||||
* one query). The TypeScript binding is the same shape over the data source a
|
||||
* TS backend already has: a `QueryProjection` declares the fields and nested
|
||||
* relations to keep, and `project()` produces records carrying *only* those —
|
||||
* the over-fetch-elimination the Shapes capability exists for, expressed
|
||||
* against plain records rather than a Django QuerySet.
|
||||
*
|
||||
* A projection composes: a relation is itself a `QueryProjection`, so nested
|
||||
* shapes prune recursively (mirrors `Shape._spec` / `_build_pair`).
|
||||
*/
|
||||
|
||||
/** A declarative projection: scalar fields plus nested relation projections. */
|
||||
export interface QueryProjection {
|
||||
/** Scalar field names to keep on each record. */
|
||||
fields: string[]
|
||||
/** Nested relations to keep, each projected by its own `QueryProjection`. */
|
||||
relations?: Record<string, QueryProjection>
|
||||
}
|
||||
|
||||
type Record_ = Record<string, any>
|
||||
|
||||
function projectOne(record: Record_, projection: QueryProjection): Record_ {
|
||||
const out: Record_ = {}
|
||||
for (const f of projection.fields) {
|
||||
if (f in record) out[f] = record[f]
|
||||
}
|
||||
for (const [name, child] of Object.entries(projection.relations ?? {})) {
|
||||
const value = record[name]
|
||||
if (value === undefined || value === null) {
|
||||
out[name] = value
|
||||
} else if (Array.isArray(value)) {
|
||||
out[name] = value.map((v) => projectOne(v, child))
|
||||
} else {
|
||||
out[name] = projectOne(value, child)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a list of records through a `QueryProjection`, keeping only the
|
||||
* declared fields + nested relations. Each output record carries nothing the
|
||||
* projection didn't name — the typed-projection guarantee.
|
||||
*/
|
||||
export function project(records: Record_[], projection: QueryProjection): Record_[] {
|
||||
return records.map((r) => projectOne(r, projection))
|
||||
}
|
||||
|
||||
/** Project a single record. */
|
||||
export function projectRecord(record: Record_, projection: QueryProjection): Record_ {
|
||||
return projectOne(record, projection)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable Shape: binds a `QueryProjection` to a name so a `@client` context
|
||||
* function can `Shape.query(source)` and return uniformly-projected records.
|
||||
* The per-ORM source differs; the projection contract does not.
|
||||
*/
|
||||
export class Shape {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly projection: QueryProjection,
|
||||
) {}
|
||||
|
||||
/** Project a record source through this shape's projection. */
|
||||
query(source: Record_[]): Record_[] {
|
||||
return project(source, this.projection)
|
||||
}
|
||||
|
||||
/** Project a single record. */
|
||||
one(record: Record_): Record_ {
|
||||
return projectRecord(record, this.projection)
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* SSR Bridge — manages a persistent Bun subprocess for React server-rendering.
|
||||
*
|
||||
* TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire
|
||||
* protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with
|
||||
* message-id correlation so concurrent renders don't cross.
|
||||
*
|
||||
* → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } }
|
||||
* ← { "id": 1, "html": "<div>...</div>" }
|
||||
* ← { "id": 1, "error": "..." } (on failure)
|
||||
*
|
||||
* The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file
|
||||
* and calls `renderToString` — no registry. It announces readiness with
|
||||
* `{ "id": 0, "ready": true }`; the bridge waits for that before accepting
|
||||
* renders, and restarts the worker if it exits.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
||||
|
||||
export interface SSRBridgeOptions {
|
||||
/** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */
|
||||
worker: string
|
||||
/** Per-render + startup timeout, seconds. Default 5. */
|
||||
timeout?: number
|
||||
/** Runtime to launch the worker. Default 'bun'. */
|
||||
runtime?: string
|
||||
/**
|
||||
* Args passed to the runtime before the worker path. Default `['run']`
|
||||
* (the Bun/`bun run <worker>` convention). Set `[]` for a runtime like
|
||||
* `node` that takes the script path directly.
|
||||
*/
|
||||
runtimeArgs?: string[]
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
html: string
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (msg: any) => void
|
||||
reject: (err: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export class SSRBridge {
|
||||
private readonly worker: string
|
||||
private readonly timeoutMs: number
|
||||
private readonly runtime: string
|
||||
private readonly runtimeArgs: string[]
|
||||
|
||||
private proc: ChildProcessWithoutNullStreams | null = null
|
||||
private counter = 0
|
||||
private buffer = ''
|
||||
private readonly pending = new Map<number, Pending>()
|
||||
private readyPromise: Promise<void> | null = null
|
||||
private readyResolve: (() => void) | null = null
|
||||
private readyReject: ((err: Error) => void) | null = null
|
||||
|
||||
constructor(options: SSRBridgeOptions) {
|
||||
this.worker = options.worker
|
||||
this.timeoutMs = (options.timeout ?? 5) * 1000
|
||||
this.runtime = options.runtime ?? 'bun'
|
||||
this.runtimeArgs = options.runtimeArgs ?? ['run']
|
||||
}
|
||||
|
||||
private ensureRunning(): Promise<void> {
|
||||
if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) {
|
||||
return this.readyPromise
|
||||
}
|
||||
|
||||
let settled = false
|
||||
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
this.readyReject = (err) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
this.proc = proc
|
||||
|
||||
proc.stdout.setEncoding('utf-8')
|
||||
proc.stdout.on('data', (chunk: string) => this.onStdout(chunk))
|
||||
// Only react to THIS proc's exit — a stale exit event (from a worker we
|
||||
// already replaced) must not null out the freshly-spawned one.
|
||||
proc.on('exit', () => this.onExit(proc))
|
||||
proc.on('error', (err) => {
|
||||
this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`))
|
||||
})
|
||||
|
||||
const startTimer = setTimeout(() => {
|
||||
this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`))
|
||||
this.shutdown()
|
||||
}, this.timeoutMs)
|
||||
|
||||
// Clear the start timer once ready settles (either way).
|
||||
this.readyPromise.then(
|
||||
() => clearTimeout(startTimer),
|
||||
() => clearTimeout(startTimer),
|
||||
)
|
||||
|
||||
return this.readyPromise
|
||||
}
|
||||
|
||||
private onStdout(chunk: string): void {
|
||||
this.buffer += chunk
|
||||
let nl: number
|
||||
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
||||
const line = this.buffer.slice(0, nl).trim()
|
||||
this.buffer = this.buffer.slice(nl + 1)
|
||||
if (!line) continue
|
||||
let msg: any
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
continue // malformed line — ignore, matches the Python reader
|
||||
}
|
||||
this.onMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(msg: any): void {
|
||||
// Ready signal (id=0).
|
||||
if (msg.id === 0 && msg.ready) {
|
||||
this.readyResolve?.()
|
||||
return
|
||||
}
|
||||
const id = msg.id
|
||||
if (typeof id === 'number' && this.pending.has(id)) {
|
||||
const p = this.pending.get(id)!
|
||||
this.pending.delete(id)
|
||||
clearTimeout(p.timer)
|
||||
p.resolve(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private onExit(proc: ChildProcessWithoutNullStreams): void {
|
||||
// Ignore exit events from a worker we've already replaced.
|
||||
if (this.proc !== null && this.proc !== proc) return
|
||||
|
||||
// Fail any in-flight requests; the next call re-spawns a fresh worker.
|
||||
const err = new Error('SSR worker exited')
|
||||
for (const [, p] of this.pending) {
|
||||
clearTimeout(p.timer)
|
||||
p.reject(err)
|
||||
}
|
||||
this.pending.clear()
|
||||
this.readyReject?.(err)
|
||||
this.proc = null
|
||||
this.readyPromise = null
|
||||
}
|
||||
|
||||
private request(method: string, params: Record<string, any>): Promise<any> {
|
||||
const id = ++this.counter
|
||||
const frame = JSON.stringify({ id, method, params }) + '\n'
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`))
|
||||
}, this.timeoutMs)
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
|
||||
try {
|
||||
this.proc!.stdin.write(frame)
|
||||
} catch (e: any) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Render a React component file to HTML. Spawns the worker on first use. */
|
||||
async render(file: string, props: Record<string, any> = {}): Promise<RenderResult> {
|
||||
await this.ensureRunning()
|
||||
const msg = await this.request('render', { file, props })
|
||||
if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`)
|
||||
return { html: msg.html }
|
||||
}
|
||||
|
||||
/** Health check — resolves true when the worker answers a ping. */
|
||||
async ping(): Promise<boolean> {
|
||||
await this.ensureRunning()
|
||||
const msg = await this.request('ping', {})
|
||||
return msg.pong === true
|
||||
}
|
||||
|
||||
/** Stop the Bun subprocess. */
|
||||
shutdown(): void {
|
||||
if (this.proc !== null) {
|
||||
try {
|
||||
this.proc.stdin.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
try {
|
||||
this.proc.kill()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
this.proc = null
|
||||
this.readyPromise = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* MWT / JWT mint + decode — HS256, cross-language parity with
|
||||
* `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`.
|
||||
*
|
||||
* 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 { 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<string, unknown>, sortKeys: boolean): string {
|
||||
if (!sortKeys) return JSON.stringify(obj)
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]
|
||||
return JSON.stringify(sorted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an HS256 JWS. `header` extras (e.g. `{kid}`) merge over the base
|
||||
* `{alg, typ}`; the JOSE header is serialized with sorted keys, exactly as
|
||||
* PyJWT's `api_jws.encode`. Returns `header.payload.signature` (base64url).
|
||||
*/
|
||||
export function signHs256(
|
||||
payload: Record<string, unknown>,
|
||||
secret: string,
|
||||
headerExtras: Record<string, unknown> = {},
|
||||
): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT', ...headerExtras }
|
||||
const headerB64 = base64urlEncode(compactJson(header, true))
|
||||
const payloadB64 = base64urlEncode(compactJson(payload, false))
|
||||
const signing = `${headerB64}.${payloadB64}`
|
||||
const sig = createHmac('sha256', secret).update(signing).digest('base64url')
|
||||
return `${signing}.${sig}`
|
||||
}
|
||||
|
||||
export interface MwtPayload {
|
||||
sub: string
|
||||
staff: boolean
|
||||
super: boolean
|
||||
pkey: string
|
||||
kid: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64urlDecode(input: string): Buffer | null {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null
|
||||
return Buffer.from(input, 'base64url')
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and validate an MWT (HS256 JWT with Mizan claims).
|
||||
* Returns MwtPayload on success, null on any failure.
|
||||
*/
|
||||
export function decodeMwt(
|
||||
token: string,
|
||||
secret: string,
|
||||
audience: string = 'mizan',
|
||||
): MwtPayload | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const [headerB64, payloadB64, signatureB64] = parts
|
||||
|
||||
const headerBytes = base64urlDecode(headerB64)
|
||||
const payloadBytes = base64urlDecode(payloadB64)
|
||||
const signatureBytes = base64urlDecode(signatureB64)
|
||||
if (!headerBytes || !payloadBytes || !signatureBytes) return null
|
||||
|
||||
const header = JSON.parse(headerBytes.toString('utf-8'))
|
||||
if (header.alg !== 'HS256') return null
|
||||
|
||||
// Recompute HMAC over `${headerB64}.${payloadB64}`
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(`${headerB64}.${payloadB64}`)
|
||||
.digest()
|
||||
if (!constantTimeEqual(expected, signatureBytes)) return null
|
||||
|
||||
const data = JSON.parse(payloadBytes.toString('utf-8'))
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (typeof data.exp !== 'number' || data.exp <= now) return null
|
||||
if (data.nbf !== undefined && typeof data.nbf === 'number' && data.nbf > now) return null
|
||||
if (data.aud !== audience) return null
|
||||
|
||||
const kid = typeof header.kid === 'string' ? header.kid : 'v1'
|
||||
|
||||
return {
|
||||
sub: String(data.sub),
|
||||
staff: Boolean(data.staff),
|
||||
super: Boolean(data.super),
|
||||
pkey: typeof data.pkey === 'string' ? data.pkey : '',
|
||||
kid,
|
||||
aud: audience,
|
||||
iat: data.iat,
|
||||
exp: data.exp,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Bearer JWT from an Authorization header value.
|
||||
* Strips the "Bearer " prefix, then validates as an MWT.
|
||||
*/
|
||||
export function decodeJwtBearer(
|
||||
authHeader: string,
|
||||
secret: string,
|
||||
audience: string = 'mizan',
|
||||
): MwtPayload | null {
|
||||
if (!authHeader) return null
|
||||
const prefix = 'Bearer '
|
||||
const token = authHeader.startsWith(prefix)
|
||||
? authHeader.slice(prefix.length)
|
||||
: authHeader
|
||||
return decodeMwt(token, secret, audience)
|
||||
}
|
||||
|
||||
/** Build an Identity from a decoded MWT payload. */
|
||||
export function identityFromMwt(payload: MwtPayload): Identity {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
isStaff: payload.staff,
|
||||
isSuperuser: payload.super,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
* Mizan TypeScript Adapter — Shared Types
|
||||
*/
|
||||
|
||||
import type { AuthPredicate } from './identity'
|
||||
import type { IrSchema } from './ir/types'
|
||||
|
||||
export class ReactContext {
|
||||
constructor(public readonly name: string) {
|
||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||
@@ -13,37 +10,15 @@ export class ReactContext {
|
||||
|
||||
export type AffectsTarget = ReactContext | string
|
||||
|
||||
/** Public auth option on the decorator. `true` normalizes to `'required'` when stored. */
|
||||
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
|
||||
auth?: 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 {
|
||||
@@ -57,20 +32,14 @@ export interface RegistryEntry {
|
||||
fn: (...args: any[]) => Promise<any>
|
||||
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
|
||||
auth?: boolean
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
ir?: IrSchema
|
||||
form?: boolean
|
||||
formName?: string
|
||||
formRole?: FormRole
|
||||
}
|
||||
|
||||
export interface ManifestContext {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Mizan Upload — first-class binary input for `@client` functions.
|
||||
*
|
||||
* Mirrors `cores/mizan-python/src/mizan_core/upload.py`. Declaring an
|
||||
* Upload-typed field in a function's `ir.input` makes a call multipart-aware:
|
||||
* the generated client switches to `multipart/form-data`, and dispatch binds
|
||||
* each file part into a uniform `UploadedFile` on the function's args.
|
||||
* Constraints declared via `File` (max size, content types) are enforced at
|
||||
* dispatch, exactly as the Python `validate_upload` enforces them.
|
||||
*
|
||||
* TypeScript has no Pydantic to introspect, so the Upload fields are read from
|
||||
* the function's declared `ir.input` shapes (`{ kind: 'upload', ... }`) rather
|
||||
* than from model metadata.
|
||||
*/
|
||||
|
||||
import type { RegistryEntry } from './types'
|
||||
import type { TypeShape } from './ir/types'
|
||||
|
||||
const SIZE_UNITS: Array<[string, number]> = [
|
||||
['GB', 1024 ** 3],
|
||||
['MB', 1024 ** 2],
|
||||
['KB', 1024],
|
||||
['B', 1],
|
||||
]
|
||||
|
||||
/** Parse a byte count. Accepts a number (bytes) or a string like `"5MB"`. */
|
||||
export function parseSize(value: number | string): number {
|
||||
if (typeof value === 'number') return value
|
||||
const s = value.trim().toUpperCase()
|
||||
for (const [unit, mult] of SIZE_UNITS) {
|
||||
if (s.endsWith(unit)) return Math.trunc(parseFloat(s.slice(0, -unit.length).trim()) * mult)
|
||||
}
|
||||
return Math.trunc(Number(s))
|
||||
}
|
||||
|
||||
/** Declarative constraints for an Upload field. */
|
||||
export interface File {
|
||||
maxSize?: number
|
||||
contentTypes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniform file handle handed to `@client` functions — adapter-agnostic.
|
||||
* Constructed by dispatch from a multipart `Blob`/`File` part.
|
||||
*/
|
||||
export class UploadedFile {
|
||||
constructor(
|
||||
public readonly filename: string | null,
|
||||
public readonly contentType: string | null,
|
||||
private readonly data: Uint8Array,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.data.byteLength
|
||||
}
|
||||
|
||||
read(): Uint8Array {
|
||||
return this.data
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return new TextDecoder().decode(this.data)
|
||||
}
|
||||
}
|
||||
|
||||
function contentTypeAllowed(contentType: string | null, allowed: string[]): boolean {
|
||||
if (!contentType) return false
|
||||
for (const ct of allowed) {
|
||||
if (ct === contentType) return true
|
||||
if (ct.endsWith('/*') && contentType.startsWith(ct.slice(0, -1))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Enforce declared constraints. Returns an error message, or null if ok. */
|
||||
export function validateUpload(file: UploadedFile, spec: File | undefined): string | null {
|
||||
if (!spec) return null
|
||||
if (spec.maxSize !== undefined && file.size > spec.maxSize) {
|
||||
return `file exceeds max size ${spec.maxSize} bytes (got ${file.size})`
|
||||
}
|
||||
if (spec.contentTypes && spec.contentTypes.length > 0 && !contentTypeAllowed(file.contentType, spec.contentTypes)) {
|
||||
return `content-type ${JSON.stringify(file.contentType)} not allowed (expected one of ${JSON.stringify(spec.contentTypes)})`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** An Upload field on a function input: name → (isList, spec). */
|
||||
interface UploadField {
|
||||
isList: boolean
|
||||
spec: File | undefined
|
||||
}
|
||||
|
||||
/** Unwrap Optional/list around an `upload` shape → [isUpload, isList, spec]. */
|
||||
function classifyUpload(shape: TypeShape): { isUpload: boolean; isList: boolean; spec: File | undefined } {
|
||||
let s = shape
|
||||
if (s.kind === 'optional') s = s.inner
|
||||
let isList = false
|
||||
if (s.kind === 'list') {
|
||||
isList = true
|
||||
s = s.inner
|
||||
}
|
||||
if (s.kind === 'upload') {
|
||||
const spec: File = {}
|
||||
if (s.maxSize !== undefined) spec.maxSize = s.maxSize
|
||||
if (s.contentTypes !== undefined) spec.contentTypes = s.contentTypes
|
||||
const hasSpec = s.maxSize !== undefined || s.contentTypes !== undefined
|
||||
return { isUpload: true, isList, spec: hasSpec ? spec : undefined }
|
||||
}
|
||||
return { isUpload: false, isList: false, spec: undefined }
|
||||
}
|
||||
|
||||
/** Map each Upload-typed field of a function's input → (isList, spec). */
|
||||
export function uploadFields(entry: RegistryEntry): Map<string, UploadField> {
|
||||
const out = new Map<string, UploadField>()
|
||||
for (const field of entry.ir?.input ?? []) {
|
||||
const { isUpload, isList, spec } = classifyUpload(field.shape)
|
||||
if (isUpload) out.set(field.name, { isList, spec })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Place uploaded files into `args` by field name, enforcing constraints.
|
||||
* Mutates `args` in place. `files` maps a field name to the parts received for
|
||||
* it (a list field receives several). Returns an error message on the first
|
||||
* constraint violation, else null. Mirrors `upload.bind_uploads`.
|
||||
*/
|
||||
export function bindUploads(
|
||||
entry: RegistryEntry,
|
||||
args: Record<string, any>,
|
||||
files: Map<string, UploadedFile[]>,
|
||||
): string | null {
|
||||
for (const [name, { isList, spec }] of uploadFields(entry)) {
|
||||
const bucket = files.get(name) ?? []
|
||||
if (bucket.length === 0) continue
|
||||
for (const f of bucket) {
|
||||
const err = validateUpload(f, spec)
|
||||
if (err !== null) return `${name}: ${err}`
|
||||
}
|
||||
args[name] = isList ? [...bucket] : bucket[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* WebSocket transport — RPC over a WebSocket connection for
|
||||
* `@client({ websocket: true })` functions.
|
||||
*
|
||||
* Parity with the Django Channels consumer and the Axum WebSocket handler: the
|
||||
* client sends JSON-RPC frames and receives correlated replies. Both the
|
||||
* mutation (`call`) and the bundled context (`fetch`) verbs route through the
|
||||
* *same* dispatch core the HTTP path uses, so invalidation, auth, and caching
|
||||
* behave identically on either transport — only the framing differs.
|
||||
*
|
||||
* Frame protocol (newline-free JSON, one object per WS message):
|
||||
*
|
||||
* → { "id": 1, "type": "call", "fn": "update_profile", "args": { ... } }
|
||||
* ← { "id": 1, "result": { ... }, "invalidate": [ ... ] }
|
||||
*
|
||||
* → { "id": 2, "type": "fetch", "context": "user", "params": { ... } }
|
||||
* ← { "id": 2, "result": { user_profile: { ... }, ... } }
|
||||
*
|
||||
* ← { "id": N, "error": { "code": "...", "message": "..." } } (on failure)
|
||||
*
|
||||
* The `id` echoes back so a client can correlate concurrent in-flight calls
|
||||
* over one socket.
|
||||
*/
|
||||
|
||||
import { handleContextFetch, handleMutationCall } from './dispatch'
|
||||
import { ANONYMOUS, type Identity } from './identity'
|
||||
|
||||
interface CallFrame {
|
||||
id?: number | string
|
||||
type: 'call'
|
||||
fn: string
|
||||
args?: Record<string, any>
|
||||
}
|
||||
|
||||
interface FetchFrame {
|
||||
id?: number | string
|
||||
type: 'fetch'
|
||||
context: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export type MizanWsFrame = CallFrame | FetchFrame
|
||||
|
||||
export interface MizanWsReply {
|
||||
id?: number | string
|
||||
result?: any
|
||||
invalidate?: any
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one inbound WebSocket frame and produce the reply object.
|
||||
*
|
||||
* `raw` is the message payload (string or already-parsed object). Routing is by
|
||||
* the frame `type`; the body of the work is the same dispatch the HTTP handlers
|
||||
* call, so a function exposed over both transports behaves identically.
|
||||
*/
|
||||
export async function handleWebSocketMessage(
|
||||
raw: string | MizanWsFrame,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanWsReply> {
|
||||
let frame: MizanWsFrame
|
||||
try {
|
||||
frame = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
} catch {
|
||||
return { error: { code: 'BAD_REQUEST', message: 'Invalid JSON frame' } }
|
||||
}
|
||||
|
||||
const id = (frame as { id?: number | string }).id
|
||||
|
||||
if (frame.type === 'call') {
|
||||
if (!frame.fn) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'fn'" } }
|
||||
const res = await handleMutationCall(frame.fn, frame.args ?? {}, identity)
|
||||
if (res.status !== 200) {
|
||||
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
|
||||
}
|
||||
const reply: MizanWsReply = { id, result: res.body.result }
|
||||
if (res.body.invalidate !== undefined) reply.invalidate = res.body.invalidate
|
||||
return reply
|
||||
}
|
||||
|
||||
if (frame.type === 'fetch') {
|
||||
if (!frame.context) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'context'" } }
|
||||
const res = await handleContextFetch(frame.context, frame.params ?? {}, identity)
|
||||
if (res.status !== 200) {
|
||||
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
|
||||
}
|
||||
return { id, result: res.body }
|
||||
}
|
||||
|
||||
return { id, error: { code: 'BAD_REQUEST', message: `Unknown frame type` } }
|
||||
}
|
||||
|
||||
/** Minimal structural type for a WebSocket-like connection. */
|
||||
export interface WebSocketLike {
|
||||
send(data: string): void
|
||||
addEventListener(type: 'message', listener: (event: { data: any }) => void): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Mizan RPC protocol to a `WebSocket`-like connection. Each inbound
|
||||
* message is dispatched via `handleWebSocketMessage` and the reply is sent back
|
||||
* as JSON. `identity` resolves the caller (host wires MWT/JWT decode here).
|
||||
*/
|
||||
export function serveWebSocket(
|
||||
ws: WebSocketLike,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): void {
|
||||
ws.addEventListener('message', async (event) => {
|
||||
const reply = await handleWebSocketMessage(
|
||||
typeof event.data === 'string' ? event.data : String(event.data),
|
||||
identity,
|
||||
)
|
||||
ws.send(JSON.stringify(reply))
|
||||
})
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Auth-parity tests — mirrors Django's auth enforcement in
|
||||
* mizan-django/src/mizan/client/executor.py (_check_auth_requirement).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
ReactContext, client, clearRegistry,
|
||||
handleContextFetch, handleMutationCall,
|
||||
setCache, resetCache, setCacheSecret, MemoryCache,
|
||||
type Identity,
|
||||
} from '../src'
|
||||
|
||||
function anon(): Identity {
|
||||
return { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
|
||||
}
|
||||
function user(): Identity {
|
||||
return { isAuthenticated: true, isStaff: false, isSuperuser: false, id: 1 }
|
||||
}
|
||||
function staff(): Identity {
|
||||
return { isAuthenticated: true, isStaff: true, isSuperuser: false, id: 2 }
|
||||
}
|
||||
function superuser(): Identity {
|
||||
return { isAuthenticated: true, isStaff: true, isSuperuser: true, id: 3 }
|
||||
}
|
||||
|
||||
describe('Auth — mutation dispatch', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('auth:true + anon → 401', async () => {
|
||||
client({ auth: true }, async function secret() { return { ok: true } })
|
||||
const r = await handleMutationCall('secret', {}, anon())
|
||||
expect(r.status).toBe(401)
|
||||
expect(r.body.code).toBe('UNAUTHORIZED')
|
||||
expect(r.body.message).toBe('Authentication required')
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
})
|
||||
|
||||
test('auth:true + user → 200', async () => {
|
||||
client({ auth: true }, async function secret() { return { ok: true } })
|
||||
const r = await handleMutationCall('secret', {}, user())
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
test("auth:'staff' + user → 403", async () => {
|
||||
client({ auth: 'staff' }, async function adminAction() { return { ok: true } })
|
||||
const r = await handleMutationCall('adminAction', {}, user())
|
||||
expect(r.status).toBe(403)
|
||||
expect(r.body.code).toBe('FORBIDDEN')
|
||||
expect(r.body.message).toBe('Staff access required')
|
||||
})
|
||||
|
||||
test("auth:'staff' + staff → 200", async () => {
|
||||
client({ auth: 'staff' }, async function adminAction() { return { ok: true } })
|
||||
const r = await handleMutationCall('adminAction', {}, staff())
|
||||
expect(r.status).toBe(200)
|
||||
})
|
||||
|
||||
test("auth:'superuser' + staff → 403", async () => {
|
||||
client({ auth: 'superuser' }, async function nuke() { return { ok: true } })
|
||||
const r = await handleMutationCall('nuke', {}, staff())
|
||||
expect(r.status).toBe(403)
|
||||
expect(r.body.message).toBe('Superuser access required')
|
||||
})
|
||||
|
||||
test("auth:'superuser' + superuser → 200", async () => {
|
||||
client({ auth: 'superuser' }, async function nuke() { return { ok: true } })
|
||||
const r = await handleMutationCall('nuke', {}, superuser())
|
||||
expect(r.status).toBe(200)
|
||||
})
|
||||
|
||||
test('callable → true → 200', async () => {
|
||||
client({ auth: (id) => id.isAuthenticated }, async function gated() { return { ok: true } })
|
||||
const r = await handleMutationCall('gated', {}, user())
|
||||
expect(r.status).toBe(200)
|
||||
})
|
||||
|
||||
test("callable → false → 403 'Access denied'", async () => {
|
||||
client({ auth: () => false }, async function gated() { return { ok: true } })
|
||||
const r = await handleMutationCall('gated', {}, user())
|
||||
expect(r.status).toBe(403)
|
||||
expect(r.body.message).toBe('Access denied')
|
||||
})
|
||||
|
||||
test("callable throws Error('msg') → 403 'msg'", async () => {
|
||||
client({ auth: () => { throw new Error('msg') } }, async function gated() { return { ok: true } })
|
||||
const r = await handleMutationCall('gated', {}, user())
|
||||
expect(r.status).toBe(403)
|
||||
expect(r.body.message).toBe('msg')
|
||||
})
|
||||
|
||||
test('callable runs before authentication gate (anon allowed if predicate true)', async () => {
|
||||
client({ auth: () => true }, async function gated() { return { ok: true } })
|
||||
const r = await handleMutationCall('gated', {}, anon())
|
||||
expect(r.status).toBe(200)
|
||||
})
|
||||
|
||||
test('invalid auth string at decoration → throws', () => {
|
||||
expect(() => {
|
||||
client({ auth: 'admin' as any }, async function bad() { return {} })
|
||||
}).toThrow('Invalid auth value')
|
||||
})
|
||||
|
||||
test('no auth + anon → 200 (default ANONYMOUS path stays open)', async () => {
|
||||
client({}, async function open() { return { ok: true } })
|
||||
const r = await handleMutationCall('open', {})
|
||||
expect(r.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth — context fetch', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('auth-gated context member + anon → 401', async () => {
|
||||
const Ctx = new ReactContext('secure')
|
||||
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
|
||||
return { id: itemId }
|
||||
})
|
||||
const r = await handleContextFetch('secure', { itemId: '1' }, anon())
|
||||
expect(r.status).toBe(401)
|
||||
expect(r.body.message).toBe('Authentication required')
|
||||
})
|
||||
|
||||
test('auth-gated context + user → 200', async () => {
|
||||
const Ctx = new ReactContext('secure')
|
||||
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
|
||||
return { id: itemId }
|
||||
})
|
||||
const r = await handleContextFetch('secure', { itemId: '1' }, user())
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.secureData).toEqual({ id: '1' })
|
||||
})
|
||||
|
||||
test('context fetch denial pre-empts a would-be cache HIT', async () => {
|
||||
const SECRET = 'auth-test-secret-32bytes-padding!'
|
||||
const Ctx = new ReactContext('secure')
|
||||
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
|
||||
return { id: itemId }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
// Prime the cache as an authorized caller.
|
||||
const primed = await handleContextFetch('secure', { itemId: '1' }, user())
|
||||
expect(primed.status).toBe(200)
|
||||
expect(primed.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
// Confirm it's now a cache HIT for an authorized caller.
|
||||
const hit = await handleContextFetch('secure', { itemId: '1' }, user())
|
||||
expect(hit.headers['X-Mizan-Cache']).toBe('HIT')
|
||||
|
||||
// Anon must get 401 even though the cache holds the entry.
|
||||
const denied = await handleContextFetch('secure', { itemId: '1' }, anon())
|
||||
expect(denied.status).toBe(401)
|
||||
expect(denied.headers['X-Mizan-Cache']).toBeUndefined()
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
})
|
||||
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
@@ -1,6 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
/** SSR fixture component — rendered by the Bun worker in the bridge test. */
|
||||
export default function Hello({ name }: { name: string }) {
|
||||
return createElement('div', { className: 'greeting' }, `Hello, ${name}!`)
|
||||
}
|
||||
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited
|
||||
* JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React
|
||||
* dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess
|
||||
* machinery (ready handshake, id correlation, render reply, ping, error frame)
|
||||
* under plain Node, independent of the real worker's install state.
|
||||
*
|
||||
* `render` echoes the props into a deterministic HTML string so the bridge's
|
||||
* request/response correlation is observable; a file named "*boom*" yields an
|
||||
* error frame to prove the failure path.
|
||||
*/
|
||||
|
||||
function respond(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n')
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
if (msg.method === 'ping') {
|
||||
respond({ id: msg.id, pong: true })
|
||||
return
|
||||
}
|
||||
if (msg.method === 'render') {
|
||||
const { file, props } = msg.params ?? {}
|
||||
if (typeof file === 'string' && file.includes('boom')) {
|
||||
respond({ id: msg.id, error: `cannot render ${file}` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, html: `<div data-file="${file}">${JSON.stringify(props ?? {})}</div>` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
process.stdin.setEncoding('utf-8')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
let nl
|
||||
while ((nl = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (line) {
|
||||
try {
|
||||
handle(JSON.parse(line))
|
||||
} catch (e) {
|
||||
respond({ id: -1, error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ready handshake — identical to the real worker.
|
||||
respond({ id: 0, ready: true })
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1.
|
||||
*
|
||||
* Each function declares the same IR type schema the Python fixture's Pydantic
|
||||
* Input/Output models imply, so `buildIr()` here emits the same KDL the Python
|
||||
* `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`)
|
||||
* subprocesses the live Python emitter and asserts equality.
|
||||
*
|
||||
* Output structs are declared under their model name (`ProfileOutput`,
|
||||
* `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames
|
||||
* them to the canonical `<camel>Output`, exactly as `_collect_named_types`
|
||||
* renames the Pydantic models.
|
||||
*/
|
||||
|
||||
import { client, ReactContext } from '../src'
|
||||
import type { NamedType, StructField } from '../src'
|
||||
|
||||
const intField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'integer' },
|
||||
})
|
||||
const strField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'string' },
|
||||
})
|
||||
const boolField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'boolean' },
|
||||
})
|
||||
|
||||
const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] }
|
||||
const OrderOutput: NamedType = {
|
||||
kind: 'struct',
|
||||
fields: [intField('id'), intField('user_id'), intField('total')],
|
||||
}
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
/** Register the AFI fixture functions with the mizan-ts registry. */
|
||||
export function registerFixture(): void {
|
||||
// echo — plain function, typed input + struct output.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [strField('text')],
|
||||
output: { kind: 'ref', name: 'EchoOutput' },
|
||||
types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } },
|
||||
},
|
||||
},
|
||||
async function echo(text: string) {
|
||||
return { message: `echo: ${text}` }
|
||||
},
|
||||
)
|
||||
|
||||
// whoami — no input.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
output: { kind: 'ref', name: 'WhoamiOutput' },
|
||||
types: {
|
||||
WhoamiOutput: {
|
||||
kind: 'struct',
|
||||
fields: [strField('email'), boolField('authenticated')],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async function whoami() {
|
||||
return { email: 'anon@example.com', authenticated: false }
|
||||
},
|
||||
)
|
||||
|
||||
// user_profile — context member.
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function user_profile(user_id: number) {
|
||||
return { user_id, name: 'placeholder' }
|
||||
},
|
||||
)
|
||||
|
||||
// user_orders — context member, list output, same param (param elevation).
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } },
|
||||
types: { OrderOutput },
|
||||
},
|
||||
},
|
||||
async function user_orders(_user_id: number) {
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
// update_profile — mutation affecting the user context.
|
||||
client(
|
||||
{
|
||||
affects: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'StatusOutput' },
|
||||
types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } },
|
||||
},
|
||||
},
|
||||
async function update_profile(_user_id: number, _name: string) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// find_user — optional return.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function find_user(_user_id: number) {
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
// rename_user — merge target.
|
||||
client(
|
||||
{
|
||||
merge: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function rename_user(user_id: number, name: string) {
|
||||
return { user_id, name }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python
|
||||
* `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`).
|
||||
*
|
||||
* The IR is the codegen contract. A TypeScript backend can only feed
|
||||
* `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends
|
||||
* emit for the same registry. This test reconstructs the AFI fixture in both
|
||||
* languages, subprocesses the live Python emitter, and asserts byte-equality —
|
||||
* the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildIr, clearRegistry } from '../src'
|
||||
import { registerFixture } from './ir-fixture'
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||
|
||||
/**
|
||||
* Reconstruct the AFI fixture in Python via `mizan_core` only (no backend
|
||||
* adapter dependency) and emit `build_ir()`. This is the cross-language oracle:
|
||||
* the same registrations the TS fixture makes, run through the reference
|
||||
* emitter.
|
||||
*/
|
||||
const PY_FIXTURE = String.raw`
|
||||
import sys
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core import registry as reg
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
reg.clear_registry()
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
class WhoamiOutput(BaseModel):
|
||||
email: str
|
||||
authenticated: bool
|
||||
|
||||
class ProfileOutput(BaseModel):
|
||||
user_id: int
|
||||
name: str
|
||||
|
||||
class OrderOutput(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
total: int
|
||||
|
||||
class StatusOutput(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput: ...
|
||||
|
||||
@client
|
||||
def whoami(request) -> WhoamiOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_orders(request, user_id: int) -> list[OrderOutput]: ...
|
||||
|
||||
@client(affects="user")
|
||||
def update_profile(request, user_id: int, name: str) -> StatusOutput: ...
|
||||
|
||||
@client
|
||||
def find_user(request, user_id: int) -> Optional[ProfileOutput]: ...
|
||||
|
||||
@client(merge="user")
|
||||
def rename_user(request, user_id: int, name: str) -> ProfileOutput: ...
|
||||
|
||||
for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]:
|
||||
reg.register(f, f.__name__)
|
||||
|
||||
sys.stdout.write(build_ir())
|
||||
`
|
||||
|
||||
function pythonBuildIr(): string {
|
||||
return execFileSync(
|
||||
'uv',
|
||||
['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
}
|
||||
|
||||
const UV_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
describe('KDL IR — buildIr()', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('emits the canonical type / function / context sections', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
|
||||
// Types are alphabetical; output structs renamed to <camel>Output.
|
||||
expect(kdl).toContain('type "OrderOutput" {')
|
||||
expect(kdl).toContain('type "echoInput" {')
|
||||
expect(kdl).toContain('type "findUserOutput" {')
|
||||
expect(kdl).toContain('type "userOrdersOutput" {')
|
||||
|
||||
// Functions alphabetical, with transport + context/affects/merge leaves.
|
||||
expect(kdl).toContain('function "echo" {')
|
||||
expect(kdl).toContain(' camel "echo"')
|
||||
expect(kdl).toContain(' has-input #true')
|
||||
expect(kdl).toContain(' output-nullable #true') // find_user
|
||||
expect(kdl).toContain(' affects "user"') // update_profile
|
||||
expect(kdl).toContain(' merge "user"') // rename_user
|
||||
|
||||
// Context section with shared param elevation.
|
||||
expect(kdl).toContain('context "user" {')
|
||||
expect(kdl).toContain(' shared-by "user_orders"')
|
||||
expect(kdl).toContain(' shared-by "user_profile"')
|
||||
})
|
||||
|
||||
test('has-input #false for a no-arg function', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
const whoami = kdl.slice(kdl.indexOf('function "whoami" {'))
|
||||
expect(whoami).toContain('has-input #false')
|
||||
expect(whoami).not.toContain('input "whoamiInput"')
|
||||
})
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)(
|
||||
'byte-identical to the Python build_ir() (cores/mizan-python)',
|
||||
() => {
|
||||
registerFixture()
|
||||
const tsKdl = buildIr()
|
||||
const pyKdl = pythonBuildIr()
|
||||
|
||||
// Line-by-line first so a divergence names the offending line.
|
||||
const tsLines = tsKdl.split('\n')
|
||||
const pyLines = pyKdl.split('\n')
|
||||
const n = Math.max(tsLines.length, pyLines.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (tsLines[i] !== pyLines[i]) {
|
||||
throw new Error(
|
||||
`KDL diverges at line ${i + 1}:\n` +
|
||||
` python: ${JSON.stringify(pyLines[i])}\n` +
|
||||
` ts: ${JSON.stringify(tsLines[i])}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(tsKdl).toBe(pyKdl)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* SSR bridge tests — spawn + drive a JSON-RPC worker subprocess.
|
||||
*
|
||||
* The bridge's contract is the newline-delimited JSON-RPC protocol over a
|
||||
* spawned worker (ready handshake, id-correlated render/ping, error frames,
|
||||
* timeout, restart). Two peers exercise it:
|
||||
*
|
||||
* - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always
|
||||
* runs, proving the full subprocess machinery independent of any install;
|
||||
* - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an
|
||||
* actual React component — runs when `bun` + the worker's deps are present.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { SSRBridge } from '../src'
|
||||
|
||||
const HERE = import.meta.dir
|
||||
const REPO_ROOT = resolve(HERE, '../../..')
|
||||
const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs')
|
||||
const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx')
|
||||
const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx')
|
||||
|
||||
// The real worker renders an actual React component. bun resolves `react`
|
||||
// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's
|
||||
// own react devDependency (installed alongside this package).
|
||||
const BUN_OK = (() => {
|
||||
try {
|
||||
execFileSync('bun', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(HERE, '../node_modules/react/package.json'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
let bridge: SSRBridge | null = null
|
||||
afterEach(() => {
|
||||
bridge?.shutdown()
|
||||
bridge = null
|
||||
})
|
||||
|
||||
describe('SSRBridge — stub worker (Node, no React)', () => {
|
||||
test('waits for ready, then renders with id correlation', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 })
|
||||
expect(r.html).toBe('<div data-file="/abs/Card.tsx">{"title":"Hi","n":3}</div>')
|
||||
})
|
||||
|
||||
test('ping health check', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
|
||||
test('concurrent renders stay correlated', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const [a, b, c] = await Promise.all([
|
||||
bridge.render('/a.tsx', { k: 'a' }),
|
||||
bridge.render('/b.tsx', { k: 'b' }),
|
||||
bridge.render('/c.tsx', { k: 'c' }),
|
||||
])
|
||||
expect(a.html).toContain('"k":"a"')
|
||||
expect(b.html).toContain('"k":"b"')
|
||||
expect(c.html).toContain('"k":"c"')
|
||||
})
|
||||
|
||||
test('worker error frame surfaces as a thrown error', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed')
|
||||
})
|
||||
|
||||
test('restarts after the worker exits', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const first = await bridge.render('/one.tsx', { k: 1 })
|
||||
expect(first.html).toContain('"k":1')
|
||||
bridge.shutdown() // simulate a crashed/stopped worker
|
||||
const second = await bridge.render('/two.tsx', { k: 2 })
|
||||
expect(second.html).toContain('"k":2')
|
||||
})
|
||||
|
||||
test('startup timeout when the worker never signals ready', async () => {
|
||||
// `true` exits immediately without a ready frame → start times out.
|
||||
bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 })
|
||||
await expect(bridge.render('/x.tsx', {})).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRBridge — real Bun worker (renderToString)', () => {
|
||||
test.skipIf(!BUN_OK)('renders a React component to HTML', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
const r = await bridge.render(HELLO_TSX, { name: 'Ryth' })
|
||||
expect(r.html).toContain('Hello, Ryth!')
|
||||
expect(r.html).toContain('class="greeting"')
|
||||
})
|
||||
|
||||
test.skipIf(!BUN_OK)('ping on the real worker', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 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, 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')
|
||||
}
|
||||
|
||||
/** Mint an HS256 MWT with node crypto, mirroring Python create_mwt. */
|
||||
function mint(payload: Record<string, any>, secret: string, kid = 'v1'): string {
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', kid, typ: 'JWT' }))
|
||||
const body = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
|
||||
return `${header}.${body}.${sig}`
|
||||
}
|
||||
|
||||
const SECRET = 'round-trip-secret'
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
function basePayload(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
sub: '7',
|
||||
staff: true,
|
||||
super: false,
|
||||
pkey: 'abc123',
|
||||
aud: 'mizan',
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + 300,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MWT round-trip', () => {
|
||||
test('valid token decodes', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
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!.pkey).toBe('abc123')
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
})
|
||||
|
||||
test('identityFromMwt maps claims', () => {
|
||||
const token = mint(basePayload({ sub: '99', staff: false, super: true }), SECRET)
|
||||
const p = decodeMwt(token, SECRET)!
|
||||
expect(identityFromMwt(p)).toEqual({
|
||||
isAuthenticated: true,
|
||||
isStaff: false,
|
||||
isSuperuser: true,
|
||||
id: 99,
|
||||
})
|
||||
})
|
||||
|
||||
test('decodeJwtBearer strips Bearer prefix', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
const p = decodeJwtBearer(`Bearer ${token}`, SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('7')
|
||||
})
|
||||
|
||||
test('null on tampered signature', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
const tampered = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA')
|
||||
expect(decodeMwt(tampered, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on wrong secret', () => {
|
||||
const token = mint(basePayload(), SECRET)
|
||||
expect(decodeMwt(token, 'other-secret')).toBeNull()
|
||||
})
|
||||
|
||||
test('null on expired exp', () => {
|
||||
const token = mint(basePayload({ exp: now - 10 }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on future nbf', () => {
|
||||
const token = mint(basePayload({ nbf: now + 1000 }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on wrong aud', () => {
|
||||
const token = mint(basePayload({ aud: 'other' }), SECRET)
|
||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
||||
})
|
||||
|
||||
test('null on malformed token', () => {
|
||||
expect(decodeMwt('not.a.jwt', SECRET)).toBeNull()
|
||||
expect(decodeMwt('onlyonepart', SECRET)).toBeNull()
|
||||
expect(decodeMwt('', SECRET)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MWT cross-language pin (Python create_mwt)', () => {
|
||||
const TOKEN = 'eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJzdWIiOiI0MiIsInN0YWZmIjp0cnVlLCJzdXBlciI6ZmFsc2UsInBrZXkiOiIwZTk5OGE5ZmYxNjkwNDYzN2EwM2QyZWEwZmJkYmY5NzQyOTdhOWQxYTVkMjViOGQ0Mjk0ZmE4ODIxMTVlNDU3IiwiYXVkIjoibWl6YW4iLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwfQ._V92JXiLSLXoyuSwbNvvJjwzgmczmC7dvX34kVSLIa8'
|
||||
const PIN_SECRET = 'pin-test-secret-mwt'
|
||||
|
||||
test('decodes the Python-minted token', () => {
|
||||
const p = decodeMwt(TOKEN, PIN_SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('42')
|
||||
expect(p!.staff).toBe(true)
|
||||
expect(p!.super).toBe(false)
|
||||
expect(p!.pkey).toBe('0e998a9ff16904637a03d2ea0fbdbf974297a9d1a5d25b8d4294fa882115e457')
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
})
|
||||
|
||||
test('identity from Python-minted token', () => {
|
||||
const p = decodeMwt(TOKEN, PIN_SECRET)!
|
||||
expect(identityFromMwt(p)).toEqual({
|
||||
isAuthenticated: true,
|
||||
isStaff: true,
|
||||
isSuperuser: false,
|
||||
id: 42,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 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)
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Upload tests — multipart File-part binding + constraint enforcement.
|
||||
*
|
||||
* Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts
|
||||
* into the function's Upload-typed inputs, and `File(...)` constraints
|
||||
* (max-size, content-type) reject at dispatch with a 400.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
client,
|
||||
clearRegistry,
|
||||
handleMultipartCall,
|
||||
parseSize,
|
||||
validateUpload,
|
||||
UploadedFile,
|
||||
type StructField,
|
||||
} from '../src'
|
||||
|
||||
const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => {
|
||||
let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes }
|
||||
if (opts.list) shape = { kind: 'list', inner: shape }
|
||||
if (opts.optional) shape = { kind: 'optional', inner: shape }
|
||||
return { name, required: !opts.optional, shape }
|
||||
}
|
||||
const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } })
|
||||
|
||||
function multipart(fn: string, args: Record<string, any>, files: Record<string, Blob | Blob[]>): FormData {
|
||||
const form = new FormData()
|
||||
form.set('fn', fn)
|
||||
form.set('args', JSON.stringify(args))
|
||||
for (const [key, val] of Object.entries(files)) {
|
||||
for (const f of Array.isArray(val) ? val : [val]) form.append(key, f)
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
describe('parseSize', () => {
|
||||
test('parses human sizes', () => {
|
||||
expect(parseSize('5MB')).toBe(5 * 1024 * 1024)
|
||||
expect(parseSize('1KB')).toBe(1024)
|
||||
expect(parseSize('2GB')).toBe(2 * 1024 ** 3)
|
||||
expect(parseSize(123)).toBe(123)
|
||||
expect(parseSize('500')).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpload', () => {
|
||||
test('max-size rejection', () => {
|
||||
const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100))
|
||||
expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size')
|
||||
expect(validateUpload(f, { maxSize: 200 })).toBeNull()
|
||||
})
|
||||
|
||||
test('content-type allowlist + wildcard', () => {
|
||||
const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1))
|
||||
expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull()
|
||||
expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull()
|
||||
expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multipart dispatch', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('binds a file part into the Upload input', async () => {
|
||||
let received: UploadedFile | null = null
|
||||
client(
|
||||
{
|
||||
affects: 'avatars',
|
||||
ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] },
|
||||
},
|
||||
async function set_avatar(user_id: number, avatar: UploadedFile) {
|
||||
received = avatar
|
||||
return { ok: true, name: avatar.filename, bytes: avatar.size }
|
||||
},
|
||||
)
|
||||
|
||||
const form = multipart('set_avatar', { user_id: 5 }, {
|
||||
avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 })
|
||||
expect(received).not.toBeNull()
|
||||
expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
test('max-size violation rejects with 400', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } },
|
||||
async function set_avatar(_avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', {}, {
|
||||
avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain('avatar:')
|
||||
expect(r.body.message).toContain('exceeds max size')
|
||||
})
|
||||
|
||||
test('content-type violation rejects with 400', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } },
|
||||
async function set_avatar(_avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', {}, {
|
||||
avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain('not allowed')
|
||||
})
|
||||
|
||||
test('list upload binds multiple parts', async () => {
|
||||
let count = 0
|
||||
client(
|
||||
{ affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } },
|
||||
async function add_photos(photos: UploadedFile[]) {
|
||||
count = photos.length
|
||||
return { ok: true, count: photos.length }
|
||||
},
|
||||
)
|
||||
const form = multipart('add_photos', {}, {
|
||||
photos: [
|
||||
new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }),
|
||||
new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }),
|
||||
],
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result.count).toBe(2)
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
test('missing fn → 400', async () => {
|
||||
const form = new FormData()
|
||||
form.set('args', '{}')
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain("'fn'")
|
||||
})
|
||||
|
||||
test('invalidation still emitted on multipart mutation', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } },
|
||||
async function set_avatar(_user_id: number, _avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', { user_id: 9 }, {
|
||||
avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.headers['X-Mizan-Invalidate']).toContain('avatars')
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
|
||||
|
||||
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from mizan_core.auth.authenticate import INVALID, AuthConfig, authenticate
|
||||
from mizan_core.auth.jwt import (
|
||||
JWTConfig,
|
||||
JWTUser,
|
||||
TokenPair,
|
||||
TokenPayload,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
create_token_pair,
|
||||
decode_token,
|
||||
refresh_tokens,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthConfig",
|
||||
"authenticate",
|
||||
"INVALID",
|
||||
"JWTConfig",
|
||||
"JWTUser",
|
||||
"TokenPair",
|
||||
"TokenPayload",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"create_token_pair",
|
||||
"decode_token",
|
||||
"refresh_tokens",
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Token → identity resolution, shared by every adapter.
|
||||
|
||||
`authenticate(headers, config)` reads `X-Mizan-Token` (MWT) first, then
|
||||
`Authorization: Bearer` (JWT), and returns an `Identity`, `None`, or the
|
||||
`INVALID` sentinel.
|
||||
|
||||
The `INVALID` sentinel is load-bearing: when a token is PRESENT but bad, the
|
||||
adapter must REJECT — never silently fall back to session auth (that would let
|
||||
a forged/expired token degrade into anonymous-or-session access). `None` means
|
||||
"no token offered" → the adapter may fall back to its own session identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, decode_token
|
||||
from mizan_core.identity import Identity
|
||||
from mizan_core.mwt import MWTUser, decode_mwt
|
||||
|
||||
|
||||
class _Invalid:
|
||||
"""Sentinel: a token was presented but failed validation."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "INVALID"
|
||||
|
||||
|
||||
INVALID = _Invalid()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
jwt: JWTConfig | None = None
|
||||
mwt_secret: str | None = None
|
||||
mwt_audience: str = "mizan"
|
||||
|
||||
|
||||
def authenticate(headers: Mapping[str, str], config: AuthConfig) -> Identity | _Invalid | None:
|
||||
"""Resolve identity from request headers. Returns Identity | INVALID | None."""
|
||||
mwt = headers.get("X-Mizan-Token") or headers.get("x-mizan-token")
|
||||
if mwt and config.mwt_secret:
|
||||
payload = decode_mwt(mwt, config.mwt_secret, audience=config.mwt_audience)
|
||||
return MWTUser(payload) if payload else INVALID
|
||||
|
||||
bearer = headers.get("Authorization") or headers.get("authorization") or ""
|
||||
if bearer.startswith("Bearer ") and config.jwt:
|
||||
payload = decode_token(bearer[7:], config.jwt, expected_type="access")
|
||||
return JWTUser(payload) if payload else INVALID
|
||||
|
||||
return None
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
JWT access/refresh tokens — adapter-agnostic (PyJWT).
|
||||
|
||||
Config is injected (`JWTConfig`) rather than read from any framework's settings.
|
||||
`validate_session` (the immediate-logout-revocation check) is Django-session-bound
|
||||
and stays in the Django adapter; `refresh_tokens` takes a `session_validator`
|
||||
callable so the core stays framework-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, NamedTuple
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JWTConfig:
|
||||
private_key: str
|
||||
public_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expires_in: int = 300
|
||||
refresh_token_expires_in: int = 604800
|
||||
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenPayload(NamedTuple):
|
||||
user_id: int | str
|
||||
session_key: str
|
||||
token_type: str
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
exp: int
|
||||
iat: int
|
||||
|
||||
|
||||
class JWTUser:
|
||||
"""Minimal `Identity` built from JWT claims — no DB query."""
|
||||
|
||||
def __init__(self, payload: TokenPayload):
|
||||
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
|
||||
self.pk = self.id
|
||||
self.is_staff = payload.is_staff
|
||||
self.is_superuser = payload.is_superuser
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"JWTUser(id={self.id})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||
|
||||
|
||||
def _mint(user_id: int | str, session_key: str, token_type: str, ttl: int,
|
||||
config: JWTConfig, is_staff: bool, is_superuser: bool) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": token_type,
|
||||
"iat": now,
|
||||
"exp": now + ttl,
|
||||
}
|
||||
return jwt.encode(payload, config.private_key, algorithm=config.algorithm)
|
||||
|
||||
|
||||
def create_access_token(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> str:
|
||||
return _mint(user_id, session_key, "access", config.access_token_expires_in,
|
||||
config, is_staff, is_superuser)
|
||||
|
||||
|
||||
def create_refresh_token(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> str:
|
||||
return _mint(user_id, session_key, "refresh", config.refresh_token_expires_in,
|
||||
config, is_staff, is_superuser)
|
||||
|
||||
|
||||
def create_token_pair(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> TokenPair:
|
||||
return TokenPair(
|
||||
access_token=create_access_token(user_id, session_key, config,
|
||||
is_staff=is_staff, is_superuser=is_superuser),
|
||||
refresh_token=create_refresh_token(user_id, session_key, config,
|
||||
is_staff=is_staff, is_superuser=is_superuser),
|
||||
expires_in=config.access_token_expires_in,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, config: JWTConfig, expected_type: str | None = None) -> TokenPayload | None:
|
||||
"""Decode + validate. None on invalid/expired token, or type mismatch."""
|
||||
try:
|
||||
payload = jwt.decode(token, config.public_key, algorithms=[config.algorithm])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if expected_type and payload.get("type") != expected_type:
|
||||
return None
|
||||
return TokenPayload(
|
||||
user_id=payload["sub"],
|
||||
session_key=payload["sid"],
|
||||
token_type=payload["type"],
|
||||
is_staff=payload.get("staff", False),
|
||||
is_superuser=payload.get("super", False),
|
||||
exp=payload["exp"],
|
||||
iat=payload["iat"],
|
||||
)
|
||||
|
||||
|
||||
def refresh_tokens(
|
||||
refresh_token: str,
|
||||
config: JWTConfig,
|
||||
session_validator: Callable[[str], bool] | None = None,
|
||||
) -> TokenPair | None:
|
||||
"""Exchange a refresh token for a new pair. None if invalid or the session is gone.
|
||||
|
||||
`session_validator(session_key) -> bool` lets the Django adapter enforce
|
||||
immediate-logout revocation; omit it (or pass a always-True) where there is
|
||||
no session store.
|
||||
"""
|
||||
payload = decode_token(refresh_token, config, expected_type="refresh")
|
||||
if payload is None:
|
||||
return None
|
||||
if session_validator is not None and not session_validator(payload.session_key):
|
||||
return None
|
||||
return create_token_pair(payload.user_id, payload.session_key, config,
|
||||
is_staff=payload.is_staff, is_superuser=payload.is_superuser)
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
Auth-guard evaluation — the adapter-agnostic core.
|
||||
|
||||
`enforce_auth` evaluates a function's `@client(auth=...)` requirement against an
|
||||
`Identity` and raises `Unauthorized`/`Forbidden` on failure. A custom `auth=callable`
|
||||
receives the adapter's NATIVE request (it may read request-specific state), passed
|
||||
through opaquely — the core never introspects it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.errors import Forbidden, InternalError, Unauthorized
|
||||
from mizan_core.identity import Identity
|
||||
|
||||
|
||||
def enforce_auth(
|
||||
identity: Identity | None,
|
||||
requirement: Any,
|
||||
native_request: Any = None,
|
||||
) -> None:
|
||||
"""Raise `Unauthorized`/`Forbidden` if `identity` fails `requirement`; else return.
|
||||
|
||||
Requirement: None | True | "required" | "staff" | "superuser" | callable(native_request)->bool.
|
||||
"""
|
||||
if requirement is None:
|
||||
return
|
||||
|
||||
if callable(requirement):
|
||||
try:
|
||||
if not requirement(native_request):
|
||||
raise Forbidden("Access denied")
|
||||
except PermissionError as e:
|
||||
raise Forbidden(str(e) or "Access denied") from e
|
||||
return
|
||||
|
||||
if not getattr(identity, "is_authenticated", False):
|
||||
raise Unauthorized("Authentication required")
|
||||
|
||||
if requirement in (True, "required"):
|
||||
return
|
||||
if requirement == "staff":
|
||||
if not getattr(identity, "is_staff", False):
|
||||
raise Forbidden("Staff access required")
|
||||
return
|
||||
if requirement == "superuser":
|
||||
if not getattr(identity, "is_superuser", False):
|
||||
raise Forbidden("Superuser access required")
|
||||
return
|
||||
|
||||
raise InternalError(f"Unknown auth requirement: {requirement!r}")
|
||||
@@ -487,10 +487,8 @@ def _create_server_function(
|
||||
# Use function name directly
|
||||
name = fn.__name__
|
||||
|
||||
# Extract type hints and signature. include_extras keeps `Annotated[...]`
|
||||
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
|
||||
# survives into the generated Input model.
|
||||
hints = get_type_hints(fn, include_extras=True)
|
||||
# Extract type hints and signature
|
||||
hints = get_type_hints(fn)
|
||||
sig = inspect.signature(fn)
|
||||
params = list(sig.parameters.items())
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"""
|
||||
The adapter-agnostic dispatch core.
|
||||
|
||||
Both `dispatch_call` (mutations/RPC) and `dispatch_context` (bundled reads) run
|
||||
the full protocol: auth → input validation → execute (`await view.acall`, which
|
||||
threadpools sync handlers) → serialize → resolve invalidation/merge → orchestrate
|
||||
origin cache. They return a `DispatchResult` the adapter renders to its native
|
||||
response. Errors raise `MizanError` (the adapter catches at its boundary).
|
||||
|
||||
The adapter owns native request parsing (multipart/JSON) and native response
|
||||
construction; it hands the core a `DispatchRequest` carrying only what the core
|
||||
reads, and renders what the core returns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from mizan_core.authguard import enforce_auth
|
||||
from mizan_core.cache.backend import CacheBackend
|
||||
from mizan_core.cache.keys import CONTEXT_KEY_PREFIX, derive_cache_key
|
||||
from mizan_core.errors import (
|
||||
BadRequest,
|
||||
InternalError,
|
||||
MizanError,
|
||||
NotFound,
|
||||
NotImplementedYet,
|
||||
ValidationFailed,
|
||||
)
|
||||
from mizan_core.identity import Identity, user_id_of
|
||||
from mizan_core.invalidation import (
|
||||
format_invalidate_header,
|
||||
resolve_invalidation,
|
||||
resolve_merges,
|
||||
)
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
|
||||
# ─── Request / result ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchRequest:
|
||||
"""What the dispatch core reads. The adapter resolves `identity` (session OR
|
||||
token) and parses `args`/`files`; `native_request` is an opaque passthrough
|
||||
handed to `view_class(...)` and to `auth=callable`."""
|
||||
|
||||
identity: Identity | None = None
|
||||
args: dict[str, Any] | None = None
|
||||
files: dict[str, list[Any]] | None = None
|
||||
native_request: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
kind: Literal["rpc", "view", "context"] = "rpc"
|
||||
native_response: Any | None = None # view-path: the handler's own response
|
||||
data: Any | None = None # rpc: serialized payload; context: bundle dict
|
||||
body_bytes: bytes | None = None # context: canonical JSON to send/cache
|
||||
cache_status: str | None = None # context: "HIT" | "MISS" | None
|
||||
invalidate: list[Any] | None = None
|
||||
merge: list[dict[str, Any]] | None = None
|
||||
invalidate_header: str | None = None
|
||||
|
||||
|
||||
# ─── Cache orchestration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CacheOrchestrator:
|
||||
"""Origin-side cache, backend + secret injected by the adapter (config seam)."""
|
||||
|
||||
def __init__(self, backend: CacheBackend | None, secret: str | None):
|
||||
self.backend = backend
|
||||
self.secret = secret
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.backend is not None and bool(self.secret)
|
||||
|
||||
def get(self, context: str, params: dict[str, Any], user_id: str | None, rev: int) -> bytes | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
return self.backend.get(derive_cache_key(self.secret, context, params, user_id, rev))
|
||||
|
||||
def put(self, context: str, params: dict[str, Any], value: bytes, user_id: str | None, rev: int) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self.backend.set(derive_cache_key(self.secret, context, params, user_id, rev), value)
|
||||
|
||||
def purge(self, invalidate: list[Any], user_id: str | None) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{entry}:")
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params")
|
||||
if params:
|
||||
self.backend.delete(derive_cache_key(self.secret, ctx, params, user_id, 0))
|
||||
else:
|
||||
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{ctx}:")
|
||||
|
||||
|
||||
# ─── Shared dispatch helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_function(fn_name: str) -> Any:
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
raise NotFound("Function not found")
|
||||
if getattr(view_class, "_meta", {}).get("private"):
|
||||
from mizan_core.errors import Forbidden
|
||||
raise Forbidden("Function is not client-callable")
|
||||
return view_class
|
||||
|
||||
|
||||
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
||||
"""Validate `input_data` against the function's Input model."""
|
||||
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
||||
return None
|
||||
required = [name for name, f in input_cls.model_fields.items() if f.is_required()]
|
||||
if not input_data:
|
||||
if required:
|
||||
raise ValidationFailed(
|
||||
"Input validation failed",
|
||||
details={"fields": {name: ["Field required"] for name in required}},
|
||||
)
|
||||
return input_cls()
|
||||
if not isinstance(input_data, dict):
|
||||
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||
try:
|
||||
return input_cls(**input_data)
|
||||
except ValidationError as e:
|
||||
raise ValidationFailed("Input validation failed", details={"errors": e.errors()}) from e
|
||||
|
||||
|
||||
def _serialize(result: Any) -> Any:
|
||||
return to_jsonable_python(result)
|
||||
|
||||
|
||||
async def _run(view: Any, validated: Any) -> Any:
|
||||
try:
|
||||
return await view.acall(validated)
|
||||
except NotImplementedError as e:
|
||||
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||
except MizanError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from e
|
||||
|
||||
|
||||
def _canonical_bytes(data: Any) -> bytes:
|
||||
return json.dumps(data, sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
# ─── Entry points ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def dispatch_call(req: DispatchRequest, fn_name: str, cache: CacheOrchestrator) -> DispatchResult:
|
||||
"""Mutation / RPC dispatch."""
|
||||
view_class = _resolve_function(fn_name)
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
enforce_auth(req.identity, meta.get("auth"), req.native_request)
|
||||
|
||||
view = view_class(req.native_request)
|
||||
validated = _validate_input(view.Input, req.args)
|
||||
result = await _run(view, validated)
|
||||
|
||||
invalidate = resolve_invalidation(view_class, req.args)
|
||||
header = format_invalidate_header(invalidate) if invalidate else None
|
||||
if invalidate:
|
||||
cache.purge(invalidate, user_id_of(req.identity))
|
||||
|
||||
if meta.get("view_path"):
|
||||
# Handler returned its own native response; carry it through + the header.
|
||||
return DispatchResult(kind="view", native_response=result,
|
||||
invalidate=invalidate, invalidate_header=header)
|
||||
|
||||
serialized = _serialize(result)
|
||||
return DispatchResult(
|
||||
kind="rpc",
|
||||
data=serialized,
|
||||
invalidate=invalidate,
|
||||
merge=resolve_merges(view_class, req.args, serialized),
|
||||
invalidate_header=header,
|
||||
)
|
||||
|
||||
|
||||
def _effective_policy(fn_names: list[str]) -> tuple[int, int | bool]:
|
||||
"""(effective_rev, effective_cache) across a context's functions."""
|
||||
rev = 0
|
||||
cache_policy: int | bool = True # True=forever, False=no-store, int=TTL
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
m = getattr(fn_cls, "_meta", {})
|
||||
rev = max(rev, m.get("rev", 0))
|
||||
fn_cache = m.get("cache", True)
|
||||
if fn_cache is False:
|
||||
return rev, False
|
||||
if isinstance(fn_cache, int):
|
||||
cache_policy = fn_cache if cache_policy is True else min(cache_policy, fn_cache)
|
||||
return rev, cache_policy
|
||||
|
||||
|
||||
async def dispatch_context(req: DispatchRequest, context_name: str, cache: CacheOrchestrator) -> DispatchResult:
|
||||
"""Bundled context read with origin-cache get/put."""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name)
|
||||
if not fn_names:
|
||||
raise NotFound(f"Context '{context_name}' not found")
|
||||
|
||||
params = req.args or {}
|
||||
rev, cache_policy = _effective_policy(fn_names)
|
||||
user_id = user_id_of(req.identity)
|
||||
use_cache = cache.enabled and cache_policy is not False
|
||||
|
||||
if use_cache:
|
||||
cached = cache.get(context_name, params, user_id, rev)
|
||||
if cached is not None:
|
||||
return DispatchResult(kind="context", body_bytes=cached, cache_status="HIT")
|
||||
|
||||
bundle: dict[str, Any] = {}
|
||||
for fn_name in fn_names:
|
||||
view_class = _resolve_function(fn_name)
|
||||
enforce_auth(req.identity, getattr(view_class, "_meta", {}).get("auth"), req.native_request)
|
||||
view = view_class(req.native_request)
|
||||
validated = _validate_input(view.Input, {k: v for k, v in params.items() if _declares(view.Input, k)})
|
||||
bundle[fn_name] = _serialize(await _run(view, validated))
|
||||
|
||||
body = _canonical_bytes(bundle)
|
||||
if use_cache:
|
||||
cache.put(context_name, params, body, user_id, rev)
|
||||
return DispatchResult(kind="context", data=bundle, body_bytes=body,
|
||||
cache_status="MISS" if use_cache else None)
|
||||
|
||||
|
||||
def _declares(input_cls: Any, name: str) -> bool:
|
||||
return bool(
|
||||
input_cls and input_cls is not BaseModel
|
||||
and getattr(input_cls, "model_fields", None)
|
||||
and name in input_cls.model_fields
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Canonical protocol-level error taxonomy.
|
||||
|
||||
Dispatch raises these typed exceptions; each backend adapter renders them to
|
||||
its native response (Django `JsonResponse`, FastAPI exception handler, …). The
|
||||
shared dispatch core never returns error envelopes — it raises, and the adapter
|
||||
catches at its boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
BAD_REQUEST = "BAD_REQUEST"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
FORBIDDEN = "FORBIDDEN"
|
||||
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
|
||||
|
||||
STATUS = {
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
ErrorCode.BAD_REQUEST: 400,
|
||||
ErrorCode.VALIDATION_ERROR: 422,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.FORBIDDEN: 403,
|
||||
ErrorCode.NOT_IMPLEMENTED: 501,
|
||||
ErrorCode.INTERNAL_ERROR: 500,
|
||||
}
|
||||
|
||||
|
||||
class MizanError(Exception):
|
||||
"""Base for protocol-level dispatch errors."""
|
||||
|
||||
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
||||
|
||||
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
return STATUS[self.code]
|
||||
|
||||
|
||||
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
|
||||
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
|
||||
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
||||
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
|
||||
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
|
||||
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
||||
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
The minimal identity contract the dispatch core reads.
|
||||
|
||||
Auth-guard evaluation and per-user cache scoping need exactly these four
|
||||
attributes — nothing about how the identity was established. Django's session
|
||||
`User`, `JWTUser`, `MWTUser`, and any token-user an adapter constructs all
|
||||
satisfy this structurally; no inheritance required.
|
||||
|
||||
`get_all_permissions()` (Django ORM) is deliberately NOT here — the MWT
|
||||
permission-key is a Django-side concern, and adding it would force every
|
||||
adapter to implement a Django-shaped method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Identity(Protocol):
|
||||
is_authenticated: bool
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
|
||||
@property
|
||||
def pk(self) -> object | None: ... # str | int | None; cache scoping stringifies it
|
||||
|
||||
|
||||
def user_id_of(identity: Identity | None) -> str | None:
|
||||
"""The cache-scoping user id — `str(pk)`, or None for anonymous/no-pk."""
|
||||
pk = getattr(identity, "pk", None)
|
||||
return str(pk) if pk is not None else None
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Server-driven invalidation + merge resolution — the adapter-agnostic core.
|
||||
|
||||
This is the canonical implementation (formerly housed in the Django executor).
|
||||
Every adapter calls `resolve_invalidation` / `resolve_merges` / `format_invalidate_header`
|
||||
so the wire shape is identical across backends.
|
||||
|
||||
Invalidation entries take one of two shapes:
|
||||
- a bare context/function name string → broad invalidation
|
||||
- {"context": name, "params": {...}} → scoped invalidation
|
||||
Function-level `affects=` resolves to the function NAME as the key (v1 refetches
|
||||
the whole context anyway).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
|
||||
__all__ = ["resolve_invalidation", "resolve_merges", "format_invalidate_header"]
|
||||
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""Classify an affects target → ("context", name, None) | ("function", name, ctx)."""
|
||||
groups = get_context_groups()
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
# Unknown — treat as a context name (non-context fn, or not-yet-registered).
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _context_param_names(context_name: str) -> set[str]:
|
||||
"""Union of Input field names across the functions in a context."""
|
||||
param_names: set[str] = set()
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
param_names.update(input_cls.model_fields.keys())
|
||||
return param_names
|
||||
|
||||
|
||||
def resolve_invalidation(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""Three-tier auto-scoping over `@client(affects=...)`. None if nothing to invalidate.
|
||||
|
||||
Tier 1: arg-name matching against the context's params → scoped entry.
|
||||
Tier 2: auth inference — Edge-side, not handled here.
|
||||
Tier 3: broad fallback — bare name.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
affects = getattr(view_class, "_meta", {}).get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
result: list[str | dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_name in seen:
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _context_param_names(ctx_for_params)
|
||||
matched = {k: v for k, v in input_data.items() if k in context_params}
|
||||
if matched:
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
result.append(target_name)
|
||||
|
||||
return result or None
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||
"""The unique function-name slot in a context whose return type matches the mutation output."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
matches: list[str] = []
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def resolve_merges(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None,
|
||||
result_data: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Build the `merge` list from `@client(merge=...)`. None when no targets resolve.
|
||||
|
||||
Each entry is `{context, slot, value, params?}`; `slot` is the context-function
|
||||
whose return type matches the mutation output (server-side type-checked routing,
|
||||
no client shape inference). Ambiguous/unmatched targets are dropped.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||
if not targets:
|
||||
return None
|
||||
|
||||
mutation_output = getattr(view_class, "Output", None)
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for ctx_name in targets:
|
||||
if ctx_name in seen:
|
||||
continue
|
||||
seen.add(ctx_name)
|
||||
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||
if slot is None:
|
||||
continue
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||
if input_data:
|
||||
matched = {k: v for k, v in input_data.items() if k in _context_param_names(ctx_name)}
|
||||
if matched:
|
||||
entry["params"] = matched
|
||||
out.append(entry)
|
||||
return out or None
|
||||
|
||||
|
||||
def format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
||||
"""Serialize invalidation targets to the `X-Mizan-Invalidate` header value.
|
||||
|
||||
Comma-separated contexts; semicolon-separated URL-encoded params per context.
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}] → "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}] → "search;q=hello%20world"
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params", {})
|
||||
if params:
|
||||
param_str = ";".join(
|
||||
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||
for k, v in sorted(params.items())
|
||||
)
|
||||
parts.append(f"{ctx};{param_str}")
|
||||
else:
|
||||
parts.append(ctx)
|
||||
return ", ".join(parts)
|
||||
@@ -17,7 +17,6 @@ KDL grammar — locked contract:
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| upload max-size=<int>? { content-type "<mime>" ... }
|
||||
}
|
||||
...
|
||||
}
|
||||
@@ -73,7 +72,6 @@ from pydantic_core import PydanticUndefined
|
||||
|
||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
from mizan_core.upload import File, classify_upload
|
||||
|
||||
|
||||
__all__ = ["build_ir"]
|
||||
@@ -246,34 +244,6 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
|
||||
_emit_type_child(alias_block, annotation, named_types)
|
||||
|
||||
|
||||
def _emit_upload_node(block: _Block, spec: File | None) -> None:
|
||||
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
|
||||
props: dict[str, str] = {}
|
||||
if spec is not None and spec.max_size is not None:
|
||||
props["max-size"] = repr(spec.max_size)
|
||||
if spec is not None and spec.content_types:
|
||||
with block.node("upload", **props) as up:
|
||||
for ct in spec.content_types:
|
||||
up.leaf("content-type", _kdl_string(ct))
|
||||
else:
|
||||
block.leaf("upload", **props)
|
||||
|
||||
|
||||
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
|
||||
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
|
||||
if is_optional and is_list:
|
||||
with block.node("optional") as opt, opt.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
elif is_optional:
|
||||
with block.node("optional") as opt:
|
||||
_emit_upload_node(opt, spec)
|
||||
elif is_list:
|
||||
with block.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
else:
|
||||
_emit_upload_node(block, spec)
|
||||
|
||||
|
||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||
with block.node("struct") as struct_block:
|
||||
@@ -289,11 +259,7 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
|
||||
props["default"] = _kdl_value(default)
|
||||
|
||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||
is_upload, is_list, is_optional, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
_emit_upload_child(field_block, is_list, is_optional, spec)
|
||||
else:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
|
||||
|
||||
class _StructShape:
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -1,15 +1,12 @@
|
||||
"""
|
||||
Mizan core registry — function and composition registration with an
|
||||
extension hook for the AFI-common capabilities that need their own
|
||||
sub-registry (channels/WebSocket, forms, shapes) to plug into.
|
||||
extension hook for backend-specific registries (channels, forms, etc.)
|
||||
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, 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.
|
||||
This is the framework-agnostic registry. Backends own their own
|
||||
type-specific registries (channels in Django Channels, forms in Django
|
||||
Forms, websockets in FastAPI, etc.) and register them as extensions
|
||||
here so the unified schema export can include them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
mizan_core.ssr — framework-agnostic server-side rendering.
|
||||
|
||||
`SSRBridge` manages a persistent Bun subprocess that renders React components to
|
||||
HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle;
|
||||
adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's
|
||||
`SSRRenderer`).
|
||||
"""
|
||||
|
||||
from mizan_core.ssr.bridge import RenderResult, SSRBridge
|
||||
|
||||
__all__ = ["SSRBridge", "RenderResult"]
|
||||
@@ -1,216 +0,0 @@
|
||||
"""
|
||||
Mizan Upload — first-class binary input for ``@client`` functions.
|
||||
|
||||
``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
|
||||
parameter makes a function multipart-aware end to end: the generated client
|
||||
switches that call to ``multipart/form-data``, and each backend adapter parses
|
||||
the file part and binds a uniform :class:`UploadedFile` into the function's
|
||||
``Input``. Constraints declared via :class:`File` are enforced at dispatch.
|
||||
|
||||
from typing import Annotated
|
||||
from mizan import client, Upload, File
|
||||
|
||||
@client(affects=UserContext)
|
||||
def set_avatar(
|
||||
request,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
|
||||
) -> dict:
|
||||
avatar.save(f"/media/{user_id}.png")
|
||||
return {"ok": True}
|
||||
|
||||
Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
|
||||
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
|
||||
carries the declarative constraints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import GetCoreSchemaHandler
|
||||
from pydantic_core import core_schema
|
||||
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Upload",
|
||||
"UploadedFile",
|
||||
"File",
|
||||
"parse_size",
|
||||
"validate_upload",
|
||||
"classify_upload",
|
||||
"upload_fields",
|
||||
"bind_uploads",
|
||||
]
|
||||
|
||||
|
||||
_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}
|
||||
|
||||
|
||||
def parse_size(value: int | str) -> int:
|
||||
"""Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = value.strip().upper()
|
||||
for unit, mult in _SIZE_UNITS.items():
|
||||
if s.endswith(unit):
|
||||
return int(float(s[: -len(unit)].strip()) * mult)
|
||||
return int(s)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class File:
|
||||
"""Declarative constraints for an ``Upload`` field, placed in ``Annotated``.
|
||||
|
||||
``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
|
||||
``content_types`` is a list of allowed MIME types; an entry ending in
|
||||
``/*`` (e.g. ``"image/*"``) matches any subtype.
|
||||
"""
|
||||
|
||||
max_size: int | str | None = None
|
||||
content_types: tuple[str, ...] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_size is not None:
|
||||
object.__setattr__(self, "max_size", parse_size(self.max_size))
|
||||
if self.content_types is not None and not isinstance(self.content_types, tuple):
|
||||
object.__setattr__(self, "content_types", tuple(self.content_types))
|
||||
|
||||
|
||||
class UploadedFile:
|
||||
"""Uniform file handle handed to ``@client`` functions, adapter-agnostic.
|
||||
|
||||
Backends construct this from their native upload object (Django
|
||||
``UploadedFile``, Starlette ``UploadFile``) so a function body stays
|
||||
portable across adapters.
|
||||
"""
|
||||
|
||||
__slots__ = ("filename", "content_type", "_data")
|
||||
|
||||
def __init__(self, filename: str | None, content_type: str | None, data: bytes):
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def save(self, path: str | os.PathLike) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(self._data)
|
||||
|
||||
|
||||
class Upload:
|
||||
"""Pydantic-composable marker type for a binary file input.
|
||||
|
||||
At validation time it accepts any :class:`UploadedFile` (the adapter has
|
||||
already bound the multipart part). The IR emitter recognizes Upload-typed
|
||||
fields and emits an ``upload`` node.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
return core_schema.no_info_plain_validator_function(cls._validate)
|
||||
|
||||
@staticmethod
|
||||
def _validate(value: Any) -> UploadedFile:
|
||||
if isinstance(value, UploadedFile):
|
||||
return value
|
||||
raise ValueError("expected an uploaded file")
|
||||
|
||||
|
||||
def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
|
||||
if not content_type:
|
||||
return False
|
||||
for ct in allowed:
|
||||
if ct == content_type:
|
||||
return True
|
||||
if ct.endswith("/*") and content_type.startswith(ct[:-1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
|
||||
"""Enforce declared constraints. Returns an error message, or ``None`` if ok."""
|
||||
if spec is None:
|
||||
return None
|
||||
if spec.max_size is not None and file.size > spec.max_size:
|
||||
return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
|
||||
if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
|
||||
return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
|
||||
return None
|
||||
|
||||
|
||||
# ─── Field classification + binding (shared by every backend adapter) ─────────
|
||||
|
||||
|
||||
def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
|
||||
"""Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
|
||||
if hasattr(annotation, "__metadata__"):
|
||||
spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
|
||||
return annotation.__origin__, spec
|
||||
return annotation, None
|
||||
|
||||
|
||||
def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
|
||||
"""Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.
|
||||
|
||||
Composes through ``Optional[...]``, ``list[...]``, and
|
||||
``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
|
||||
wherever it appears (Pydantic lifts a top-level marker into
|
||||
``field_info.metadata``; nested markers stay inside the annotation).
|
||||
"""
|
||||
spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
|
||||
ann = field_info.annotation
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
ann, is_optional = extract_optional(ann)
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
elem = extract_list_element(ann)
|
||||
is_list = elem is not None
|
||||
if is_list:
|
||||
ann, s = _strip_annotated_meta(elem); spec = spec or s
|
||||
return ann is Upload, is_list, is_optional, spec
|
||||
|
||||
|
||||
def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
|
||||
"""Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
|
||||
out: dict[str, tuple[bool, File | None]] = {}
|
||||
for name, field_info in model.model_fields.items():
|
||||
is_upload, is_list, _is_opt, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
out[name] = (is_list, spec)
|
||||
return out
|
||||
|
||||
|
||||
def bind_uploads(
|
||||
input_cls: Any,
|
||||
args: dict[str, Any],
|
||||
files: dict[str, list[UploadedFile]],
|
||||
) -> str | None:
|
||||
"""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 (an array field receives several). Returns an error message on the
|
||||
first constraint violation, else ``None``. Absent files are left for
|
||||
Pydantic's required/optional handling.
|
||||
"""
|
||||
for fname, (is_list, spec) in upload_fields(input_cls).items():
|
||||
bucket = files.get(fname) or []
|
||||
if not bucket:
|
||||
continue
|
||||
for f in bucket:
|
||||
err = validate_upload(f, spec)
|
||||
if err is not None:
|
||||
return f"{fname}: {err}"
|
||||
args[fname] = list(bucket) if is_list else bucket[0]
|
||||
return None
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Unit tests for the adapter-agnostic dispatch core."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.auth import AuthConfig, JWTConfig, INVALID, authenticate, create_access_token
|
||||
from mizan_core.authguard import enforce_auth
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
||||
from mizan_core.errors import Forbidden, Unauthorized
|
||||
from mizan_core.invalidation import format_invalidate_header, resolve_invalidation
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
|
||||
class Ident:
|
||||
def __init__(self, authed=True, staff=False, su=False, pk=1):
|
||||
self.is_authenticated = authed
|
||||
self.is_staff = staff
|
||||
self.is_superuser = su
|
||||
self.pk = pk
|
||||
|
||||
|
||||
# ─── authguard ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_auth_required_anonymous():
|
||||
with pytest.raises(Unauthorized):
|
||||
enforce_auth(None, True)
|
||||
|
||||
|
||||
def test_auth_required_authenticated():
|
||||
enforce_auth(Ident(), True) # no raise
|
||||
|
||||
|
||||
def test_auth_staff_denied_then_allowed():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(staff=False), "staff")
|
||||
enforce_auth(Ident(staff=True), "staff")
|
||||
|
||||
|
||||
def test_auth_superuser():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(su=False), "superuser")
|
||||
enforce_auth(Ident(su=True), "superuser")
|
||||
|
||||
|
||||
def test_auth_callable_false_and_raise():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(), lambda r: False)
|
||||
with pytest.raises(Forbidden, match="custom"):
|
||||
enforce_auth(Ident(), lambda r: (_ for _ in ()).throw(PermissionError("custom")))
|
||||
|
||||
|
||||
# ─── authenticate / INVALID sentinel ────────────────────────────────────────
|
||||
|
||||
|
||||
def _cfg():
|
||||
return AuthConfig(jwt=JWTConfig(private_key="k" * 32, public_key="k" * 32))
|
||||
|
||||
|
||||
def test_authenticate_jwt_ok():
|
||||
cfg = _cfg()
|
||||
tok = create_access_token("7", "sess", cfg.jwt, is_staff=True)
|
||||
ident = authenticate({"Authorization": f"Bearer {tok}"}, cfg)
|
||||
assert ident.pk == 7 and ident.is_staff and ident.is_authenticated
|
||||
|
||||
|
||||
def test_authenticate_bad_token_is_invalid_sentinel():
|
||||
assert authenticate({"Authorization": "Bearer garbage"}, _cfg()) is INVALID
|
||||
|
||||
|
||||
def test_authenticate_no_token_is_none():
|
||||
assert authenticate({}, _cfg()) is None
|
||||
|
||||
|
||||
# ─── invalidation + header ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_invalidation_three_tier_and_header():
|
||||
clear_registry()
|
||||
UserCtx = "user"
|
||||
|
||||
class Out(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client(context=UserCtx)
|
||||
def user_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(affects=UserCtx)
|
||||
def update_profile(request, user_id: int, name: str) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(update_profile, "update_profile")
|
||||
|
||||
# Tier 1: user_id matches context param → scoped
|
||||
inv = resolve_invalidation(update_profile, {"user_id": 5, "name": "x"})
|
||||
assert inv == [{"context": "user", "params": {"user_id": 5}}]
|
||||
assert format_invalidate_header(inv) == "user;user_id=5"
|
||||
|
||||
# Tier 3: no matching param → broad
|
||||
inv2 = resolve_invalidation(update_profile, {"name": "x"})
|
||||
assert inv2 == ["user"]
|
||||
clear_registry()
|
||||
|
||||
|
||||
# ─── dispatch_call end to end ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dispatch_call_auth_and_invalidation():
|
||||
clear_registry()
|
||||
|
||||
class Out(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(affects="user", auth="staff")
|
||||
def secure_update(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(secure_update, "secure_update")
|
||||
|
||||
cache = CacheOrchestrator(None, None)
|
||||
|
||||
# non-staff rejected
|
||||
with pytest.raises(Forbidden):
|
||||
asyncio.run(dispatch_call(
|
||||
DispatchRequest(identity=Ident(staff=False), args={"user_id": 1}),
|
||||
"secure_update", cache,
|
||||
))
|
||||
|
||||
# staff passes, invalidation resolved
|
||||
res = asyncio.run(dispatch_call(
|
||||
DispatchRequest(identity=Ident(staff=True), args={"user_id": 1}),
|
||||
"secure_update", cache,
|
||||
))
|
||||
assert res.kind == "rpc" and res.data == {"ok": True}
|
||||
assert res.invalidate == [{"context": "user", "params": {"user_id": 1}}]
|
||||
assert res.invalidate_header == "user;user_id=1"
|
||||
clear_registry()
|
||||
@@ -26,15 +26,6 @@ pub struct FunctionArgs {
|
||||
pub merge: Vec<Path>,
|
||||
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<String>,
|
||||
/// `form_name = "..."` + `form_role = "schema"|"validate"|"submit"` — the
|
||||
/// Forms binding's per-endpoint metadata, mirroring the Django form
|
||||
/// `_meta` keys. Carried into the IR (`is-form`/`form-name`/`form-role`).
|
||||
pub form_name: Option<String>,
|
||||
pub form_role: Option<String>,
|
||||
}
|
||||
|
||||
impl FunctionArgs {
|
||||
@@ -54,16 +45,10 @@ 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, auth, form_name, form_role",
|
||||
"unknown attribute key; expected one of: context, affects, merge",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -72,12 +57,10 @@ 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`, `private`, or `auth`",
|
||||
"unknown flag; expected `websocket` or `private`",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -116,21 +99,6 @@ fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_str(expr: &Expr) -> syn::Result<String> {
|
||||
if let Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(s),
|
||||
..
|
||||
}) = expr
|
||||
{
|
||||
Ok(s.value())
|
||||
} else {
|
||||
Err(syn::Error::new_spanned(
|
||||
expr,
|
||||
"expected a string literal (e.g. `\"staff\"`)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
||||
match expr {
|
||||
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
||||
@@ -215,11 +183,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
});
|
||||
}
|
||||
quote! {
|
||||
// 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)]
|
||||
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
|
||||
pub struct #input_type_ident {
|
||||
#(#field_defs)*
|
||||
}
|
||||
@@ -389,20 +353,6 @@ 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,
|
||||
@@ -439,10 +389,6 @@ 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>(
|
||||
|
||||
@@ -105,15 +105,6 @@ 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 `<T as MizanType>::TYPE_NAME` (associated const).
|
||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
||||
@@ -158,19 +149,6 @@ pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
||||
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<TokenStream> {
|
||||
|
||||
108
cores/mizan-rust/Cargo.lock
generated
108
cores/mizan-rust/Cargo.lock
generated
@@ -13,82 +13,12 @@ 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"
|
||||
@@ -104,12 +34,6 @@ 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"
|
||||
@@ -141,14 +65,11 @@ name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"hmac",
|
||||
"indoc",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -228,23 +149,6 @@ 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"
|
||||
@@ -256,24 +160,12 @@ 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"
|
||||
|
||||
@@ -11,9 +11,6 @@ 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"
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
//! JWT + MWT — HS256 mint and verify, byte-pinned to the Python core.
|
||||
//!
|
||||
//! Pinned references:
|
||||
//! * JWT → `cores/mizan-python/src/mizan_core/auth/jwt.py`
|
||||
//! * MWT → `cores/mizan-python/src/mizan_core/mwt.py`
|
||||
//!
|
||||
//! These are RFC 7519 JWTs over HMAC-SHA256. Byte-identical output to PyJWT
|
||||
//! 2.x requires reproducing its exact serialization, which a generic JWT crate
|
||||
//! does not expose:
|
||||
//!
|
||||
//! * the JOSE **header** keys are emitted in **sorted** order with compact
|
||||
//! `(",", ":")` separators — `{"alg":"HS256","typ":"JWT"}`, or with a
|
||||
//! `kid`, `{"alg":"HS256","kid":"v1","typ":"JWT"}`;
|
||||
//! * the **payload** keys are emitted in **insertion** order (PyJWT does not
|
||||
//! sort the claims) with the same compact separators;
|
||||
//! * both segments are base64url-encoded **without padding**.
|
||||
//!
|
||||
//! So a mint here builds each segment's bytes deliberately (sorted header,
|
||||
//! ordered claims) and signs `header.payload`. `tests/token_pin.rs` pins the
|
||||
//! exact tokens against the Python reference for fixed inputs.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Current unix time in seconds — the `now` adapters pass to mint/verify when
|
||||
/// they aren't pinning a fixed clock (tests inject a fixed value for byte
|
||||
/// determinism).
|
||||
pub fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn b64url(bytes: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
|
||||
URL_SAFE_NO_PAD.decode(s).ok()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, signing_input: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC any key length");
|
||||
mac.update(signing_input.as_bytes());
|
||||
b64url(&mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Build a JOSE header for HS256 with optional `kid`, keys in sorted order
|
||||
/// (`alg` < `kid` < `typ`) and compact separators — byte-identical to PyJWT.
|
||||
fn header_json(kid: Option<&str>) -> String {
|
||||
match kid {
|
||||
Some(kid) => format!(
|
||||
"{{\"alg\":\"HS256\",\"kid\":{},\"typ\":\"JWT\"}}",
|
||||
json_str(kid)
|
||||
),
|
||||
None => "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode one JSON string literal byte-for-byte with PyJWT's serializer,
|
||||
/// which is `json.dumps` with the default `ensure_ascii=True`: short escapes
|
||||
/// for `"`, `\`, `\b\f\n\r\t`, and `\uXXXX` for the rest of the C0 range and
|
||||
/// every non-ASCII code point (surrogate pairs above the BMP).
|
||||
fn json_str(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0c}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||
let mut buf = [0u16; 2];
|
||||
for unit in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn json_bool(b: bool) -> &'static str {
|
||||
if b {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint `header.payload.signature` from a pre-serialized payload body. The
|
||||
/// payload bytes are authored by the caller so claim ordering is under exact
|
||||
/// control (PyJWT preserves insertion order).
|
||||
fn encode(secret: &str, kid: Option<&str>, payload_json: &str) -> String {
|
||||
let header = b64url(header_json(kid).as_bytes());
|
||||
let payload = b64url(payload_json.as_bytes());
|
||||
let signing_input = format!("{header}.{payload}");
|
||||
let sig = sign(secret, &signing_input);
|
||||
format!("{signing_input}.{sig}")
|
||||
}
|
||||
|
||||
/// Verify the HS256 signature over `header.payload` and return the decoded
|
||||
/// payload bytes. Constant-time-ish: recompute and compare the signature.
|
||||
fn verify_signature(secret: &str, token: &str) -> Option<Vec<u8>> {
|
||||
let mut parts = token.splitn(3, '.');
|
||||
let header_b64 = parts.next()?;
|
||||
let payload_b64 = parts.next()?;
|
||||
let sig_b64 = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let signing_input = format!("{header_b64}.{payload_b64}");
|
||||
let expected = sign(secret, &signing_input);
|
||||
// base64url of HMAC is fixed-length; a direct compare is adequate here and
|
||||
// matches the reference's PyJWT-side verification semantics.
|
||||
if !ct_eq(expected.as_bytes(), sig_b64.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
b64url_decode(payload_b64)
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Read the `kid` claim from the (unverified) JOSE header — needed before
|
||||
/// signature verification to mirror `decode_mwt`'s `get_unverified_header`.
|
||||
fn unverified_kid(token: &str) -> Option<String> {
|
||||
let header_b64 = token.split('.').next()?;
|
||||
let bytes = b64url_decode(header_b64)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
v.get("kid")
|
||||
.and_then(|k| k.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
// ─── JWT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// JWT signing/verification config — Rust analog of `JWTConfig`. HS256 only
|
||||
/// here (the byte-pinned algorithm); `private_key` doubles as the verify key.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub access_ttl: i64,
|
||||
pub refresh_ttl: i64,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
secret: secret.into(),
|
||||
access_ttl: 300,
|
||||
refresh_ttl: 604_800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded JWT claims — Rust analog of `TokenPayload`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JwtPayload {
|
||||
pub sub: String,
|
||||
pub sid: String,
|
||||
pub staff: bool,
|
||||
pub superuser: bool,
|
||||
pub token_type: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Build the JWT claims body in PyJWT-insertion order: sub, sid, staff, super,
|
||||
/// type, iat, exp. (Matches `jwt.py::_mint`.)
|
||||
fn jwt_payload_json(
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
token_type: &str,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"sub\":{},\"sid\":{},\"staff\":{},\"super\":{},\"type\":{},\"iat\":{},\"exp\":{}}}",
|
||||
json_str(sub),
|
||||
json_str(sid),
|
||||
json_bool(staff),
|
||||
json_bool(superuser),
|
||||
json_str(token_type),
|
||||
iat,
|
||||
exp,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mint_jwt(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
token_type: &str,
|
||||
ttl: i64,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
let payload = jwt_payload_json(sub, sid, staff, superuser, token_type, now, now + ttl);
|
||||
encode(&cfg.secret, None, &payload)
|
||||
}
|
||||
|
||||
/// Mint an access token. `now` is unix-seconds (injected for determinism).
|
||||
pub fn create_access_token(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
mint_jwt(cfg, sub, sid, "access", cfg.access_ttl, staff, superuser, now)
|
||||
}
|
||||
|
||||
/// Mint a refresh token.
|
||||
pub fn create_refresh_token(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
mint_jwt(
|
||||
cfg,
|
||||
sub,
|
||||
sid,
|
||||
"refresh",
|
||||
cfg.refresh_ttl,
|
||||
staff,
|
||||
superuser,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode + validate a JWT. `None` on a bad signature, malformed token,
|
||||
/// expiry (against `now`), or a `type` mismatch. Mirrors `decode_token`.
|
||||
pub fn decode_jwt(
|
||||
token: &str,
|
||||
cfg: &JwtConfig,
|
||||
expected_type: Option<&str>,
|
||||
now: i64,
|
||||
) -> Option<JwtPayload> {
|
||||
let payload_bytes = verify_signature(&cfg.secret, token)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
let exp = v.get("exp")?.as_i64()?;
|
||||
if now >= exp {
|
||||
return None;
|
||||
}
|
||||
let token_type = v.get("type")?.as_str()?.to_string();
|
||||
if let Some(want) = expected_type {
|
||||
if token_type != want {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(JwtPayload {
|
||||
sub: v.get("sub")?.as_str()?.to_string(),
|
||||
sid: v.get("sid")?.as_str()?.to_string(),
|
||||
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
token_type,
|
||||
iat: v.get("iat")?.as_i64()?,
|
||||
exp,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── MWT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decoded MWT claims — Rust analog of `MWTPayload`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MwtPayload {
|
||||
pub sub: String,
|
||||
pub staff: bool,
|
||||
pub superuser: bool,
|
||||
pub pkey: String,
|
||||
pub kid: String,
|
||||
pub aud: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Compute the permission-state hash — full SHA-256 hex over
|
||||
/// `"{staff}:{super}:{sorted,comma-joined perms}"`. Matches
|
||||
/// `mwt.py::compute_permission_key` byte-for-byte.
|
||||
pub fn compute_permission_key(staff: bool, superuser: bool, perms: &[String]) -> String {
|
||||
use sha2::Digest;
|
||||
let mut sorted: Vec<&String> = perms.iter().collect();
|
||||
sorted.sort();
|
||||
let staff_c = if staff { "1" } else { "0" };
|
||||
let super_c = if superuser { "1" } else { "0" };
|
||||
let joined: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
|
||||
let blob = format!("{staff_c}:{super_c}:{}", joined.join(","));
|
||||
let digest = Sha256::digest(blob.as_bytes());
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Build the MWT claims body in `create_mwt` insertion order: sub, staff,
|
||||
/// super, pkey, aud, iat, nbf, exp.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mwt_payload_json(
|
||||
sub: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
pkey: &str,
|
||||
aud: &str,
|
||||
iat: i64,
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"sub\":{},\"staff\":{},\"super\":{},\"pkey\":{},\"aud\":{},\"iat\":{},\"nbf\":{},\"exp\":{}}}",
|
||||
json_str(sub),
|
||||
json_bool(staff),
|
||||
json_bool(superuser),
|
||||
json_str(pkey),
|
||||
json_str(aud),
|
||||
iat,
|
||||
nbf,
|
||||
exp,
|
||||
)
|
||||
}
|
||||
|
||||
/// Mint an MWT from already-resolved identity fields. `pkey` is the permission
|
||||
/// hash (see `compute_permission_key`); `now` is unix-seconds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_mwt(
|
||||
secret: &str,
|
||||
sub: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
pkey: &str,
|
||||
ttl: i64,
|
||||
audience: &str,
|
||||
kid: &str,
|
||||
now: i64,
|
||||
) -> String {
|
||||
let payload = mwt_payload_json(sub, staff, superuser, pkey, audience, now, now, now + ttl);
|
||||
encode(secret, Some(kid), &payload)
|
||||
}
|
||||
|
||||
/// Decode + validate an MWT. `None` on bad signature, malformed token, expiry,
|
||||
/// not-yet-valid (`nbf`), or audience mismatch. Mirrors `decode_mwt`.
|
||||
pub fn decode_mwt(token: &str, secret: &str, audience: &str, now: i64) -> Option<MwtPayload> {
|
||||
let kid = unverified_kid(token).unwrap_or_else(|| "v1".to_string());
|
||||
let payload_bytes = verify_signature(secret, token)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
|
||||
let exp = v.get("exp")?.as_i64()?;
|
||||
if now >= exp {
|
||||
return None;
|
||||
}
|
||||
if let Some(nbf) = v.get("nbf").and_then(|n| n.as_i64()) {
|
||||
if now < nbf {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let aud = v.get("aud").and_then(|a| a.as_str()).unwrap_or("");
|
||||
if aud != audience {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(MwtPayload {
|
||||
sub: v.get("sub")?.as_str()?.to_string(),
|
||||
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
pkey: v
|
||||
.get("pkey")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
kid,
|
||||
aud: audience.to_string(),
|
||||
iat: v.get("iat")?.as_i64()?,
|
||||
exp,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Identity + auth-guard enforcement ───────────────────────────────────────
|
||||
|
||||
/// The identity a token resolves to — Rust analog of `Identity`. `None`
|
||||
/// (anonymous) and `Invalid` (a present-but-bad token) are distinct: the
|
||||
/// adapter must REJECT on `Invalid`, never silently downgrade to anonymous.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
pub user_id: String,
|
||||
pub is_staff: bool,
|
||||
pub is_superuser: bool,
|
||||
}
|
||||
|
||||
impl From<&JwtPayload> for Identity {
|
||||
fn from(p: &JwtPayload) -> Self {
|
||||
Self {
|
||||
user_id: p.sub.clone(),
|
||||
is_staff: p.staff,
|
||||
is_superuser: p.superuser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&MwtPayload> for Identity {
|
||||
fn from(p: &MwtPayload) -> Self {
|
||||
Self {
|
||||
user_id: p.sub.clone(),
|
||||
is_staff: p.staff,
|
||||
is_superuser: p.superuser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving identity from request headers. Mirrors the Python
|
||||
/// `Identity | INVALID | None` contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthOutcome {
|
||||
/// A valid token resolved to this identity.
|
||||
Authenticated(Identity),
|
||||
/// No token was offered — the adapter may fall back to session identity.
|
||||
Anonymous,
|
||||
/// A token was present but failed validation — the adapter MUST reject.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Auth config carried by the adapter — JWT and/or MWT secrets. Either may be
|
||||
/// absent; a token type with no configured secret is ignored. Mirrors
|
||||
/// `AuthConfig`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthConfig {
|
||||
pub jwt: Option<JwtConfig>,
|
||||
pub mwt_secret: Option<String>,
|
||||
pub mwt_audience: String,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
jwt: None,
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve identity from `X-Mizan-Token` (MWT) then `Authorization: Bearer`
|
||||
/// (JWT). Header lookup is case-sensitive on the names the adapter passes in;
|
||||
/// pass both casings or normalize upstream. Mirrors `authenticate`.
|
||||
pub fn authenticate(
|
||||
mwt_header: Option<&str>,
|
||||
bearer_header: Option<&str>,
|
||||
config: &AuthConfig,
|
||||
now: i64,
|
||||
) -> AuthOutcome {
|
||||
if let (Some(mwt), Some(secret)) = (mwt_header, config.mwt_secret.as_deref()) {
|
||||
if !mwt.is_empty() {
|
||||
return match decode_mwt(mwt, secret, &config.mwt_audience, now) {
|
||||
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||
None => AuthOutcome::Invalid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(bearer), Some(jwt_cfg)) = (bearer_header, config.jwt.as_ref()) {
|
||||
if let Some(token) = bearer.strip_prefix("Bearer ") {
|
||||
return match decode_jwt(token, jwt_cfg, Some("access"), now) {
|
||||
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||
None => AuthOutcome::Invalid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AuthOutcome::Anonymous
|
||||
}
|
||||
|
||||
/// The `@client(auth=...)` requirement a function declares. `Callable` carries
|
||||
/// the host's own predicate — the adapter resolves it; the core stays free of
|
||||
/// the native request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
None,
|
||||
Required,
|
||||
Staff,
|
||||
Superuser,
|
||||
}
|
||||
|
||||
impl AuthRequirement {
|
||||
/// Parse the IR/`FunctionSpec` auth string into a requirement.
|
||||
/// `"required" | "staff" | "superuser"` → the matching variant; anything
|
||||
/// else (including the absence of an `auth=`) → `None`.
|
||||
pub fn from_str_opt(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("required") | Some("true") => AuthRequirement::Required,
|
||||
Some("staff") => AuthRequirement::Staff,
|
||||
Some("superuser") => AuthRequirement::Superuser,
|
||||
_ => AuthRequirement::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce a function's `auth=` against the resolved identity. `Ok(())` to
|
||||
/// proceed; `Err(MizanError)` (`Unauthorized`/`Forbidden`) to reject. Mirrors
|
||||
/// `authguard.enforce_auth`.
|
||||
pub fn enforce_auth(
|
||||
identity: Option<&Identity>,
|
||||
requirement: &AuthRequirement,
|
||||
) -> Result<(), crate::runtime::MizanError> {
|
||||
use crate::runtime::MizanError;
|
||||
if matches!(requirement, AuthRequirement::None) {
|
||||
return Ok(());
|
||||
}
|
||||
let ident = match identity {
|
||||
Some(i) => i,
|
||||
None => return Err(MizanError::Unauthorized("Authentication required".into())),
|
||||
};
|
||||
match requirement {
|
||||
AuthRequirement::None | AuthRequirement::Required => Ok(()),
|
||||
AuthRequirement::Staff => {
|
||||
if ident.is_staff {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MizanError::Forbidden("Staff access required".into()))
|
||||
}
|
||||
}
|
||||
AuthRequirement::Superuser => {
|
||||
if ident.is_superuser {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MizanError::Forbidden("Superuser access required".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
//! Origin-side cache: HMAC-SHA256 key derivation + a pluggable backend.
|
||||
//!
|
||||
//! Byte-pinned to `cores/mizan-python/src/mizan_core/cache/keys.py`. The HMAC
|
||||
//! message is the JSON-canonical form `{"c":ctx,"p":{sorted params},"r":rev}`
|
||||
//! (with optional `"u":user_id`), emitted with Python's `json.dumps(...,
|
||||
//! sort_keys=True, separators=(",", ":"))` byte layout: keys sorted, no
|
||||
//! whitespace. Every Mizan adapter must produce the identical key for
|
||||
//! identical inputs — `tests/cache_keys_pin.rs` pins this against the Python
|
||||
//! reference and the committed cross-language vectors.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Context prefix for broad purge (SCAN pattern), mirroring Python's
|
||||
/// `CONTEXT_KEY_PREFIX`.
|
||||
pub const CONTEXT_KEY_PREFIX: &str = "ctx:";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Normalize a param value to its cross-language-stable string form.
|
||||
///
|
||||
/// Python `str(True)` is `"True"` but JS `String(true)` is `"true"`; the
|
||||
/// reference picks the JSON-native spelling. Numbers and strings stringify
|
||||
/// directly. This must match `keys.py::_normalize` exactly.
|
||||
fn normalize(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Bool(true) => "true".to_string(),
|
||||
Value::Bool(false) => "false".to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
// Arrays/objects have no scalar param meaning; fall back to the JSON
|
||||
// text, matching Python's `str(v)` catch-all for non-scalars.
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-escape a string into `out` byte-for-byte with Python's
|
||||
/// `json.dumps(..., ensure_ascii=True)`: the short escapes for `"`, `\`,
|
||||
/// `\b\f\n\r\t`, `\uXXXX` for the rest of the C0 control range, and — because
|
||||
/// the reference leaves `ensure_ascii` at its default `True` — `\uXXXX` for
|
||||
/// every non-ASCII code point, encoded as a UTF-16 surrogate pair when the
|
||||
/// code point is above the BMP (e.g. `😀` → `😀`).
|
||||
fn push_json_string(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0c}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||
let mut buf = [0u16; 2];
|
||||
for unit in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
/// Build the exact HMAC message bytes: `{"c":...,"p":{...},"r":...}` with an
|
||||
/// optional `"u":...`. Keys are emitted in sorted order (c, p, r, u) and the
|
||||
/// `p` object's keys are sorted too — equivalent to `sort_keys=True`.
|
||||
fn canonical_message(
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> String {
|
||||
let mut msg = String::new();
|
||||
msg.push('{');
|
||||
|
||||
// "c"
|
||||
msg.push_str("\"c\":");
|
||||
push_json_string(&mut msg, context);
|
||||
|
||||
// "p" — object of normalized, sorted params (BTreeMap iterates sorted).
|
||||
msg.push_str(",\"p\":{");
|
||||
for (i, (k, v)) in params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
msg.push(',');
|
||||
}
|
||||
push_json_string(&mut msg, k);
|
||||
msg.push(':');
|
||||
push_json_string(&mut msg, &normalize(v));
|
||||
}
|
||||
msg.push('}');
|
||||
|
||||
// "r"
|
||||
msg.push_str(",\"r\":");
|
||||
msg.push_str(&rev.to_string());
|
||||
|
||||
// "u" (optional) — sorts after "r".
|
||||
if let Some(uid) = user_id {
|
||||
msg.push_str(",\"u\":");
|
||||
push_json_string(&mut msg, uid);
|
||||
}
|
||||
|
||||
msg.push('}');
|
||||
msg
|
||||
}
|
||||
|
||||
/// Derive a deterministic HMAC-SHA256 cache key.
|
||||
///
|
||||
/// Returns `ctx:{context}:{hmac_hex}` so broad purge can SCAN by the prefix
|
||||
/// `ctx:{context}:*`. Byte-identical to the Python/TS reference for identical
|
||||
/// inputs.
|
||||
pub fn derive_cache_key(
|
||||
secret: &str,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> String {
|
||||
let message = canonical_message(context, params, user_id, rev);
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
||||
mac.update(message.as_bytes());
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("{CONTEXT_KEY_PREFIX}{context}:{hex}")
|
||||
}
|
||||
|
||||
/// Pluggable origin cache store. The HTTP adapter injects a backend (memory
|
||||
/// for tests, Redis in production); dispatch reads/writes through it.
|
||||
pub trait CacheBackend: Send + Sync {
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>>;
|
||||
fn set(&self, key: &str, value: Vec<u8>);
|
||||
fn delete(&self, key: &str);
|
||||
/// Delete every key beginning with `prefix` (broad purge).
|
||||
fn delete_by_prefix(&self, prefix: &str);
|
||||
}
|
||||
|
||||
/// In-memory `CacheBackend` for tests and single-process deployments. Mirrors
|
||||
/// the Python `MemoryCache` — a dict guarded by a lock, no persistence.
|
||||
#[derive(Default)]
|
||||
pub struct MemoryCache {
|
||||
store: std::sync::Mutex<BTreeMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheBackend for MemoryCache {
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.store.lock().unwrap().get(key).cloned()
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: Vec<u8>) {
|
||||
self.store.lock().unwrap().insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) {
|
||||
self.store.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
fn delete_by_prefix(&self, prefix: &str) {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|k, _| !k.starts_with(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin-side cache orchestrator — backend + secret injected by the adapter
|
||||
/// (the config seam). Mirrors Python's `CacheOrchestrator`: disabled (a no-op)
|
||||
/// until both a backend and a secret are present.
|
||||
pub struct CacheOrchestrator {
|
||||
backend: Option<std::sync::Arc<dyn CacheBackend>>,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
impl CacheOrchestrator {
|
||||
pub fn new(backend: Option<std::sync::Arc<dyn CacheBackend>>, secret: Option<String>) -> Self {
|
||||
Self { backend, secret }
|
||||
}
|
||||
|
||||
/// A disabled orchestrator — every op is a no-op. Used by stateless apps.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
backend: None,
|
||||
secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.backend.is_some() && self.secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn key(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> Option<String> {
|
||||
let secret = self.secret.as_deref()?;
|
||||
Some(derive_cache_key(secret, context, params, user_id, rev))
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> Option<Vec<u8>> {
|
||||
if !self.enabled() {
|
||||
return None;
|
||||
}
|
||||
let backend = self.backend.as_ref()?;
|
||||
let key = self.key(context, params, user_id, rev)?;
|
||||
backend.get(&key)
|
||||
}
|
||||
|
||||
pub fn put(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
value: Vec<u8>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) {
|
||||
if !self.enabled() {
|
||||
return;
|
||||
}
|
||||
if let (Some(backend), Some(key)) =
|
||||
(self.backend.as_ref(), self.key(context, params, user_id, rev))
|
||||
{
|
||||
backend.set(&key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Purge the cache entries named by an invalidation list. A scoped entry
|
||||
/// (`ScopedContext`) deletes its single derived key; a bare context purges
|
||||
/// by prefix — exactly Python's `CacheOrchestrator.purge`.
|
||||
pub fn purge(&self, invalidate: &[crate::runtime::InvalidationTarget], user_id: Option<&str>) {
|
||||
if !self.enabled() {
|
||||
return;
|
||||
}
|
||||
let backend = match self.backend.as_ref() {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
for entry in invalidate {
|
||||
match entry {
|
||||
crate::runtime::InvalidationTarget::Context(ctx)
|
||||
| crate::runtime::InvalidationTarget::Function(ctx) => {
|
||||
backend.delete_by_prefix(&format!("{CONTEXT_KEY_PREFIX}{ctx}:"));
|
||||
}
|
||||
crate::runtime::InvalidationTarget::ScopedContext { context, params } => {
|
||||
let params_tree: BTreeMap<String, Value> =
|
||||
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
if let Some(key) = self.key(context, ¶ms_tree, user_id, 0) {
|
||||
backend.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,6 @@ pub enum TypeShape {
|
||||
Optional(Box<TypeShape>),
|
||||
Enum(Vec<&'static str>),
|
||||
Union(Vec<TypeShape>),
|
||||
/// An `Upload`-typed field — a binary file input. Emits the IR `upload`
|
||||
/// type-child (matching `cores/mizan-python`'s `_emit_upload_node`), with
|
||||
/// optional declarative `max-size` / `content-type` constraints. `None`s
|
||||
/// mean an unconstrained upload.
|
||||
Upload {
|
||||
max_size: Option<i64>,
|
||||
content_types: &'static [&'static str],
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user