Compare commits
61 Commits
7daec1c2e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587be8c4ab | |||
| ae684a36cb | |||
| adcc027894 | |||
| 6c5f6f1fba | |||
| 58d2cb2848 | |||
| b41f469bbd | |||
| 66b2db81fb | |||
| 67ad91b673 | |||
| 4effcc7597 | |||
| 776e0cf27a | |||
| ffdf9aa24d | |||
| 578e124d67 | |||
| a5ef93b879 | |||
| 22dcf0e3c1 | |||
| 54f060c273 | |||
| a1d1d6928f | |||
| 45bde51166 | |||
| 9900f8a36f | |||
| 7fb0c4a400 | |||
| 43bcf3f26f | |||
| c15c6f3e14 | |||
| cc887fb1f6 | |||
| f0f7a93ed2 | |||
| 255e10cb21 | |||
| 19ce4d4a2a | |||
| 0a95f3c860 | |||
| aaaf80cdbf | |||
| 2982741aad | |||
| 63c9a9c4ce | |||
| 4e4d1bb6b1 | |||
| dd41f0c25f | |||
| 76fce2dc85 | |||
| 9150cdc5ee | |||
| 37e61c646b | |||
| 9d2781b52c | |||
| fe39fcb229 | |||
| 6eca514777 | |||
| 5c1c583164 | |||
| 2d7cf3eb39 | |||
| bb88fd984b | |||
| 07f1c7842c | |||
| 9c837cf285 | |||
| cdd15b3810 | |||
| 499aa0e038 | |||
| c20de182e1 | |||
| 6108845d99 | |||
| 1c6d9075ad | |||
| 27c30d7e50 | |||
| 24ff0ae66d | |||
| 1b5dca5ab3 | |||
| 658cbebce1 | |||
| 711e92ac4d | |||
| c237a6379b | |||
| 4147679e6b | |||
| e5f8fafc01 | |||
| 7f5542e305 | |||
| dbbb269696 | |||
| 4744ff052e | |||
| 54581d184f | |||
| d7ec13c43c | |||
| a2388b3ab2 |
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: django
|
||||
working-directory: backends/mizan-django
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: react
|
||||
working-directory: frontends/mizan-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,10 @@ node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
|
||||
# Rust — every crate's build dir, anywhere in the tree
|
||||
target/
|
||||
**/target/
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
# Architecture Rework: Cache Keying & Invalidation
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Source:** 8 independent expert reviews (Cloudflare, Enterprise Backend, Django, SaaS Founder, Next.js, React Query, Framework Authoring, Serverless Architecture)
|
||||
|
||||
---
|
||||
|
||||
## Status Key
|
||||
|
||||
- [x] Fixed
|
||||
- [ ] **BUG** — broken in shipped code
|
||||
- [ ] **DESIGN** — must resolve before implementing cache layer
|
||||
- [ ] **SPEC** — needs specification before building
|
||||
- [ ] **OPS** — operational gap for production readiness
|
||||
- [ ] **DX** — developer experience issue
|
||||
- [ ] **BUSINESS** — product/pricing concern
|
||||
|
||||
---
|
||||
|
||||
## Bugs in Shipped Code
|
||||
|
||||
### [x] BUG: `Vary: Authorization, Cookie` does nothing on Cloudflare
|
||||
**Files:** `executor.py:787`, `dispatch.ts:77`, `edge-compat.test.ts:75-79`
|
||||
|
||||
Cloudflare ignores all Vary values except `Accept-Encoding` and `Accept` (images only). This header creates a false sense of security — someone reading the code assumes different Authorization headers produce different cache entries. They do not. The edge-compat tests assert the presence of this non-functional header, reinforcing the illusion.
|
||||
|
||||
**Origin:** Claude hallucination in a prior session. Not a design decision.
|
||||
|
||||
**Fix:** Remove the header from both Python and TypeScript. Remove test assertions. Add a code comment explaining why Vary is not used and pointing to the HMAC cache key strategy (when implemented).
|
||||
|
||||
### [x] BUG: `fn` vs `function` wire protocol key mismatch
|
||||
**Files:** `executor.py:619`, `runtime/index.ts:128`
|
||||
|
||||
The Django executor reads `body.get("fn")`. The TypeScript runtime sends `{ function: functionName }`. These don't match. Would break on first real use of the new TS runtime against the Django backend.
|
||||
|
||||
**Fix:** Align on one key name. Whichever is chosen, document it as stable wire format.
|
||||
|
||||
### [x] BUG: `max-age=0` defeats the PSR caching model
|
||||
**File:** `executor.py:786`
|
||||
|
||||
`Cache-Control: public, max-age=0, stale-while-revalidate=300` means the origin gets hit on every request for background revalidation. This conflicts with PSR's purge-based freshness model, where content should be cached until explicitly invalidated.
|
||||
|
||||
**Fix:** For PSR-eligible contexts, emit `Cache-Control: public, s-maxage=31536000`. The CDN caches forever; purge is the only freshness mechanism. Reserve `max-age=0, stale-while-revalidate` for contexts that opt out of PSR or use time-based revalidation.
|
||||
|
||||
---
|
||||
|
||||
## Critical Design Flaws
|
||||
|
||||
### [ ] DESIGN: HMAC concatenation without delimiter
|
||||
**Severity:** Security vulnerability — cache key collisions across different logical entries
|
||||
|
||||
`HMAC(secret, context + user_id + params)` without structured separation means `"user" + "12" + "3"` collides with `"user1" + "2" + "3"`.
|
||||
|
||||
**Fix:** Use null-byte delimiters: `HMAC(secret, context + "\x00" + user_id + "\x00" + canonical_sorted_params)`. Or HMAC over a JSON-canonical form. Document the canonical form as part of the AFI protocol spec.
|
||||
|
||||
### [ ] DESIGN: Full context flush on deploy = thundering herd
|
||||
**Severity:** Operational — self-inflicted DDoS on every deploy that changes a decorator
|
||||
|
||||
Every deploy that changes any `@client` decorator nukes all cached content for affected contexts. Teams deploying 3-5x/day means the Edge cache is cold 3-5x/day. 100K concurrent users + 10 contexts = 1M origin requests in seconds post-deploy.
|
||||
|
||||
**Preferred fix:** Versioned cache keys. Include a manifest content hash in the cache key. Old and new entries coexist during transition. No purge, no thundering herd. 2x cache storage during transition (negligible). Old entries expire naturally via TTL or LRU eviction.
|
||||
|
||||
**Alternative fix:** Granular per-context diffing. Only flush contexts whose function signatures, params, or auth requirements actually changed. The manifest already contains per-context param lists to support this.
|
||||
|
||||
### [ ] DESIGN: Purge token in customer Workers exposes shared cache
|
||||
**Severity:** Security — one compromised customer can purge all customers' cache
|
||||
|
||||
Every customer Edge Worker deployment carries a Cloudflare API token with `Zone:Cache Purge` permission for `render.mizan.cloud`.
|
||||
|
||||
**Fix:** Build a purge proxy Worker on the Mizan zone. Validates purge requests (HMAC signature + customer-scoped URL pattern matching) before forwarding to the Cloudflare purge API. No customer Worker ever holds a direct zone API token.
|
||||
|
||||
### [ ] DESIGN: Permission key race condition
|
||||
**Severity:** Data correctness — stale content served for duration of JWT lifetime
|
||||
|
||||
User permission changes (e.g., tier upgrade) don't take effect until JWT expires because: (1) cache key uses only `user_id`, not tier, and (2) permission key comparison uses the JWT-derived value, which is stale until refresh.
|
||||
|
||||
**Options:**
|
||||
- (a) Make permission-relevant attributes part of the cache key (increases cardinality).
|
||||
- (b) Accept the JWT-lifetime staleness window, document as known constraint.
|
||||
- (c) Add short-TTL revalidation for permission-sensitive contexts.
|
||||
|
||||
**Decision needed before implementation.**
|
||||
|
||||
### [ ] DESIGN: No `waitUntil()` in purge/warm flow
|
||||
**Severity:** Latency — client blocks on cache management operations
|
||||
|
||||
If a mutation invalidates N URLs, the Edge Worker must complete all purge API calls before responding. Each call is 50-200ms.
|
||||
|
||||
**Fix:** Return mutation response immediately. Fire all purge and warming fetches inside `waitUntil()`. Same Worker invocation, no extra billing, client doesn't block.
|
||||
|
||||
---
|
||||
|
||||
## Missing Specifications
|
||||
|
||||
### [ ] SPEC: Secret rotation protocol
|
||||
No rotation mechanism, no dual-secret acceptance window, no compromise recovery procedure. Rotating the single secret invalidates every HMAC globally.
|
||||
|
||||
**Need:** Key derivation hierarchy (master secret -> per-context derived keys). Rotation at context level. Dual-secret acceptance window during rotation. Document compromise recovery procedure.
|
||||
|
||||
### [ ] SPEC: GDPR right-to-erasure for cached content
|
||||
HMAC keys make targeted per-user cache purge difficult. Must reconstruct every possible HMAC for every context x param combination for a given user.
|
||||
|
||||
**Need:** `purge_by_user(user_id)` operation that iterates manifest contexts to reconstruct all HMACs. Tractable if context count is bounded. Audit trail for compliance proof.
|
||||
|
||||
### [ ] SPEC: Cache adapter conformance requirements
|
||||
|
||||
Every Mizan backend adapter (Python, TypeScript, and future: PHP, C#, Go, etc.) must
|
||||
implement the origin-side cache protocol. This is NOT a binary ABI or pluggable backend
|
||||
interface. It is a set of operations each adapter implements in its own language, backed
|
||||
by Redis. Conformance is verified by a shared test suite (same model as the existing
|
||||
edge-compat tests that prove Python and TypeScript produce identical protocol output).
|
||||
|
||||
**Storage:** Redis. Not pluggable. Not in-memory-only. Redis handles persistence,
|
||||
cross-worker sharing, and crash recovery. The adapter is a thin protocol layer over
|
||||
Redis commands.
|
||||
|
||||
**Required operations:**
|
||||
|
||||
```
|
||||
cache_get(context: string, params: dict, user_id: string | null, rev: int) -> CachedResponse | null
|
||||
```
|
||||
Derives HMAC key from inputs using JSON-canonical form, fetches from Redis.
|
||||
|
||||
```
|
||||
cache_put(context: string, params: dict, user_id: string | null, rev: int, response: CachedResponse) -> void
|
||||
```
|
||||
Derives HMAC key, stores response in Redis. Also maintains a reverse index
|
||||
(context + params -> HMAC keys) so `cache_purge` can find entries to delete.
|
||||
|
||||
```
|
||||
cache_purge(context: string, params: dict | null) -> int
|
||||
```
|
||||
Looks up the reverse index for matching entries, deletes them from Redis.
|
||||
Returns number of entries purged. When `params` is null, purges entire context.
|
||||
|
||||
```
|
||||
cache_purge_user(user_id: string) -> int
|
||||
```
|
||||
Iterates all contexts in the manifest, reconstructs HMAC keys for the given
|
||||
user_id across all param combinations in the reverse index, deletes them.
|
||||
Required for GDPR right-to-erasure.
|
||||
|
||||
**HMAC key derivation (must be identical across all adapters):**
|
||||
|
||||
```
|
||||
key = HMAC-SHA256(secret, JSON.stringify({
|
||||
"c": context,
|
||||
"p": sorted_params,
|
||||
"r": rev,
|
||||
"u": user_id // omitted for public content
|
||||
}, sort_keys=True))
|
||||
```
|
||||
|
||||
**MWT validation (must be identical across all adapters):**
|
||||
|
||||
Validate the `X-Mizan-Token` header as a standard JWT (HMAC-SHA256). Extract `sub`
|
||||
(user_id) for cache key derivation, check `exp` for token freshness.
|
||||
|
||||
**Conformance test suite:**
|
||||
|
||||
Each adapter must pass a shared set of protocol conformance tests verifying:
|
||||
- Identical HMAC output for identical inputs (cross-language determinism)
|
||||
- Identical MWT validation behavior
|
||||
- Correct purge semantics (scoped and broad)
|
||||
- Correct reverse index maintenance
|
||||
- Correct `cache_purge_user` behavior
|
||||
|
||||
### [ ] SPEC: Client-side cache lifecycle
|
||||
Runtime is ~95 lines. No `staleTime`, `isFetching`/`isLoading` distinction, garbage collection, retry logic, optimistic updates, `refetchOnWindowFocus`.
|
||||
|
||||
**Minimum viable:**
|
||||
- Loading/fetching state distinction (don't throw on missing data)
|
||||
- Error return shape: `{ data, isLoading, isFetching, error }`
|
||||
- `refetchOnWindowFocus` as default
|
||||
- Mutation lifecycle with rollback support for optimistic updates
|
||||
- Garbage collection for unmounted context data (configurable delay)
|
||||
|
||||
### [x] SPEC: Per-context cache policy
|
||||
|
||||
`cache=` on `@client` accepts three forms:
|
||||
|
||||
- **Omitted (default):** Invalidation-based. Emits `s-maxage=31536000`. Cache forever,
|
||||
purge on mutation. Use when your backend is the source of truth.
|
||||
- **`cache=60` (integer seconds):** TTL-based. Emits `s-maxage=60`. Accept bounded
|
||||
staleness. Use for unobservable mutations — when your backend mirrors external data
|
||||
(third-party APIs, aggregations, upstream services) and cannot know when it changes.
|
||||
- **`cache=False`:** Never cache. Emits `Cache-Control: no-store`. Use for
|
||||
non-deterministic functions (`random()`, `datetime.now()`).
|
||||
|
||||
This is the escape hatch for data the backend doesn't own the mutation scope for.
|
||||
Positioned in docs as: "Are you the source of truth, or a mirror? Source of truth →
|
||||
use `affects=`. Mirror → use `cache=N`."
|
||||
|
||||
The `cache=int` value flows into the edge manifest per-context, so the Edge Worker
|
||||
and CDN respect it without special handling (`s-maxage` is standard CDN behavior).
|
||||
|
||||
### [ ] SPEC: Extension points for cache/invalidation lifecycle
|
||||
Zero hooks for third-party code. No pre-invalidation hook, no custom cache key function, no invalidation transport plugin.
|
||||
|
||||
**Minimum viable:**
|
||||
- `CacheBackend` protocol (third parties implement custom backends)
|
||||
- `on_invalidate(context, params)` event hook (monitoring/debugging)
|
||||
- Document these as public API from day one
|
||||
|
||||
### [x] SPEC: Manifest versioning
|
||||
The manifest has no version field. When the schema evolves, Edge Workers can't distinguish v1 from v2 format.
|
||||
|
||||
**Fix:** Add `"version": 1` to manifest root before anyone deploys it. Edge Workers check version and fail fast on unknown versions.
|
||||
|
||||
### [x] SPEC: Wire format convention
|
||||
Python emits `snake_case` params (`user_id`). TypeScript conventionally uses `camelCase` (`userId`). The `USER_SCOPED_PARAMS` set in `manifest.ts` contains both conventions. Invalidation headers from Python won't match TypeScript keys expecting `camelCase`.
|
||||
|
||||
**Fix:** Document `snake_case` as the wire format convention. TypeScript adapters convert at the boundary.
|
||||
|
||||
---
|
||||
|
||||
## Operational Gaps
|
||||
|
||||
### [ ] OPS: No cache observability
|
||||
No hit/miss metrics, no cache key debugging, no invalidation audit trail, no manifest version tracking.
|
||||
|
||||
**Need:** `X-Mizan-Cache-Status` response header (HIT/MISS/BYPASS/STALE/PURGED/DYNAMIC). Structured logging in Edge Worker. Console-level invalidation event log for devtools.
|
||||
|
||||
### [ ] OPS: Purge rate limits at scale
|
||||
Cloudflare zone purge API: 500 req/10s (free/pro), 2500/10s (Enterprise). Bulk operations can exceed this.
|
||||
|
||||
**Need:** Batch purge requests (up to 30 URLs per API call). Document rate limits. Design Cache Tags upgrade path for Enterprise.
|
||||
|
||||
### [ ] OPS: Purge-then-warm race condition
|
||||
Warming fetch arriving at a PoP before purge propagates gets a cache HIT on stale data.
|
||||
|
||||
**Fix:** Use `Cache-Control: no-cache` or `cf: { cacheTtl: 0 }` on warming requests to force revalidation.
|
||||
|
||||
### [ ] OPS: PSR warming only warms one colo
|
||||
Warming fetch from a Worker runs in a single datacenter. Only warms that colo's cache (+ upper-tier if Tiered Cache active). Does not warm all 300+ PoPs.
|
||||
|
||||
**Document:** PSR warming reduces origin load by warming the shield tier. First request from each edge PoP is still a cache miss to the shield. Not zero-latency for all users.
|
||||
|
||||
---
|
||||
|
||||
## Django Integration Concerns
|
||||
|
||||
### [ ] DX: `@client` breaks decorator stacking
|
||||
`@client` returns a class (`FunctionWrapper`), not a callable. `@login_required`, `@csrf_exempt`, `@cache_page` cannot compose with it.
|
||||
|
||||
**Options:**
|
||||
- (a) Make `@client` return a wraps-compatible callable that also carries metadata (Django Ninja approach).
|
||||
- (b) Document incompatibility prominently. Provide Mizan-native equivalents. State that `@client` replaces `@login_required` (via `auth=`), `@cache_page` (via context caching), etc.
|
||||
|
||||
### [ ] DX: `JWTUser` too thin for complex auth checks
|
||||
Works for `is_staff`/`is_superuser`. Fails for allauth relations, DRF permissions, `request.user.groups.all()`, user model relations.
|
||||
|
||||
**Need:** Document limitation. Provide `get_full_user()` helper that does DB lookup when needed. Or optionally expand JWT claims.
|
||||
|
||||
### [ ] DX: Transaction safety of invalidation
|
||||
Invalidation in response body is optimistic — fires before `ATOMIC_REQUESTS` commits. If transaction rolls back, invalidation was already sent.
|
||||
|
||||
**Need:** Document as known behavior. Recommend `transaction.on_commit()` for critical paths. When building `mizan-cache`, consider two-phase: mark for invalidation during request, execute purge on commit.
|
||||
|
||||
### [ ] DX: Admin/ORM writes invisible to invalidation
|
||||
Only `@client(affects=...)` functions trigger invalidation. Django admin saves, management commands, direct ORM writes are invisible.
|
||||
|
||||
**Need:** Document clearly. Provide manual purge API: `purge_context('products', params={'product_id': 42})`.
|
||||
|
||||
### [ ] DX: Cache adapter integration for Django
|
||||
The Python cache adapter is a thin protocol layer over Redis (not a Django cache backend).
|
||||
Django developers call `mizan.cache.get(context, params, user_id, rev)` directly.
|
||||
Provide a `mizan.cache.clear()` for test fixture teardown. Document that this is
|
||||
separate from Django's `CACHES` framework — Mizan owns its own cache protocol.
|
||||
|
||||
---
|
||||
|
||||
## Business/Product Concerns
|
||||
|
||||
### [ ] BUSINESS: Free tier + Cloudflare free = 80% of paid product
|
||||
Existing `Cache-Control` headers on context fetches are CDN-ready. A developer puts Cloudflare free tier in front and gets stale-while-revalidate at 300+ PoPs for $0. The 20% gap (user-scoped HMAC keying, PSR, render Workers) doesn't exist in code yet.
|
||||
|
||||
### [ ] BUSINESS: $20/seat wrong pricing model
|
||||
"Seat" is undefined for a framework. Usage-based ($0.50/100K requests with generous free tier) or flat-per-project ($29/month) converts better for infrastructure products.
|
||||
|
||||
### [ ] BUSINESS: Ship framework first, cloud second
|
||||
The framework has working code. The cloud product has zero. Risk: building both depletes runway before either has adoption. Recommended: get 500 devs using `@client` + `affects=` on their VPS first, then build the Edge product for the gap they actually hit.
|
||||
|
||||
---
|
||||
|
||||
## Validated Design Decisions (No Changes Needed)
|
||||
|
||||
These were confirmed sound by multiple reviewers:
|
||||
|
||||
- **Declarative invalidation graph** (`affects=` + auto-scoping) — unanimously praised as genuinely novel
|
||||
- **Two-zone `fetch()` pattern** — correct architecture for global CDN caching from Workers
|
||||
- **Cross-language protocol** — Python/TS with identical manifests, proven by parallel test suites
|
||||
- **Manifest-driven URL resolution** — eliminates need for cache inventory state (no KV/DOs needed)
|
||||
- **Typed `ReactContext` for `affects` targeting** — prevents the string-fragility concern (string form is escape hatch only)
|
||||
- **Replacing React Query** — correct decision given context bundling + transport transparency goals
|
||||
- **Cost model** — ~$5/month Cloudflare at 10K DAU, ~$20/month at 10x. Origin infra is the real cost.
|
||||
- **Origin-side Redis cache as L2** — viable fallback behind CDN, same protocol as Edge
|
||||
|
||||
---
|
||||
|
||||
## Unique Expert Insights
|
||||
|
||||
**Cloudflare Expert:**
|
||||
- Add `cf.cacheTtl` and `cf.cacheEverything` to all `fetch()` subrequests — don't rely solely on response headers
|
||||
- Consider Cache Tags (`Cache-Tag` response header) from day one for Enterprise upgrade path
|
||||
- Consider Durable Objects for per-user cache coordination as alternative to HMAC-in-URL
|
||||
|
||||
**Enterprise Architect:**
|
||||
- Key derivation hierarchy: master secret derives per-context keys. Compromise of one context doesn't affect others.
|
||||
- `X-Mizan-Cache-Version` header on every response for self-healing on version mismatch
|
||||
|
||||
**Serverless Expert:**
|
||||
- Use `renderToReadableStream` (streaming SSR) in Render Worker, not `renderToString`. Memory and CPU budget are tight (128MB / 50ms).
|
||||
- Cache manifest in `globalThis` in Edge Worker — do not read from KV per-request
|
||||
- AWS portability: CloudFront invalidation pricing is 10-100x more expensive. Design TTL-based alternative.
|
||||
|
||||
**Next.js Expert:**
|
||||
- PSR doesn't address cold-start pages (initial population before any mutation) or render fan-out (10K parameterized variants re-rendering on one mutation)
|
||||
- No streaming/Suspense/progressive delivery — entire context response blocks on slowest function
|
||||
|
||||
**React Query Expert:**
|
||||
- Wire existing WebSocket push infrastructure to emit invalidation events for named contexts
|
||||
- Generated hooks should return `{ data, isLoading, isFetching, error }`, not throw on missing data
|
||||
|
||||
**Django Architect:**
|
||||
- DRF `TokenAuthentication` collision: both use `Authorization: Bearer`, Mizan's JWT decode rejects DRF tokens with a 401
|
||||
- `mizan-cache` as Django cache backend, not separate system
|
||||
|
||||
**Framework Authoring:**
|
||||
- Define `CacheBackend` protocol before implementing — the abstraction is cheaper to get right before users exist
|
||||
- Add `"version": 1` to manifest root now — adding it later is harder
|
||||
- `@client` is approaching parameter overload — if `cache` becomes extensible, use `CachePolicy` object pattern, not more kwargs
|
||||
|
||||
**SaaS Founder:**
|
||||
- The debugging UX for HMAC cache is a black box — invest in an invalidation graph debugging UI as a paid feature
|
||||
- The `affects=` auto-refetch is the "wow" moment — optimize time-to-that-moment in onboarding
|
||||
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.
|
||||
23
ISSUES.md
Normal file
23
ISSUES.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Mizan — Known Issues
|
||||
|
||||
Status board against the current codebase: Rust codegen (`protocol/mizan-codegen`),
|
||||
KDL IR, kernel-owned frontend state (`@mizan/base`). Issues that the earlier
|
||||
expert-review board filed against the deleted JavaScript codegen and the
|
||||
pre-kernel `mizan-react` provider have been removed — they audited files that
|
||||
no longer exist.
|
||||
|
||||
## Open
|
||||
|
||||
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||
- [ ] **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`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||
|
||||
## Resolved this pass
|
||||
|
||||
- [x] **Codegen test suite compile break** — every `mizan-codegen` test constructed `SourceConfig` without the `rust`/`script` fields added alongside the Rust-backend work. Suite now compiles and is green.
|
||||
- [x] **React parity baseline** — the emitter correctly drops the dead `initSession`/`MizanError` top-level imports (they are only re-exported, never used in the module body); baseline regenerated. Fixed the template whitespace artifact that indented the `} from '@mizan/base'` closing brace.
|
||||
- [x] **Edge manifest non-determinism** — `generate_edge_manifest` iterated registration order; now sorts context and mutation keys, so the manifest is deterministic regardless of registration order.
|
||||
- [x] **Dead code removed** — `workers/mizan-ssr/src/test-worker.tsx` (a relic of the rejected `registerComponent` registry), unused TS helpers `isResponseReturn` and `sortedStringify` (mizan-ts), the unused `IndexMap` import (`emit/python.rs`), the dead `debug_expose_names` Django setting, and the dead `package.json` exports + vite aliases (`./client/nextjs`, `./allauth`, `./allauth/nextjs`) pointing at source that does not exist.
|
||||
95
LICENSE
Normal file
95
LICENSE
Normal file
@@ -0,0 +1,95 @@
|
||||
Copyright (c) 2026 Ryth Azhur
|
||||
|
||||
Elastic License 2.0
|
||||
|
||||
URL: https://www.elastic.co/licensing/elastic-license
|
||||
|
||||
## Acceptance
|
||||
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
||||
non-sublicensable, non-transferable license to use, copy, distribute, make
|
||||
available, and prepare derivative works of the software, in each case subject to
|
||||
the limitations and conditions below.
|
||||
|
||||
## Limitations
|
||||
|
||||
You may not provide the software to third parties as a hosted or managed
|
||||
service, where the service provides users with access to any substantial set of
|
||||
the features or functionality of the software.
|
||||
|
||||
You may not move, change, disable, or circumvent the license key functionality
|
||||
in the software, and you may not remove or obscure any functionality in the
|
||||
software that is protected by the license key.
|
||||
|
||||
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
||||
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
||||
to applicable law.
|
||||
|
||||
## Patents
|
||||
|
||||
The licensor grants you a license, under any patent claims the licensor can
|
||||
license, or becomes able to license, to make, have made, use, sell, offer for
|
||||
sale, import and have imported the software, in each case subject to the
|
||||
limitations and conditions in this license. This license does not cover any
|
||||
patent claims that you cause to be infringed by modifications or additions to
|
||||
the software. If you or your company make any written claim that the software
|
||||
infringes or contributes to infringement of any patent, your patent license for
|
||||
the software granted under these terms ends immediately. If your company makes
|
||||
such a claim, your patent license ends immediately for work on behalf of your
|
||||
company.
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you
|
||||
also gets a copy of these terms.
|
||||
|
||||
If you modify the software, you must include in any modified copies of the
|
||||
software prominent notices stating that you have modified the software.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not imply any licenses other than those expressly granted in
|
||||
these terms.
|
||||
|
||||
## Termination
|
||||
|
||||
If you use the software in violation of these terms, such use is not licensed,
|
||||
and your licenses will automatically terminate. If the licensor provides you
|
||||
with a notice of your violation, and you cease all violation of this license no
|
||||
later than 30 days after you receive that notice, your licenses will be
|
||||
reinstated retroactively. However, if you violate these terms after such
|
||||
reinstatement, any additional violation of these terms will cause your licenses
|
||||
to terminate automatically and permanently.
|
||||
|
||||
## No Liability
|
||||
|
||||
*As far as the law allows, the software comes as is, without any warranty or
|
||||
condition, and the licensor will not be liable to you for any damages arising
|
||||
out of these terms or the use or nature of the software, under any kind of
|
||||
legal claim.*
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the entity offering these terms, and the **software** is the
|
||||
software the licensor makes available under these terms, including any portion
|
||||
of it.
|
||||
|
||||
**you** refers to the individual or entity agreeing to these terms.
|
||||
|
||||
**your company** is any legal entity, sole proprietorship, or other kind of
|
||||
organization that you work for, plus all organizations that have control over,
|
||||
are under the control of, or are under common control with that
|
||||
organization. **control** means ownership of substantially all the assets of an
|
||||
entity, or the power to direct its management and policies by vote, contract, or
|
||||
otherwise. Control can be direct or indirect.
|
||||
|
||||
**your licenses** are all the licenses granted to you for the software under
|
||||
these terms.
|
||||
|
||||
**use** means anything you do with the software requiring one of your licenses.
|
||||
|
||||
**trademark** means trademarks, service marks, and similar rights.
|
||||
15
MIZAN.md
15
MIZAN.md
@@ -1,14 +1,19 @@
|
||||
# MIZAN — Named Contexts & Mutation Architecture
|
||||
|
||||
> **Historical design spec.** The original named-contexts / mutation design
|
||||
> document from the January 2025 design conversation. Kept as a record of design
|
||||
> intent, not as a description of the current build — names and surfaces here
|
||||
> predate the implementation (the codegen is the Rust binary
|
||||
> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For
|
||||
> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen
|
||||
> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`,
|
||||
> `CACHE_KEYING.md`, `MWT_SPEC.md`).
|
||||
|
||||
## For Claude Code
|
||||
|
||||
This plan was written by Ryth's Claude.ai session after an extended design conversation
|
||||
reviewing the full codebase, the original @compose discussion from January 2025, and
|
||||
several rounds of architectural refinement. Treat this as the spec.
|
||||
|
||||
The framework formerly called mizan is now called **MIZAN**. Package names, imports,
|
||||
and references should be updated accordingly. The internal codegen engine is called
|
||||
**Maison** — it lives inside Mizan and does not need its own public surface.
|
||||
several rounds of architectural refinement.
|
||||
|
||||
---
|
||||
|
||||
|
||||
24
Makefile
24
Makefile
@@ -1,24 +1,40 @@
|
||||
.PHONY: install test test-django test-react 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
|
||||
|
||||
DJANGO = packages/mizan-django
|
||||
REACT = packages/mizan-react
|
||||
CORE = cores/mizan-python
|
||||
DJANGO = backends/mizan-django
|
||||
FASTAPI = backends/mizan-fastapi
|
||||
REACT = frontends/mizan-react
|
||||
AFI = tests/afi
|
||||
|
||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
install:
|
||||
cd $(CORE) && uv pip install -e .
|
||||
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||
cd $(FASTAPI) && uv pip install -e ".[dev]"
|
||||
cd $(REACT) && npm install
|
||||
|
||||
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test: test-django test-react
|
||||
test: test-core test-django test-fastapi test-react test-afi
|
||||
|
||||
test-core:
|
||||
cd $(CORE) && uv run --extra dev pytest
|
||||
|
||||
test-django:
|
||||
cd $(DJANGO) && uv run pytest
|
||||
|
||||
test-fastapi:
|
||||
cd $(FASTAPI) && uv run pytest
|
||||
|
||||
test-react:
|
||||
cd $(REACT) && npm test
|
||||
|
||||
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
|
||||
# schemas for the same @client fixture. Substrate-level gate, not e2e.
|
||||
test-afi:
|
||||
cd $(AFI) && uv run pytest
|
||||
|
||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||
|
||||
test-integration: docker-up
|
||||
|
||||
365
README.md
365
README.md
@@ -1,297 +1,122 @@
|
||||
# mizan
|
||||
# Mizan
|
||||
|
||||
Django + React server functions framework. RPC, not REST.
|
||||
|
||||
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
|
||||
Mizan is an Application Framework Interface (AFI). A single `@client` decorator on a
|
||||
server function generates a typed frontend client; cache invalidation and caching are
|
||||
handled by the protocol.
|
||||
|
||||
```python
|
||||
# Django
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
from mizan import client, ReactContext
|
||||
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
# Context function — bundled into GET /api/mizan/ctx/user/
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> UserShape:
|
||||
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
|
||||
|
||||
# Mutation — invalidation scoped automatically by matching param name
|
||||
@client(affects=UserContext)
|
||||
def update_profile(request, user_id: int, name: str) -> dict:
|
||||
...
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
|
||||
```
|
||||
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
|
||||
reference implementation; per-adapter support is inventoried below.
|
||||
|
||||
## Packages
|
||||
> **Status:** Mizan is not production-tested. It passes its own test suites but has not
|
||||
> been run in a production deployment. Treat it as pre-release.
|
||||
|
||||
| Package | Path | Install |
|
||||
|---------|------|---------|
|
||||
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
|
||||
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
|
||||
## Documentation
|
||||
|
||||
## Quick Start
|
||||
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
||||
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
|
||||
|
||||
### 1. Django setup
|
||||
## Backend adapters
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = [
|
||||
"mizan",
|
||||
"myapp",
|
||||
]
|
||||
Every adapter implements the same AFI wire protocol. The matrix below inventories
|
||||
support per adapter, grouped to separate protocol guarantees from Django-specific
|
||||
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
|
||||
when that adapter wires the capability into its own dispatch surface, not merely that a
|
||||
shared core primitive exists.
|
||||
|
||||
# urls.py
|
||||
from django.urls import include, path
|
||||
urlpatterns = [
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
|
||||
|
||||
# asgi.py (for WebSocket support)
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
### Protocol core
|
||||
|
||||
### 2. Define server functions
|
||||
The surface every Mizan adapter implements.
|
||||
|
||||
```python
|
||||
# myapp/mizan_clients.py
|
||||
from django.http import HttpRequest
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|
|
||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
|
||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
### Edge, cache & enforcement
|
||||
|
||||
@client
|
||||
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
Protocol transports and guarantees co-equal with the body channel in the spec.
|
||||
|
||||
register(echo, "echo")
|
||||
```
|
||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|
|
||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
||||
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
||||
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
||||
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
||||
|
||||
### 3. Register in apps.py
|
||||
> **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.
|
||||
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
name = "myapp"
|
||||
### Stack extensions (Django)
|
||||
|
||||
def ready(self):
|
||||
import myapp.mizan_clients # noqa: F401
|
||||
```
|
||||
Django ecosystem features Mizan wraps. Other adapters provide these only where the
|
||||
target stack calls for them.
|
||||
|
||||
### 4. Generate TypeScript
|
||||
| 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) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
```bash
|
||||
# django.config.mjs
|
||||
export default {
|
||||
source: {
|
||||
django: {
|
||||
managePath: '../backend/manage.py',
|
||||
command: ['uv', 'run', 'python'],
|
||||
},
|
||||
},
|
||||
output: 'src/api/generated.ts',
|
||||
}
|
||||
```
|
||||
**Notes**
|
||||
|
||||
```bash
|
||||
npx mizan-generate
|
||||
```
|
||||
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||
Invalidation rides in the JSON response body; there is no header channel.
|
||||
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
|
||||
WebSocket handler yet.
|
||||
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
|
||||
4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
||||
adapter carries typed input/output through the KDL IR; the projection primitive
|
||||
itself is Django-only.
|
||||
5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
|
||||
enforce them. Rust/Axum has no enforcement either.
|
||||
6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
||||
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
||||
rather than fetching over HTTP.
|
||||
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||
parity; CSRF is Django-only.
|
||||
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||
codegen source — it demonstrates the cache + invalidation protocol is
|
||||
language-agnostic.
|
||||
|
||||
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
|
||||
## Conformance
|
||||
|
||||
### 5. Use in React
|
||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
|
||||
currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
|
||||
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
|
||||
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
|
||||
|
||||
```tsx
|
||||
// layout.tsx
|
||||
import { DjangoContext } from '@/api'
|
||||
## License
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return <DjangoContext>{children}</DjangoContext>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// page.tsx
|
||||
import { useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||
|
||||
function MyComponent() {
|
||||
const user = useCurrentUser()
|
||||
const echo = useEcho()
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const result = await echo({ text: 'hello' })
|
||||
console.log(result.message) // typed
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
| Backend | Frontend (generated) | Transport |
|
||||
|---------|---------------------|-----------|
|
||||
| `@client` | `useXxx()` | HTTP |
|
||||
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
|
||||
| `@client(context='local')` | `useXxx()` with params | HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
|
||||
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
| `@compose(...)` | Combined providers | varies |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
React app
|
||||
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
|
||||
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
|
||||
├─ useEcho() ← generated function hook
|
||||
├─ useContactForm() ← generated form hook (Zod + server validation)
|
||||
└─ useChatChannel() ← generated channel hook (WebSocket)
|
||||
│
|
||||
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
|
||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||
│
|
||||
Django executor
|
||||
├─ Pydantic input validation
|
||||
├─ Auth check (session, JWT, or custom)
|
||||
├─ Function execution
|
||||
└─ Pydantic output serialization
|
||||
```
|
||||
|
||||
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||
|
||||
## Code Generation
|
||||
|
||||
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
|
||||
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
|
||||
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
|
||||
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
|
||||
| `generated.channels.ts` | Channel message types |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
|
||||
| `index.ts` | Consolidated re-exports |
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors from server functions are thrown as `DjangoError`:
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await echo({ text: 'hello' })
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
|
||||
e.message // Human-readable message
|
||||
e.details // Field-level validation errors, etc.
|
||||
e.isAuthError()
|
||||
e.isValidationError()
|
||||
e.getFieldErrors('email')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
|
||||
|
||||
## Forms
|
||||
|
||||
Django forms get typed React hooks with client-side Zod validation:
|
||||
|
||||
```python
|
||||
# Django
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
live_validation=True,
|
||||
)
|
||||
name = forms.CharField(max_length=100)
|
||||
email = forms.EmailField()
|
||||
message = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
def on_submit_success(self, request):
|
||||
send_email(self.cleaned_data)
|
||||
return {"sent": True}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const form = useContactForm()
|
||||
|
||||
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
|
||||
form.data // { name: '', email: '', message: '' }
|
||||
form.set('email', v) // typed setter
|
||||
form.errors // field-level errors (Zod + server)
|
||||
form.submit() // → { success: true, data: { sent: true } }
|
||||
```
|
||||
|
||||
## Channels
|
||||
|
||||
WebSocket channels with typed messages:
|
||||
|
||||
```python
|
||||
# Django
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
class ReactMessage(BaseModel):
|
||||
text: str
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
user: str
|
||||
|
||||
def authorize(self, params):
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(text=msg.text, user=self.user.email)
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const chat = useChatChannel({ room: 'general' })
|
||||
|
||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
chat.messages // ChatDjangoMessage[]
|
||||
chat.send({ text: 'hello' })
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Django unit tests
|
||||
cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
|
||||
|
||||
# React unit tests
|
||||
cd packages/mizan-react && npm test
|
||||
|
||||
# E2E integration tests (real browser, real backend)
|
||||
docker compose -f examples/django-react-site/docker-compose.test.yml up -d
|
||||
cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 &
|
||||
npx playwright test
|
||||
|
||||
# All at once
|
||||
make test-all
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mizan/
|
||||
packages/
|
||||
mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
|
||||
mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
|
||||
mizan-react/ React adapter (thin wrapper around runtime)
|
||||
examples/
|
||||
django-react-site/ E2E tests + Django backend
|
||||
django-react-desktop-app/ PyWebView desktop app
|
||||
```
|
||||
Mizan is licensed under the [Elastic License 2.0](LICENSE) (SPDX: `Elastic-2.0`). You
|
||||
may use, copy, modify, and distribute it freely, including in commercial products you
|
||||
build on top of it. You may **not** provide Mizan to third parties as a hosted or
|
||||
managed service that exposes a substantial set of its features.
|
||||
|
||||
186
ROADMAP.md
186
ROADMAP.md
@@ -1,88 +1,51 @@
|
||||
# Mizan Roadmap
|
||||
|
||||
## v1 — Django + React
|
||||
## v1 — Django + Multi-Framework (React, Vue, Svelte)
|
||||
|
||||
### Done
|
||||
|
||||
- **@client decorator** — `context=`, `affects=`, `auth=`, `websocket=`
|
||||
- **ReactContext class** — type-safe context/affects references with linting
|
||||
- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch
|
||||
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
|
||||
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
|
||||
- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]`
|
||||
- **Param elevation** — shared params become required provider props, non-shared become optional
|
||||
- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen
|
||||
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
|
||||
- **JWT + session auth** — auto-detected, CSRF handled
|
||||
- **Shapes** — Pydantic + django-readers for typed query projections
|
||||
- **WebSocket channels** — real-time bidirectional communication
|
||||
- **Codegen** — generates typed React providers, hooks, mutations from schema
|
||||
- **CDN-ready headers** — `Cache-Control`, deterministic JSON on context GETs, `no-store` on mutations
|
||||
- [x] **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
|
||||
- [x] **`ReactContext` class** — type-safe context/affects references with linting
|
||||
- [x] **Named contexts** — functions sharing a context name grouped into one provider and one fetch
|
||||
- [x] **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
|
||||
- [x] **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
|
||||
- [x] **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
|
||||
- [x] **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
|
||||
- [x] **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form
|
||||
- [x] **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
|
||||
- [x] **JWT + session auth** — auto-detected, CSRF handled
|
||||
- [x] **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
|
||||
- [x] **Shapes** — Pydantic + django-readers for typed query projections
|
||||
- [x] **WebSocket channels** — real-time bidirectional communication
|
||||
- [x] **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
|
||||
- [x] **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions; deterministic (sorted) output
|
||||
- [x] **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC; the worker resolves components by file path (`import(file)` + `renderToString`)
|
||||
- [x] **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
|
||||
- [x] **Rust codegen** — `protocol/mizan-codegen`, a Rust binary reading KDL IR and emitting per-target clients (react, vue, svelte, channels, stage1, python, rust), each byte-parity-tested. `mizan-generate` is the thin npm launcher.
|
||||
- [x] **React wrapper layer** — codegen emits the `MizanContext` root provider, `useMizan` escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`
|
||||
- [x] **Additional backend adapters** — `mizan-ts` (TypeScript), `mizan-rust-axum` (Rust/Axum with three-way parity), `mizan-tauri`
|
||||
- [x] **Frontend transports** — `mizan-tauri-transport`, `mizan-webview-transport`, `mizan-webview-channels`
|
||||
|
||||
### Next: X-Mizan-Invalidate Header
|
||||
---
|
||||
|
||||
Second invalidation transport. For view responses (redirects, HTML), invalidation goes in an HTTP header instead of the JSON body. Both transports are first-class AFI spec.
|
||||
### Next
|
||||
|
||||
- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications`
|
||||
- Comma-separated contexts, semicolon-separated params per context
|
||||
- Decorator auto-adds header to any HttpResponse with `affects=`
|
||||
- Edge reads this header to purge cached pages
|
||||
- Runtime also reads it on XHR/fetch responses (htmx path)
|
||||
- [ ] **Vue / Svelte runtime packages** — `frontends/mizan-vue` and `frontends/mizan-svelte` are unimplemented stubs. The codegen emits their clients (byte-parity-tested), but a kernel-adapter runtime package and a live-backend example are owed for each.
|
||||
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
||||
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
||||
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
||||
- [ ] **Cache hardening** — 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`).
|
||||
|
||||
### Next: Return-Type Branching
|
||||
---
|
||||
|
||||
`@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior:
|
||||
## Core Consolidation — Rust Binary
|
||||
|
||||
- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body.
|
||||
- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header.
|
||||
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.
|
||||
|
||||
Same decorator. Same `affects=`. Same invalidation graph. Two paths.
|
||||
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.
|
||||
|
||||
### Next: affects_params
|
||||
|
||||
Scoped invalidation with a lambda that extracts which params were affected:
|
||||
|
||||
```python
|
||||
@client(affects='user', affects_params=lambda req: {'user_id': req.user.pk})
|
||||
def update_name(request, name: str) -> dict:
|
||||
...
|
||||
```
|
||||
|
||||
Produces `invalidate: [{context: "user", params: {user_id: 5}}]` in JSON body or `X-Mizan-Invalidate: user;user_id=5` in header.
|
||||
|
||||
### Next: Edge Manifest
|
||||
|
||||
`mizan-generate --manifest` compiles the decorator registry + Django URL conf into static JSON for Edge:
|
||||
|
||||
```json
|
||||
{
|
||||
"contexts": {
|
||||
"user": {
|
||||
"endpoints": ["/api/mizan/ctx/user/"],
|
||||
"views": ["/profile/:user_id/"],
|
||||
"params": ["user_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Edge reads the manifest at deploy time. When it receives `X-Mizan-Invalidate: user;user_id=5`, it resolves URL patterns with params and purges `/profile/5/` and `/api/mizan/ctx/user/?user_id=5`.
|
||||
|
||||
Generated alongside React code. Covers both RPC and view-path functions.
|
||||
|
||||
### Next: Codegen Rewrite
|
||||
|
||||
Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response.
|
||||
|
||||
### Next: SSR Bridge
|
||||
|
||||
Django renders React components server-side via a persistent Bun subprocess.
|
||||
|
||||
- Bun worker: stdin/stdout JSON-RPC, `renderToString`, component registry
|
||||
- Django bridge: subprocess management, IPC, request synthesis
|
||||
- Template tag: `{% mizan_render "ProfilePage" user_profile=profile %}`
|
||||
- Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers
|
||||
- Generated contexts check SSR data before first fetch
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -92,7 +55,7 @@ Django renders React components server-side via a persistent Bun subprocess.
|
||||
|
||||
Cloudflare Workers for automatic edge caching.
|
||||
|
||||
- Reads the Edge manifest to configure cache rules
|
||||
- Reads the edge manifest to configure cache rules
|
||||
- Context GETs cached at edge, keyed by context name + params
|
||||
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
|
||||
- Reads JSON `invalidate` key from RPC responses for the same purpose
|
||||
@@ -118,74 +81,13 @@ One-command deployment for Django + React apps.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Spec (AFI)
|
||||
## Reference
|
||||
|
||||
The protocol is the product. Two invalidation transports. Every endpoint CDN-ready.
|
||||
Wire protocol shapes (context fetch, mutation call, invalidation transports) are documented in `CLAUDE.md`. Architectural details for specific subsystems live in `docs/`:
|
||||
|
||||
### Context fetch
|
||||
|
||||
```
|
||||
GET /api/mizan/ctx/<name>/?param=value
|
||||
|
||||
200 OK
|
||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
||||
|
||||
{
|
||||
"function_a": { ... },
|
||||
"function_b": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation call (RPC path — JSON body transport)
|
||||
|
||||
```
|
||||
POST /api/mizan/call/
|
||||
Cache-Control: no-store
|
||||
|
||||
{
|
||||
"result": { ... },
|
||||
"invalidate": ["context_name"]
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation call (View path — header transport)
|
||||
|
||||
```
|
||||
POST /profile/update/
|
||||
302 Found
|
||||
Location: /profile/5/
|
||||
Cache-Control: no-store
|
||||
X-Mizan-Invalidate: user;user_id=5, notifications
|
||||
```
|
||||
|
||||
### Scoped invalidation (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { ... },
|
||||
"invalidate": [
|
||||
"notifications",
|
||||
{ "context": "user", "params": { "user_id": 5 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Scoped invalidation (Header)
|
||||
|
||||
```
|
||||
X-Mizan-Invalidate: user;user_id=5, notifications
|
||||
```
|
||||
|
||||
### Edge manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"contexts": {
|
||||
"user": {
|
||||
"endpoints": ["/api/mizan/ctx/user/"],
|
||||
"views": ["/profile/:user_id/"],
|
||||
"params": ["user_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `docs/AFI_ARCHITECTURE.md` — package architecture, kernel model, adapter strategy
|
||||
- `docs/CACHE_KEYING.md` — HMAC cache key derivation
|
||||
- `docs/MWT_SPEC.md` — Mizan Web Token format
|
||||
- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge
|
||||
- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer
|
||||
- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers
|
||||
|
||||
206
backends/mizan-django/README.md
Normal file
206
backends/mizan-django/README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# mizan-django
|
||||
|
||||
Django backend adapter for the Mizan protocol. One decorator on a server
|
||||
function. Typed React client generated. Invalidation automatic.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
uv add "mizan[channels]"
|
||||
# or with allauth integration:
|
||||
uv add "mizan[channels,allauth]"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = ["mizan", "myapp", ...]
|
||||
|
||||
MIZAN_CACHE_SECRET = "..." # 32-byte HMAC signing key
|
||||
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
|
||||
MIZAN_MWT_SECRET = "..." # MWT signing key (separate from cache + JWT)
|
||||
```
|
||||
|
||||
```python
|
||||
# urls.py
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
# asgi.py — for WebSocket / Channels support
|
||||
from django.core.asgi import get_asgi_application
|
||||
from mizan import wrap_asgi
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
## Define server functions
|
||||
|
||||
```python
|
||||
# myapp/clients.py
|
||||
from mizan.client import client
|
||||
from mizan.setup import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
|
||||
register(echo, "echo")
|
||||
```
|
||||
|
||||
Auto-discover `clients.py` modules from each Django app:
|
||||
|
||||
```python
|
||||
# myapp/apps.py
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MyAppConfig(AppConfig):
|
||||
name = "myapp"
|
||||
|
||||
def ready(self) -> None:
|
||||
from mizan.setup import mizan_clients
|
||||
mizan_clients("myapp") # imports myapp/clients.py — triggers @client side effects
|
||||
```
|
||||
|
||||
## `@client` parameters
|
||||
|
||||
```python
|
||||
@client # plain RPC function
|
||||
@client(context="global") # singleton context — fetched once, SSR-hydrated
|
||||
@client(context="user") # named context — fetched per provider mount
|
||||
@client(affects="user") # mutation — invalidates the user context
|
||||
@client(affects=user_profile) # mutation — invalidates a specific function
|
||||
@client(websocket=True) # WebSocket transport (requires channels)
|
||||
@client(auth=True) # requires authentication
|
||||
@client(auth="staff") # requires is_staff
|
||||
@client(auth="superuser") # requires is_superuser
|
||||
@client(auth=lambda req: ...) # custom predicate
|
||||
@client(route="/profile/<id>/") # view-path function (returns HttpResponse)
|
||||
@client(rev=2) # cache revision (busts on bump)
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
Django Forms become server functions + typed React hooks with Zod validation:
|
||||
|
||||
```python
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="contact", title="Contact Us", submit_label="Send")
|
||||
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
message = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
def on_submit_success(self, request):
|
||||
send_email(self.cleaned_data)
|
||||
return {"sent": True}
|
||||
```
|
||||
|
||||
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
||||
gets `useContactForm()`.
|
||||
|
||||
## Channels
|
||||
|
||||
WebSocket-native RPC via a flag flip:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
user: str
|
||||
|
||||
def authorize(self, params):
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params):
|
||||
return f"chat_{params.room}"
|
||||
```
|
||||
|
||||
Frontend gets `useChatChannel({ room })`.
|
||||
|
||||
## Generate the frontend
|
||||
|
||||
The codegen is the `mizan-generate` Rust binary (source at
|
||||
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
|
||||
launcher that dispatches to the platform binary). From your frontend
|
||||
project, point a `mizan.toml` at the Django backend and run the CLI:
|
||||
|
||||
```toml
|
||||
# frontend/mizan.toml
|
||||
output = "src/api"
|
||||
targets = ["react"]
|
||||
|
||||
[source.django]
|
||||
manage_path = "../backend/manage.py"
|
||||
command = ["uv", "run", "python"] # optional — defaults to ["python"]
|
||||
|
||||
[source.django.env]
|
||||
PYTHONPATH = "../backend"
|
||||
DJANGO_SETTINGS_MODULE = "myproject.settings"
|
||||
```
|
||||
|
||||
```bash
|
||||
mizan-generate --config mizan.toml
|
||||
```
|
||||
|
||||
The codegen drives Django's management command (`export_mizan_ir`) under
|
||||
the hood, parses the emitted KDL IR, then emits Stage 1 (typed
|
||||
`callXxx`/`fetchXxx` over the runtime kernel) + Stage 2 (`<MizanContext>`
|
||||
provider, per-context providers, `use{Hook}()` hooks) into `src/api/`.
|
||||
|
||||
```tsx
|
||||
// app.tsx
|
||||
import { MizanContext } from "./api"
|
||||
|
||||
export default function App({ children }) {
|
||||
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// any component
|
||||
import { useEcho, useCurrentUser } from "./api"
|
||||
|
||||
const echo = useEcho()
|
||||
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
|
||||
|
||||
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
uv sync --extra dev --extra channels
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
mizan-django is one of two reference backend adapters (the other is
|
||||
`backends/mizan-fastapi`). Both implement the same Mizan protocol on top of
|
||||
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
|
||||
keys). See `docs/AFI_ARCHITECTURE.md`.
|
||||
@@ -1,10 +1,12 @@
|
||||
[project]
|
||||
name = "mizan"
|
||||
version = "1.0.1"
|
||||
license = "Elastic-2.0"
|
||||
description = "Django + React server functions framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mizan-core",
|
||||
"django>=5.0",
|
||||
"django-ninja>=1.0",
|
||||
"django-readers>=2.0",
|
||||
@@ -12,6 +14,9 @@ dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
cache = [
|
||||
"redis>=5.0",
|
||||
40
backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md
vendored
Normal file
40
backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Cache Module — Known Issues
|
||||
|
||||
Open issues against the current cache implementation. Resolved items are
|
||||
removed once their fix lands.
|
||||
|
||||
## Correctness
|
||||
|
||||
### Purge race condition (non-atomic index operations)
|
||||
`cache_purge` reads the index and deletes as separate operations. A
|
||||
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||
|
||||
### Cross-language stringification divergence
|
||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||
value types are not yet pinned in the protocol spec — so Python and
|
||||
TypeScript HMAC keys can still diverge on an un-normalized type.
|
||||
|
||||
## Performance / Operability
|
||||
|
||||
### Broad purge leaves per-param sub-indexes
|
||||
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||
sub-indexes — a slow Redis memory leak.
|
||||
|
||||
### No thundering-herd protection
|
||||
Concurrent cold misses on the same key all execute and write. No
|
||||
single-flight / request-coalescing.
|
||||
|
||||
## API shape
|
||||
|
||||
### cache_get / cache_put argument inconsistency
|
||||
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||
inputs from module globals — two access patterns for one concern.
|
||||
|
||||
## Coverage
|
||||
|
||||
### RedisCache lacks test coverage
|
||||
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||
142
backends/mizan-django/src/mizan/cache/__init__.py
vendored
Normal file
142
backends/mizan-django/src/mizan/cache/__init__.py
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
||||
|
||||
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
||||
Scoped purge recomputes the key and deletes directly.
|
||||
Broad purge uses key-prefix scan (rare operation).
|
||||
|
||||
Usage:
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.cache.backend import CacheBackend, MemoryCache, RedisCache
|
||||
from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX
|
||||
|
||||
logger = logging.getLogger("mizan.cache")
|
||||
|
||||
_cache_instance: CacheBackend | None = None
|
||||
_initialized = False
|
||||
_init_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_cache() -> CacheBackend | None:
|
||||
"""
|
||||
Get the configured cache backend, or None if caching is disabled.
|
||||
Thread-safe.
|
||||
"""
|
||||
global _cache_instance, _initialized
|
||||
if _initialized:
|
||||
return _cache_instance
|
||||
|
||||
with _init_lock:
|
||||
if _initialized:
|
||||
return _cache_instance
|
||||
|
||||
_initialized = True
|
||||
try:
|
||||
from mizan.setup.settings import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.cache_secret and settings.cache_redis_url:
|
||||
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||
elif settings.cache_secret and not settings.cache_redis_url:
|
||||
logger.warning(
|
||||
"MIZAN_CACHE_SECRET is set but MIZAN_CACHE_REDIS_URL is missing. "
|
||||
"Cache is disabled."
|
||||
)
|
||||
elif settings.cache_redis_url and not settings.cache_secret:
|
||||
logger.warning(
|
||||
"MIZAN_CACHE_REDIS_URL is set but MIZAN_CACHE_SECRET is missing. "
|
||||
"Cache is disabled."
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to initialize Mizan cache", exc_info=True)
|
||||
_cache_instance = None
|
||||
|
||||
return _cache_instance
|
||||
|
||||
|
||||
def set_cache(backend: CacheBackend | None) -> None:
|
||||
"""Override the cache backend. For testing."""
|
||||
global _cache_instance, _initialized
|
||||
_cache_instance = backend
|
||||
_initialized = True
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset to uninitialized state. For testing teardown."""
|
||||
global _cache_instance, _initialized
|
||||
_cache_instance = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
def cache_get(
|
||||
secret: str,
|
||||
backend: CacheBackend,
|
||||
context: str,
|
||||
params: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
rev: int = 0,
|
||||
) -> bytes | None:
|
||||
"""Look up a cached context response."""
|
||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||
return backend.get(key)
|
||||
|
||||
|
||||
def cache_put(
|
||||
secret: str,
|
||||
backend: CacheBackend,
|
||||
context: str,
|
||||
params: dict[str, Any],
|
||||
value: bytes,
|
||||
user_id: str | None = None,
|
||||
rev: int = 0,
|
||||
) -> None:
|
||||
"""Store a context response in the cache."""
|
||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||
backend.set(key, value)
|
||||
|
||||
|
||||
def cache_purge(
|
||||
backend: CacheBackend,
|
||||
context: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
secret: str | None = None,
|
||||
user_id: str | None = None,
|
||||
rev: int = 0,
|
||||
) -> int:
|
||||
"""
|
||||
Purge cached entries for a context.
|
||||
|
||||
Scoped purge (params provided): recomputes the HMAC key and deletes
|
||||
it directly. One DELETE, no index needed.
|
||||
|
||||
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
||||
This is a rare operation (Tier 3 fallback in invalidation).
|
||||
"""
|
||||
if params is not None and len(params) > 0 and secret:
|
||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||
return 1 if backend.delete(key) else 0
|
||||
else:
|
||||
prefix = f"{CONTEXT_KEY_PREFIX}{context}:"
|
||||
return backend.delete_by_prefix(prefix)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CacheBackend",
|
||||
"MemoryCache",
|
||||
"RedisCache",
|
||||
"get_cache",
|
||||
"set_cache",
|
||||
"reset_cache",
|
||||
"cache_get",
|
||||
"cache_put",
|
||||
"cache_purge",
|
||||
]
|
||||
@@ -523,6 +523,47 @@ def __getattr__(name):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Core Registry Extension
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _ChannelsExtension:
|
||||
"""
|
||||
Plugs the channel registry into mizan_core.registry as the 'channels'
|
||||
extension. Schema output goes under schema['channels'] in the unified
|
||||
registry export consumed by codegen.
|
||||
"""
|
||||
|
||||
def all(self) -> dict:
|
||||
return dict(_registry)
|
||||
|
||||
def schema(self) -> dict:
|
||||
out: dict[str, Any] = {}
|
||||
for name, channel_class in _registry.items():
|
||||
channel_schema: dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": "channel",
|
||||
"bidirectional": False,
|
||||
}
|
||||
if getattr(channel_class, "Params", None):
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
if getattr(channel_class, "ReactMessage", None):
|
||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||
channel_schema["bidirectional"] = True
|
||||
if getattr(channel_class, "DjangoMessage", None):
|
||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||
out[name] = channel_schema
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
from mizan_core.registry import register_extension as _register_extension
|
||||
_register_extension("channels", _ChannelsExtension())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exports
|
||||
# =============================================================================
|
||||
@@ -400,7 +400,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
- User context from WebSocket session is passed to function
|
||||
"""
|
||||
from mizan.client.executor import execute_function, FunctionError
|
||||
from mizan.setup.registry import get_function
|
||||
from mizan_core.registry import get_function
|
||||
|
||||
request_id = content.get("id")
|
||||
fn_name = content.get("fn")
|
||||
@@ -2,16 +2,24 @@
|
||||
mizan.client - Server function implementation.
|
||||
|
||||
This subpackage contains everything needed to make server functions work:
|
||||
- The @client decorator
|
||||
- ServerFunction base class
|
||||
- Function execution logic
|
||||
- JWT authentication (integral to server functions)
|
||||
- The @client decorator (lives in mizan_core.client.function)
|
||||
- ServerFunction base class (mizan_core.client.function)
|
||||
- Function execution logic (.executor — Django-specific dispatch)
|
||||
- JWT authentication (.jwt — Django-specific session integration)
|
||||
|
||||
Usage:
|
||||
from mizan.client import client, ServerFunction, compose
|
||||
"""
|
||||
|
||||
from .function import (
|
||||
# Register the Django framework response base so view-path detection works
|
||||
# in mizan_core.client.function. Has to happen before any @client-decorated
|
||||
# code is evaluated.
|
||||
from django.http import HttpResponseBase as _HttpResponseBase
|
||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||
_set_response_base(_HttpResponseBase)
|
||||
|
||||
|
||||
from mizan_core.client.function import (
|
||||
# Decorator
|
||||
client,
|
||||
# Context markers
|
||||
@@ -23,12 +23,12 @@ from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
from mizan.setup.registry import get_function, get_context_groups
|
||||
from mizan_core.registry import get_function, get_context_groups
|
||||
from mizan.setup.settings import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -161,6 +161,42 @@ def _check_auth_requirement(
|
||||
return None
|
||||
|
||||
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
|
||||
|
||||
def _purge_cache_for_invalidation(
|
||||
invalidate: list,
|
||||
request: HttpRequest | None = None,
|
||||
) -> None:
|
||||
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
|
||||
cache = get_cache()
|
||||
if cache is None:
|
||||
return
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.cache_secret:
|
||||
return
|
||||
|
||||
user_id = None
|
||||
if request and hasattr(request, 'user') and hasattr(request.user, 'pk'):
|
||||
uid = getattr(request.user, 'pk', None)
|
||||
if uid is not None:
|
||||
user_id = str(uid)
|
||||
|
||||
try:
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
cache_purge(cache, entry)
|
||||
elif isinstance(entry, dict):
|
||||
cache_purge(
|
||||
cache, entry["context"], entry.get("params"),
|
||||
secret=settings.cache_secret,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
_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.
|
||||
@@ -269,6 +305,75 @@ def _resolve_invalidation(
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _resolve_merges(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None,
|
||||
result_data: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve merge targets from @client(merge=...).
|
||||
|
||||
Each entry is `{context, slot, value, params?}` — `slot` is the
|
||||
function-name inside the context bundle the value lands in, resolved
|
||||
server-side by matching the mutation's return type against each
|
||||
context-function's return type. Kernel does no shape inference.
|
||||
|
||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||
Entries whose slot can't be uniquely resolved are dropped.
|
||||
"""
|
||||
if view_class is None:
|
||||
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 _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
matches: list[str] = []
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and type_matcher(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
@@ -310,7 +415,7 @@ def execute_function(
|
||||
request: HttpRequest,
|
||||
fn_name: str,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> FunctionResult | FunctionError:
|
||||
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||
"""
|
||||
Execute a registered server function.
|
||||
|
||||
@@ -444,17 +549,60 @@ def execute_function(
|
||||
from django.http import HttpResponseBase
|
||||
|
||||
if isinstance(output, HttpResponseBase):
|
||||
# View path — add invalidation header, pass through the response
|
||||
# View path — add invalidation header + purge origin cache
|
||||
invalidate = _resolve_invalidation(view_class, input_data)
|
||||
if invalidate:
|
||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||
_purge_cache_for_invalidation(invalidate, request)
|
||||
output["Cache-Control"] = "no-store"
|
||||
return output
|
||||
|
||||
# RPC path — serialize output
|
||||
if output is None:
|
||||
return FunctionResult(data=None)
|
||||
return FunctionResult(data=output.model_dump())
|
||||
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
||||
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
||||
# out wire-ready without a per-shape branch.
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
return FunctionResult(data=to_jsonable_python(output))
|
||||
|
||||
|
||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Attempt to authenticate the request using MWT (Mizan Web Token).
|
||||
|
||||
Checks the X-Mizan-Token header. If present and valid, sets request.user
|
||||
to an MWTUser. Returns True on success, False if no MWT header or invalid.
|
||||
"""
|
||||
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
||||
if not token:
|
||||
return False
|
||||
|
||||
try:
|
||||
settings = get_settings()
|
||||
if not settings.mwt_secret:
|
||||
logging.getLogger("mizan.mwt").warning(
|
||||
"X-Mizan-Token header present but MIZAN_MWT_SECRET is not configured"
|
||||
)
|
||||
return False
|
||||
|
||||
from mizan_core.mwt import decode_mwt, MWTUser
|
||||
|
||||
payload = decode_mwt(token, settings.mwt_secret)
|
||||
if payload is None:
|
||||
return False
|
||||
|
||||
request.user = MWTUser(payload)
|
||||
request._mizan_mwt_authenticated = True
|
||||
return True
|
||||
except Exception:
|
||||
logging.getLogger("mizan.mwt").warning(
|
||||
"MWT authentication failed unexpectedly", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _has_mwt_header(request: HttpRequest) -> bool:
|
||||
"""Check if request has an X-Mizan-Token header."""
|
||||
return bool(request.META.get("HTTP_X_MIZAN_TOKEN", ""))
|
||||
|
||||
|
||||
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
@@ -502,43 +650,45 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
||||
return auth_header.startswith("Bearer ")
|
||||
|
||||
|
||||
def _csrf_protect_unless_jwt(view_func):
|
||||
def _csrf_protect_unless_token(view_func):
|
||||
"""
|
||||
Decorator that applies CSRF protection unless JWT auth is used.
|
||||
Decorator that applies CSRF protection unless token auth is used.
|
||||
|
||||
JWT tokens are self-authenticating (the token itself proves the request
|
||||
is legitimate), so CSRF protection is not needed.
|
||||
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
||||
Both are self-authenticating, so CSRF protection is not needed.
|
||||
|
||||
Security: If JWT is provided but invalid, reject the request - do NOT
|
||||
fall back to session auth. This prevents attacks where an invalid token
|
||||
is sent alongside a valid session cookie.
|
||||
Security: If a token is provided but invalid, reject the request - do NOT
|
||||
fall back to session auth.
|
||||
"""
|
||||
csrf_protected_view = csrf_protect(view_func)
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
# Check if JWT header is present
|
||||
has_jwt = _has_jwt_header(request)
|
||||
|
||||
if has_jwt:
|
||||
# JWT header present - try to authenticate
|
||||
if _try_jwt_auth(request):
|
||||
# JWT valid - skip CSRF, proceed
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
else:
|
||||
# JWT invalid - reject (do NOT fall back to session)
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
else:
|
||||
# No JWT - use session auth with CSRF
|
||||
|
||||
# No token — session auth with CSRF
|
||||
return csrf_protected_view(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@_csrf_protect_unless_jwt
|
||||
@_csrf_protect_unless_token
|
||||
def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
"""
|
||||
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
||||
@@ -652,29 +802,19 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
view_class = get_function(fn_name)
|
||||
response_data = {"result": result.data}
|
||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||
merges = _resolve_merges(view_class, input_data, result.data)
|
||||
|
||||
if invalidate_contexts:
|
||||
response_data["invalidate"] = invalidate_contexts
|
||||
if merges:
|
||||
response_data["merge"] = merges
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Always set the header transport too (Edge reads this)
|
||||
if invalidate_contexts:
|
||||
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
||||
|
||||
# Purge origin-side cache for invalidated contexts
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
cache = get_cache()
|
||||
if cache is not None:
|
||||
try:
|
||||
for entry in invalidate_contexts:
|
||||
if isinstance(entry, str):
|
||||
cache_purge(cache, entry)
|
||||
elif isinstance(entry, dict):
|
||||
cache_purge(cache, entry["context"], entry.get("params"))
|
||||
except Exception:
|
||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
||||
_purge_cache_for_invalidation(invalidate_contexts, request)
|
||||
|
||||
return response
|
||||
|
||||
@@ -732,20 +872,30 @@ def execute_context(
|
||||
|
||||
def _jwt_auth_only(view_func):
|
||||
"""
|
||||
Decorator that handles JWT auth for GET endpoints (no CSRF needed for GET).
|
||||
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
|
||||
Checks MWT first, then legacy JWT.
|
||||
"""
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
has_jwt = _has_jwt_header(request)
|
||||
if has_jwt:
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
# No JWT — session auth (no CSRF needed for GET)
|
||||
|
||||
# No token — session auth (no CSRF needed for GET)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -775,22 +925,48 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
|
||||
params = request.GET.dict()
|
||||
|
||||
# Origin-side cache lookup
|
||||
# Resolve effective rev and cache policy across all functions in this context
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
cache = get_cache()
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
effective_rev = 0
|
||||
effective_cache: 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:
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
fn_rev = meta.get("rev", 0)
|
||||
effective_rev = max(effective_rev, fn_rev)
|
||||
fn_cache = meta.get("cache", True)
|
||||
if fn_cache is False:
|
||||
effective_cache = False
|
||||
break
|
||||
elif isinstance(fn_cache, int):
|
||||
if effective_cache is True:
|
||||
effective_cache = fn_cache
|
||||
else:
|
||||
effective_cache = min(effective_cache, fn_cache)
|
||||
|
||||
# Origin-side cache lookup (skip if cache=False)
|
||||
cache_backend = get_cache()
|
||||
cache_settings = get_settings()
|
||||
user_id = None
|
||||
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
|
||||
user_id = str(request.user.pk)
|
||||
if cache is not None and cache_settings.cache_secret:
|
||||
use_cache = (
|
||||
cache_backend is not None
|
||||
and cache_settings.cache_secret
|
||||
and effective_cache is not False
|
||||
)
|
||||
if use_cache:
|
||||
try:
|
||||
cached = cache_get(
|
||||
cache_settings.cache_secret, cache, context_name, params,
|
||||
user_id=user_id,
|
||||
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||
user_id=user_id, rev=effective_rev,
|
||||
)
|
||||
if cached is not None:
|
||||
response = HttpResponse(cached, content_type="application/json")
|
||||
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["X-Mizan-Cache"] = "HIT"
|
||||
return response
|
||||
except Exception:
|
||||
@@ -815,19 +991,16 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||
|
||||
# CDN-ready headers
|
||||
# max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
|
||||
# s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
|
||||
# No Vary header — Cloudflare ignores Vary for personalized content.
|
||||
# User-scoped cache keying will use HMAC-based keys instead.
|
||||
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
||||
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
||||
# The browser and non-Mizan intermediaries must not cache.
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Store in origin-side cache
|
||||
if cache is not None and cache_settings.cache_secret:
|
||||
# Store in origin-side cache (skip if cache=False)
|
||||
if use_cache:
|
||||
try:
|
||||
cache_put(
|
||||
cache_settings.cache_secret, cache, context_name, params,
|
||||
response.content, user_id=user_id,
|
||||
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||
response.content, user_id=user_id, rev=effective_rev,
|
||||
)
|
||||
response["X-Mizan-Cache"] = "MISS"
|
||||
except Exception:
|
||||
156
backends/mizan-django/src/mizan/export/__init__.py
Normal file
156
backends/mizan-django/src/mizan/export/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator.
|
||||
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
"""
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
|
||||
groups = get_context_groups()
|
||||
registry = get_registry()
|
||||
all_functions = registry.get("functions", {})
|
||||
|
||||
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||
|
||||
for ctx_name, fn_names in sorted(groups.items()):
|
||||
param_names: set[str] = set()
|
||||
functions_meta: list[dict[str, Any]] = []
|
||||
page_routes: list[str] = []
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = all_functions.get(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
for param_name in input_cls.model_fields:
|
||||
param_names.add(param_name)
|
||||
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
route = meta.get("route")
|
||||
view_path = meta.get("view_path")
|
||||
|
||||
fn_entry: dict[str, Any] = {
|
||||
"name": fn_name,
|
||||
"path": "view" if view_path else "rpc",
|
||||
}
|
||||
if route:
|
||||
fn_entry["route"] = route
|
||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||
page_routes.append(route)
|
||||
if meta.get("rev"):
|
||||
fn_entry["rev"] = meta["rev"]
|
||||
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||
fn_entry["cache"] = meta["cache"]
|
||||
functions_meta.append(fn_entry)
|
||||
|
||||
sorted_params = sorted(param_names)
|
||||
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||
|
||||
ctx_entry: dict[str, Any] = {
|
||||
"functions": functions_meta,
|
||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||
"params": sorted_params,
|
||||
"user_scoped": user_scoped,
|
||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||
}
|
||||
|
||||
if page_routes:
|
||||
ctx_entry["page_routes"] = page_routes
|
||||
if view_urls and ctx_name in view_urls:
|
||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||
|
||||
manifest["contexts"][ctx_name] = ctx_entry
|
||||
|
||||
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
if not meta.get("affects"):
|
||||
continue
|
||||
|
||||
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||
|
||||
# Auto-scoped params — function params that match context params
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
fn_params = set(input_cls.model_fields.keys())
|
||||
auto_scoped: list[str] = []
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_param_names: set[str] = set()
|
||||
ctx_fns = groups.get(ctx_name, [])
|
||||
for ctx_fn_name in ctx_fns:
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls is None:
|
||||
continue
|
||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||
for p in fn_params:
|
||||
if p in ctx_param_names and p not in auto_scoped:
|
||||
auto_scoped.append(p)
|
||||
if auto_scoped:
|
||||
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||
|
||||
if meta.get("private"):
|
||||
mutation["private"] = True
|
||||
if meta.get("route"):
|
||||
mutation["route"] = meta["route"]
|
||||
mutation["methods"] = meta.get("methods", ["POST"])
|
||||
|
||||
manifest["mutations"][fn_name] = mutation
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def generate_edge_manifest_json(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
indent: int = 2,
|
||||
) -> str:
|
||||
"""JSON-serialize the Edge manifest."""
|
||||
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||
@@ -292,8 +292,8 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import validate_form_instance
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
config: mizanFormMeta = form_class.mizan
|
||||
form_name = config.name
|
||||
@@ -484,8 +484,8 @@ def _register_formset_functions(
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import build_formset_validation
|
||||
from .formset_utils import forms_to_formset_post_data
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
formset_class = formset_factory(form_class)
|
||||
|
||||
@@ -630,3 +630,48 @@ def _register_formset_functions(
|
||||
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
|
||||
FormsetSubmitFunction.Output = FormsetSubmitPass
|
||||
register(FormsetSubmitFunction, f"{form_name}.formset.submit")
|
||||
|
||||
|
||||
# ─── Public helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def register_form(
|
||||
form_class: type,
|
||||
name: str,
|
||||
submit_handler: Any = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a Django Form class as Mizan server functions.
|
||||
|
||||
Creates and registers `{name}.schema`, `{name}.validate`, and
|
||||
`{name}.submit` (if a submit_handler is provided).
|
||||
"""
|
||||
from mizan_core.client.function import create_form_functions
|
||||
from mizan_core.registry import register
|
||||
|
||||
schema_fn, validate_fn, submit_fn = create_form_functions(
|
||||
form_class, name, submit_handler
|
||||
)
|
||||
register(schema_fn, f"{name}.schema")
|
||||
register(validate_fn, f"{name}.validate")
|
||||
if submit_fn:
|
||||
register(submit_fn, f"{name}.submit")
|
||||
|
||||
|
||||
def get_forms() -> dict[str, list]:
|
||||
"""
|
||||
Group registered form-related functions by their form name.
|
||||
|
||||
Returns a mapping like:
|
||||
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
||||
"""
|
||||
from mizan_core.registry import get_all_functions
|
||||
|
||||
forms: dict[str, list] = {}
|
||||
for name, cls in get_all_functions().items():
|
||||
meta = getattr(cls, "_meta", {})
|
||||
if not meta.get("form"):
|
||||
continue
|
||||
form_name = meta.get("form_name")
|
||||
forms.setdefault(form_name, []).append(cls)
|
||||
return forms
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
JWT Server Functions
|
||||
JWT & MWT Server Functions
|
||||
|
||||
JWT token operations exposed as mizan server functions.
|
||||
Token operations exposed as mizan server functions.
|
||||
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
|
||||
from mizan.client import client
|
||||
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||
from mizan_core.mwt import create_mwt
|
||||
|
||||
|
||||
class TokenPairOutput(BaseModel):
|
||||
@@ -99,3 +100,42 @@ def jwt_refresh(request: HttpRequest, refresh_token: str) -> TokenPairOutput:
|
||||
refresh_token=tokens.refresh_token,
|
||||
expires_in=tokens.expires_in,
|
||||
)
|
||||
|
||||
|
||||
# ── MWT (Mizan Web Token) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class MWTOutput(BaseModel):
|
||||
"""MWT token response."""
|
||||
token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@client
|
||||
def mwt_obtain(request: HttpRequest) -> MWTOutput:
|
||||
"""
|
||||
Obtain a Mizan Web Token from an authenticated session.
|
||||
|
||||
Requires session authentication (cookie-based login).
|
||||
Returns an MWT for the X-Mizan-Token header — stateless,
|
||||
cache-aware authentication with permission staleness detection.
|
||||
|
||||
Usage (from frontend):
|
||||
const { token, expires_in } = await call('mwt_obtain')
|
||||
// Use token in X-Mizan-Token header
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
from mizan.setup.settings import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.mwt_secret:
|
||||
raise ValueError(
|
||||
"MIZAN_MWT_SECRET is not configured. MWT requires a signing secret."
|
||||
)
|
||||
|
||||
token = create_mwt(user, settings.mwt_secret, ttl=settings.mwt_ttl)
|
||||
return MWTOutput(token=token, expires_in=settings.mwt_ttl)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Mizan IR (KDL) export — Django management command.
|
||||
|
||||
Usage:
|
||||
python manage.py export_mizan_ir
|
||||
|
||||
Triggers Mizan client discovery to populate the registry, then writes
|
||||
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
||||
consumes this directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
# Load every project-side @client function so the registry is
|
||||
# populated before we emit. Conventionally apps/*/clients.py.
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
|
||||
mizan_clients("apps")
|
||||
self.stdout.write(build_ir(), ending="")
|
||||
@@ -1,36 +1,40 @@
|
||||
"""
|
||||
mizan.setup - Integration and registration utilities.
|
||||
mizan.setup - Django integration helpers.
|
||||
|
||||
This subpackage contains everything developers need to integrate mizan:
|
||||
- Registry for server functions and channels
|
||||
- Auto-discovery for apps
|
||||
- Configuration settings
|
||||
|
||||
Usage:
|
||||
from mizan.setup import mizan_clients, register, get_function
|
||||
The function/composition registry now lives in `mizan_core.registry`.
|
||||
Channels register themselves through the channel-specific registry in
|
||||
`mizan.channels`. Forms register through `mizan.forms`. This module
|
||||
re-exports the helpers that Django mizan users typically reach for, so
|
||||
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
||||
working as a single curated surface.
|
||||
"""
|
||||
|
||||
from .registry import (
|
||||
from mizan_core.registry import (
|
||||
register,
|
||||
register_as,
|
||||
register_form,
|
||||
register_compose,
|
||||
get_function,
|
||||
get_channel,
|
||||
get_compose,
|
||||
get_view,
|
||||
get_all_functions,
|
||||
get_all_channels,
|
||||
get_all_compositions,
|
||||
get_registry,
|
||||
get_schema,
|
||||
get_contexts,
|
||||
get_context_groups,
|
||||
get_forms,
|
||||
validate_registry,
|
||||
clear_registry,
|
||||
)
|
||||
|
||||
from mizan.channels import (
|
||||
get_channel,
|
||||
get_registered_channels as get_all_channels,
|
||||
)
|
||||
|
||||
from mizan.forms import (
|
||||
register_form,
|
||||
get_forms,
|
||||
)
|
||||
|
||||
from .discovery import (
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
@@ -52,7 +56,6 @@ __all__ = [
|
||||
"get_function",
|
||||
"get_channel",
|
||||
"get_compose",
|
||||
"get_view",
|
||||
"get_all_functions",
|
||||
"get_all_channels",
|
||||
"get_all_compositions",
|
||||
@@ -18,8 +18,8 @@ from typing import Any
|
||||
|
||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
|
||||
from .registry import register, get_function
|
||||
from mizan.client.function import ServerFunction
|
||||
from mizan_core.registry import register, get_function
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
|
||||
class _RegisterServerFunctions:
|
||||
@@ -71,9 +71,6 @@ def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||
visitor.visit(_RegisterServerFunctions())
|
||||
|
||||
from .registry import validate_registry
|
||||
validate_registry()
|
||||
|
||||
|
||||
def mizan_module(module_path: str) -> None:
|
||||
"""
|
||||
@@ -14,15 +14,18 @@ from django.conf import settings as django_settings
|
||||
class mizanSettings:
|
||||
"""mizan configuration."""
|
||||
|
||||
# Whether to expose function names in DEBUG mode errors
|
||||
debug_expose_names: bool
|
||||
|
||||
# Cache signing secret (required when cache is enabled)
|
||||
# Cache HMAC signing secret (required when cache is enabled)
|
||||
cache_secret: str | None
|
||||
|
||||
# Redis URL for cache backend (None = cache disabled)
|
||||
cache_redis_url: str | None
|
||||
|
||||
# MWT signing secret (separate from cache secret for blast radius containment)
|
||||
mwt_secret: str | None
|
||||
|
||||
# MWT token lifetime in seconds (default: 300 = 5 minutes)
|
||||
mwt_ttl: int
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> mizanSettings:
|
||||
@@ -30,14 +33,14 @@ def get_settings() -> mizanSettings:
|
||||
Load mizan settings from Django settings.
|
||||
|
||||
Settings:
|
||||
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||
MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None)
|
||||
MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None)
|
||||
"""
|
||||
return mizanSettings(
|
||||
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
||||
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
|
||||
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None),
|
||||
mwt_secret=getattr(django_settings, "MIZAN_MWT_SECRET", None),
|
||||
mwt_ttl=getattr(django_settings, "MIZAN_MWT_TTL", 300),
|
||||
)
|
||||
|
||||
|
||||
25
backends/mizan-django/src/mizan/ssr/__init__.py
Normal file
25
backends/mizan-django/src/mizan/ssr/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
mizan.ssr — Server-side rendering via Bun subprocess.
|
||||
|
||||
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'OPTIONS': {
|
||||
'worker_path': 'frontend/ssr-worker.tsx',
|
||||
'timeout': 5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then use Django's standard render():
|
||||
|
||||
return render(request, 'ProfilePage', {'user_id': 5})
|
||||
|
||||
The component name is the template name. The context dict becomes props.
|
||||
"""
|
||||
|
||||
from .backend import MizanTemplates
|
||||
|
||||
__all__ = ["MizanTemplates"]
|
||||
100
backends/mizan-django/src/mizan/ssr/backend.py
Normal file
100
backends/mizan-django/src/mizan/ssr/backend.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'DIRS': [BASE_DIR / 'frontend'],
|
||||
'OPTIONS': {
|
||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .bridge import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
"""Renders a .tsx/.jsx file via the SSR bridge."""
|
||||
|
||||
def __init__(self, file_path: str, bridge: SSRBridge) -> None:
|
||||
self.file_path = file_path
|
||||
self.origin = None
|
||||
self._bridge = bridge
|
||||
|
||||
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
|
||||
import json as _json
|
||||
|
||||
props = dict(context) if context else {}
|
||||
props.pop("request", None)
|
||||
props.pop("csrf_token", None)
|
||||
|
||||
result = self._bridge.render(self.file_path, props)
|
||||
|
||||
# Serialize props as hydration data for client-side React
|
||||
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||
|
||||
return mark_safe(
|
||||
f'<div id="mizan-root">{result.html}</div>'
|
||||
f'<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>'
|
||||
)
|
||||
|
||||
|
||||
class MizanTemplates(BaseEngine):
|
||||
"""
|
||||
Django template backend that renders React components via Bun.
|
||||
|
||||
Template names are file paths resolved against DIRS.
|
||||
Same model as Django's built-in template engines.
|
||||
"""
|
||||
|
||||
def __init__(self, params: dict[str, Any]) -> None:
|
||||
options = params.pop("OPTIONS", {})
|
||||
params.setdefault("NAME", "mizan")
|
||||
params.setdefault("APP_DIRS", False)
|
||||
super().__init__(params)
|
||||
|
||||
self._worker = options.get("worker")
|
||||
self._timeout = options.get("timeout", 5)
|
||||
self._bridge: SSRBridge | None = None
|
||||
|
||||
if not self._worker:
|
||||
raise ValueError(
|
||||
"MizanTemplates requires OPTIONS['worker'] — "
|
||||
"the path to mizan-ssr's worker.tsx"
|
||||
)
|
||||
|
||||
def get_bridge(self) -> SSRBridge:
|
||||
if self._bridge is None:
|
||||
self._bridge = SSRBridge(
|
||||
worker_path=self._worker,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return self._bridge
|
||||
|
||||
def get_template(self, template_name: str) -> MizanTemplate:
|
||||
for dir_path in self.dirs:
|
||||
file_path = os.path.join(dir_path, template_name)
|
||||
if os.path.isfile(file_path):
|
||||
return MizanTemplate(
|
||||
os.path.abspath(file_path),
|
||||
self.get_bridge(),
|
||||
)
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
def from_string(self, template_code: str) -> MizanTemplate:
|
||||
raise TemplateDoesNotExist(
|
||||
"MizanTemplates renders .tsx files, not template strings."
|
||||
)
|
||||
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
|
||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||
Response: {"id": 1, "html": "<div>...</div>"}
|
||||
|
||||
The subprocess stays alive across requests. It is started on first use
|
||||
and restarted automatically if it crashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("mizan.ssr")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""Result of an SSR render call."""
|
||||
html: str
|
||||
|
||||
|
||||
class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||
self._worker_path = worker_path
|
||||
self._timeout = timeout
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||
self._counter = 0
|
||||
self._pending: dict[int, threading.Event] = {}
|
||||
self._results: dict[int, dict] = {}
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure cleanup on process exit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
"""Start the Bun subprocess if it's not running."""
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
|
||||
if self._proc is not None:
|
||||
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
|
||||
|
||||
self._ready.clear()
|
||||
self._proc = subprocess.Popen(
|
||||
["bun", "run", self._worker_path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Wait for the "ready" signal from the worker
|
||||
if not self._ready.wait(timeout=self._timeout):
|
||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||
self.shutdown()
|
||||
raise TimeoutError("SSR worker failed to start")
|
||||
|
||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||
|
||||
def _read_responses(self) -> None:
|
||||
"""Background thread that reads JSON responses from stdout."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8")
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
|
||||
continue
|
||||
|
||||
msg_id = msg.get("id")
|
||||
|
||||
# Ready signal (id=0)
|
||||
if msg_id == 0 and msg.get("ready"):
|
||||
self._ready.set()
|
||||
continue
|
||||
|
||||
if msg_id is not None and msg_id in self._pending:
|
||||
self._results[msg_id] = msg
|
||||
self._pending[msg_id].set()
|
||||
except Exception:
|
||||
logger.warning("SSR reader thread exited", exc_info=True)
|
||||
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
|
||||
Args:
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
RenderResult with the HTML string.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
self._counter += 1
|
||||
msg_id = self._counter
|
||||
|
||||
event = threading.Event()
|
||||
self._pending[msg_id] = event
|
||||
|
||||
request = json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "render",
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||
with self._write_lock:
|
||||
try:
|
||||
self._proc.stdin.write(request.encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
self._pending.pop(msg_id, None)
|
||||
raise RuntimeError(f"SSR worker pipe broken: {e}")
|
||||
|
||||
if not event.wait(self._timeout):
|
||||
self._pending.pop(msg_id, None)
|
||||
raise TimeoutError(
|
||||
f"SSR render of '{file}' timed out after {self._timeout}s"
|
||||
)
|
||||
|
||||
self._pending.pop(msg_id, None)
|
||||
result = self._results.pop(msg_id)
|
||||
|
||||
if "error" in result:
|
||||
raise RuntimeError(f"SSR render failed: {result['error']}")
|
||||
|
||||
return RenderResult(html=result["html"])
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the Bun subprocess."""
|
||||
if self._proc is not None:
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
@@ -32,7 +32,7 @@ from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
)
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import clear_registry, register
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from django.test import RequestFactory, TestCase, TransactionTestCase, override_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||
from mizan.setup.registry import clear_registry
|
||||
from mizan_core.registry import clear_registry
|
||||
from mizan.client import client
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
|
||||
|
||||
def setup_benchmark_functions():
|
||||
"""Register benchmark server functions."""
|
||||
from mizan.setup.registry import register
|
||||
from mizan_core.registry import register
|
||||
|
||||
clear_registry()
|
||||
|
||||
@@ -928,13 +928,13 @@ class WebSocketRPCTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Clear mizan registry
|
||||
from mizan.setup.registry import clear_registry
|
||||
from mizan_core.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
# Register test functions
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from mizan_core.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
@@ -964,7 +964,7 @@ class WebSocketRPCTests(TestCase):
|
||||
register(rpc_auth_required, "rpc_auth_required")
|
||||
|
||||
def tearDown(self):
|
||||
from mizan.setup.registry import clear_registry
|
||||
from mizan_core.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
@@ -17,15 +17,15 @@ from mizan.client.executor import (
|
||||
execute_function,
|
||||
execute_context,
|
||||
)
|
||||
from mizan.setup.registry import (
|
||||
from mizan_core.registry import (
|
||||
clear_registry,
|
||||
register,
|
||||
register_as,
|
||||
register_form,
|
||||
get_schema,
|
||||
get_contexts,
|
||||
get_function,
|
||||
)
|
||||
from mizan.forms import register_form
|
||||
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
@@ -597,7 +597,7 @@ class ContextTests(TestCase):
|
||||
|
||||
def test_context_groups(self):
|
||||
"""Test get_context_groups() groups functions by context name."""
|
||||
from mizan.setup.registry import get_context_groups
|
||||
from mizan_core.registry import get_context_groups
|
||||
|
||||
UserCtx = ReactContext("user")
|
||||
|
||||
@@ -1019,9 +1019,8 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertIn("team_info", data)
|
||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||
|
||||
# CDN-ready headers
|
||||
self.assertIn("public", response["Cache-Control"])
|
||||
self.assertIn("s-maxage", response["Cache-Control"])
|
||||
# Mizan handles caching via its protocol; origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_error_not_cached(self):
|
||||
"""Context fetch errors must not be cached."""
|
||||
@@ -1034,6 +1033,44 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_mutation_response_includes_merge(self):
|
||||
"""@client(merge=...) emits a merge entry carrying the return value."""
|
||||
from mizan.client.executor import function_call_view
|
||||
|
||||
UserCtx = ReactContext("user")
|
||||
|
||||
@client(context=UserCtx)
|
||||
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
@client(merge=UserCtx)
|
||||
def rename(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(rename, "rename")
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/mizan/call/",
|
||||
json.dumps({"fn": "rename", "args": {"user_id": 7, "name": "Ryth"}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
|
||||
response = function_call_view(request)
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertIn("merge", data)
|
||||
# Server resolves slot — user_profile is the unique ValidOutput-returning fn in the context
|
||||
self.assertEqual(
|
||||
data["merge"],
|
||||
[{"context": "user", "slot": "user_profile", "params": {"user_id": 7}, "value": {"valid": True}}],
|
||||
)
|
||||
# Merge-only mutations don't emit invalidate
|
||||
self.assertNotIn("invalidate", data)
|
||||
self.assertNotIn("X-Mizan-Invalidate", response)
|
||||
|
||||
|
||||
class ContextFetchTests(TestCase):
|
||||
"""Tests for the bundled context fetch endpoint (execute_context)."""
|
||||
@@ -1148,8 +1185,8 @@ class ChannelTests(TestCase):
|
||||
|
||||
def test_register_channel(self):
|
||||
"""Test channel registration."""
|
||||
from mizan.channels import register as register_channel, get_channel
|
||||
|
||||
@register_as("test-channel")
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
@@ -1157,15 +1194,13 @@ class ChannelTests(TestCase):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
from mizan.setup.registry import get_channel
|
||||
|
||||
channel = get_channel("test-channel")
|
||||
self.assertEqual(channel, TestChannel)
|
||||
register_channel(TestChannel, "test-channel")
|
||||
self.assertEqual(get_channel("test-channel"), TestChannel)
|
||||
|
||||
def test_channel_schema_export(self):
|
||||
"""Test channel schema export."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
@register_as("chat")
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: int
|
||||
@@ -1180,6 +1215,8 @@ class ChannelTests(TestCase):
|
||||
def authorize(self, params):
|
||||
return True
|
||||
|
||||
register_channel(ChatChannel, "chat")
|
||||
|
||||
schema = get_schema()
|
||||
|
||||
self.assertIn("channels", schema)
|
||||
@@ -1194,8 +1231,8 @@ class ChannelTests(TestCase):
|
||||
|
||||
def test_server_push_only_channel(self):
|
||||
"""Test channel without ReactMessage (server-push only)."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
@register_as("notifications")
|
||||
class NotificationsChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
title: str
|
||||
@@ -1203,6 +1240,7 @@ class ChannelTests(TestCase):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
register_channel(NotificationsChannel, "notifications")
|
||||
schema = get_schema()
|
||||
notif_schema = schema["channels"]["notifications"]
|
||||
|
||||
@@ -1383,6 +1421,30 @@ class TypeAnnotationTests(TestCase):
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertIsNone(result.data)
|
||||
|
||||
def test_list_basemodel_return_not_wrapped(self):
|
||||
"""list[BaseModel] should reach the wire as a bare array, not {result: [...]}."""
|
||||
|
||||
class Item(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@client
|
||||
def list_items(request: HttpRequest) -> list[Item]:
|
||||
return [Item(id=1, name="a"), Item(id=2, name="b")]
|
||||
|
||||
register(list_items, "list_items")
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.user = AnonymousUser()
|
||||
|
||||
result = execute_function(request, "list_items", {})
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(
|
||||
result.data,
|
||||
[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RPC Mode Tests
|
||||
@@ -1785,9 +1847,8 @@ class HTTPIntegrationTests(TestCase):
|
||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||
self.assertEqual(data["user_orders"]["count"], 50)
|
||||
|
||||
# CDN headers
|
||||
self.assertIn("public", response["Cache-Control"])
|
||||
self.assertIn("s-maxage", response["Cache-Control"])
|
||||
# Mizan handles caching; origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_fetch_string_to_int_coercion(self):
|
||||
"""Query params arrive as strings. Pydantic must coerce to int."""
|
||||
@@ -2061,7 +2122,7 @@ class ReturnTypeBranchingTests(TestCase):
|
||||
register(profile_page, "profile_page")
|
||||
|
||||
# It's in the context groups (for invalidation graph)
|
||||
from mizan.setup.registry import get_context_groups
|
||||
from mizan_core.registry import get_context_groups
|
||||
groups = get_context_groups()
|
||||
self.assertIn("user", groups)
|
||||
self.assertIn("profile_page", groups["user"])
|
||||
@@ -2142,16 +2203,10 @@ class EdgeCompatibilityTests(TestCase):
|
||||
|
||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||
|
||||
def test_context_get_is_cacheable(self):
|
||||
"""Context GET has Cache-Control that allows CDN caching."""
|
||||
def test_context_get_no_store(self):
|
||||
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
|
||||
cc = response["Cache-Control"]
|
||||
self.assertIn("public", cc)
|
||||
self.assertIn("s-maxage", cc)
|
||||
# Must NOT have no-store or private
|
||||
self.assertNotIn("no-store", cc)
|
||||
self.assertNotIn("private", cc)
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_mutation_post_not_cacheable(self):
|
||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
||||
@@ -2786,100 +2841,44 @@ class PrivateAndRouteTests(TestCase):
|
||||
# ── Cache conformance tests ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CacheKeyDerivationTests(TestCase):
|
||||
"""Tests that HMAC cache key derivation is deterministic and correct."""
|
||||
|
||||
SECRET = "test-cache-secret"
|
||||
|
||||
def test_deterministic_output(self):
|
||||
"""Same inputs always produce the same key."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
||||
self.assertEqual(key1, key2)
|
||||
self.assertEqual(len(key1), 64) # SHA-256 hex digest
|
||||
|
||||
def test_param_order_irrelevant(self):
|
||||
"""Parameter ordering does not affect the key."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
|
||||
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
|
||||
self.assertEqual(key1, key2)
|
||||
|
||||
def test_different_user_ids_different_keys(self):
|
||||
"""Different user_ids produce different cache keys."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
|
||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
|
||||
self.assertNotEqual(key1, key2)
|
||||
|
||||
def test_rev_changes_key(self):
|
||||
"""Different rev values produce different cache keys."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
|
||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
|
||||
self.assertNotEqual(key1, key2)
|
||||
|
||||
def test_no_delimiter_collision(self):
|
||||
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
|
||||
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
|
||||
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
|
||||
self.assertNotEqual(key1, key2)
|
||||
|
||||
def test_public_vs_user_scoped(self):
|
||||
"""Public (no user_id) and user-scoped produce different keys."""
|
||||
from mizan.cache.keys import derive_cache_key
|
||||
|
||||
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
|
||||
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
|
||||
self.assertNotEqual(public, scoped)
|
||||
|
||||
|
||||
class CacheBackendTests(TestCase):
|
||||
"""Tests for MemoryCache backend operations."""
|
||||
|
||||
def setUp(self):
|
||||
from mizan.cache.backend import MemoryCache
|
||||
from mizan_core.cache.backend import MemoryCache
|
||||
self.cache = MemoryCache()
|
||||
|
||||
def test_get_miss(self):
|
||||
"""Empty cache returns None."""
|
||||
self.assertIsNone(self.cache.get("nonexistent"))
|
||||
|
||||
def test_put_then_get(self):
|
||||
def test_set_then_get(self):
|
||||
"""Store and retrieve a value."""
|
||||
self.cache.put("key1", b'{"data": true}', ["mizan:idx:ctx"])
|
||||
self.cache.set("key1", b'{"data": true}')
|
||||
result = self.cache.get("key1")
|
||||
self.assertEqual(result, b'{"data": true}')
|
||||
|
||||
def test_index_tracking(self):
|
||||
"""Put adds the key to specified indexes."""
|
||||
self.cache.put("key1", b"v1", ["mizan:idx:user", "mizan:idx:user:user_id=5"])
|
||||
self.assertIn("key1", self.cache.get_index("mizan:idx:user"))
|
||||
self.assertIn("key1", self.cache.get_index("mizan:idx:user:user_id=5"))
|
||||
|
||||
def test_delete_many(self):
|
||||
"""Delete multiple keys at once."""
|
||||
self.cache.put("k1", b"v1", [])
|
||||
self.cache.put("k2", b"v2", [])
|
||||
count = self.cache.delete_many(["k1", "k2"])
|
||||
self.assertEqual(count, 2)
|
||||
def test_delete(self):
|
||||
"""Delete a key."""
|
||||
self.cache.set("k1", b"v1")
|
||||
self.assertTrue(self.cache.delete("k1"))
|
||||
self.assertIsNone(self.cache.get("k1"))
|
||||
self.assertIsNone(self.cache.get("k2"))
|
||||
|
||||
def test_delete_by_prefix(self):
|
||||
"""Delete by prefix removes matching keys only."""
|
||||
self.cache.set("ctx:user:abc", b"v1")
|
||||
self.cache.set("ctx:user:def", b"v2")
|
||||
self.cache.set("ctx:products:ghi", b"v3")
|
||||
count = self.cache.delete_by_prefix("ctx:user:")
|
||||
self.assertEqual(count, 2)
|
||||
self.assertIsNone(self.cache.get("ctx:user:abc"))
|
||||
self.assertIsNotNone(self.cache.get("ctx:products:ghi"))
|
||||
|
||||
def test_clear(self):
|
||||
"""Clear removes everything."""
|
||||
self.cache.put("k1", b"v1", ["idx1"])
|
||||
self.cache.set("k1", b"v1")
|
||||
self.cache.clear()
|
||||
self.assertIsNone(self.cache.get("k1"))
|
||||
self.assertEqual(self.cache.get_index("idx1"), set())
|
||||
|
||||
|
||||
class CachePurgeTests(TestCase):
|
||||
@@ -2889,7 +2888,7 @@ class CachePurgeTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
from mizan.cache import cache_put, set_cache
|
||||
from mizan.cache.backend import MemoryCache
|
||||
from mizan_core.cache.backend import MemoryCache
|
||||
|
||||
self.cache = MemoryCache()
|
||||
set_cache(self.cache)
|
||||
@@ -2903,10 +2902,10 @@ class CachePurgeTests(TestCase):
|
||||
reset_cache()
|
||||
|
||||
def test_scoped_purge(self):
|
||||
"""Purging user;user_id=5 removes only that entry."""
|
||||
"""Purging user;user_id=5 recomputes key and deletes directly."""
|
||||
from mizan.cache import cache_purge, cache_get
|
||||
|
||||
count = cache_purge(self.cache, "user", {"user_id": "5"})
|
||||
count = cache_purge(self.cache, "user", {"user_id": "5"}, secret=self.SECRET)
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
# user_id=5 is gone
|
||||
@@ -2933,7 +2932,7 @@ class CacheIntegrationTests(TestCase):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
from mizan.cache import set_cache
|
||||
from mizan.cache.backend import MemoryCache
|
||||
from mizan_core.cache.backend import MemoryCache
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
|
||||
self.cache = MemoryCache()
|
||||
@@ -3028,3 +3027,440 @@ class CacheIntegrationTests(TestCase):
|
||||
# User 6 should still be cached
|
||||
r6 = self._fetch_context("user_id=6")
|
||||
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")
|
||||
|
||||
|
||||
# ── Rev and cache parameter tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class RevParameterTests(TestCase):
|
||||
"""Tests for the @client(rev=N) parameter."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def test_rev_stored_in_meta(self):
|
||||
"""@client(rev=2) stores rev in function metadata."""
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx, rev=2)
|
||||
def my_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(my_fn, "my_fn")
|
||||
meta = getattr(get_function("my_fn"), "_meta", {})
|
||||
self.assertEqual(meta["rev"], 2)
|
||||
|
||||
def test_rev_default_not_in_meta(self):
|
||||
"""@client with default rev=0 does not store rev in meta."""
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx)
|
||||
def my_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(my_fn, "my_fn")
|
||||
meta = getattr(get_function("my_fn"), "_meta", {})
|
||||
self.assertNotIn("rev", meta)
|
||||
|
||||
def test_rev_changes_cache_key(self):
|
||||
"""Different rev values produce different HMAC cache keys."""
|
||||
from mizan_core.cache.keys import derive_cache_key
|
||||
|
||||
key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
|
||||
key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
|
||||
self.assertNotEqual(key_v0, key_v1)
|
||||
|
||||
def test_rev_in_manifest(self):
|
||||
"""Manifest includes rev on functions that set it."""
|
||||
from mizan.export import generate_edge_manifest
|
||||
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx, rev=3)
|
||||
def versioned_fn(request: HttpRequest, item_id: int) -> dict:
|
||||
return {}
|
||||
|
||||
register(versioned_fn, "versioned_fn")
|
||||
manifest = generate_edge_manifest()
|
||||
fn_entry = manifest["contexts"]["data"]["functions"][0]
|
||||
self.assertEqual(fn_entry["rev"], 3)
|
||||
|
||||
def test_rev_not_in_manifest_when_default(self):
|
||||
"""Manifest omits rev when it's the default (0)."""
|
||||
from mizan.export import generate_edge_manifest
|
||||
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx)
|
||||
def default_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(default_fn, "default_fn")
|
||||
manifest = generate_edge_manifest()
|
||||
fn_entry = manifest["contexts"]["data"]["functions"][0]
|
||||
self.assertNotIn("rev", fn_entry)
|
||||
|
||||
|
||||
class CacheParameterTests(TestCase):
|
||||
"""Tests for the @client(cache=...) parameter."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def test_cache_int_stored_in_meta(self):
|
||||
"""@client(cache=60) stores cache TTL in meta."""
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx, cache=60)
|
||||
def my_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(my_fn, "my_fn")
|
||||
meta = getattr(get_function("my_fn"), "_meta", {})
|
||||
self.assertEqual(meta["cache"], 60)
|
||||
|
||||
def test_cache_false_stored_in_meta(self):
|
||||
"""@client(cache=False) stores False in meta."""
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx, cache=False)
|
||||
def my_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(my_fn, "my_fn")
|
||||
meta = getattr(get_function("my_fn"), "_meta", {})
|
||||
self.assertIs(meta["cache"], False)
|
||||
|
||||
def test_cache_default_not_in_meta(self):
|
||||
"""Default cache=True is not stored in meta."""
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx)
|
||||
def my_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(my_fn, "my_fn")
|
||||
meta = getattr(get_function("my_fn"), "_meta", {})
|
||||
self.assertNotIn("cache", meta)
|
||||
|
||||
def test_cache_in_manifest(self):
|
||||
"""Manifest includes cache TTL on functions that set it."""
|
||||
from mizan.export import generate_edge_manifest
|
||||
|
||||
Ctx = ReactContext("data")
|
||||
|
||||
@client(context=Ctx, cache=120)
|
||||
def ttl_fn(request: HttpRequest) -> dict:
|
||||
return {}
|
||||
|
||||
register(ttl_fn, "ttl_fn")
|
||||
manifest = generate_edge_manifest()
|
||||
fn_entry = manifest["contexts"]["data"]["functions"][0]
|
||||
self.assertEqual(fn_entry["cache"], 120)
|
||||
|
||||
|
||||
class CachePolicyIntegrationTests(TestCase):
|
||||
"""Tests for effective cache policy resolution in context_fetch_view."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def test_cache_int_still_no_store_header(self):
|
||||
"""cache=60 affects origin Redis TTL, but HTTP header is always no-store."""
|
||||
Ctx = ReactContext("trending")
|
||||
|
||||
@client(context=Ctx, cache=60)
|
||||
def trending(request: HttpRequest) -> dict:
|
||||
return {"items": []}
|
||||
|
||||
register(trending, "trending")
|
||||
response = self.client.get("/api/mizan/ctx/trending/")
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_cache_false_no_store(self):
|
||||
"""Context with cache=False emits no-store."""
|
||||
Ctx = ReactContext("random")
|
||||
|
||||
@client(context=Ctx, cache=False)
|
||||
def random_fn(request: HttpRequest) -> dict:
|
||||
return {"value": 42}
|
||||
|
||||
register(random_fn, "random_fn")
|
||||
response = self.client.get("/api/mizan/ctx/random/")
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_effective_rev_is_maximum(self):
|
||||
"""Context with mixed revs uses the maximum for cache key."""
|
||||
from mizan_core.cache.keys import derive_cache_key
|
||||
from mizan.cache import set_cache, reset_cache
|
||||
from mizan_core.cache.backend import MemoryCache
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
from django.test import override_settings
|
||||
|
||||
Ctx = ReactContext("versioned")
|
||||
|
||||
@client(context=Ctx, rev=0)
|
||||
def old_fn(request: HttpRequest, item_id: int) -> dict:
|
||||
return {"old": True}
|
||||
|
||||
@client(context=Ctx, rev=2)
|
||||
def new_fn(request: HttpRequest, item_id: int) -> dict:
|
||||
return {"new": True}
|
||||
|
||||
register(old_fn, "old_fn")
|
||||
register(new_fn, "new_fn")
|
||||
|
||||
mem_cache = MemoryCache()
|
||||
set_cache(mem_cache)
|
||||
|
||||
with override_settings(MIZAN_CACHE_SECRET="test", MIZAN_CACHE_REDIS_URL="dummy"):
|
||||
clear_settings_cache()
|
||||
r1 = self.client.get("/api/mizan/ctx/versioned/?item_id=1")
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
|
||||
# The cache key should use rev=2 (max)
|
||||
expected_key = derive_cache_key("test", "versioned", {"item_id": "1"}, rev=2)
|
||||
self.assertIn(expected_key, mem_cache._store)
|
||||
|
||||
reset_cache()
|
||||
clear_settings_cache()
|
||||
|
||||
|
||||
# ── MWT (Mizan Web Token) tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class MWTAuthIntegrationTests(TestCase):
|
||||
"""Tests for MWT authentication in the executor."""
|
||||
|
||||
SECRET = "test-mwt-auth-secret-thats-32bytes!" # 32+ bytes for HS256
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
self.factory = RequestFactory()
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
clear_settings_cache()
|
||||
|
||||
UserCtx = ReactContext("user")
|
||||
|
||||
@client(context=UserCtx, auth=True)
|
||||
def protected_fn(request: HttpRequest, user_id: int) -> dict:
|
||||
return {"viewer": request.user.pk}
|
||||
|
||||
register(protected_fn, "protected_fn")
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
clear_settings_cache()
|
||||
|
||||
def test_mwt_auth_via_header(self):
|
||||
"""Request with valid X-Mizan-Token authenticates."""
|
||||
from mizan_core.mwt import create_mwt
|
||||
from mizan.client.executor import _try_mwt_auth
|
||||
from django.test import override_settings
|
||||
|
||||
user = MagicMock()
|
||||
user.pk = 5
|
||||
user.is_staff = False
|
||||
user.is_superuser = False
|
||||
user.get_all_permissions = MagicMock(return_value=set())
|
||||
|
||||
token = create_mwt(user, self.SECRET)
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.META["HTTP_X_MIZAN_TOKEN"] = token
|
||||
request.user = MagicMock(is_authenticated=False)
|
||||
|
||||
with override_settings(MIZAN_MWT_SECRET=self.SECRET):
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
clear_settings_cache()
|
||||
result = _try_mwt_auth(request)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(request.user.pk, 5)
|
||||
self.assertTrue(request.user.is_authenticated)
|
||||
|
||||
def test_mwt_invalid_returns_401(self):
|
||||
"""Invalid X-Mizan-Token returns 401 on context fetch."""
|
||||
from django.test import override_settings
|
||||
|
||||
with override_settings(MIZAN_MWT_SECRET=self.SECRET):
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
clear_settings_cache()
|
||||
response = self.client.get(
|
||||
"/api/mizan/ctx/user/?user_id=5",
|
||||
HTTP_X_MIZAN_TOKEN="invalid-token",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_legacy_jwt_still_works(self):
|
||||
"""Authorization: Bearer still accepted alongside MWT."""
|
||||
from mizan.jwt.tokens import create_token_pair
|
||||
from tests.models import EmailUser
|
||||
|
||||
user = EmailUser.objects.create_user(email="legacy@test.com", password="pass")
|
||||
self.client.login(email="legacy@test.com", password="pass")
|
||||
session_key = self.client.session.session_key
|
||||
|
||||
tokens = create_token_pair(
|
||||
user.pk, session_key,
|
||||
is_staff=False, is_superuser=False,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/mizan/ctx/user/?user_id=5",
|
||||
HTTP_AUTHORIZATION=f"Bearer {tokens.access_token}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
# ── Redis backend tests (requires running Redis on port 6399) ──────────
|
||||
|
||||
|
||||
import os
|
||||
|
||||
REDIS_URL = os.environ.get("MIZAN_TEST_REDIS_URL", "redis://localhost:6399/0")
|
||||
|
||||
|
||||
def _redis_available() -> bool:
|
||||
"""Check if a test Redis instance is reachable."""
|
||||
try:
|
||||
import redis
|
||||
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
||||
client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_SKIP_REDIS = not _redis_available()
|
||||
_SKIP_MSG = f"Redis not available at {REDIS_URL}"
|
||||
|
||||
|
||||
class RedisCacheBackendTests(TestCase):
|
||||
"""Tests for RedisCache against a real Redis instance."""
|
||||
|
||||
SECRET = "test-cache-secret-for-redis-32b!"
|
||||
|
||||
def setUp(self):
|
||||
if _SKIP_REDIS:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
from mizan_core.cache.backend import RedisCache
|
||||
self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
|
||||
self.cache.clear()
|
||||
|
||||
def tearDown(self):
|
||||
if not _SKIP_REDIS:
|
||||
self.cache.clear()
|
||||
|
||||
def test_get_miss(self):
|
||||
"""Empty cache returns None."""
|
||||
self.assertIsNone(self.cache.get("nonexistent"))
|
||||
|
||||
def test_set_then_get(self):
|
||||
"""Store and retrieve a value."""
|
||||
self.cache.set("key1", b'{"data": true}')
|
||||
result = self.cache.get("key1")
|
||||
self.assertEqual(result, b'{"data": true}')
|
||||
|
||||
def test_delete(self):
|
||||
"""Delete a key."""
|
||||
self.cache.set("k1", b"v1")
|
||||
self.assertTrue(self.cache.delete("k1"))
|
||||
self.assertIsNone(self.cache.get("k1"))
|
||||
|
||||
def test_delete_nonexistent(self):
|
||||
"""Delete a nonexistent key returns False."""
|
||||
self.assertFalse(self.cache.delete("ghost"))
|
||||
|
||||
def test_delete_by_prefix(self):
|
||||
"""Delete all keys matching a prefix."""
|
||||
self.cache.set("ctx:user:abc", b"v1")
|
||||
self.cache.set("ctx:user:def", b"v2")
|
||||
self.cache.set("ctx:products:ghi", b"v3")
|
||||
count = self.cache.delete_by_prefix("ctx:user:")
|
||||
self.assertEqual(count, 2)
|
||||
self.assertIsNone(self.cache.get("ctx:user:abc"))
|
||||
self.assertIsNone(self.cache.get("ctx:user:def"))
|
||||
self.assertIsNotNone(self.cache.get("ctx:products:ghi"))
|
||||
|
||||
def test_ttl_is_set(self):
|
||||
"""Set applies a TTL on the cache key."""
|
||||
import redis
|
||||
self.cache.set("ttl_key", b"value")
|
||||
client = redis.from_url(REDIS_URL)
|
||||
ttl = client.ttl("mizan:test:ttl_key")
|
||||
client.close()
|
||||
self.assertGreater(ttl, 0)
|
||||
self.assertLessEqual(ttl, self.cache._ttl)
|
||||
|
||||
def test_clear(self):
|
||||
"""Clear removes all keys with our prefix."""
|
||||
self.cache.set("k1", b"v1")
|
||||
self.cache.set("k2", b"v2")
|
||||
self.cache.clear()
|
||||
self.assertIsNone(self.cache.get("k1"))
|
||||
self.assertIsNone(self.cache.get("k2"))
|
||||
|
||||
def test_clear_preserves_other_prefixes(self):
|
||||
"""Clear only removes keys with our prefix, not others."""
|
||||
import redis
|
||||
client = redis.from_url(REDIS_URL)
|
||||
client.set("other:key", "should_survive")
|
||||
self.cache.set("k1", b"v1")
|
||||
self.cache.clear()
|
||||
self.assertIsNone(self.cache.get("k1"))
|
||||
self.assertEqual(client.get("other:key"), b"should_survive")
|
||||
client.delete("other:key")
|
||||
client.close()
|
||||
|
||||
|
||||
class RedisCachePurgeTests(TestCase):
|
||||
"""Tests for cache_purge against real Redis."""
|
||||
|
||||
SECRET = "test-cache-secret-for-redis-32b!"
|
||||
|
||||
def setUp(self):
|
||||
if _SKIP_REDIS:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
from mizan_core.cache.backend import RedisCache
|
||||
from mizan.cache import cache_put
|
||||
self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
|
||||
self.cache.clear()
|
||||
|
||||
cache_put(self.SECRET, self.cache, "user", {"user_id": "5"}, b'{"u5":true}')
|
||||
cache_put(self.SECRET, self.cache, "user", {"user_id": "6"}, b'{"u6":true}')
|
||||
|
||||
def tearDown(self):
|
||||
if not _SKIP_REDIS:
|
||||
self.cache.clear()
|
||||
|
||||
def test_scoped_purge(self):
|
||||
"""Scoped purge recomputes key and deletes directly."""
|
||||
from mizan.cache import cache_purge, cache_get
|
||||
|
||||
count = cache_purge(
|
||||
self.cache, "user", {"user_id": "5"}, secret=self.SECRET,
|
||||
)
|
||||
self.assertEqual(count, 1)
|
||||
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
|
||||
self.assertIsNotNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
|
||||
|
||||
def test_broad_purge(self):
|
||||
"""Broad purge uses prefix scan to remove all entries in context."""
|
||||
from mizan.cache import cache_purge, cache_get
|
||||
|
||||
count = cache_purge(self.cache, "user")
|
||||
self.assertEqual(count, 2)
|
||||
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
|
||||
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
|
||||
@@ -42,7 +42,7 @@ from mizan.client.executor import (
|
||||
FunctionResult,
|
||||
execute_function,
|
||||
)
|
||||
from mizan.setup.registry import clear_registry, get_function, register
|
||||
from mizan_core.registry import clear_registry, get_function, register
|
||||
from mizan.client import ServerFunction, client
|
||||
|
||||
|
||||
@@ -1179,7 +1179,7 @@ class RegistrationSecurityTests(TestCase):
|
||||
But a DIFFERENT function cannot take over an existing name.
|
||||
"""
|
||||
from mizan.client import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
from mizan_core.registry import register
|
||||
|
||||
# Register first function
|
||||
class OriginalFunc(ServerFunction):
|
||||
@@ -29,7 +29,7 @@ from mizan.client.executor import (
|
||||
execute_function,
|
||||
function_call_view,
|
||||
)
|
||||
from mizan.setup.registry import clear_registry, register, register_as, get_function
|
||||
from mizan_core.registry import clear_registry, register, register_as, get_function
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
162
backends/mizan-django/src/mizan/tests/test_ssr.py
Normal file
162
backends/mizan-django/src/mizan/tests/test_ssr.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Tests for the Mizan SSR bridge and template backend.
|
||||
|
||||
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
||||
Tests skip gracefully if Bun is not available.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
|
||||
from django.test import SimpleTestCase, RequestFactory
|
||||
|
||||
# Path to the test worker
|
||||
_SSR_WORKER = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..", "..", "..", "..", "..", # up to repo root
|
||||
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
||||
)
|
||||
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
||||
|
||||
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||
_SKIP_MSG = "Bun not available"
|
||||
|
||||
|
||||
class SSRBridgeTests(SimpleTestCase):
|
||||
"""Tests for the SSR bridge subprocess manager."""
|
||||
|
||||
def setUp(self):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.bridge import SSRBridge
|
||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "bridge"):
|
||||
self.bridge.shutdown()
|
||||
|
||||
def test_ping(self):
|
||||
"""Worker starts and responds to ping."""
|
||||
self.assertTrue(self.bridge.ping())
|
||||
|
||||
def test_render_simple(self):
|
||||
"""Renders a simple component to HTML."""
|
||||
result = self.bridge.render("Hello", {"name": "World"})
|
||||
self.assertIn("Hello,", result.html)
|
||||
self.assertIn("World", result.html)
|
||||
|
||||
def test_render_with_props(self):
|
||||
"""Renders a component with multiple props."""
|
||||
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
||||
self.assertIn("Alice", result.html)
|
||||
self.assertIn("42", result.html)
|
||||
|
||||
def test_render_missing_component(self):
|
||||
"""Rendering an unregistered component raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("NonExistent", {})
|
||||
self.assertIn("not registered", str(ctx.exception))
|
||||
|
||||
def test_render_error(self):
|
||||
"""Component that throws during render raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("Broken", {})
|
||||
self.assertIn("Render error", str(ctx.exception))
|
||||
|
||||
def test_crash_recovery(self):
|
||||
"""Bridge restarts the worker if it dies."""
|
||||
# First render works
|
||||
result = self.bridge.render("Hello", {"name": "Before"})
|
||||
self.assertIn("Before", result.html)
|
||||
|
||||
# Kill the subprocess
|
||||
self.bridge._proc.kill()
|
||||
self.bridge._proc.wait()
|
||||
|
||||
# Next render should restart and work
|
||||
result = self.bridge.render("Hello", {"name": "After"})
|
||||
self.assertIn("After", result.html)
|
||||
|
||||
def test_concurrent_renders(self):
|
||||
"""Multiple threads can render simultaneously."""
|
||||
results = {}
|
||||
errors = {}
|
||||
|
||||
def render_in_thread(name: str, idx: int):
|
||||
try:
|
||||
result = self.bridge.render("Hello", {"name": name})
|
||||
results[idx] = result.html
|
||||
except Exception as e:
|
||||
errors[idx] = e
|
||||
|
||||
threads = []
|
||||
for i in range(5):
|
||||
t = threading.Thread(target=render_in_thread, args=(f"User{i}", i))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
||||
self.assertEqual(len(results), 5)
|
||||
for i in range(5):
|
||||
self.assertIn(f"User{i}", results[i])
|
||||
|
||||
|
||||
class SSRTemplateBackendTests(SimpleTestCase):
|
||||
"""Tests for the MizanTemplates Django template backend."""
|
||||
|
||||
def setUp(self):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.backend import MizanTemplates
|
||||
self.engine = MizanTemplates({
|
||||
"NAME": "mizan-test",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"worker_path": _SSR_WORKER,
|
||||
"timeout": 5,
|
||||
},
|
||||
})
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||
self.engine._bridge.shutdown()
|
||||
|
||||
def test_get_template(self):
|
||||
"""get_template returns a MizanTemplate."""
|
||||
from mizan.ssr.backend import MizanTemplate
|
||||
template = self.engine.get_template("Hello")
|
||||
self.assertIsInstance(template, MizanTemplate)
|
||||
self.assertEqual(template.component_name, "Hello")
|
||||
|
||||
def test_template_render(self):
|
||||
"""MizanTemplate.render() produces HTML."""
|
||||
template = self.engine.get_template("Hello")
|
||||
html = template.render({"name": "Django"})
|
||||
self.assertIn("Hello,", html)
|
||||
self.assertIn("Django", html)
|
||||
self.assertIn('data-mizan-component="Hello"', html)
|
||||
|
||||
def test_template_render_strips_django_internals(self):
|
||||
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
||||
template = self.engine.get_template("Hello")
|
||||
request = self.factory.get("/")
|
||||
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
||||
self.assertIn("Test", html)
|
||||
|
||||
def test_from_string_raises(self):
|
||||
"""from_string is not supported."""
|
||||
from django.template import TemplateDoesNotExist
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
self.engine.from_string("<div>Not supported</div>")
|
||||
@@ -8,7 +8,7 @@ HTTP endpoints:
|
||||
|
||||
Security:
|
||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
||||
- Use the management command instead: python manage.py export_mizan_schema
|
||||
- Use the management command instead: python manage.py export_mizan_ir
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
186
backends/mizan-fastapi/README.md
Normal file
186
backends/mizan-fastapi/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# mizan-fastapi
|
||||
|
||||
FastAPI backend adapter for the Mizan protocol. One decorator on a server
|
||||
function. Typed React client generated. Invalidation automatic.
|
||||
|
||||
## Scope
|
||||
|
||||
mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context
|
||||
bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes,
|
||||
and SSR are out of scope for the FastAPI adapter — FastAPI projects use
|
||||
native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's
|
||||
own SSR ecosystem).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
uv add mizan-fastapi
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```python
|
||||
# main.py
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from mizan_fastapi import (
|
||||
MizanError,
|
||||
mizan_exception_handler,
|
||||
mizan_validation_handler,
|
||||
router as mizan_router,
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mizan_router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||
```
|
||||
|
||||
The exception handlers render every error path through the Mizan envelope
|
||||
(`{"error": {"code", "message", "details"}}`) so the kernel's `MizanError`
|
||||
parses status + code on the frontend regardless of which failure happened.
|
||||
|
||||
## Define server functions
|
||||
|
||||
```python
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
|
||||
register(echo, "echo")
|
||||
```
|
||||
|
||||
mizan-fastapi has no auto-discovery (FastAPI doesn't have an app registry
|
||||
to walk). Register every `@client`-decorated function explicitly. A typical
|
||||
project keeps registrations in `main.py` (alongside the FastAPI app) or in
|
||||
a dedicated `clients.py` imported during startup.
|
||||
|
||||
## `@client` parameters
|
||||
|
||||
```python
|
||||
@client # plain RPC function
|
||||
@client(context="global") # singleton context — fetched once, SSR-hydrated
|
||||
@client(context="user") # named context — fetched per provider mount
|
||||
@client(affects="user") # mutation — invalidates the user context
|
||||
@client(affects=user_profile) # mutation — invalidates a specific function
|
||||
@client(auth=True) # requires authentication
|
||||
@client(auth="staff") # requires is_staff
|
||||
@client(auth="superuser") # requires is_superuser
|
||||
@client(auth=lambda req: ...) # custom predicate
|
||||
@client(rev=2) # cache revision (busts on bump)
|
||||
```
|
||||
|
||||
`websocket=True`, Forms, and Channels parameters are accepted by the
|
||||
decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi —
|
||||
those features only have effect when paired with mizan-django.
|
||||
|
||||
## Auth integration
|
||||
|
||||
The executor expects `request.state.user` to be populated by your FastAPI
|
||||
middleware or dependency tree before dispatch:
|
||||
|
||||
```python
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def attach_user(request: Request, call_next):
|
||||
request.state.user = await resolve_user_from_token(request)
|
||||
return await call_next(request)
|
||||
```
|
||||
|
||||
Where `resolve_user_from_token` returns either a user object with
|
||||
`is_authenticated`, `is_staff`, `is_superuser` attributes, or `None` for an
|
||||
anonymous request. The executor branches on those for `auth=True`,
|
||||
`auth="staff"`, `auth="superuser"` requirements.
|
||||
|
||||
## Generate the frontend
|
||||
|
||||
The codegen is the `mizan-generate` Rust binary (source at
|
||||
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
|
||||
launcher that dispatches to the platform binary). Point a `mizan.toml` at
|
||||
your FastAPI app and run the CLI:
|
||||
|
||||
```toml
|
||||
# frontend/mizan.toml
|
||||
output = "src/api"
|
||||
targets = ["react"]
|
||||
|
||||
[source.fastapi]
|
||||
module = "main" # module to import for @client side effects
|
||||
cwd = "../backend" # python cwd for module resolution
|
||||
command = ["uv", "run", "python"] # optional — defaults to ["python"]
|
||||
```
|
||||
|
||||
```bash
|
||||
mizan-generate --config mizan.toml
|
||||
```
|
||||
|
||||
The codegen drives `python -m mizan_fastapi.ir <module>` under the hood,
|
||||
parses the emitted KDL IR, then emits Stage 1 (typed `callXxx`/`fetchXxx`
|
||||
over the runtime kernel) + Stage 2 (`<MizanContext>` provider, per-context
|
||||
providers, `use{Hook}()` hooks) into `src/api/`.
|
||||
|
||||
```tsx
|
||||
// app.tsx
|
||||
import { MizanContext } from "./api"
|
||||
|
||||
export default function App({ children }) {
|
||||
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// any component
|
||||
import { useEcho, useCurrentUser } from "./api"
|
||||
|
||||
const echo = useEcho()
|
||||
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
|
||||
|
||||
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Schema export CLI
|
||||
|
||||
For codegen consumption (or any tooling that wants the Mizan schema):
|
||||
|
||||
```bash
|
||||
python -m mizan_fastapi.ir <module>
|
||||
```
|
||||
|
||||
Imports the named module (which must register every `@client` function as
|
||||
import-time side effects), then prints the Mizan KDL IR to stdout.
|
||||
Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes
|
||||
either backend the same subprocess way.
|
||||
|
||||
## Architecture
|
||||
|
||||
mizan-fastapi is one of two reference backend adapters (the other is
|
||||
`backends/mizan-django`). Both implement the same Mizan protocol on top of
|
||||
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
|
||||
keys). The AFI conformance suite at `tests/afi/` gates that the two adapters
|
||||
emit equivalent schemas for the same registered functions. See
|
||||
`docs/AFI_ARCHITECTURE.md`.
|
||||
|
||||
A live e2e harness exercises this adapter end-to-end at
|
||||
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
|
||||
→ FastAPI server, 14/14 Playwright tests).
|
||||
33
backends/mizan-fastapi/pyproject.toml
Normal file
33
backends/mizan-fastapi/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "mizan-fastapi"
|
||||
version = "0.1.0"
|
||||
license = "Elastic-2.0"
|
||||
description = "Mizan FastAPI backend adapter — HTTP RPC dispatch + context bundling, built on mizan-core."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mizan-core",
|
||||
"fastapi>=0.110",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mizan_fastapi"]
|
||||
|
||||
[tool.uv.sources]
|
||||
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
python_classes = ["*Tests", "*Test", "Test*"]
|
||||
python_functions = ["test_*"]
|
||||
54
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
54
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||
|
||||
HTTP RPC dispatch and context bundling on top of mizan-core's function
|
||||
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
|
||||
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
|
||||
SSR frameworks).
|
||||
|
||||
Usage:
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
|
||||
# Register your @client-decorated functions
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import register
|
||||
from .my_functions import echo
|
||||
register(echo, "echo")
|
||||
"""
|
||||
|
||||
from .executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
BadRequest,
|
||||
ValidationFailed,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotImplementedYet,
|
||||
InternalError,
|
||||
compute_invalidation,
|
||||
execute_function,
|
||||
)
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"mizan_exception_handler",
|
||||
"mizan_validation_handler",
|
||||
"execute_function",
|
||||
"compute_invalidation",
|
||||
"ErrorCode",
|
||||
"MizanError",
|
||||
"NotFound",
|
||||
"BadRequest",
|
||||
"ValidationFailed",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotImplementedYet",
|
||||
"InternalError",
|
||||
]
|
||||
263
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
263
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
RPC dispatch — looks up registered functions, validates input against the
|
||||
function's Pydantic Input model, executes, and returns the serialized result.
|
||||
|
||||
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||
let them propagate to your own handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
|
||||
# ─── 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]:
|
||||
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
||||
return [_invalidation_target(target, input_data or {}) for target in affects]
|
||||
|
||||
|
||||
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
||||
"""Build the `merge` list from @client(merge=...) metadata.
|
||||
|
||||
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||
function inside the context bundle the value lands in. The slot is
|
||||
resolved server-side via `types_match_for_merge` so the kernel does
|
||||
no shape inference — the server has the schema, type-checked routing
|
||||
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||
with a warning; the consumer falls back to refetch via `affects`.
|
||||
"""
|
||||
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||
if not targets:
|
||||
return []
|
||||
mutation_output = getattr(view_class, "Output", None)
|
||||
out: list[dict[str, Any]] = []
|
||||
for ctx_name in targets:
|
||||
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||
if slot is None:
|
||||
continue
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
||||
scoped = _scoped_params(ctx_name, input_data or {})
|
||||
if scoped:
|
||||
entry["params"] = scoped
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||
"""Find the unique function-name slot whose return type matches the mutation's output.
|
||||
|
||||
Returns None on no match or ambiguous match (multiple candidates).
|
||||
"""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
matches: list[str] = []
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Match input args against the context's declared Input field names."""
|
||||
fn_names = get_context_groups().get(context_name, [])
|
||||
declared: set[str] = set()
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
declared.update(input_cls.model_fields.keys())
|
||||
return {k: v for k, v in input_data.items() if k in declared}
|
||||
|
||||
|
||||
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||
match target.get("type"):
|
||||
case "context":
|
||||
name = target["name"]
|
||||
scoped = _scoped_params(name, input_data)
|
||||
return {"context": name, "params": scoped} if scoped else name
|
||||
case "function":
|
||||
return {"function": target["name"]}
|
||||
case _:
|
||||
return target
|
||||
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Mizan IR (KDL) export CLI for FastAPI backends.
|
||||
|
||||
Usage:
|
||||
python -m mizan_fastapi.ir <module>
|
||||
|
||||
Imports the named module (whose import side effects must register every
|
||||
@client function with `mizan_core.registry`), then writes the canonical
|
||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if len(args) != 1:
|
||||
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
module_name = args[0]
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
except Exception as e:
|
||||
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
sys.stdout.write(build_ir())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
109
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
109
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
FastAPI router exposing Mizan's HTTP endpoints:
|
||||
|
||||
POST /call/ — RPC dispatch
|
||||
GET /ctx/{context_name}/ — bundled context fetch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
from .executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
compute_invalidation,
|
||||
compute_merges,
|
||||
execute_function,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
||||
return JSONResponse(payload, status_code=status_code, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/session/")
|
||||
async def session_init() -> JSONResponse:
|
||||
"""Session-init probe. Parity with mizan-django's session endpoint.
|
||||
|
||||
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||
null token so the response shape stays uniform across backends. The
|
||||
wire-parity harness uses this endpoint as its readiness probe.
|
||||
"""
|
||||
return _no_store({"csrfToken": None})
|
||||
|
||||
|
||||
class CallBody(BaseModel):
|
||||
fn: str = Field(..., min_length=1)
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.post("/call/")
|
||||
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||
fn_class = get_function(body.fn)
|
||||
result = await execute_function(request, body.fn, body.args)
|
||||
invalidate = compute_invalidation(fn_class, body.args)
|
||||
merges = compute_merges(fn_class, body.args, result)
|
||||
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||
if merges:
|
||||
payload["merge"] = merges
|
||||
return _no_store(payload)
|
||||
|
||||
|
||||
@router.get("/ctx/{context_name}/")
|
||||
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||
fn_names = get_context_groups().get(context_name)
|
||||
if not fn_names:
|
||||
raise NotFound(f"Context '{context_name}' not found")
|
||||
|
||||
params = dict(request.query_params)
|
||||
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||
return _no_store(bundled)
|
||||
|
||||
|
||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def mizan_exception_handler(_request: Request, exc: MizanError) -> JSONResponse:
|
||||
"""FastAPI exception handler — renders MizanError to the protocol's error envelope."""
|
||||
body: dict[str, Any] = {"error": {"code": exc.code.value, "message": exc.message}}
|
||||
if exc.details:
|
||||
body["error"]["details"] = exc.details
|
||||
return _no_store(body, status_code=exc.status_code)
|
||||
|
||||
|
||||
async def mizan_validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""Maps malformed request bodies (invalid JSON, missing top-level fields) to BAD_REQUEST."""
|
||||
return _no_store(
|
||||
{
|
||||
"error": {
|
||||
"code": ErrorCode.BAD_REQUEST.value,
|
||||
"message": "Invalid request body",
|
||||
"details": {"errors": exc.errors()},
|
||||
}
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
0
backends/mizan-fastapi/tests/__init__.py
Normal file
0
backends/mizan-fastapi/tests/__init__.py
Normal file
262
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
262
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SumOutput(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
email: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
class ItemOutput(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
|
||||
clear_registry()
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"echo: {text}")
|
||||
|
||||
@client
|
||||
def add(request, a: int, b: int) -> SumOutput:
|
||||
return SumOutput(total=a + b)
|
||||
|
||||
@client(context="user")
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email="anon@example.com", authenticated=False)
|
||||
|
||||
@client(context="user")
|
||||
def user_count(request) -> SumOutput:
|
||||
return SumOutput(total=42)
|
||||
|
||||
@client(affects="user")
|
||||
def update_email(request, email: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"updated: {email}")
|
||||
|
||||
@client(auth=True)
|
||||
def whoami(request) -> UserOutput:
|
||||
return UserOutput(email="real@example.com", authenticated=True)
|
||||
|
||||
@client
|
||||
def list_items(request) -> list[ItemOutput]:
|
||||
return [ItemOutput(id=1, name="a"), ItemOutput(id=2, name="b")]
|
||||
|
||||
@client
|
||||
def find_item(request, item_id: int) -> ItemOutput | None:
|
||||
return ItemOutput(id=item_id, name="found") if item_id > 0 else None
|
||||
|
||||
@client(merge="items")
|
||||
def set_item_name(request, id: int, name: str) -> ItemOutput:
|
||||
return ItemOutput(id=id, name=name)
|
||||
|
||||
@client(context="items")
|
||||
def items_list(request) -> list[ItemOutput]:
|
||||
return [ItemOutput(id=1, name="orig")]
|
||||
|
||||
@client
|
||||
async def async_echo(request, text: str) -> EchoOutput:
|
||||
# await something on the loop to prove we're really running async
|
||||
await asyncio.sleep(0)
|
||||
return EchoOutput(message=f"async: {text}")
|
||||
|
||||
register(echo, "echo")
|
||||
register(add, "add")
|
||||
register(current_user, "current_user")
|
||||
register(user_count, "user_count")
|
||||
register(update_email, "update_email")
|
||||
register(whoami, "whoami")
|
||||
register(list_items, "list_items")
|
||||
register(find_item, "find_item")
|
||||
register(set_item_name, "set_item_name")
|
||||
register(items_list, "items_list")
|
||||
register(async_echo, "async_echo")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ─── RPC dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FunctionCallTests:
|
||||
def test_simple_call_returns_result(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["message"] == "echo: hi"
|
||||
assert body["invalidate"] == []
|
||||
|
||||
def test_call_with_typed_input(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["total"] == 5
|
||||
|
||||
def test_unknown_function_returns_not_found(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "ghost"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
def test_validation_error_returns_422(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_missing_required_input_returns_validation_error(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_missing_fn_field_returns_400(self, http):
|
||||
r = http.post("/api/mizan/call/", json={})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"]["code"] == "BAD_REQUEST"
|
||||
|
||||
def test_invalid_json_returns_400(self, http):
|
||||
r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_response_carries_no_store(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}})
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
# ─── Context bundling ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ContextFetchTests:
|
||||
def test_context_returns_bundled_results(self, http):
|
||||
r = http.get("/api/mizan/ctx/user/")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "current_user" in body
|
||||
assert "user_count" in body
|
||||
assert body["current_user"]["email"] == "anon@example.com"
|
||||
assert body["user_count"]["total"] == 42
|
||||
|
||||
def test_unknown_context_returns_not_found(self, http):
|
||||
r = http.get("/api/mizan/ctx/ghost/")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuthTests:
|
||||
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
|
||||
|
||||
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||
assert r.status_code == 401
|
||||
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
class InvalidationTests:
|
||||
def test_mutation_emits_invalidate_list(self, http):
|
||||
r = http.post(
|
||||
"/api/mizan/call/",
|
||||
json={"fn": "update_email", "args": {"email": "new@example.com"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# affects='user' is a context-name string → invalidate list contains 'user'
|
||||
assert "user" in body["invalidate"]
|
||||
|
||||
|
||||
# ─── Structured-output shapes ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class StructuredOutputTests:
|
||||
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
||||
|
||||
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"] == [
|
||||
{"id": 1, "name": "a"},
|
||||
{"id": 2, "name": "b"},
|
||||
]
|
||||
|
||||
def test_optional_basemodel_returns_inner_or_none(self, http):
|
||||
r_found = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 5}})
|
||||
assert r_found.status_code == 200
|
||||
assert r_found.json()["result"] == {"id": 5, "name": "found"}
|
||||
|
||||
r_missing = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 0}})
|
||||
assert r_missing.status_code == 200
|
||||
assert r_missing.json()["result"] is None
|
||||
|
||||
|
||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AsyncHandlerTests:
|
||||
"""`async def` handlers dispatch on the loop via view.acall."""
|
||||
|
||||
def test_async_handler_returns_awaited_result(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"] == {"message": "async: hello"}
|
||||
|
||||
|
||||
class MergeTests:
|
||||
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
||||
|
||||
def test_merge_target_emits_merge_entry(self, http):
|
||||
r = http.post(
|
||||
"/api/mizan/call/",
|
||||
json={"fn": "set_item_name", "args": {"id": 42, "name": "renamed"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
||||
assert body["merge"] == [
|
||||
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||
]
|
||||
# invalidate stays empty when only merge is declared
|
||||
assert body["invalidate"] == []
|
||||
591
backends/mizan-rust-axum/Cargo.lock
generated
Normal file
591
backends/mizan-rust-axum/Cargo.lock
generated
Normal file
@@ -0,0 +1,591 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linkme"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
|
||||
dependencies = [
|
||||
"linkme-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkme-impl"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mizan-axum"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"mizan-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mizan-macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
15
backends/mizan-rust-axum/Cargo.toml
Normal file
15
backends/mizan-rust-axum/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "mizan-axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "axum HTTP adapter for Mizan — typed RPC dispatch + context-bundle fetch on top of mizan-core's compile-time function registry."
|
||||
license = "Elastic-2.0"
|
||||
|
||||
[dependencies]
|
||||
mizan-core = { path = "../../cores/mizan-rust" }
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
27
backends/mizan-rust-axum/src/errors.rs
Normal file
27
backends/mizan-rust-axum/src/errors.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
||||
//! with a Cache-Control: no-store header.
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::MizanError;
|
||||
|
||||
pub struct ApiError(pub MizanError);
|
||||
|
||||
impl From<MizanError> for ApiError {
|
||||
fn from(e: MizanError) -> Self {
|
||||
Self(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = StatusCode::from_u16(self.0.http_status())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut resp = (status, Json(self.0.to_json())).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
}
|
||||
162
backends/mizan-rust-axum/src/handlers.rs
Normal file
162
backends/mizan-rust-axum/src/handlers.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
|
||||
/// Type-erased application state threaded into every `dispatch()` call via
|
||||
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallBody {
|
||||
pub fn_: Option<String>,
|
||||
#[serde(rename = "fn")]
|
||||
pub function_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl CallBody {
|
||||
fn resolved_name(&self) -> Option<&str> {
|
||||
self.function_name
|
||||
.as_deref()
|
||||
.or(self.fn_.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CallResponse {
|
||||
pub result: Value,
|
||||
pub invalidate: Vec<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub merge: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn no_store(json: Value) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch.
|
||||
pub async fn function_call(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Json(body): Json<CallBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let fn_name = body
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
|
||||
let fn_spec = lookup_function(&fn_name)
|
||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let payload = CallResponse {
|
||||
result,
|
||||
invalidate,
|
||||
merge: merge_payload,
|
||||
};
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
pub async fn context_fetch(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Path(context_name): Path<String>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if lookup_context(&context_name).is_none() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(&context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||
// params get parsed via the per-function input_params primitive table.
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
Ok(no_store(Value::Object(bundled)))
|
||||
}
|
||||
|
||||
/// Coerce string-valued query params into typed JSON values using the
|
||||
/// function's declared input_params. Strings that don't parse stay as
|
||||
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
||||
fn coerce_query_args(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
params: &BTreeMap<String, String>,
|
||||
) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(raw) = params.get(ip.name) {
|
||||
let parsed = match ip.primitive {
|
||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||
serde_json::Number::from_f64(v).map(Value::Number)
|
||||
}),
|
||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||
};
|
||||
if let Some(v) = parsed {
|
||||
out.insert(ip.name.into(), v);
|
||||
} else {
|
||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||
/// readiness-probe consumers see a well-formed response.
|
||||
pub async fn session_init() -> Response {
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
}
|
||||
58
backends/mizan-rust-axum/src/lib.rs
Normal file
58
backends/mizan-rust-axum/src/lib.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```ignore
|
||||
//! use axum::Router;
|
||||
//! use mizan_axum::router;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let app = Router::new().nest("/api/mizan", router());
|
||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||
//! axum::serve(listener, app).await.unwrap();
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
mod errors;
|
||||
mod handlers;
|
||||
|
||||
pub use errors::ApiError;
|
||||
pub use handlers::{
|
||||
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
||||
};
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Build the Mizan router with user-supplied app state. The state is
|
||||
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
||||
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
|
||||
/// type.
|
||||
///
|
||||
/// Mount under a prefix:
|
||||
/// `Router::new().nest("/api/mizan", router(my_state))`.
|
||||
pub fn router<S>(state: S) -> Router
|
||||
where
|
||||
S: Any + Send + Sync + 'static,
|
||||
{
|
||||
let state: AppStateAny = Arc::new(state);
|
||||
Router::new()
|
||||
.route("/session/", get(handlers::session_init))
|
||||
.route("/call/", post(handlers::function_call))
|
||||
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Router variant for callers that have no app state to thread — the
|
||||
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
||||
/// and other stateless test apps.
|
||||
pub fn router_stateless() -> Router {
|
||||
router(())
|
||||
}
|
||||
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
backends/mizan-tauri/Cargo.toml
Normal file
12
backends/mizan-tauri/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "mizan-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
|
||||
license = "Elastic-2.0"
|
||||
|
||||
[dependencies]
|
||||
mizan-core = { path = "../../cores/mizan-rust" }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
290
backends/mizan-tauri/README.md
Normal file
290
backends/mizan-tauri/README.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# mizan-tauri
|
||||
|
||||
Tauri backend adapter for the Mizan protocol. One plugin call on the Rust
|
||||
side. `#[mizan::client]` on async functions. Typed React client generated.
|
||||
Invalidation automatic — same protocol surface as mizan-fastapi /
|
||||
mizan-django / mizan-rust-axum, routed through Tauri's IPC instead of HTTP.
|
||||
|
||||
## Scope
|
||||
|
||||
mizan-tauri targets the **AFI-common subset** — RPC dispatch, context
|
||||
bundling, server-driven invalidation/merge. The transport channel is
|
||||
Tauri's `invoke()`; the dispatch table is the linkme-backed `FUNCTIONS`
|
||||
slice from `mizan-core`. No HTTP server is involved — the Tauri runtime
|
||||
handles message framing, the plugin handles dispatch.
|
||||
|
||||
Forms / SSR / Channels are out of scope (those are Django-side primitives).
|
||||
Tauri apps using mizan-tauri get RPC + context bundling + invalidation,
|
||||
nothing more.
|
||||
|
||||
## Install
|
||||
|
||||
```toml
|
||||
# src-tauri/Cargo.toml
|
||||
[dependencies]
|
||||
tauri = "2"
|
||||
mizan-core = { path = "../../mizan/cores/mizan-rust" }
|
||||
mizan-tauri = { path = "../../mizan/backends/mizan-tauri" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@mizan/base": "file:../mizan/frontends/mizan-base",
|
||||
"@mizan/tauri-transport": "file:../mizan/frontends/mizan-tauri-transport",
|
||||
"@tauri-apps/api": "^2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Setup — Rust
|
||||
|
||||
Install the plugin on the Tauri builder. The plugin registers a single
|
||||
command (`plugin:mizan|mizan_invoke`) that routes call/fetch envelopes
|
||||
through the function registry. No per-function `#[tauri::command]` is
|
||||
needed; the macro-emitted FunctionSpec IS the dispatch table.
|
||||
|
||||
```rust
|
||||
// src-tauri/src/lib.rs
|
||||
mod commands;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(mizan_tauri::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
|
||||
`commands` must be reachable from the binary's link graph — `mod
|
||||
commands;` works (private mod stays linked because `lib.rs` references
|
||||
it through file inclusion). If a separate binary (e.g. the IR-export
|
||||
bin below) also needs to see the registrations, mark it `pub mod
|
||||
commands;` so the integration-test / sibling-binary path can force-link.
|
||||
|
||||
## Define server functions
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands.rs
|
||||
use mizan_core::{self as mizan, MizanError, RequestHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, mizan_core::Mizan)]
|
||||
pub struct Greeting {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[mizan::client]
|
||||
pub async fn greet(_req: &RequestHandle<'_>, name: String) -> Greeting {
|
||||
Greeting { message: format!("hello, {name}") }
|
||||
}
|
||||
|
||||
// Result<T, MizanError> is supported when the function can fail; the
|
||||
// dispatch wrapper `?`-unwraps it so server-side errors surface as the
|
||||
// protocol's standard {code, message, details?} envelope.
|
||||
#[mizan::client]
|
||||
pub async fn read_file(
|
||||
_req: &RequestHandle<'_>,
|
||||
path: String,
|
||||
) -> Result<Greeting, MizanError> {
|
||||
let body = std::fs::read_to_string(&path)
|
||||
.map_err(|e| MizanError::NotFound(e.to_string()))?;
|
||||
Ok(Greeting { message: body })
|
||||
}
|
||||
```
|
||||
|
||||
`#[mizan::client]` parameters mirror the other backends — `context = …`,
|
||||
`affects = …`, `merge = …`, `private`. See `mizan-rust-axum`'s README for
|
||||
the full set.
|
||||
|
||||
### App-state access
|
||||
|
||||
The first parameter is `req: &RequestHandle<'_>` — the same handle the
|
||||
HTTP adapter threads through. Inside a Tauri-mounted plugin, the handle
|
||||
wraps `tauri::AppHandle`, so user functions can downcast for access to
|
||||
Tauri's managed-state container or event emission:
|
||||
|
||||
```rust
|
||||
#[mizan::client]
|
||||
pub async fn store_value(req: &RequestHandle<'_>, key: String) -> Greeting {
|
||||
let app = req.downcast::<tauri::AppHandle>()
|
||||
.expect("Tauri AppHandle threaded by mizan-tauri");
|
||||
// app.state::<MyState>(), app.emit(...), etc.
|
||||
Greeting { message: format!("stored {key}") }
|
||||
}
|
||||
```
|
||||
|
||||
Stateless functions ignore the handle (`_req: &RequestHandle<'_>`).
|
||||
|
||||
## IR export binary
|
||||
|
||||
mizan-generate needs the consumer crate's IR. Add a small bin that
|
||||
references each `#[mizan::client]` function (so linkme keeps the
|
||||
distributed slice's entries) and prints `mizan_core::build_ir()`:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/bin/emit_mizan_ir.rs
|
||||
//
|
||||
// Cargo.toml adds:
|
||||
// [[bin]]
|
||||
// name = "emit-mizan-ir"
|
||||
// path = "src/bin/emit_mizan_ir.rs"
|
||||
//
|
||||
// linkme only collects from translation units that survive
|
||||
// dead-code elimination; this fn names one item per file carrying
|
||||
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
|
||||
// keeps them in the final binary.
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _force_link() {
|
||||
use my_app_lib::commands;
|
||||
let _ = commands::greet;
|
||||
let _ = commands::read_file;
|
||||
// ... one per #[mizan::client] function
|
||||
}
|
||||
|
||||
fn main() {
|
||||
_force_link();
|
||||
print!("{}", mizan_core::build_ir());
|
||||
}
|
||||
```
|
||||
|
||||
## Generate the frontend
|
||||
|
||||
```toml
|
||||
# mizan.toml at the project root
|
||||
project_id = "my-tauri-app"
|
||||
output = "src/api"
|
||||
targets = ["react"]
|
||||
|
||||
[source.rust]
|
||||
manifest_path = "src-tauri/Cargo.toml"
|
||||
bin = "emit-mizan-ir"
|
||||
|
||||
# Optional — author the Rust types from Pydantic models via decoru.
|
||||
# Omit this block for pure-Rust usage.
|
||||
[source.rust.pydantic]
|
||||
module = "my_app.schema"
|
||||
output = "src-tauri/src/schema.rs"
|
||||
command = ["uv", "run", "python"] # any python with `decoru` importable
|
||||
header = """\
|
||||
// AUTO-GENERATED by mizan-generate (source.rust.pydantic step).
|
||||
// Source of truth: my_app/schema.py.
|
||||
// DO NOT EDIT BY HAND. Regenerate with: `mizan-generate`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
"""
|
||||
```
|
||||
|
||||
```bash
|
||||
mizan-generate --config mizan.toml
|
||||
```
|
||||
|
||||
The Pydantic pre-step auto-discovers `BaseModel` subclasses AND `Enum`
|
||||
subclasses declared in the named module; decoru emits the structs, and a
|
||||
small inline emitter renders enums (PascalCase variants from Python
|
||||
member names, `#[serde(rename_all = "snake_case")]`, `#[default]` on the
|
||||
last variant so decoru's `impl Default` keeps compiling).
|
||||
|
||||
The Rust step then runs `cargo run --bin emit-mizan-ir`, parses the
|
||||
emitted KDL, and dispatches the configured `targets` to their emitters
|
||||
(`stage1` → typed `callXxx`/`fetchXxx`; `react` → `<MizanContext>` +
|
||||
per-context providers + `use{Hook}()` hooks).
|
||||
|
||||
## Setup — TS
|
||||
|
||||
```tsx
|
||||
// src/main.tsx
|
||||
import { configure } from "@mizan/base";
|
||||
import { tauriTransport } from "@mizan/tauri-transport";
|
||||
|
||||
// Route every mizanCall / mizanFetch through Tauri's IPC. Must run
|
||||
// before any generated callXxx() executes — top-level at the module
|
||||
// entry is the safe place.
|
||||
configure({ transport: tauriTransport() });
|
||||
```
|
||||
|
||||
```tsx
|
||||
// any component
|
||||
import { callGreet } from "@/api";
|
||||
|
||||
const greeting = await callGreet({ name: "world" });
|
||||
console.log(greeting.message);
|
||||
```
|
||||
|
||||
For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the
|
||||
imperative `callGreet` with `isPending`/`error` state), wrap your tree
|
||||
with `<MizanContext>` at the root — same as the HTTP-transport setup. The
|
||||
generated provider is transport-agnostic; it reads from `config.transport`
|
||||
the kernel is using.
|
||||
|
||||
### tsconfig / vite preserve symlinks
|
||||
|
||||
The `@mizan/*` packages are typically linked via `file:` in package.json.
|
||||
Without `preserveSymlinks`, both TypeScript and Vite/Rollup follow the
|
||||
symlinks to their real location and fail to resolve the linked packages'
|
||||
peer dependencies (`@tauri-apps/api`, `@mizan/base`) from there.
|
||||
|
||||
```jsonc
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"preserveSymlinks": true,
|
||||
// …
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
},
|
||||
// …
|
||||
});
|
||||
```
|
||||
|
||||
## Wire protocol
|
||||
|
||||
Same envelope as the HTTP adapter, wrapped in a Tauri invoke payload:
|
||||
|
||||
```ts
|
||||
// call
|
||||
invoke('plugin:mizan|mizan_invoke', {
|
||||
envelope: { op: 'call', fn: 'greet', args: { name: 'world' } }
|
||||
})
|
||||
// → { result: { message: "hello, world" }, invalidate: [], merge?: [...] }
|
||||
|
||||
// fetch (context bundling)
|
||||
invoke('plugin:mizan|mizan_invoke', {
|
||||
envelope: { op: 'fetch', context: 'user', params: { user_id: 42 } }
|
||||
})
|
||||
// → { user_profile: {...}, user_orders: [...] } (flat bundle)
|
||||
```
|
||||
|
||||
Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport`
|
||||
re-wraps them into the same `MizanError` shape the HTTP transport
|
||||
produces, so consumer code is identical regardless of transport.
|
||||
|
||||
## Reference application
|
||||
|
||||
`claude-manage` is the production reference — Tauri + React + Pydantic
|
||||
schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and
|
||||
`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app.
|
||||
|
||||
## Architecture
|
||||
|
||||
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
|
||||
adapters dispatch through the same compile-time `FUNCTIONS` registry,
|
||||
same `compute_invalidation` / `compute_merges` logic, same KDL IR
|
||||
emitted by `build_ir()`. The only difference is the wire surface — axum
|
||||
takes POST `/call/` and GET `/ctx/:name/`, mizan-tauri takes a single
|
||||
`mizan_invoke` command with an op-tagged envelope.
|
||||
220
backends/mizan-tauri/src/lib.rs
Normal file
220
backends/mizan-tauri/src/lib.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tauri::Builder::default()
|
||||
//! .plugin(mizan_tauri::init())
|
||||
//! .run(tauri::generate_context!())
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||
//! consumes. There is no per-function tauri::command; the registry IS
|
||||
//! the dispatch table.
|
||||
//!
|
||||
//! Wire envelope:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||
//! see one error surface regardless of transport.
|
||||
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
};
|
||||
|
||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::<R>::new("mizan")
|
||||
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||
.build()
|
||||
}
|
||||
|
||||
// === Wire envelope ===
|
||||
|
||||
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||
/// field of the invoke payload.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op")]
|
||||
pub enum Envelope {
|
||||
#[serde(rename = "call")]
|
||||
Call {
|
||||
/// Wire-level function name — registered name on the Rust side.
|
||||
#[serde(rename = "fn")]
|
||||
function_name: String,
|
||||
#[serde(default)]
|
||||
args: Map<String, Value>,
|
||||
},
|
||||
#[serde(rename = "fetch")]
|
||||
Fetch {
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
params: Map<String, Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl From<MizanError> for ErrorPayload {
|
||||
fn from(e: MizanError) -> Self {
|
||||
let details = if let MizanError::ValidationFailed { details, .. } = &e {
|
||||
Some(details.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
code: e.code(),
|
||||
message: e.message().to_string(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Dispatch ===
|
||||
|
||||
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||
/// handler — the consumer never wires it directly.
|
||||
///
|
||||
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
envelope: Envelope,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
match envelope {
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
fn_name: &str,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||
ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let mut payload = json!({
|
||||
"result": result,
|
||||
"invalidate": invalidate,
|
||||
});
|
||||
if let Some(merge) = merge_payload {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.expect("payload is a JSON object")
|
||||
.insert("merge".into(), Value::Array(merge));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn handle_fetch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as
|
||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||
/// carries typed JSON, so the filter is sufficient on its own.
|
||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
out.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "Elastic-2.0"
|
||||
}
|
||||
44
backends/mizan-ts/src/cache/backend.ts
vendored
Normal file
44
backends/mizan-ts/src/cache/backend.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Cache backends — MemoryCache for testing.
|
||||
*
|
||||
* Simple key-value store. No reverse indexes.
|
||||
*/
|
||||
|
||||
export interface CacheBackend {
|
||||
get(key: string): string | null
|
||||
set(key: string, value: string): void
|
||||
delete(key: string): boolean
|
||||
deleteByPrefix(prefix: string): number
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export class MemoryCache implements CacheBackend {
|
||||
private _store = new Map<string, string>()
|
||||
|
||||
get(key: string): string | null {
|
||||
return this._store.get(key) ?? null
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
this._store.set(key, value)
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
return this._store.delete(key)
|
||||
}
|
||||
|
||||
deleteByPrefix(prefix: string): number {
|
||||
let count = 0
|
||||
for (const key of [...this._store.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._store.delete(key)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._store.clear()
|
||||
}
|
||||
}
|
||||
72
backends/mizan-ts/src/cache/index.ts
vendored
Normal file
72
backends/mizan-ts/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* mizan cache — TypeScript adapter.
|
||||
*
|
||||
* Same protocol as Python's mizan.cache. Cross-language conformance
|
||||
* verified by pin tests. No reverse indexes — scoped purge recomputes
|
||||
* the key directly, broad purge uses prefix scan.
|
||||
*/
|
||||
|
||||
export { MemoryCache } from './backend'
|
||||
export type { CacheBackend } from './backend'
|
||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
|
||||
import type { CacheBackend } from './backend'
|
||||
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
|
||||
let _cacheInstance: CacheBackend | null = null
|
||||
|
||||
export function getCache(): CacheBackend | null {
|
||||
return _cacheInstance
|
||||
}
|
||||
|
||||
export function setCache(backend: CacheBackend | null): void {
|
||||
_cacheInstance = backend
|
||||
}
|
||||
|
||||
export function resetCache(): void {
|
||||
_cacheInstance = null
|
||||
}
|
||||
|
||||
export function cacheGet(
|
||||
secret: string,
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
params: Record<string, any>,
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): string | null {
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
return backend.get(key)
|
||||
}
|
||||
|
||||
export function cachePut(
|
||||
secret: string,
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
params: Record<string, any>,
|
||||
value: string,
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): void {
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
backend.set(key, value)
|
||||
}
|
||||
|
||||
export function cachePurge(
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
params?: Record<string, any> | null,
|
||||
secret?: string | null,
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): number {
|
||||
if (params && secret) {
|
||||
// Scoped purge — recompute key and delete directly
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
return backend.delete(key) ? 1 : 0
|
||||
} else {
|
||||
// Broad purge — prefix scan
|
||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||
return backend.deleteByPrefix(prefix)
|
||||
}
|
||||
}
|
||||
57
backends/mizan-ts/src/cache/keys.ts
vendored
Normal file
57
backends/mizan-ts/src/cache/keys.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
*
|
||||
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
||||
* Cross-language conformance verified by pin tests.
|
||||
*
|
||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
const CONTEXT_KEY_PREFIX = 'ctx:'
|
||||
|
||||
/**
|
||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
||||
*/
|
||||
function stableStringify(obj: any): string {
|
||||
if (obj === null || obj === undefined) return 'null'
|
||||
if (typeof obj === 'string') return JSON.stringify(obj)
|
||||
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
|
||||
if (Array.isArray(obj)) {
|
||||
return '[' + obj.map(stableStringify).join(',') + ']'
|
||||
}
|
||||
const keys = Object.keys(obj).sort()
|
||||
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
|
||||
return '{' + pairs.join(',') + '}'
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic HMAC-SHA256 cache key.
|
||||
*
|
||||
* Returns "ctx:{context}:{hmac_hex}".
|
||||
*/
|
||||
export function deriveCacheKey(
|
||||
secret: string,
|
||||
context: string,
|
||||
params: Record<string, any>,
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): string {
|
||||
const sortedParams: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
||||
sortedParams[k] = String(v)
|
||||
}
|
||||
|
||||
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
|
||||
if (userId !== undefined) {
|
||||
keyData.u = String(userId)
|
||||
}
|
||||
|
||||
const message = stableStringify(keyData)
|
||||
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
|
||||
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
|
||||
}
|
||||
|
||||
export { CONTEXT_KEY_PREFIX }
|
||||
@@ -52,10 +52,6 @@ function extractParams(fn: Function): ParamDef[] {
|
||||
})
|
||||
}
|
||||
|
||||
function isResponseReturn(result: any): boolean {
|
||||
return result instanceof Response
|
||||
}
|
||||
|
||||
/**
|
||||
* Function wrapper — registers a standalone function.
|
||||
*
|
||||
@@ -102,6 +98,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
|
||||
register(entry)
|
||||
@@ -132,6 +130,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
|
||||
register(entry)
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
import { getFunction, getContextGroups } from './registry'
|
||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||
|
||||
let _cacheSecret: string | null = null
|
||||
|
||||
/** Set the cache secret for origin-side caching. */
|
||||
export function setCacheSecret(secret: string | null): void {
|
||||
_cacheSecret = secret
|
||||
}
|
||||
|
||||
export interface MizanResponse {
|
||||
status: number
|
||||
@@ -14,10 +22,6 @@ export interface MizanResponse {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
function sortedStringify(data: any): string {
|
||||
return JSON.stringify(data, Object.keys(data).sort())
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /api/mizan/ctx/:contextName/
|
||||
*
|
||||
@@ -38,6 +42,29 @@ export async function handleContextFetch(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||
let effectiveRev = 0
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
|
||||
}
|
||||
|
||||
// Origin-side cache lookup
|
||||
const cacheBackend = getCache()
|
||||
const cacheSecret = _cacheSecret
|
||||
if (cacheBackend && cacheSecret) {
|
||||
try {
|
||||
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
|
||||
if (cached !== null) {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.parse(cached),
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss on error */ }
|
||||
}
|
||||
|
||||
const results: Record<string, any> = {}
|
||||
|
||||
for (const fnName of fnNames) {
|
||||
@@ -67,12 +94,33 @@ export async function handleContextFetch(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve effective cache policy for origin-side cache decision
|
||||
let effectiveCache: number | boolean = true
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (!entry) continue
|
||||
if (entry.cache === false) { effectiveCache = false; break }
|
||||
if (typeof entry.cache === 'number') {
|
||||
effectiveCache = effectiveCache === true
|
||||
? entry.cache
|
||||
: Math.min(effectiveCache as number, entry.cache)
|
||||
}
|
||||
}
|
||||
|
||||
// Store in origin-side cache (skip if cache=False)
|
||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||
try {
|
||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||
} catch { /* cache store failure is non-fatal */ }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: results,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=0, s-maxage=31536000',
|
||||
'Cache-Control': 'no-store',
|
||||
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -134,6 +182,20 @@ export async function handleMutationCall(
|
||||
if (invalidate) {
|
||||
responseData.invalidate = invalidate
|
||||
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
|
||||
|
||||
// Purge origin-side cache
|
||||
const cb = getCache()
|
||||
if (cb) {
|
||||
try {
|
||||
for (const entry of invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
cachePurge(cb, entry)
|
||||
} else {
|
||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
||||
}
|
||||
}
|
||||
} catch { /* purge failure is non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, body: responseData, headers }
|
||||
@@ -11,3 +11,7 @@ export type { MizanResponse } from './dispatch'
|
||||
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||
|
||||
export { generateManifest } from './manifest'
|
||||
|
||||
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
||||
export type { CacheBackend } from './cache'
|
||||
export { setCacheSecret } from './dispatch'
|
||||
@@ -88,7 +88,7 @@ export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
||||
const { context, params } = entry
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const paramStr = Object.entries(params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
||||
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
|
||||
.join(';')
|
||||
parts.push(`${context};${paramStr}`)
|
||||
@@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
fnEntry.methods = entry.methods || ['GET']
|
||||
pageRoutes.push(entry.route)
|
||||
}
|
||||
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
||||
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
|
||||
functions.push(fnEntry)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ClientOptions {
|
||||
route?: string
|
||||
methods?: string[]
|
||||
auth?: boolean
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ParamDef {
|
||||
@@ -36,6 +38,8 @@ export interface RegistryEntry {
|
||||
route?: string
|
||||
methods?: string[]
|
||||
auth?: boolean
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ManifestContext {
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src'
|
||||
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
@@ -52,11 +52,9 @@ describe('Edge Compatibility', () => {
|
||||
|
||||
// ── Cache-Control correctness ───────────────────────────────────────
|
||||
|
||||
test('context GET is cacheable', async () => {
|
||||
test('context GET emits no-store', async () => {
|
||||
const r = await handleContextFetch('user', { userId: '5' })
|
||||
expect(r.headers['Cache-Control']).toContain('public')
|
||||
expect(r.headers['Cache-Control']).toContain('s-maxage')
|
||||
expect(r.headers['Cache-Control']).not.toContain('no-store')
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
})
|
||||
|
||||
test('mutation POST not cacheable', async () => {
|
||||
@@ -228,4 +226,200 @@ describe('Manifest', () => {
|
||||
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
||||
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
||||
})
|
||||
|
||||
test('rev appears in manifest', () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('data')
|
||||
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
|
||||
return { value: itemId }
|
||||
})
|
||||
|
||||
const m = generateManifest()
|
||||
const fn = m.contexts.data.functions[0]
|
||||
expect(fn.rev).toBe(3)
|
||||
})
|
||||
|
||||
test('cache TTL appears in manifest', () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('trending')
|
||||
client({ context: Ctx, cache: 60 }, async function trendingFn() {
|
||||
return { items: [] }
|
||||
})
|
||||
|
||||
const m = generateManifest()
|
||||
const fn = m.contexts.trending.functions[0]
|
||||
expect(fn.cache).toBe(60)
|
||||
})
|
||||
|
||||
test('cache=60 still emits no-store on HTTP', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('live')
|
||||
client({ context: Ctx, cache: 60 }, async function liveFn() {
|
||||
return { score: 42 }
|
||||
})
|
||||
|
||||
const r = await handleContextFetch('live', {})
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
})
|
||||
|
||||
test('cache=false sets no-store', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('random')
|
||||
client({ context: Ctx, cache: false }, async function randomFn() {
|
||||
return { value: Math.random() }
|
||||
})
|
||||
|
||||
const r = await handleContextFetch('random', {})
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cache Conformance Tests ────────────────────────────────────────────
|
||||
|
||||
describe('Cache Conformance', () => {
|
||||
const SECRET = 'test-pin-secret-that-is-32bytes!'
|
||||
|
||||
test('deriveCacheKey determinism', () => {
|
||||
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||
expect(k1).toBe(k2)
|
||||
expect(k1).toStartWith('ctx:user:')
|
||||
expect(k1).toHaveLength('ctx:user:'.length + 64)
|
||||
})
|
||||
|
||||
test('deriveCacheKey param order irrelevant', () => {
|
||||
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
|
||||
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
|
||||
expect(k1).toBe(k2)
|
||||
})
|
||||
|
||||
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
||||
// These exact values are pinned from Python's derive_cache_key output.
|
||||
// If this test fails, cross-language cache key compatibility is broken.
|
||||
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||
|
||||
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
|
||||
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
|
||||
})
|
||||
|
||||
test('MemoryCache get/set/clear', () => {
|
||||
const cache = new MemoryCache()
|
||||
expect(cache.get('k1')).toBeNull()
|
||||
|
||||
cache.set('k1', '{"data":true}')
|
||||
expect(cache.get('k1')).toBe('{"data":true}')
|
||||
|
||||
cache.clear()
|
||||
expect(cache.get('k1')).toBeNull()
|
||||
})
|
||||
|
||||
test('scoped purge recomputes key directly', () => {
|
||||
const cache = new MemoryCache()
|
||||
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
|
||||
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
|
||||
|
||||
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
|
||||
expect(count).toBe(1)
|
||||
|
||||
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
|
||||
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
|
||||
})
|
||||
|
||||
test('broad purge removes all entries', () => {
|
||||
const cache = new MemoryCache()
|
||||
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
|
||||
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
|
||||
|
||||
const count = cachePurge(cache, 'user')
|
||||
expect(count).toBe(2)
|
||||
|
||||
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
|
||||
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
|
||||
})
|
||||
|
||||
test('handleContextFetch caches response', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('cached')
|
||||
client({ context: Ctx }, async function cachedFn(itemId: number) {
|
||||
return { value: itemId }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
const r1 = await handleContextFetch('cached', { itemId: '1' })
|
||||
expect(r1.status).toBe(200)
|
||||
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
const r2 = await handleContextFetch('cached', { itemId: '1' })
|
||||
expect(r2.status).toBe(200)
|
||||
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
|
||||
expect(r2.body).toEqual(r1.body)
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('handleMutationCall purges cache', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('product')
|
||||
client({ context: Ctx }, async function getProduct(productId: number) {
|
||||
return { id: productId }
|
||||
})
|
||||
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
// Prime cache
|
||||
await handleContextFetch('product', { productId: '1' })
|
||||
|
||||
// Mutate
|
||||
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
|
||||
|
||||
// Cache should be purged — next fetch is MISS
|
||||
const r = await handleContextFetch('product', { productId: '1' })
|
||||
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('scoped invalidation preserves other entries', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('user')
|
||||
client({ context: Ctx }, async function userProfile(userId: number) {
|
||||
return { name: `user_${userId}` }
|
||||
})
|
||||
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
// Prime both users
|
||||
await handleContextFetch('user', { userId: '5' })
|
||||
await handleContextFetch('user', { userId: '6' })
|
||||
|
||||
// Mutate only user 5
|
||||
await handleMutationCall('editUser', { userId: 5, name: 'New' })
|
||||
|
||||
// User 6 should still be cached
|
||||
const r6 = await handleContextFetch('user', { userId: '6' })
|
||||
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
|
||||
|
||||
// User 5 should be a miss
|
||||
const r5 = await handleContextFetch('user', { userId: '5' })
|
||||
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
})
|
||||
27
cores/mizan-python/pyproject.toml
Normal file
27
cores/mizan-python/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
license = "Elastic-2.0"
|
||||
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mizan_core"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
python_classes = ["*Tests", "*Test", "Test*"]
|
||||
python_functions = ["test_*"]
|
||||
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user