Compare commits
3 Commits
6c5f6f1fba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587be8c4ab | |||
| ae684a36cb | |||
| adcc027894 |
@@ -1,4 +1,4 @@
|
|||||||
name: Publish Django package to Gitea registry
|
name: Publish Django package to PyPI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Publish React package to Gitea registry
|
name: Publish React package to npm
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,6 +34,3 @@ examples/django-react-site/harness/test-results/
|
|||||||
.env.*
|
.env.*
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Agent worktrees (transient scratch — never tracked)
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
473
CLAUDE.md
473
CLAUDE.md
@@ -1,473 +0,0 @@
|
|||||||
# 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, 'components/Hello.tsx', props)` — the template name is a `.tsx`/`.jsx` **file path** (resolved against `DIRS`), not a component name — 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. The worker `import()`s the file and renders it (no component registry):
|
|
||||||
```
|
|
||||||
→ {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {"name": "World"}}}
|
|
||||||
← {"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',
|
|
||||||
'DIRS': [BASE_DIR / 'frontend'],
|
|
||||||
'OPTIONS': {
|
|
||||||
'worker': 'path/to/mizan-ssr/src/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, 'components/Profile.tsx', {'profile': profile})
|
|
||||||
```
|
|
||||||
|
|
||||||
`render()` calls `MizanTemplates.get_template('components/Profile.tsx')` — the name is a file path resolved to an absolute path against `DIRS` — which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC (`{file, props}`) to the Bun worker.
|
|
||||||
|
|
||||||
### SSR Bridge (bridge.py)
|
|
||||||
|
|
||||||
- Spawns `bun run <worker>` 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
|
|
||||||
- Resolves the component by **file path** — `import(file)` (cached) — no registry
|
|
||||||
- Calls `renderToString(createElement(Component, props))` on the imported default export
|
|
||||||
- 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.
|
|
||||||
107
INVARIANTS.md
Normal file
107
INVARIANTS.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Application Framework Interface Invariants
|
||||||
|
|
||||||
|
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
|
||||||
|
|
||||||
|
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
|
||||||
|
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
|
||||||
|
|
||||||
|
## Backend Adapters
|
||||||
|
|
||||||
|
Django (python)
|
||||||
|
FastAPI (python)
|
||||||
|
Typescript (generic)
|
||||||
|
Rust/Axum (generic)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
## Frontend Adapters
|
||||||
|
|
||||||
|
React (Typescript)
|
||||||
|
Vue (Typescript)
|
||||||
|
Svelte (Typescript)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
### Client Function RPC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
No REST endpoints.
|
||||||
|
|
||||||
|
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
|
||||||
|
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
|
||||||
|
|
||||||
|
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
|
||||||
|
|
||||||
|
### Named Contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
|
||||||
|
|
||||||
|
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
|
||||||
|
|
||||||
|
### Mutation Invalidation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
|
||||||
|
|
||||||
|
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
|
||||||
|
|
||||||
|
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
|
||||||
|
|
||||||
|
### API Shapes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A backend adapter supports the "API Shape" feature to the fullest extent:
|
||||||
|
|
||||||
|
- ORM Integration
|
||||||
|
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
|
||||||
|
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
|
||||||
|
|
||||||
|
### Canonical IR & Codegen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
|
||||||
|
|
||||||
|
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
|
||||||
|
|
||||||
|
### Client Kernel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
|
||||||
|
|
||||||
|
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
|
||||||
|
|
||||||
|
### SSR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
|
||||||
|
|
||||||
|
## Compositions
|
||||||
|
|
||||||
|
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
|
||||||
|
|
||||||
|
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
|
||||||
|
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.
|
||||||
@@ -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.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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`.
|
- [ ] **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
|
## 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
|
CORE = cores/mizan-python
|
||||||
DJANGO = backends/mizan-django
|
DJANGO = backends/mizan-django
|
||||||
@@ -30,24 +30,11 @@ test-fastapi:
|
|||||||
test-react:
|
test-react:
|
||||||
cd $(REACT) && npm test
|
cd $(REACT) && npm test
|
||||||
|
|
||||||
# AFI conformance — two gates, substrate-level, not e2e:
|
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
|
||||||
# test_codegen_parity.py — Django/FastAPI/Rust emit byte-identical KDL IR.
|
# schemas for the same @client fixture. Substrate-level gate, not e2e.
|
||||||
# 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.
|
|
||||||
test-afi:
|
test-afi:
|
||||||
cd $(AFI) && uv run pytest
|
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 ──────────────────────────────────────────────────────
|
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test-integration: docker-up
|
test-integration: docker-up
|
||||||
|
|||||||
116
README.md
116
README.md
@@ -33,86 +33,86 @@ reference implementation; per-adapter support is inventoried below.
|
|||||||
|
|
||||||
## Backend adapters
|
## Backend adapters
|
||||||
|
|
||||||
Every adapter implements the same AFI wire protocol. The matrix below is **generated**
|
Every adapter implements the same AFI wire protocol. The matrix below inventories
|
||||||
from the conformance probes in [`tests/afi/`](tests/afi/) by `make parity-table` — it is
|
support per adapter, grouped to separate protocol guarantees from Django-specific
|
||||||
output, not prose. A cell goes `✅` only when that adapter wires the capability into its
|
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
|
||||||
own dispatch surface; it cannot be set to "supported" or "Django-only" by editing this
|
when that adapter wires the capability into its own dispatch surface, not merely that a
|
||||||
file (a hand-edit fails `python tests/afi/parity_table.py --check` in CI, the same
|
shared core primitive exists.
|
||||||
forcing function the codegen byte-parity tests use).
|
|
||||||
|
|
||||||
Every capability in the matrix is **AFI-common** — each adapter owes a binding, and a
|
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
|
||||||
`❌` 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.
|
|
||||||
|
|
||||||
### Protocol core
|
### Protocol core
|
||||||
|
|
||||||
|
The surface every Mizan adapter implements.
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
|
||||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||||
| File uploads (`Upload` type) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
### Edge, cache & enforcement
|
### Edge, cache & enforcement
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
Protocol transports and guarantees co-equal with the body channel in the spec.
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Origin-side HMAC cache | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Edge manifest export | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| PSR (`render_strategy` in manifest) | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
|
|
||||||
### Extension points
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
| WebSocket transport (`websocket=` declared) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
||||||
| SSR bridge (subprocess renderer) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
||||||
| JWT auth (access / refresh) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||||
| MWT (edge identity token) | ✅ | ✅ | ✅ | — | ✅ |
|
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
| Typed query projection (Shapes) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
| Forms (schema / validate / submit) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| 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**
|
**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.
|
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||||
- **Edge manifest export** — The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge.
|
Invalidation rides in the JSON response body; there is no header channel.
|
||||||
- **MWT (edge identity token)** — MWT exists to key an edge cache; without an edge there is nothing to key.
|
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
|
||||||
- **Typed query projection (Shapes)** — The capability is AFI-common; the binding is per-ORM (django-readers on Django, the project's ORM elsewhere).
|
WebSocket handler yet.
|
||||||
- **Forms (schema / validate / submit)** — The capability is AFI-common; the binding is per-framework (Django Forms on Django, Pydantic-or-equivalent elsewhere).
|
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
|
||||||
<!-- MIZAN:PARITY:END -->
|
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
|
## Conformance
|
||||||
|
|
||||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/), at
|
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
|
||||||
two layers:
|
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
|
||||||
- **IR-shape parity** (`test_codegen_parity.py`) — Django, FastAPI, and the Rust adapter
|
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
|
||||||
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.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@@ -34,11 +34,21 @@
|
|||||||
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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`).
|
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Core Consolidation — Rust Binary
|
||||||
|
|
||||||
|
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
|
||||||
|
|
||||||
|
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
|
||||||
|
|
||||||
|
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mizan Cloud (closed-source)
|
## Mizan Cloud (closed-source)
|
||||||
|
|
||||||
### Mizan Edge
|
### Mizan Edge
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ from . import setup
|
|||||||
from .channels import ReactChannel
|
from .channels import ReactChannel
|
||||||
from .channels import register as register_channel
|
from .channels import register as register_channel
|
||||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
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
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
@@ -165,10 +164,6 @@ __all__ = [
|
|||||||
"GlobalContext",
|
"GlobalContext",
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
# File uploads
|
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
# Setup
|
# Setup
|
||||||
"mizan_clients",
|
"mizan_clients",
|
||||||
"mizan_module",
|
"mizan_module",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# Cache Module — Known Issues
|
# Cache Module — Known Issues
|
||||||
|
|
||||||
Open issues against the current cache implementation. The cache uses
|
Open issues against the current cache implementation. Resolved items are
|
||||||
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key;
|
removed once their fix lands.
|
||||||
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.
|
|
||||||
|
|
||||||
## Correctness
|
## 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
|
### Cross-language stringification divergence
|
||||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
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
|
## 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
|
### No thundering-herd protection
|
||||||
Concurrent cold misses on the same key all execute and write. No
|
Concurrent cold misses on the same key all execute and write. No
|
||||||
single-flight / request-coalescing.
|
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.cache import get_cache, cache_get, cache_put, cache_purge
|
||||||
from mizan_core.registry import get_function, get_context_groups
|
from mizan_core.registry import get_function, get_context_groups
|
||||||
from mizan_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
|
from mizan.setup.settings import get_settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -116,14 +112,53 @@ def _check_auth_requirement(
|
|||||||
Django User (from session). Either way, no additional DB query is made
|
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.
|
for the built-in checks. Custom callables may query DB if they choose.
|
||||||
"""
|
"""
|
||||||
# Evaluation lives in the shared core (mizan_core.authguard); the callable
|
if auth_requirement is None:
|
||||||
# path receives the native Django request. Core raises; we render to the
|
return None
|
||||||
# Django-shim FunctionError shape the executor expects.
|
|
||||||
try:
|
user = request.user
|
||||||
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
|
|
||||||
|
# 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
|
return None
|
||||||
except _CoreMizanError as e:
|
|
||||||
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
|
|
||||||
|
|
||||||
|
|
||||||
_cache_log = logging.getLogger("mizan.cache")
|
_cache_log = logging.getLogger("mizan.cache")
|
||||||
@@ -162,6 +197,51 @@ def _purge_cache_for_invalidation(
|
|||||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
_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(
|
def _resolve_invalidation(
|
||||||
view_class: type | None,
|
view_class: type | None,
|
||||||
input_data: dict[str, Any] | None = 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 a list suitable for both JSON body and header serialization.
|
||||||
Returns None if no invalidation needed.
|
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(
|
def _resolve_merges(
|
||||||
@@ -199,12 +321,94 @@ def _resolve_merges(
|
|||||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||||
Entries whose slot can't be uniquely resolved are dropped.
|
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:
|
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||||
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core)."""
|
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||||
return _core_inval.format_invalidate_header(invalidate)
|
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(
|
def execute_function(
|
||||||
@@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
is_multipart = content_type.startswith("multipart/form-data")
|
is_multipart = content_type.startswith("multipart/form-data")
|
||||||
|
|
||||||
if is_multipart:
|
if is_multipart:
|
||||||
# Multipart carries two shapes: a form submission (Django Form path) or
|
# Multipart form data - used by form submit functions
|
||||||
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
|
|
||||||
fn_name = request.POST.get("fn")
|
fn_name = request.POST.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Missing 'fn' field",
|
message="Missing 'fn' field",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
fn_class = get_function(fn_name)
|
# Get form data (excluding 'fn')
|
||||||
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
|
|
||||||
|
|
||||||
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"}
|
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||||
|
|
||||||
|
# Attach parsed form data and files to request for form functions
|
||||||
request._mizan_form_data = input_data
|
request._mizan_form_data = input_data
|
||||||
request._mizan_form_files = request.FILES
|
request._mizan_form_files = request.FILES
|
||||||
else:
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON body - standard RPC
|
# 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`;
|
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||||
Django exposes it through `python manage.py export_edge_manifest` and this
|
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||||
re-export. The manifest maps contexts to URL patterns and params, consumed by
|
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||||
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
|
codegen, the manifest drives CDN purging.
|
||||||
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||||
@@ -13,10 +12,145 @@ Usage:
|
|||||||
|
|
||||||
from __future__ import annotations
|
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__ = [
|
__all__ = [
|
||||||
"generate_edge_manifest",
|
"generate_edge_manifest",
|
||||||
"generate_edge_manifest_json",
|
"generate_edge_manifest_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||||
|
patterns and params for CDN cache purging.
|
||||||
|
|
||||||
|
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||||
|
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||||
|
1. Looks up 'user' in the manifest
|
||||||
|
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||||
|
3. Purges the resolved URLs + the context API endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
|
view_urls: Optional mapping of context names to URL patterns for
|
||||||
|
view-path functions. These are URLs that Edge should
|
||||||
|
also purge when a context is invalidated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
groups = get_context_groups()
|
||||||
|
registry = get_registry()
|
||||||
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in sorted(groups.items()):
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
page_routes: list[str] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
for param_name in input_cls.model_fields:
|
||||||
|
param_names.add(param_name)
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
route = meta.get("route")
|
||||||
|
view_path = meta.get("view_path")
|
||||||
|
|
||||||
|
fn_entry: dict[str, Any] = {
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if view_path else "rpc",
|
||||||
|
}
|
||||||
|
if route:
|
||||||
|
fn_entry["route"] = route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(route)
|
||||||
|
if meta.get("rev"):
|
||||||
|
fn_entry["rev"] = meta["rev"]
|
||||||
|
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||||
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
sorted_params = sorted(param_names)
|
||||||
|
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted_params,
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
|
}
|
||||||
|
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
if not meta.get("affects"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||||
|
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||||
|
|
||||||
|
# Auto-scoped params — function params that match context params
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
auto_scoped: list[str] = []
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_param_names: set[str] = set()
|
||||||
|
ctx_fns = groups.get(ctx_name, [])
|
||||||
|
for ctx_fn_name in ctx_fns:
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls is None:
|
||||||
|
continue
|
||||||
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
|
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||||
|
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||||
|
for p in fn_params:
|
||||||
|
if p in ctx_param_names and p not in auto_scoped:
|
||||||
|
auto_scoped.append(p)
|
||||||
|
if auto_scoped:
|
||||||
|
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||||
|
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation["route"] = meta["route"]
|
||||||
|
mutation["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
indent: int = 2,
|
||||||
|
) -> str:
|
||||||
|
"""JSON-serialize the Edge manifest."""
|
||||||
|
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||||
|
|||||||
@@ -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`)
|
Uses PyJWT directly - no allauth dependency.
|
||||||
lives in the core; this module binds it to Django settings and keeps the
|
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||||
session-revocation check (`validate_session`), which is Django-session-specific.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from mizan_core.auth import jwt as _core_jwt
|
import jwt
|
||||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
|
|
||||||
from .settings import get_settings
|
from .settings import get_settings
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"TokenPair",
|
class TokenPair(NamedTuple):
|
||||||
"TokenPayload",
|
"""Access and refresh token pair."""
|
||||||
"JWTUser",
|
access_token: str
|
||||||
"create_access_token",
|
refresh_token: str
|
||||||
"create_refresh_token",
|
expires_in: int
|
||||||
"create_token_pair",
|
|
||||||
"decode_token",
|
|
||||||
"validate_session",
|
|
||||||
"refresh_tokens",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> JWTConfig:
|
class TokenPayload(NamedTuple):
|
||||||
s = get_settings()
|
"""Decoded token payload."""
|
||||||
return JWTConfig(
|
user_id: int | str
|
||||||
private_key=s.private_key,
|
session_key: str
|
||||||
public_key=s.public_key,
|
token_type: str
|
||||||
algorithm=s.algorithm,
|
is_staff: bool
|
||||||
access_token_expires_in=s.access_token_expires_in,
|
is_superuser: bool
|
||||||
refresh_token_expires_in=s.refresh_token_expires_in,
|
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:
|
def create_refresh_token(
|
||||||
return _core_jwt.create_access_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
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:
|
def create_token_pair(
|
||||||
return _core_jwt.create_refresh_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
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:
|
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||||
return _core_jwt.create_token_pair(user_id, session_key, _config(),
|
"""
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
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:
|
try:
|
||||||
return _core_jwt.decode_token(token, _config(), expected_type=expected_type)
|
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:
|
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
|
This is the key to immediate logout revocation - if the session
|
||||||
Django-session-bound piece; the core's `refresh_tokens` takes it as an
|
is destroyed, tokens tied to it become invalid.
|
||||||
injected `session_validator`.
|
|
||||||
"""
|
"""
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
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
|
return True
|
||||||
|
|
||||||
|
# Use the configured session engine
|
||||||
engine = import_module(django_settings.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)
|
return session.exists(session_key)
|
||||||
|
|
||||||
|
|
||||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
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.template.backends.base import BaseEngine
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from mizan_core.ssr import SSRBridge
|
from .bridge import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
class MizanTemplate:
|
class MizanTemplate:
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
SSR Bridge — manages a persistent Bun subprocess for React rendering.
|
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||||
|
|
||||||
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
|
|
||||||
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
|
|
||||||
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
|
|
||||||
render path — so the subprocess lifecycle and wire protocol are authored once.
|
|
||||||
|
|
||||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||||
|
|
||||||
@@ -38,7 +33,7 @@ class SSRBridge:
|
|||||||
"""
|
"""
|
||||||
Manages a persistent Bun subprocess for server-side rendering.
|
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.
|
Request-response matching via message IDs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
|
|||||||
|
|
||||||
def test_jwt_expired_with_session(self):
|
def test_jwt_expired_with_session(self):
|
||||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||||
# Create token with past expiration by mocking time (minting lives in the core now)
|
# Create token with past expiration by mocking time
|
||||||
with patch("mizan_core.auth.jwt.time.time", return_value=0):
|
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||||
tokens = create_token_pair(
|
tokens = create_token_pair(
|
||||||
self.user.pk,
|
self.user.pk,
|
||||||
self.session_key,
|
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",
|
"mizan-core",
|
||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"python-multipart>=0.0.9",
|
|
||||||
"sqlalchemy>=2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
|||||||
@@ -2,23 +2,9 @@
|
|||||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||||
|
|
||||||
HTTP RPC dispatch and context bundling on top of mizan-core's function
|
HTTP RPC dispatch and context bundling on top of mizan-core's function
|
||||||
registry, sharing the auth / invalidation / cache / upload core with the
|
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
|
||||||
Django adapter.
|
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
|
||||||
|
SSR frameworks).
|
||||||
The full AFI-common surface is wired here over FastAPI-native primitives,
|
|
||||||
each riding the shared core:
|
|
||||||
|
|
||||||
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
|
|
||||||
functions through the same `mizan_core.dispatch` as `POST /call/`.
|
|
||||||
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
|
|
||||||
`mizan_core.ssr.SSRBridge` Bun subprocess.
|
|
||||||
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
|
|
||||||
console entry) emit the manifest derived in `mizan_core.manifest`, including
|
|
||||||
each context's `render_strategy`.
|
|
||||||
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
|
|
||||||
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
|
|
||||||
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
|
|
||||||
role functions over Pydantic.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -48,54 +34,14 @@ from .executor import (
|
|||||||
compute_invalidation,
|
compute_invalidation,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
# Register the FastAPI/Starlette response base so view-path detection works in
|
|
||||||
# mizan_core.client.function (a @client function returning a Response is a
|
|
||||||
# view-path function — header-only invalidation, "view" in the edge manifest).
|
|
||||||
# Must run before any @client-decorated code is evaluated.
|
|
||||||
from starlette.responses import Response as _Response
|
|
||||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
|
||||||
_set_response_base(_Response)
|
|
||||||
|
|
||||||
from . import shapes, forms
|
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
from .auth import MizanAuthMiddleware, mizan_auth
|
|
||||||
from .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__ = [
|
__all__ = [
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
"mizan_auth",
|
|
||||||
"MizanAuthMiddleware",
|
|
||||||
"MizanConfig",
|
|
||||||
"from_env",
|
|
||||||
"router",
|
"router",
|
||||||
"mizan_exception_handler",
|
"mizan_exception_handler",
|
||||||
"mizan_validation_handler",
|
"mizan_validation_handler",
|
||||||
"execute_function",
|
"execute_function",
|
||||||
"compute_invalidation",
|
"compute_invalidation",
|
||||||
"edge_manifest",
|
|
||||||
"generate_edge_manifest",
|
|
||||||
"render_strategies",
|
|
||||||
"SSRRenderer",
|
|
||||||
"shapes",
|
|
||||||
"forms",
|
|
||||||
"Shape",
|
|
||||||
"Diff",
|
|
||||||
"NestedDiff",
|
|
||||||
"mizanForm",
|
|
||||||
"FormConfig",
|
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
"MizanError",
|
"MizanError",
|
||||||
"NotFound",
|
"NotFound",
|
||||||
|
|||||||
@@ -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)
|
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||||
lives in `mizan_core`; this module re-exports the canonical error taxonomy and
|
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||||
keeps backward-compatible helpers. The router drives `dispatch_call` /
|
let them propagate to your own handler.
|
||||||
`dispatch_context` directly to get invalidation + origin cache.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
from fastapi.encoders import jsonable_encoder
|
||||||
from mizan_core.errors import (
|
from pydantic import BaseModel, ValidationError
|
||||||
BadRequest,
|
|
||||||
ErrorCode,
|
|
||||||
Forbidden,
|
|
||||||
InternalError,
|
|
||||||
MizanError,
|
|
||||||
NotFound,
|
|
||||||
NotImplementedYet,
|
|
||||||
Unauthorized,
|
|
||||||
ValidationFailed,
|
|
||||||
)
|
|
||||||
from mizan_core.invalidation import resolve_invalidation, resolve_merges
|
|
||||||
|
|
||||||
__all__ = [
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
"ErrorCode",
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
"MizanError",
|
|
||||||
"NotFound",
|
|
||||||
"BadRequest",
|
|
||||||
"ValidationFailed",
|
|
||||||
"Unauthorized",
|
|
||||||
"Forbidden",
|
|
||||||
"NotImplementedYet",
|
|
||||||
"InternalError",
|
|
||||||
"compute_invalidation",
|
|
||||||
"compute_merges",
|
|
||||||
"execute_function",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_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]:
|
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
||||||
"""`@client(affects=...)` → invalidation list (empty when none). Shared core."""
|
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||||
return resolve_invalidation(view_class, input_data) or []
|
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]]:
|
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."""
|
"""Build the `merge` list from @client(merge=...) metadata.
|
||||||
return resolve_merges(view_class, input_data, result) or []
|
|
||||||
|
|
||||||
|
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||||
async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any:
|
function inside the context bundle the value lands in. The slot is
|
||||||
"""Dispatch a function and return its serialized result (auth enforced via core).
|
resolved server-side via `types_match_for_merge` so the kernel does
|
||||||
|
no shape inference — the server has the schema, type-checked routing
|
||||||
Backward-compat entry point; the router uses `dispatch_call` directly to also
|
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||||
capture invalidation/merge and run the origin cache.
|
with a warning; the consumer falls back to refetch via `affects`.
|
||||||
"""
|
"""
|
||||||
identity = getattr(getattr(request, "state", None), "user", None)
|
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||||
res = await dispatch_call(
|
if not targets:
|
||||||
DispatchRequest(identity=identity, args=input_data, native_request=request),
|
return []
|
||||||
fn_name,
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
_NO_CACHE,
|
out: list[dict[str, Any]] = []
|
||||||
)
|
for ctx_name in targets:
|
||||||
return res.data
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field
|
||||||
from starlette.datastructures import UploadFile
|
|
||||||
|
|
||||||
from mizan_core.auth import INVALID, authenticate
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
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 .config import MizanConfig, get_config
|
from .executor import (
|
||||||
|
ErrorCode,
|
||||||
|
MizanError,
|
||||||
|
NotFound,
|
||||||
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -44,12 +45,11 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
|||||||
|
|
||||||
@router.get("/session/")
|
@router.get("/session/")
|
||||||
async def session_init() -> JSONResponse:
|
async def session_init() -> JSONResponse:
|
||||||
"""Session-init 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
|
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||||
session mechanism with no FastAPI equivalent, so this returns a null token —
|
null token so the response shape stays uniform across backends. The
|
||||||
the difference is in the token's backing mechanism, not in whether the
|
wire-parity harness uses this endpoint as its readiness probe.
|
||||||
endpoint is owed. The wire-parity harness uses it as its readiness probe.
|
|
||||||
"""
|
"""
|
||||||
return _no_store({"csrfToken": None})
|
return _no_store({"csrfToken": None})
|
||||||
|
|
||||||
@@ -59,197 +59,29 @@ class CallBody(BaseModel):
|
|||||||
args: dict[str, Any] = Field(default_factory=dict)
|
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/")
|
@router.post("/call/")
|
||||||
async def function_call(request: Request) -> JSONResponse:
|
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||||
"""RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with
|
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||||
the `X-Mizan-Invalidate` header alongside the body."""
|
fn_class = get_function(body.fn)
|
||||||
cfg = get_config(request)
|
result = await execute_function(request, body.fn, body.args)
|
||||||
fn, args = await _parse_call(request)
|
invalidate = compute_invalidation(fn_class, body.args)
|
||||||
res = await dispatch_call(
|
merges = compute_merges(fn_class, body.args, result)
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request),
|
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||||
fn, cfg.cache,
|
if merges:
|
||||||
)
|
payload["merge"] = merges
|
||||||
payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []}
|
return _no_store(payload)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ctx/{context_name}/")
|
@router.get("/ctx/{context_name}/")
|
||||||
async def context_fetch(context_name: str, request: Request) -> Response:
|
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||||
"""Bundled context fetch — origin-cached. `{function_name: result, ...}`."""
|
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||||
cfg = get_config(request)
|
fn_names = get_context_groups().get(context_name)
|
||||||
res = await dispatch_context(
|
if not fn_names:
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params),
|
raise NotFound(f"Context '{context_name}' not found")
|
||||||
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)
|
|
||||||
|
|
||||||
|
params = dict(request.query_params)
|
||||||
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
|
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||||
|
return _no_store(bundled)
|
||||||
|
|
||||||
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
|
|
||||||
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
|
|
||||||
token decode from the handshake headers. A present-but-invalid token rejects.
|
|
||||||
|
|
||||||
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
|
|
||||||
identically over either transport.
|
|
||||||
"""
|
|
||||||
existing = getattr(getattr(websocket, "state", None), "user", None)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
ident = authenticate(websocket.headers, cfg.auth)
|
|
||||||
if ident is INVALID:
|
|
||||||
raise Unauthorized("Invalid or expired token")
|
|
||||||
return ident
|
|
||||||
|
|
||||||
|
|
||||||
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
|
|
||||||
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
|
|
||||||
if exc.details:
|
|
||||||
err["details"] = exc.details
|
|
||||||
return {"id": request_id, "ok": False, "error": err}
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/")
|
|
||||||
async def websocket_rpc(websocket: WebSocket) -> None:
|
|
||||||
"""WebSocket RPC transport for `@client(websocket=True)` functions.
|
|
||||||
|
|
||||||
Frame protocol (parity with mizan-django's Channels consumer):
|
|
||||||
|
|
||||||
→ {"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
|
|
||||||
← {"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
|
|
||||||
← {"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
|
|
||||||
|
|
||||||
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
|
|
||||||
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
|
|
||||||
and origin-cache purge are identical across transports. Only functions that
|
|
||||||
declared `websocket=True` are callable here; an HTTP-only function returns a
|
|
||||||
`FORBIDDEN` frame rather than executing.
|
|
||||||
"""
|
|
||||||
cfg = get_config(websocket)
|
|
||||||
await websocket.accept()
|
|
||||||
try:
|
|
||||||
identity = _ws_identity(websocket, cfg)
|
|
||||||
except Unauthorized as exc:
|
|
||||||
await websocket.send_json(_error_frame(None, exc))
|
|
||||||
await websocket.close(code=1008)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
content = await websocket.receive_json()
|
|
||||||
await _handle_ws_rpc(websocket, content, identity, cfg)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
|
|
||||||
"""Dispatch one WS RPC frame through the shared dispatch core."""
|
|
||||||
if content.get("action") != "rpc":
|
|
||||||
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
|
|
||||||
return
|
|
||||||
|
|
||||||
request_id = content.get("id")
|
|
||||||
fn_name = content.get("fn")
|
|
||||||
args = content.get("args", {})
|
|
||||||
|
|
||||||
if not fn_name:
|
|
||||||
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
|
|
||||||
return
|
|
||||||
|
|
||||||
fn_class = get_function(fn_name)
|
|
||||||
if fn_class is None:
|
|
||||||
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
|
|
||||||
return
|
|
||||||
if not getattr(fn_class, "_meta", {}).get("websocket"):
|
|
||||||
await websocket.send_json(
|
|
||||||
_error_frame(
|
|
||||||
request_id,
|
|
||||||
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = await dispatch_call(
|
|
||||||
DispatchRequest(identity=identity, args=args, native_request=websocket),
|
|
||||||
fn_name, cfg.cache,
|
|
||||||
)
|
|
||||||
except MizanError as exc:
|
|
||||||
await websocket.send_json(_error_frame(request_id, exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
|
|
||||||
"invalidate": res.invalidate or []}
|
|
||||||
if res.merge:
|
|
||||||
frame["merge"] = res.merge
|
|
||||||
await websocket.send_json(frame)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"base64",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -39,7 +38,6 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"multer",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -47,10 +45,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sha1",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -78,90 +74,18 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block-buffer"
|
|
||||||
version = "0.10.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crypto-common"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
"typenum",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "data-encoding"
|
|
||||||
version = "2.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "digest"
|
|
||||||
version = "0.10.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|
||||||
dependencies = [
|
|
||||||
"block-buffer",
|
|
||||||
"crypto-common",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encoding_rs"
|
|
||||||
version = "0.8.35"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -186,23 +110,6 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-macro"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-sink"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -216,49 +123,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-macro",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "generic-array"
|
|
||||||
version = "0.14.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|
||||||
dependencies = [
|
|
||||||
"typenum",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -411,15 +286,10 @@ name = "mizan-axum"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
|
||||||
"futures-util",
|
|
||||||
"http-body-util",
|
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
"multer",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
@@ -429,13 +299,10 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
|
||||||
"hmac",
|
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -448,23 +315,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "multer"
|
|
||||||
version = "3.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"encoding_rs",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"memchr",
|
|
||||||
"mime",
|
|
||||||
"spin",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -483,15 +333,6 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ppv-lite86"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -510,36 +351,6 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rand_chacha",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -618,28 +429,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha1"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha2"
|
|
||||||
version = "0.10.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -662,18 +451,6 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -691,33 +468,12 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -737,18 +493,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"tokio",
|
|
||||||
"tungstenite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -813,48 +557,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"rand",
|
|
||||||
"sha1",
|
|
||||||
"thiserror",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typenum"
|
|
||||||
version = "1.20.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf-8"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "version_check"
|
|
||||||
version = "0.9.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -876,26 +584,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.8.50"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.8.50"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -7,17 +7,9 @@ license = "Elastic-2.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mizan-core = { path = "../../cores/mizan-rust" }
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
axum = { version = "0.7", features = ["ws", "multipart"] }
|
axum = "0.7"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
futures-util = "0.3"
|
|
||||||
multer = "3"
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
|
||||||
tokio-tungstenite = "0.24"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
|
|||||||
@@ -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`
|
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||||
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
|
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
|
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||||
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
|
|
||||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
use std::any::Any;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::ApiError;
|
||||||
use crate::state::MizanState;
|
|
||||||
|
/// Type-erased application state threaded into every `dispatch()` call via
|
||||||
|
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
||||||
|
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||||
|
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -30,7 +33,9 @@ pub struct CallBody {
|
|||||||
|
|
||||||
impl CallBody {
|
impl CallBody {
|
||||||
fn resolved_name(&self) -> Option<&str> {
|
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
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
|
/// POST /call/ — RPC dispatch.
|
||||||
/// through the shared `authenticate`. A present-but-invalid token rejects with
|
|
||||||
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
|
|
||||||
pub(crate) fn identity_from_headers(
|
|
||||||
headers: &HeaderMap,
|
|
||||||
state: &MizanState,
|
|
||||||
) -> Result<Option<Identity>, ApiError> {
|
|
||||||
let mwt = headers
|
|
||||||
.get("X-Mizan-Token")
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
let bearer = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
|
|
||||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
|
||||||
AuthOutcome::Anonymous => Ok(None),
|
|
||||||
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
|
|
||||||
"Invalid or expired token".into(),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enforce a function's `@client(auth=...)` against the resolved identity.
|
|
||||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
|
|
||||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
|
||||||
enforce_auth(identity, &req).map_err(ApiError)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject a client call into a `private` function (no RPC endpoint).
|
|
||||||
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(ApiError(MizanError::Forbidden(
|
|
||||||
"Function is not client-callable".into(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uid_str(identity: Option<&Identity>) -> Option<String> {
|
|
||||||
identity.map(|i| i.user_id.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
|
|
||||||
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
|
|
||||||
/// invalidated contexts.
|
|
||||||
pub async fn function_call(
|
pub async fn function_call(
|
||||||
State(state): State<Arc<MizanState>>,
|
State(app_state): State<AppStateAny>,
|
||||||
headers: HeaderMap,
|
Json(body): Json<CallBody>,
|
||||||
body: axum::body::Body,
|
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let identity = identity_from_headers(&headers, &state)?;
|
let fn_name = body
|
||||||
let content_type = headers
|
.resolved_name()
|
||||||
.get(header::CONTENT_TYPE)
|
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
|
let fn_spec = lookup_function(&fn_name)
|
||||||
parse_multipart(&content_type, body).await?
|
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||||
} else {
|
|
||||||
parse_json_call(body).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
ApiError(MizanError::NotFound(format!(
|
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||||
"function {fn_name:?} not registered"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
reject_if_private(fn_spec)?;
|
|
||||||
guard(fn_spec, identity.as_ref())?;
|
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||||
let result = fn_spec
|
.iter()
|
||||||
.dispatch(req, Value::Object(args.clone()))
|
.map(InvalidationTarget::to_json)
|
||||||
.await
|
.collect();
|
||||||
.map_err(ApiError)?;
|
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||||
|
|
||||||
let targets = compute_invalidation(fn_spec, &args);
|
|
||||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
|
||||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Purge the origin cache for everything this mutation invalidated.
|
|
||||||
if !targets.is_empty() {
|
|
||||||
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = CallResponse {
|
let payload = CallResponse {
|
||||||
result,
|
result,
|
||||||
invalidate,
|
invalidate,
|
||||||
merge: merge_payload,
|
merge: merge_payload,
|
||||||
};
|
};
|
||||||
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
|
Ok(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
|
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
|
|
||||||
let call: CallBody = serde_json::from_slice(&bytes)
|
|
||||||
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
|
|
||||||
let fn_name = call
|
|
||||||
.resolved_name()
|
|
||||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
|
||||||
.to_string();
|
|
||||||
Ok((fn_name, call.args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
|
|
||||||
/// Each file part binds into the matching Upload-typed input field as a
|
|
||||||
/// base64-carrying value the `mizan_core::Upload` field deserializes.
|
|
||||||
async fn parse_multipart(
|
|
||||||
content_type: &str,
|
|
||||||
body: axum::body::Body,
|
|
||||||
) -> Result<(String, Map<String, Value>), ApiError> {
|
|
||||||
let boundary = multer::parse_boundary(content_type)
|
|
||||||
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
|
|
||||||
let stream = body.into_data_stream();
|
|
||||||
let mut mp = multer::Multipart::new(stream, boundary);
|
|
||||||
|
|
||||||
let mut fn_name: Option<String> = None;
|
|
||||||
let mut args: Map<String, Value> = Map::new();
|
|
||||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
|
||||||
|
|
||||||
while let Some(field) = mp
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
|
|
||||||
{
|
|
||||||
let name = field.name().unwrap_or("").to_string();
|
|
||||||
let filename = field.file_name().map(|s| s.to_string());
|
|
||||||
let part_content_type = field.content_type().map(|s| s.to_string());
|
|
||||||
|
|
||||||
if filename.is_some() {
|
|
||||||
// A file part → the JSON shape `mizan_core::Upload` deserializes
|
|
||||||
// (filename, content_type, base64 bytes).
|
|
||||||
let data = field
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
|
|
||||||
files.entry(name).or_default().push(uploaded_file_json(
|
|
||||||
filename,
|
|
||||||
part_content_type,
|
|
||||||
&data,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
let text = field
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
|
|
||||||
if name == "fn" {
|
|
||||||
fn_name = Some(text);
|
|
||||||
} else if name == "args" {
|
|
||||||
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
|
|
||||||
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
|
|
||||||
})?;
|
|
||||||
if let Value::Object(m) = parsed {
|
|
||||||
args = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind file parts into args by field name (single vs list).
|
|
||||||
for (field_name, parts) in files {
|
|
||||||
if parts.len() == 1 {
|
|
||||||
args.insert(field_name, parts.into_iter().next().unwrap());
|
|
||||||
} else {
|
|
||||||
args.insert(field_name, Value::Array(parts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fn_name =
|
|
||||||
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
|
|
||||||
Ok((fn_name, args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a received file part as the JSON shape an `Upload` field expects.
|
|
||||||
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
|
||||||
use base64::Engine;
|
|
||||||
serde_json::json!({
|
|
||||||
"filename": filename,
|
|
||||||
"content_type": content_type,
|
|
||||||
"data_b64": STANDARD.encode(data),
|
|
||||||
"size": data.len(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
|
|
||||||
pub async fn context_fetch(
|
pub async fn context_fetch(
|
||||||
State(state): State<Arc<MizanState>>,
|
State(app_state): State<AppStateAny>,
|
||||||
headers: HeaderMap,
|
|
||||||
Path(context_name): Path<String>,
|
Path(context_name): Path<String>,
|
||||||
Query(params): Query<BTreeMap<String, String>>,
|
Query(params): Query<BTreeMap<String, String>>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
@@ -262,8 +101,6 @@ pub async fn context_fetch(
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
let identity = identity_from_headers(&headers, &state)?;
|
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -275,130 +112,22 @@ pub async fn context_fetch(
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin cache: the canonical-JSON bundle body is keyed by (context,
|
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||||
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
|
// params get parsed via the per-function input_params primitive table.
|
||||||
let cache_params: BTreeMap<String, Value> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
|
||||||
.collect();
|
|
||||||
let uid = uid_str(identity.as_ref());
|
|
||||||
|
|
||||||
if let Some(cached) = state
|
|
||||||
.cache
|
|
||||||
.get(&context_name, &cache_params, uid.as_deref(), 0)
|
|
||||||
{
|
|
||||||
return Ok(cached_response(cached, "HIT"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce auth per member (the bundle is only as open as its strictest fn).
|
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in &members {
|
||||||
guard(*fn_spec, identity.as_ref())?;
|
|
||||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
let result = fn_spec
|
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||||
.dispatch(req, Value::Object(args))
|
|
||||||
.await
|
|
||||||
.map_err(ApiError)?;
|
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = canonical_bytes(&Value::Object(bundled));
|
Ok(no_store(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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
|
/// Coerce string-valued query params into typed JSON values using the
|
||||||
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
|
/// function's declared input_params. Strings that don't parse stay as
|
||||||
fn canonical_bytes(v: &Value) -> Vec<u8> {
|
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
||||||
fn sort(v: &Value) -> Value {
|
|
||||||
match v {
|
|
||||||
Value::Object(m) => {
|
|
||||||
let mut keys: Vec<&String> = m.keys().collect();
|
|
||||||
keys.sort();
|
|
||||||
let mut out = Map::new();
|
|
||||||
for k in keys {
|
|
||||||
out.insert(k.clone(), sort(&m[k]));
|
|
||||||
}
|
|
||||||
Value::Object(out)
|
|
||||||
}
|
|
||||||
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
|
|
||||||
other => other.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Python's default separators add a space after ':' and ','. Match that so
|
|
||||||
// a Rust-written cache body and a Python-written one are byte-equal.
|
|
||||||
let sorted = sort(v);
|
|
||||||
python_json(&sorted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
|
|
||||||
/// (`", "` and `": "`).
|
|
||||||
fn python_json(v: &Value) -> Vec<u8> {
|
|
||||||
let compact = serde_json::to_string(v).unwrap();
|
|
||||||
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
|
|
||||||
// This is a structural transform on the already-sorted value, so the
|
|
||||||
// bytes match `json.dumps` for the JSON value space Mizan returns.
|
|
||||||
let spaced = respace(&compact);
|
|
||||||
spaced.into_bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert the spaces Python's default `json.dumps` uses after structural
|
|
||||||
/// `,`/`:` — but only outside string literals.
|
|
||||||
fn respace(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len() + s.len() / 8);
|
|
||||||
let mut in_str = false;
|
|
||||||
let mut escaped = false;
|
|
||||||
for c in s.chars() {
|
|
||||||
if in_str {
|
|
||||||
out.push(c);
|
|
||||||
if escaped {
|
|
||||||
escaped = false;
|
|
||||||
} else if c == '\\' {
|
|
||||||
escaped = true;
|
|
||||||
} else if c == '"' {
|
|
||||||
in_str = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match c {
|
|
||||||
'"' => {
|
|
||||||
in_str = true;
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
',' => out.push_str(", "),
|
|
||||||
':' => out.push_str(": "),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
|
|
||||||
let mut resp = (StatusCode::OK, body).into_response();
|
|
||||||
let h = resp.headers_mut();
|
|
||||||
h.insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("application/json"),
|
|
||||||
);
|
|
||||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
||||||
if !cache_status.is_empty() {
|
|
||||||
if let Ok(v) = HeaderValue::from_str(cache_status) {
|
|
||||||
h.insert("X-Mizan-Cache", v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coerce string-valued query params into typed JSON via the function's
|
|
||||||
/// declared input_params.
|
|
||||||
fn coerce_query_args(
|
fn coerce_query_args(
|
||||||
fn_spec: &dyn FunctionSpec,
|
fn_spec: &dyn FunctionSpec,
|
||||||
params: &BTreeMap<String, String>,
|
params: &BTreeMap<String, String>,
|
||||||
@@ -408,88 +137,26 @@ fn coerce_query_args(
|
|||||||
if let Some(raw) = params.get(ip.name) {
|
if let Some(raw) = params.get(ip.name) {
|
||||||
let parsed = match ip.primitive {
|
let parsed = match ip.primitive {
|
||||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||||
mizan_core::Primitive::Number => raw
|
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||||
.parse::<f64>()
|
serde_json::Number::from_f64(v).map(Value::Number)
|
||||||
.ok()
|
}),
|
||||||
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
|
|
||||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||||
};
|
};
|
||||||
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||||
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
|
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||||
/// mechanism; the endpoint here returns a null token and serves as the
|
/// readiness-probe consumers see a well-formed response.
|
||||||
/// readiness probe the wire-parity harness uses.
|
|
||||||
pub async fn session_init() -> Response {
|
pub async fn session_init() -> Response {
|
||||||
no_store(serde_json::json!({ "csrfToken": null }))
|
let body = serde_json::json!({ "csrfToken": null });
|
||||||
}
|
no_store(body)
|
||||||
|
|
||||||
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
|
|
||||||
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
|
|
||||||
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
|
|
||||||
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
|
|
||||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
|
||||||
no_store(manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
|
|
||||||
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
|
|
||||||
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
|
|
||||||
/// re-renders. This is the adapter telling Edge *how* to cache each context —
|
|
||||||
/// the PSR half of the manifest, addressable per-context.
|
|
||||||
pub async fn psr_descriptor(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Path(context_name): Path<String>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
|
||||||
let ctx = manifest
|
|
||||||
.get("contexts")
|
|
||||||
.and_then(|c| c.get(&context_name))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} not in manifest"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
let render_strategy = ctx
|
|
||||||
.get("render_strategy")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
let page_routes = ctx
|
|
||||||
.get("page_routes")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
|
||||||
Ok(no_store(serde_json::json!({
|
|
||||||
"context": context_name,
|
|
||||||
"render_strategy": render_strategy,
|
|
||||||
"page_routes": page_routes,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
|
|
||||||
/// output, derived from the registered type graph by `mizan_core::shapes`.
|
|
||||||
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
|
|
||||||
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotFound(format!(
|
|
||||||
"no shape projection for {fn_name:?}"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
Ok(no_store(projection_to_json(&proj)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
|
||||||
let mut fields = Vec::new();
|
|
||||||
for f in &proj.fields {
|
|
||||||
match f {
|
|
||||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
|
||||||
shapes::ShapeField::Nested(n, sub) => {
|
|
||||||
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::json!({ "type": proj.type_name, "fields": fields })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
|
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||||
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
|
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! use axum::Router;
|
//! use axum::Router;
|
||||||
//! use mizan_axum::{router, MizanState};
|
//! use mizan_axum::router;
|
||||||
//!
|
//!
|
||||||
//! #[tokio::main]
|
//! #[tokio::main]
|
||||||
//! async fn main() {
|
//! async fn main() {
|
||||||
//! let state = MizanState::builder()
|
//! let app = Router::new().nest("/api/mizan", router());
|
||||||
//! .app_state(MyState { /* ... */ })
|
|
||||||
//! .build();
|
|
||||||
//! let app = Router::new().nest("/api/mizan", router(state));
|
|
||||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||||
//! axum::serve(listener, app).await.unwrap();
|
//! axum::serve(listener, app).await.unwrap();
|
||||||
//! }
|
//! }
|
||||||
@@ -19,62 +15,44 @@
|
|||||||
//!
|
//!
|
||||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||||
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
|
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||||
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
|
//! * `GET /ctx/:name/` — bundled context fetch
|
||||||
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
|
|
||||||
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
|
|
||||||
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
|
|
||||||
//! * `GET /shape/:fn/` — typed query projection (Shapes)
|
|
||||||
//! * `POST /ssr/` — server-side render via the Bun worker
|
|
||||||
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
|
|
||||||
|
|
||||||
mod errors;
|
mod errors;
|
||||||
mod forms;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod ssr;
|
|
||||||
mod state;
|
|
||||||
mod ws;
|
|
||||||
|
|
||||||
pub use errors::ApiError;
|
pub use errors::ApiError;
|
||||||
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
|
pub use handlers::{
|
||||||
pub use ssr::{ssr_render, SsrRequest};
|
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
||||||
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
|
};
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Build the Mizan router with a fully-configured [`MizanState`] (app state +
|
/// Build the Mizan router with user-supplied app state. The state is
|
||||||
/// auth + cache + optional SSR worker). Mount under a prefix:
|
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
||||||
/// `Router::new().nest("/api/mizan", router(state))`.
|
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
|
||||||
pub fn router(state: Arc<MizanState>) -> Router {
|
/// 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()
|
Router::new()
|
||||||
.route("/session/", get(handlers::session_init))
|
.route("/session/", get(handlers::session_init))
|
||||||
.route("/call/", post(handlers::function_call))
|
.route("/call/", post(handlers::function_call))
|
||||||
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
||||||
.route("/ws/", get(ws::ws_handler))
|
|
||||||
.route("/manifest/", get(handlers::edge_manifest))
|
|
||||||
.route("/psr/:context_name/", get(handlers::psr_descriptor))
|
|
||||||
.route("/shape/:fn_name/", get(handlers::shape_projection))
|
|
||||||
.route("/ssr/", post(ssr::ssr_render))
|
|
||||||
.route("/form/:form_name/schema/", post(forms::form_schema))
|
|
||||||
.route("/form/:form_name/validate/", post(forms::form_validate))
|
|
||||||
.route("/form/:form_name/submit/", post(forms::form_submit))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Router variant for the common case of just an app state, no auth/cache.
|
/// Router variant for callers that have no app state to thread — the
|
||||||
pub fn router_with_state<S>(app_state: S) -> Router
|
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
||||||
where
|
/// and other stateless test apps.
|
||||||
S: Any + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
router(MizanState::builder().app_state(app_state).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Router variant for callers that have no app state to thread — the dispatch
|
|
||||||
/// path receives a unit-typed handle. Used by the AFI fixture and stateless
|
|
||||||
/// test apps.
|
|
||||||
pub fn router_stateless() -> Router {
|
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 = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"subtle",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1229,15 +1228,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -1555,7 +1545,7 @@ dependencies = [
|
|||||||
"cesu8",
|
"cesu8",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -1564,15 +1554,37 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1670,9 +1682,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.14"
|
version = "0.1.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1776,13 +1788,10 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
|
||||||
"hmac",
|
|
||||||
"linkme",
|
"linkme",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1799,12 +1808,10 @@ dependencies = [
|
|||||||
name = "mizan-tauri"
|
name = "mizan-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,7 +1842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -1849,7 +1856,7 @@ version = "0.6.0+11769913"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2460,9 +2467,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2901,12 +2908,6 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3359,21 +3360,9 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-macros"
|
|
||||||
version = "2.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3796,9 +3785,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3809,9 +3798,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.72"
|
version = "0.4.71"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -3819,9 +3808,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -3829,9 +3818,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3842,9 +3831,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -3898,9 +3887,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -10,8 +10,3 @@ mizan-core = { path = "../../cores/mizan-rust" }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tauri = { version = "2", features = ["test"] }
|
|
||||||
tokio = { version = "1", features = ["rt", "macros"] }
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||||
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
|
|
||||||
//!
|
//!
|
||||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||||
//!
|
//!
|
||||||
@@ -10,137 +9,79 @@
|
|||||||
//! .expect("error while running tauri application");
|
//! .expect("error while running tauri application");
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The plugin exposes commands reachable from the JS-side
|
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||||
//! `@mizan/tauri-transport`:
|
//! `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/
|
//! Wire envelope:
|
||||||
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
|
|
||||||
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
|
|
||||||
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
|
|
||||||
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
|
|
||||||
//! Tauri `Channel<T>` carries the push stream instead.
|
|
||||||
//!
|
|
||||||
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
|
|
||||||
//!
|
//!
|
||||||
//! ```json
|
//! ```json
|
||||||
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
|
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||||
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
|
//! { "op": "fetch", "context": "session", "params": {} }
|
||||||
//! { "op": "shape", "fn": "user_profile" }
|
|
||||||
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Response shapes mirror the HTTP adapter:
|
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||||
|
//! mizan-rust-axum:
|
||||||
//!
|
//!
|
||||||
//! * `call` → `{ result, invalidate, merge? }`
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
//! * `shape` → `{ type, fields }`
|
|
||||||
//! * `form` → the form function's result
|
|
||||||
//!
|
//!
|
||||||
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
|
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||||
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
|
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||||
//! `authenticate` and enforced against each function's `auth=` requirement.
|
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||||
//! There is no header channel over IPC, so the token rides the envelope.
|
//! see one error surface regardless of transport.
|
||||||
//!
|
|
||||||
//! Errors come back as the `Err` variant of the command's `Result`, which
|
|
||||||
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
|
|
||||||
//! into a `MizanError`.
|
|
||||||
|
|
||||||
mod ssr;
|
|
||||||
|
|
||||||
pub use ssr::{ssr_render, MizanSsr};
|
|
||||||
|
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
|
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||||
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
|
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use tauri::ipc::Channel;
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Manager, Runtime,
|
Runtime,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
|
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||||
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
|
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||||
/// dispatch commands read it from managed state.
|
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||||
pub struct MizanTauriConfig {
|
|
||||||
pub auth: AuthConfig,
|
|
||||||
pub cache: CacheOrchestrator,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MizanTauriConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
auth: AuthConfig::new(),
|
|
||||||
cache: CacheOrchestrator::disabled(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
|
|
||||||
/// Registers a default (auth-off, cache-disabled) config if the consumer
|
|
||||||
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
|
|
||||||
/// and `plugin:mizan|mizan_subscribe`.
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::<R>::new("mizan")
|
Builder::<R>::new("mizan")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||||
mizan_invoke,
|
|
||||||
mizan_subscribe,
|
|
||||||
ssr::ssr_render
|
|
||||||
])
|
|
||||||
.setup(|app, _api| {
|
|
||||||
if app.try_state::<MizanTauriConfig>().is_none() {
|
|
||||||
app.manage(MizanTauriConfig::default());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Wire envelope ===
|
// === Wire envelope ===
|
||||||
|
|
||||||
/// One Mizan request. Tauri's serde deserializer pulls this out of the
|
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||||
/// `envelope` field of the invoke payload.
|
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||||
|
/// field of the invoke payload.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "op")]
|
#[serde(tag = "op")]
|
||||||
pub enum Envelope {
|
pub enum Envelope {
|
||||||
#[serde(rename = "call")]
|
#[serde(rename = "call")]
|
||||||
Call {
|
Call {
|
||||||
|
/// Wire-level function name — registered name on the Rust side.
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
function_name: String,
|
function_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
args: Map<String, Value>,
|
args: Map<String, Value>,
|
||||||
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
|
|
||||||
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
|
|
||||||
#[serde(default)]
|
|
||||||
token: Option<String>,
|
|
||||||
},
|
},
|
||||||
#[serde(rename = "fetch")]
|
#[serde(rename = "fetch")]
|
||||||
Fetch {
|
Fetch {
|
||||||
context: String,
|
context: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
params: Map<String, Value>,
|
params: Map<String, Value>,
|
||||||
#[serde(default)]
|
|
||||||
token: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "shape")]
|
|
||||||
Shape {
|
|
||||||
#[serde(rename = "fn")]
|
|
||||||
function_name: String,
|
|
||||||
},
|
|
||||||
#[serde(rename = "form")]
|
|
||||||
Form {
|
|
||||||
form: String,
|
|
||||||
role: String,
|
|
||||||
#[serde(default)]
|
|
||||||
args: Value,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||||
/// `{"code", "message", "details?"}` shape.
|
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||||
|
/// this and constructs a `MizanError`.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ErrorPayload {
|
pub struct ErrorPayload {
|
||||||
pub code: &'static str,
|
pub code: &'static str,
|
||||||
@@ -164,336 +105,110 @@ impl From<MizanError> for ErrorPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Auth ===
|
// === Dispatch ===
|
||||||
|
|
||||||
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
|
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||||
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
|
/// handler — the consumer never wires it directly.
|
||||||
/// `INVALID`-sentinel contract); absent → anonymous.
|
///
|
||||||
fn identity_from_token(
|
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||||
token: Option<&str>,
|
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
config: &MizanTauriConfig,
|
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||||
) -> Result<Option<Identity>, MizanError> {
|
/// emission. Stateless functions ignore the handle.
|
||||||
let (mwt, bearer) = match token {
|
|
||||||
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
|
|
||||||
Some(t) => (Some(t), None),
|
|
||||||
None => (None, None),
|
|
||||||
};
|
|
||||||
match authenticate(mwt, bearer, &config.auth, now_unix()) {
|
|
||||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
|
||||||
AuthOutcome::Anonymous => Ok(None),
|
|
||||||
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
|
|
||||||
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Dispatch commands ===
|
|
||||||
|
|
||||||
/// The single Mizan request/response command. Tauri auto-injects `app`; the
|
|
||||||
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
|
|
||||||
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn mizan_invoke<R: Runtime>(
|
async fn mizan_invoke<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
envelope: Envelope,
|
envelope: Envelope,
|
||||||
) -> Result<Value, ErrorPayload> {
|
) -> Result<Value, ErrorPayload> {
|
||||||
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
|
|
||||||
/// response (or a `MizanError`). This is the programmatic entry point the
|
|
||||||
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
|
|
||||||
/// tests) can drive the Mizan protocol without the IPC serialization layer.
|
|
||||||
pub async fn dispatch<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
envelope: Envelope,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
// Read the managed config (lifetime-bound to `app`, which outlives this
|
|
||||||
// dispatch); fall back to a default if none was registered. The `State`
|
|
||||||
// guard is held across the awaits below.
|
|
||||||
let managed = app.try_state::<MizanTauriConfig>();
|
|
||||||
let default;
|
|
||||||
let cfg: &MizanTauriConfig = match managed.as_ref() {
|
|
||||||
Some(state) => state.inner(),
|
|
||||||
None => {
|
|
||||||
default = MizanTauriConfig::default();
|
|
||||||
&default
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match envelope {
|
match envelope {
|
||||||
Envelope::Call {
|
Envelope::Call {
|
||||||
function_name,
|
function_name,
|
||||||
args,
|
args,
|
||||||
token,
|
} => handle_call(&app, &function_name, args).await,
|
||||||
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
|
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||||
Envelope::Fetch {
|
|
||||||
context,
|
|
||||||
params,
|
|
||||||
token,
|
|
||||||
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
|
|
||||||
Envelope::Shape { function_name } => handle_shape(&function_name),
|
|
||||||
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_call<R: Runtime>(
|
async fn handle_call<R: Runtime>(
|
||||||
app: &tauri::AppHandle<R>,
|
app: &tauri::AppHandle<R>,
|
||||||
cfg: &MizanTauriConfig,
|
|
||||||
fn_name: &str,
|
fn_name: &str,
|
||||||
mut args: Map<String, Value>,
|
args: Map<String, Value>,
|
||||||
token: Option<&str>,
|
) -> Result<Value, ErrorPayload> {
|
||||||
) -> Result<Value, MizanError> {
|
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||||
let identity = identity_from_token(token, cfg)?;
|
ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"function {fn_name:?} not registered"
|
||||||
let fn_spec = lookup_function(fn_name)
|
)))
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
|
})?;
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
|
||||||
}
|
|
||||||
guard(fn_spec, identity.as_ref())?;
|
|
||||||
|
|
||||||
// Bind any file parts the envelope carries into the call args (see
|
|
||||||
// `bind_uploads`).
|
|
||||||
bind_uploads(fn_spec, &mut args)?;
|
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
let req = RequestHandle::new(app);
|
||||||
let result = fn_spec.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> = compute_invalidation(fn_spec, &args)
|
||||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
.iter()
|
||||||
|
.map(InvalidationTarget::to_json)
|
||||||
|
.collect();
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
let merges = compute_merges(fn_spec, &args, &result);
|
||||||
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
|
};
|
||||||
|
|
||||||
// Purge the origin cache for everything this mutation invalidated.
|
let mut payload = json!({
|
||||||
if !targets.is_empty() {
|
"result": result,
|
||||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
"invalidate": invalidate,
|
||||||
cfg.cache.purge(&targets, uid.as_deref());
|
});
|
||||||
}
|
if let Some(merge) = merge_payload {
|
||||||
|
payload
|
||||||
let mut payload = json!({ "result": result, "invalidate": invalidate });
|
.as_object_mut()
|
||||||
if !merges.is_empty() {
|
.expect("payload is a JSON object")
|
||||||
payload.as_object_mut().unwrap().insert(
|
.insert("merge".into(), Value::Array(merge));
|
||||||
"merge".into(),
|
|
||||||
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_fetch<R: Runtime>(
|
async fn handle_fetch<R: Runtime>(
|
||||||
app: &tauri::AppHandle<R>,
|
app: &tauri::AppHandle<R>,
|
||||||
cfg: &MizanTauriConfig,
|
|
||||||
context_name: &str,
|
context_name: &str,
|
||||||
params: Map<String, Value>,
|
params: Map<String, Value>,
|
||||||
token: Option<&str>,
|
) -> Result<Value, ErrorPayload> {
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
let identity = identity_from_token(token, cfg)?;
|
|
||||||
|
|
||||||
if lookup_context(context_name).is_none() {
|
if lookup_context(context_name).is_none() {
|
||||||
return Err(MizanError::NotFound(format!(
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
"context {context_name:?} not registered"
|
"context {context_name:?} not registered"
|
||||||
)));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.filter(|f| f.context() == Some(context_name))
|
.filter(|f| f.context() == Some(context_name))
|
||||||
.collect();
|
.collect();
|
||||||
if members.is_empty() {
|
if members.is_empty() {
|
||||||
return Err(MizanError::NotFound(format!(
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
"context {context_name:?} has no registered members"
|
"context {context_name:?} has no registered members"
|
||||||
)));
|
))));
|
||||||
}
|
|
||||||
|
|
||||||
// Origin cache: a desktop shell still benefits from memoizing a context
|
|
||||||
// bundle by (context, params, user). Key the params as JSON values.
|
|
||||||
let cache_params: std::collections::BTreeMap<String, Value> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
|
||||||
|
|
||||||
if let Some(cached) = cfg
|
|
||||||
.cache
|
|
||||||
.get(context_name, &cache_params, uid.as_deref(), 0)
|
|
||||||
{
|
|
||||||
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
|
|
||||||
return Ok(v);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bundled = Map::new();
|
let mut bundled = Map::new();
|
||||||
for fn_spec in &members {
|
for fn_spec in &members {
|
||||||
guard(*fn_spec, identity.as_ref())?;
|
|
||||||
let args = filter_args(*fn_spec, ¶ms);
|
let args = filter_args(*fn_spec, ¶ms);
|
||||||
let req = RequestHandle::new(app);
|
let req = RequestHandle::new(app);
|
||||||
let result = fn_spec.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);
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = Value::Object(bundled);
|
Ok(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `shape` op — the typed query projection for a function's output, derived by
|
/// Filter the envelope's params down to keys this function declares as
|
||||||
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
|
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||||
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
|
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||||
let proj = shapes::project_function_output(fn_name)
|
/// carries typed JSON, so the filter is sufficient on its own.
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
|
|
||||||
Ok(projection_to_json(&proj))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
|
||||||
let mut fields = Vec::new();
|
|
||||||
for f in &proj.fields {
|
|
||||||
match f {
|
|
||||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
|
||||||
shapes::ShapeField::Nested(n, sub) => {
|
|
||||||
fields.push(json!({ n.clone(): projection_to_json(sub) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
json!({ "type": proj.type_name, "fields": fields })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
|
|
||||||
/// Forms binding). `form_validate` / `form_submit` map to the registered
|
|
||||||
/// function whose `(form_name, form_role)` matches.
|
|
||||||
async fn handle_form<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
role: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
match role {
|
|
||||||
"schema" => form_schema(app, form_name).await,
|
|
||||||
"validate" => form_validate(app, form_name, args).await,
|
|
||||||
"submit" => form_submit(app, form_name, args).await,
|
|
||||||
other => Err(MizanError::BadRequest(format!(
|
|
||||||
"unknown form role {other:?} (expected schema|validate|submit)"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
|
||||||
FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch_form_role<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
role: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
let fn_spec = lookup_form_fn(form_name, role)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
|
|
||||||
let args_value = match args {
|
|
||||||
Value::Object(_) | Value::Null => args,
|
|
||||||
other => json!({ "data": other }),
|
|
||||||
};
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
fn_spec.dispatch(req, args_value).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_schema<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "schema", Value::Null).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_validate<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "validate", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_submit<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "submit", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === WebSocket-equivalent: IPC subscription channel ===
|
|
||||||
|
|
||||||
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
pub struct SubscriptionFrame {
|
|
||||||
pub result: Value,
|
|
||||||
pub invalidate: Vec<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
|
|
||||||
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
|
|
||||||
/// the push stream instead — the IPC transport's co-equal of the HTTP
|
|
||||||
/// WebSocket. The initial dispatch result is emitted immediately on the
|
|
||||||
/// channel; subsequent server-side pushes use the same `on_event` channel.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn mizan_subscribe<R: Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
function_name: String,
|
|
||||||
args: Map<String, Value>,
|
|
||||||
on_event: Channel<SubscriptionFrame>,
|
|
||||||
) -> Result<(), ErrorPayload> {
|
|
||||||
subscribe(&app, &function_name, args, on_event)
|
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
|
|
||||||
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
|
|
||||||
/// wraps — exposed for embedders and behavior tests.
|
|
||||||
pub async fn subscribe<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
function_name: &str,
|
|
||||||
args: Map<String, Value>,
|
|
||||||
on_event: Channel<SubscriptionFrame>,
|
|
||||||
) -> Result<(), MizanError> {
|
|
||||||
let fn_spec = lookup_function(function_name)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
|
||||||
}
|
|
||||||
// Only `#[mizan(websocket)]` functions are exposed over the subscription
|
|
||||||
// channel — the same transport boundary the HTTP WebSocket enforces.
|
|
||||||
if !matches!(
|
|
||||||
fn_spec.transport(),
|
|
||||||
mizan_core::Transport::Websocket | mizan_core::Transport::Both
|
|
||||||
) {
|
|
||||||
return Err(MizanError::BadRequest(format!(
|
|
||||||
"function {function_name:?} is not exposed over the subscription transport"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
|
||||||
let invalidate = compute_invalidation(fn_spec, &args)
|
|
||||||
.iter()
|
|
||||||
.map(InvalidationTarget::to_json)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
on_event
|
|
||||||
.send(SubscriptionFrame { result, invalidate })
|
|
||||||
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helpers ===
|
|
||||||
|
|
||||||
/// Filter the envelope's params down to keys this function declares as input.
|
|
||||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||||
let mut out = Map::new();
|
let mut out = Map::new();
|
||||||
for ip in fn_spec.input_params() {
|
for ip in fn_spec.input_params() {
|
||||||
@@ -503,45 +218,3 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind file parts carried in the IPC envelope into the call args.
|
|
||||||
///
|
|
||||||
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
|
|
||||||
/// JSON object `{filename, content_type, data_b64}` (the JS transport
|
|
||||||
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
|
|
||||||
/// deserializes, so for a single file the arg is already in place. This binder
|
|
||||||
/// performs the one transform IPC needs: a top-level `_files` map
|
|
||||||
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
|
|
||||||
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
|
|
||||||
/// also validates that anything presenting as a file carries `data_b64`,
|
|
||||||
/// surfacing a clear error before the typed `Upload` deserialize runs.
|
|
||||||
fn bind_uploads(
|
|
||||||
fn_spec: &dyn FunctionSpec,
|
|
||||||
args: &mut Map<String, Value>,
|
|
||||||
) -> Result<(), MizanError> {
|
|
||||||
if let Some(Value::Object(files)) = args.remove("_files") {
|
|
||||||
for (field, parts) in files {
|
|
||||||
args.insert(field, parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The set of param names this function declares — only validate args that
|
|
||||||
// could land in a typed field.
|
|
||||||
let declared: std::collections::HashSet<&str> =
|
|
||||||
fn_spec.input_params().iter().map(|p| p.name).collect();
|
|
||||||
for (name, value) in args.iter() {
|
|
||||||
if !declared.contains(name.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Value::Object(obj) = value {
|
|
||||||
let looks_like_file =
|
|
||||||
obj.contains_key("filename") || obj.contains_key("content_type");
|
|
||||||
if looks_like_file && !obj.contains_key("data_b64") {
|
|
||||||
return Err(MizanError::BadRequest(format!(
|
|
||||||
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
"name": "@mizan/ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"react": "^19",
|
|
||||||
"react-dom": "^19",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19",
|
"bun-types": "latest"
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"bun-types": "latest",
|
|
||||||
"react": "^19",
|
|
||||||
"react-dom": "^19"
|
|
||||||
},
|
},
|
||||||
"license": "Elastic-2.0"
|
"license": "Elastic-2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
|
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||||
import { register } from './registry'
|
import { register } from './registry'
|
||||||
|
|
||||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||||
@@ -21,25 +21,6 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
|
|
||||||
if (!merge) return undefined
|
|
||||||
const items = Array.isArray(merge) ? merge : [merge]
|
|
||||||
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize the public auth option into the stored requirement.
|
|
||||||
* 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(
|
function normalizeAffects(
|
||||||
affects: ClientOptions['affects'],
|
affects: ClientOptions['affects'],
|
||||||
): RegistryEntry['affects'] | undefined {
|
): 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.
|
* 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(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)
|
// Function wrapper form: client(options, fn)
|
||||||
if (fn && typeof fn === 'function') {
|
if (fn && typeof fn === 'function') {
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
|
const context = resolveContext(options.context)
|
||||||
|
const affects = normalizeAffects(options.affects)
|
||||||
|
|
||||||
|
if (context && affects) {
|
||||||
|
throw new Error('context and affects are mutually exclusive')
|
||||||
|
}
|
||||||
|
|
||||||
const name = fn.name || 'anonymous'
|
const name = fn.name || 'anonymous'
|
||||||
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
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decorator form: @client(options)
|
// Decorator form: @client(options)
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
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
|
return descriptor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
import { ANONYMOUS, type Identity } from './identity'
|
|
||||||
import type { AuthRequirement } from './types'
|
|
||||||
import { UploadedFile, bindUploads } from './upload'
|
|
||||||
|
|
||||||
let _cacheSecret: string | null = null
|
let _cacheSecret: string | null = null
|
||||||
|
|
||||||
@@ -25,54 +22,6 @@ export interface MizanResponse {
|
|||||||
headers: Record<string, string>
|
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/
|
* Handle GET /api/mizan/ctx/:contextName/
|
||||||
*
|
*
|
||||||
@@ -81,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse {
|
|||||||
export async function handleContextFetch(
|
export async function handleContextFetch(
|
||||||
contextName: string,
|
contextName: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const groups = getContextGroups()
|
const groups = getContextGroups()
|
||||||
const fnNames = groups[contextName]
|
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)
|
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||||
let effectiveRev = 0
|
let effectiveRev = 0
|
||||||
for (const fnName of fnNames) {
|
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
|
* Dispatches to a named function. Returns result + invalidation.
|
||||||
* form (`handleMultipartCall`) binds file parts first, then routes here.
|
|
||||||
*/
|
*/
|
||||||
export async function handleMutationCall(
|
export async function handleMutationCall(
|
||||||
fnName: string,
|
fnName: string,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const entry = getFunction(fnName)
|
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 {
|
try {
|
||||||
const argValues = entry.params.map(p => args[p.name])
|
const argValues = entry.params.map(p => args[p.name])
|
||||||
const result = await entry.fn(...argValues)
|
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 { ReactContext } from './types'
|
||||||
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement, FormRole } from './types'
|
export type { ClientOptions, EdgeManifest, RegistryEntry } 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 { client } from './decorator'
|
export { client } from './decorator'
|
||||||
|
|
||||||
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
||||||
|
|
||||||
export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch'
|
export { handleContextFetch, handleMutationCall } from './dispatch'
|
||||||
export type { MizanResponse } from './dispatch'
|
export type { MizanResponse } from './dispatch'
|
||||||
|
|
||||||
export { UploadedFile, parseSize, validateUpload, bindUploads, uploadFields } from './upload'
|
|
||||||
export type { File as UploadFile } from './upload'
|
|
||||||
|
|
||||||
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
|
||||||
export { generateManifest } from './manifest'
|
export { generateManifest } from './manifest'
|
||||||
|
|
||||||
export { handleSessionInit, sessionInitRoute, SESSION_INIT_PATH, SESSION_INIT_METHOD } from './session'
|
|
||||||
|
|
||||||
export { SSRBridge } from './ssr'
|
|
||||||
export type { SSRBridgeOptions, RenderResult } from './ssr'
|
|
||||||
|
|
||||||
export { handleWebSocketMessage, serveWebSocket } from './websocket'
|
|
||||||
export type { MizanWsFrame, MizanWsReply, WebSocketLike } from './websocket'
|
|
||||||
|
|
||||||
export { buildIr, snakeToCamel } from './ir'
|
|
||||||
export type { IrSchema, TypeShape, NamedType, StructField, Primitive, DefaultValue } from './ir'
|
|
||||||
|
|
||||||
export { Shape, project, projectRecord } from './shapes'
|
|
||||||
export type { QueryProjection } from './shapes'
|
|
||||||
|
|
||||||
export { registerForm, formSchema, validateForm } from './forms'
|
|
||||||
export type {
|
|
||||||
FormField,
|
|
||||||
FormDefinition,
|
|
||||||
FieldSchema,
|
|
||||||
FormSchemaOutput,
|
|
||||||
FormValidationOutput,
|
|
||||||
FormSubmitHandler,
|
|
||||||
FormRegistration,
|
|
||||||
} from './forms'
|
|
||||||
|
|
||||||
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
||||||
export type { CacheBackend } from './cache'
|
export type { CacheBackend } from './cache'
|
||||||
export { setCacheSecret } from './dispatch'
|
export { setCacheSecret } from './dispatch'
|
||||||
|
|||||||
@@ -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
|
* Mizan TypeScript Adapter — Shared Types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AuthPredicate } from './identity'
|
|
||||||
import type { IrSchema } from './ir/types'
|
|
||||||
|
|
||||||
export class ReactContext {
|
export class ReactContext {
|
||||||
constructor(public readonly name: string) {
|
constructor(public readonly name: string) {
|
||||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||||
@@ -13,37 +10,15 @@ export class ReactContext {
|
|||||||
|
|
||||||
export type AffectsTarget = ReactContext | string
|
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 {
|
export interface ClientOptions {
|
||||||
context?: ReactContext | string
|
context?: ReactContext | string
|
||||||
affects?: AffectsTarget | AffectsTarget[]
|
affects?: AffectsTarget | AffectsTarget[]
|
||||||
/** Contexts the mutation's return value merges into (vs. refetch). */
|
|
||||||
merge?: AffectsTarget | AffectsTarget[]
|
|
||||||
private?: boolean
|
private?: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthOption
|
auth?: boolean
|
||||||
websocket?: boolean
|
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
/**
|
|
||||||
* IR type schema (input fields + output shape). TypeScript has no Pydantic
|
|
||||||
* to introspect, so the codegen IR is declared here. Without it the
|
|
||||||
* function still dispatches, but `buildIr()` cannot emit its types.
|
|
||||||
*/
|
|
||||||
ir?: IrSchema
|
|
||||||
/** Forms binding: marks this as a form function and names its role. */
|
|
||||||
form?: boolean
|
|
||||||
formName?: string
|
|
||||||
formRole?: FormRole
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParamDef {
|
export interface ParamDef {
|
||||||
@@ -57,20 +32,14 @@ export interface RegistryEntry {
|
|||||||
fn: (...args: any[]) => Promise<any>
|
fn: (...args: any[]) => Promise<any>
|
||||||
context?: string
|
context?: string
|
||||||
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
|
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
|
||||||
merge?: string[]
|
|
||||||
params: ParamDef[]
|
params: ParamDef[]
|
||||||
private: boolean
|
private: boolean
|
||||||
viewPath: boolean
|
viewPath: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthRequirement
|
auth?: boolean
|
||||||
websocket?: boolean
|
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
ir?: IrSchema
|
|
||||||
form?: boolean
|
|
||||||
formName?: string
|
|
||||||
formRole?: FormRole
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestContext {
|
export interface ManifestContext {
|
||||||
|
|||||||
@@ -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"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
"pydantic>=2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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
|
# Use function name directly
|
||||||
name = fn.__name__
|
name = fn.__name__
|
||||||
|
|
||||||
# Extract type hints and signature. include_extras keeps `Annotated[...]`
|
# Extract type hints and signature
|
||||||
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
|
hints = get_type_hints(fn)
|
||||||
# survives into the generated Input model.
|
|
||||||
hints = get_type_hints(fn, include_extras=True)
|
|
||||||
sig = inspect.signature(fn)
|
sig = inspect.signature(fn)
|
||||||
params = list(sig.parameters.items())
|
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> }
|
| list { <type-child> }
|
||||||
| optional { <type-child> }
|
| optional { <type-child> }
|
||||||
| enum "<v1>" "<v2>" ...
|
| 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.registry import get_all_functions, get_context_groups, get_function
|
||||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||||
from mizan_core.upload import File, classify_upload
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["build_ir"]
|
__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)
|
_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:
|
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||||
with block.node("struct") as struct_block:
|
with block.node("struct") as struct_block:
|
||||||
@@ -289,10 +259,6 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
|
|||||||
props["default"] = _kdl_value(default)
|
props["default"] = _kdl_value(default)
|
||||||
|
|
||||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Mizan core registry — function and composition registration with an
|
||||||
extension hook for the AFI-common capabilities that need their own
|
extension hook for backend-specific registries (channels, forms, etc.)
|
||||||
sub-registry (channels/WebSocket, forms, shapes) to plug into.
|
to plug into.
|
||||||
|
|
||||||
This is the framework-agnostic registry. The extension points
|
This is the framework-agnostic registry. Backends own their own
|
||||||
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
|
type-specific registries (channels in Django Channels, forms in Django
|
||||||
a binding for each, on its own stack — Django Channels or a native
|
Forms, websockets in FastAPI, etc.) and register them as extensions
|
||||||
WebSocket route; Django Forms or Pydantic; django-readers or the project's
|
here so the unified schema export can include them.
|
||||||
ORM. The capability is common; the binding is per-stack. Each adapter wires
|
|
||||||
its binding so the unified schema export sees it; an unwired one is a gap on
|
|
||||||
the capability-parity board (`tests/afi/`), not a framework-specific feature.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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 merge: Vec<Path>,
|
||||||
pub websocket: bool,
|
pub websocket: bool,
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
/// `auth = "required" | "staff" | "superuser"` (or bare `auth` ⇒
|
|
||||||
/// "required") — the `@client(auth=...)` guard. Bare-true and the string
|
|
||||||
/// `"required"` both mean "must be authenticated".
|
|
||||||
pub auth: Option<String>,
|
|
||||||
/// `form_name = "..."` + `form_role = "schema"|"validate"|"submit"` — the
|
|
||||||
/// Forms binding's per-endpoint metadata, mirroring the Django form
|
|
||||||
/// `_meta` keys. Carried into the IR (`is-form`/`form-name`/`form-role`).
|
|
||||||
pub form_name: Option<String>,
|
|
||||||
pub form_role: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FunctionArgs {
|
impl FunctionArgs {
|
||||||
@@ -54,16 +45,10 @@ impl FunctionArgs {
|
|||||||
out.affects = collect_paths(&nv.value)?;
|
out.affects = collect_paths(&nv.value)?;
|
||||||
} else if nv.path.is_ident("merge") {
|
} else if nv.path.is_ident("merge") {
|
||||||
out.merge = collect_paths(&nv.value)?;
|
out.merge = collect_paths(&nv.value)?;
|
||||||
} else if nv.path.is_ident("auth") {
|
|
||||||
out.auth = Some(expect_str(&nv.value)?);
|
|
||||||
} else if nv.path.is_ident("form_name") {
|
|
||||||
out.form_name = Some(expect_str(&nv.value)?);
|
|
||||||
} else if nv.path.is_ident("form_role") {
|
|
||||||
out.form_role = Some(expect_str(&nv.value)?);
|
|
||||||
} else {
|
} else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
nv.path,
|
nv.path,
|
||||||
"unknown attribute key; expected one of: context, affects, merge, auth, form_name, form_role",
|
"unknown attribute key; expected one of: context, affects, merge",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,12 +57,10 @@ impl FunctionArgs {
|
|||||||
out.websocket = true;
|
out.websocket = true;
|
||||||
} else if p.is_ident("private") {
|
} else if p.is_ident("private") {
|
||||||
out.private = true;
|
out.private = true;
|
||||||
} else if p.is_ident("auth") {
|
|
||||||
out.auth = Some("required".to_string());
|
|
||||||
} else {
|
} else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
p,
|
p,
|
||||||
"unknown flag; expected `websocket`, `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>> {
|
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
||||||
match expr {
|
match expr {
|
||||||
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
||||||
@@ -215,11 +183,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
quote! {
|
quote! {
|
||||||
// The synthetic Input is only ever *deserialized* (from the call's
|
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
|
||||||
// JSON args by the dispatch wrapper); it is never serialized, so it
|
|
||||||
// derives `Deserialize` only. Dropping `Serialize` lets binary
|
|
||||||
// field types like `Upload` (deserialize-only) participate.
|
|
||||||
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Deserialize)]
|
|
||||||
pub struct #input_type_ident {
|
pub struct #input_type_ident {
|
||||||
#(#field_defs)*
|
#(#field_defs)*
|
||||||
}
|
}
|
||||||
@@ -389,20 +353,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
let output_nullable = analysis.nullable;
|
let output_nullable = analysis.nullable;
|
||||||
let private = args.private;
|
let private = args.private;
|
||||||
|
|
||||||
let auth_value = match &args.auth {
|
|
||||||
Some(a) => quote! { ::std::option::Option::Some(#a) },
|
|
||||||
None => quote! { ::std::option::Option::None },
|
|
||||||
};
|
|
||||||
let is_form = args.form_name.is_some() || args.form_role.is_some();
|
|
||||||
let form_name_value = match &args.form_name {
|
|
||||||
Some(n) => quote! { ::std::option::Option::Some(#n) },
|
|
||||||
None => quote! { ::std::option::Option::None },
|
|
||||||
};
|
|
||||||
let form_role_value = match &args.form_role {
|
|
||||||
Some(r) => quote! { ::std::option::Option::Some(#r) },
|
|
||||||
None => quote! { ::std::option::Option::None },
|
|
||||||
};
|
|
||||||
|
|
||||||
let dispatch_body = build_dispatch(
|
let dispatch_body = build_dispatch(
|
||||||
&item,
|
&item,
|
||||||
&input_args,
|
&input_args,
|
||||||
@@ -439,10 +389,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
|||||||
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
||||||
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
||||||
fn private(&self) -> bool { #private }
|
fn private(&self) -> bool { #private }
|
||||||
fn auth(&self) -> ::std::option::Option<&'static str> { #auth_value }
|
|
||||||
fn is_form(&self) -> bool { #is_form }
|
|
||||||
fn form_name(&self) -> ::std::option::Option<&'static str> { #form_name_value }
|
|
||||||
fn form_role(&self) -> ::std::option::Option<&'static str> { #form_role_value }
|
|
||||||
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
||||||
|
|
||||||
fn dispatch<'a>(
|
fn dispatch<'a>(
|
||||||
|
|||||||
@@ -105,15 +105,6 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
|||||||
if let Some(p) = primitive_of(ty) {
|
if let Some(p) = primitive_of(ty) {
|
||||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
||||||
}
|
}
|
||||||
if is_upload(ty) {
|
|
||||||
// An `Upload`-typed field emits the IR `upload` type-child rather than
|
|
||||||
// a `ref`, matching the Python emitter. Constraints (`max-size`,
|
|
||||||
// `content-type`) aren't carried in this baseline — an unconstrained
|
|
||||||
// upload — but the wire/IR shape is the recognized `upload` node.
|
|
||||||
return quote! {
|
|
||||||
::mizan_core::TypeShape::Upload { max_size: ::std::option::Option::None, content_types: &[] }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
||||||
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
||||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
||||||
@@ -158,19 +149,6 @@ pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
|||||||
type_args.next()
|
type_args.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if `ty` names the `mizan_core::Upload` marker (by its last path
|
|
||||||
/// segment) — the binary file-input type.
|
|
||||||
pub fn is_upload(ty: &Type) -> bool {
|
|
||||||
match ty {
|
|
||||||
Type::Path(TypePath { qself: None, path }) => path
|
|
||||||
.segments
|
|
||||||
.last()
|
|
||||||
.map(|s| s.ident == "Upload")
|
|
||||||
.unwrap_or(false),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
||||||
/// known primitive scalar.
|
/// known primitive scalar.
|
||||||
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
||||||
|
|||||||
2445
cores/mizan-rust-ssr/Cargo.lock
generated
Normal file
2445
cores/mizan-rust-ssr/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
cores/mizan-rust-ssr/Cargo.toml
Normal file
22
cores/mizan-rust-ssr/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "mizan-rust-ssr"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Mizan SSR engine — embeds a deno_core V8 runtime (with deno_web) to render the build-time JS bundle to HTML in-process. No node, no bun, at serve time."
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# deno_core + deno_web are added via `cargo add` so the resolver pins a
|
||||||
|
# version-matched pair (deno_web constrains deno_core); the web-platform
|
||||||
|
# globals react-dom/server.browser needs (TextEncoder, timers, MessagePort,
|
||||||
|
# streams) come from deno_web as real implementations, not hand-rolled shims.
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
deno_core = "0.403.0"
|
||||||
|
deno_web = "0.281.0"
|
||||||
|
deno_webidl = "0.250.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] }
|
||||||
133
cores/mizan-rust-ssr/src/lib.rs
Normal file
133
cores/mizan-rust-ssr/src/lib.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! Mizan SSR engine.
|
||||||
|
//!
|
||||||
|
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
|
||||||
|
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
|
||||||
|
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
|
||||||
|
//! global render function; the engine evals it once and calls it per request.
|
||||||
|
//! No external JS runtime — node and bun are build-time tools only.
|
||||||
|
//!
|
||||||
|
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
|
||||||
|
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
|
||||||
|
//! web-platform implementations, not shims (a partial polyfill is
|
||||||
|
//! silent-failure-shaped: it passes until a render path hits the gap).
|
||||||
|
//!
|
||||||
|
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
|
||||||
|
//! per-render data crosses as a `v8::json::parse`d value passed as a function
|
||||||
|
//! argument, so a prop string has no source to break out of — code injection
|
||||||
|
//! is structurally absent, not filtered.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use deno_core::{v8, JsRuntime, RuntimeOptions};
|
||||||
|
use deno_web::{BlobStore, InMemoryBroadcastChannel};
|
||||||
|
|
||||||
|
/// Install the web-platform globals react-dom touches at module-init, pulled
|
||||||
|
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
|
||||||
|
/// registers these as lazy modules and installs nothing on `globalThis` until
|
||||||
|
/// asked — this is the minimal bootstrap that asks.
|
||||||
|
const INSTALL_WEB_GLOBALS: &str = r#"{
|
||||||
|
const lazy = (s) => Deno.core.loadExtScript(s);
|
||||||
|
const mp = lazy("ext:deno_web/13_message_port.js");
|
||||||
|
globalThis.MessageChannel = mp.MessageChannel;
|
||||||
|
globalThis.MessagePort = mp.MessagePort;
|
||||||
|
const te = lazy("ext:deno_web/08_text_encoding.js");
|
||||||
|
globalThis.TextEncoder = te.TextEncoder;
|
||||||
|
globalThis.TextDecoder = te.TextDecoder;
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
|
||||||
|
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
||||||
|
/// means an engine is not `Send`; hold one per worker thread).
|
||||||
|
pub struct SsrEngine {
|
||||||
|
runtime: JsRuntime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsrEngine {
|
||||||
|
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
|
||||||
|
pub fn new(bundle: String) -> Result<Self> {
|
||||||
|
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||||
|
extensions: vec![
|
||||||
|
deno_webidl::deno_webidl::init(),
|
||||||
|
deno_web::deno_web::init(
|
||||||
|
Arc::new(BlobStore::default()),
|
||||||
|
None, // maybe_location
|
||||||
|
false, // enable_css_parser_features
|
||||||
|
InMemoryBroadcastChannel::default(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
runtime
|
||||||
|
.execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
|
||||||
|
.context("installing web-platform globals")?;
|
||||||
|
runtime
|
||||||
|
.execute_script("[mizan:bundle]", bundle)
|
||||||
|
.context("evaluating the SSR bundle")?;
|
||||||
|
Ok(Self { runtime })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
|
||||||
|
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
||||||
|
/// argument — never spliced into evaluated source.
|
||||||
|
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
||||||
|
deno_core::scope!(scope, &mut self.runtime);
|
||||||
|
let context = scope.get_current_context();
|
||||||
|
let global = context.global(scope);
|
||||||
|
|
||||||
|
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
|
||||||
|
let func_val = global
|
||||||
|
.get(scope, key.into())
|
||||||
|
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
|
||||||
|
let func: v8::Local<v8::Function> = func_val
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("renderApp is not a function"))?;
|
||||||
|
|
||||||
|
let props_str = v8::String::new(scope, props_json).context("intern props")?;
|
||||||
|
let props = v8::json::parse(scope, props_str)
|
||||||
|
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
|
||||||
|
|
||||||
|
let recv = v8::undefined(scope).into();
|
||||||
|
let result = func
|
||||||
|
.call(scope, recv, &[props])
|
||||||
|
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
|
||||||
|
Ok(result.to_rust_string_lossy(scope))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renders_react_bundle_in_embedded_v8() {
|
||||||
|
let bundle = std::fs::read_to_string(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixture/bundle.js"
|
||||||
|
))
|
||||||
|
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
|
||||||
|
|
||||||
|
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||||
|
let html = engine.render(r#"{"name":"World"}"#).expect("render");
|
||||||
|
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn props_cannot_inject_code() {
|
||||||
|
// A prop value that would break out of a string-built `renderApp(...)`
|
||||||
|
// call. Through the value-call path it is inert data: it reaches the
|
||||||
|
// component as a string, never as source.
|
||||||
|
let bundle = std::fs::read_to_string(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixture/bundle.js"
|
||||||
|
))
|
||||||
|
.expect("fixture bundle");
|
||||||
|
|
||||||
|
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||||
|
let html = engine
|
||||||
|
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
||||||
|
.expect("render");
|
||||||
|
// The payload rendered as text; it did not execute.
|
||||||
|
assert!(html.contains("__pwned"));
|
||||||
|
}
|
||||||
|
}
|
||||||
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
bundle.js
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user