Compare commits
41 Commits
7daec1c2e2
...
c15c6f3e14
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: django
|
working-directory: backends/mizan-django
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: react
|
working-directory: frontends/mizan-react
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
467
CLAUDE.md
Normal file
467
CLAUDE.md
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
# Mizan — Technical Reference
|
||||||
|
|
||||||
|
## What Mizan Is
|
||||||
|
|
||||||
|
Mizan is an Application Framework Interface (AFI). One decorator on a server function. Typed client generated. Invalidation automatic. Caching protocol-driven. SSR via subprocess.
|
||||||
|
|
||||||
|
Django + React ships first. The protocol is language-agnostic (proven by mizan-ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Layout
|
||||||
|
|
||||||
|
Tree organized by role. Per-framework adapters wrap a single shared kernel; codegen targets the adapter.
|
||||||
|
|
||||||
|
```
|
||||||
|
backends/ server protocol adapters
|
||||||
|
mizan-django/ Django adapter
|
||||||
|
mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope)
|
||||||
|
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
|
||||||
|
frontends/ client kernel + per-framework adapters
|
||||||
|
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
|
||||||
|
mizan-react/ React contexts + hooks over the kernel
|
||||||
|
mizan-vue/ Vue composables over the kernel
|
||||||
|
mizan-svelte/ Svelte stores/runes over the kernel
|
||||||
|
cores/ shared language-level primitives
|
||||||
|
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
|
||||||
|
protocol/ protocol-level tooling
|
||||||
|
mizan-generate/ codegen — fetches schema from any backend, emits typed React/Vue/Svelte client
|
||||||
|
workers/ runtime workers / bridges
|
||||||
|
mizan-ssr/ Bun subprocess used by the Django template backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Three Protocols
|
||||||
|
|
||||||
|
### 1. RPC Protocol (Anti-REST)
|
||||||
|
|
||||||
|
No resources. No CRUD. Functions in, results out.
|
||||||
|
|
||||||
|
**Context fetch (reads):**
|
||||||
|
```
|
||||||
|
GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
||||||
|
|
||||||
|
200 OK
|
||||||
|
Cache-Control: no-store
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_profile": {"name": "Ryth", "email": "ryth@example.com"},
|
||||||
|
"user_orders": [{"id": 1, "total": 100}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All functions sharing a context name are bundled into one response. Keys are function names. Values are return values.
|
||||||
|
|
||||||
|
**Mutation call (writes):**
|
||||||
|
```
|
||||||
|
POST /api/mizan/call/
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}
|
||||||
|
|
||||||
|
200 OK
|
||||||
|
Cache-Control: no-store
|
||||||
|
X-Mizan-Invalidate: user;user_id=5
|
||||||
|
|
||||||
|
{
|
||||||
|
"result": {"ok": true},
|
||||||
|
"invalidate": [{"context": "user", "params": {"user_id": 5}}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Invalidation-on-Mutation Protocol
|
||||||
|
|
||||||
|
Two transports for the same signal. Both are first-class.
|
||||||
|
|
||||||
|
**Transport 1 — JSON body** (for RPC/SPA clients):
|
||||||
|
```json
|
||||||
|
{"result": {...}, "invalidate": ["user"]}
|
||||||
|
{"result": {...}, "invalidate": [{"context": "user", "params": {"user_id": 5}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transport 2 — HTTP header** (for Edge, htmx, view-path functions):
|
||||||
|
```
|
||||||
|
X-Mizan-Invalidate: user
|
||||||
|
X-Mizan-Invalidate: user;user_id=5
|
||||||
|
X-Mizan-Invalidate: user;user_id=5, notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: comma-separated contexts, semicolon-separated URL-encoded params per context.
|
||||||
|
|
||||||
|
**Three-tier auto-scoping** (no developer annotation needed):
|
||||||
|
1. **Argument name matching:** mutation has `user_id` param, context has `user_id` param → scoped automatically
|
||||||
|
2. **Auth inference:** Edge-side concern (reads JWT/MWT to extract user identity)
|
||||||
|
3. **Broad fallback:** invalidate all instances of the context
|
||||||
|
|
||||||
|
**Return-type branching** determines which transport:
|
||||||
|
- Function returns data (dict, BaseModel) → RPC path → JSON body + header
|
||||||
|
- Function returns HttpResponse (redirect, HTML) → View path → header only
|
||||||
|
|
||||||
|
### 3. Frontend-Agnostic Rendering (SSR + PSR)
|
||||||
|
|
||||||
|
**SSR** — Django template backend integration. `render(request, 'ProfilePage', props)` calls a persistent Bun subprocess that runs `renderToString`.
|
||||||
|
|
||||||
|
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
|
||||||
|
|
||||||
|
**The Bun worker protocol** — JSON-RPC over stdin/stdout:
|
||||||
|
```
|
||||||
|
→ {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {"userId": 5}}}
|
||||||
|
← {"id": 1, "html": "<div>...</div>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker stays alive across requests. Django's `SSRBridge` manages the subprocess lifecycle with thread-safe request correlation via message IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The @client Decorator — Full API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mizan import client, ReactContext, GlobalContext
|
||||||
|
|
||||||
|
UserContext = ReactContext('user')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `context` | `ReactContext \| str \| False` | `False` | Named context for grouping. `False` = standalone function. |
|
||||||
|
| `affects` | `ReactContext \| str \| list` | `None` | What this mutation invalidates. Mutually exclusive with `context`. |
|
||||||
|
| `private` | `bool` | `False` | Not client-callable. No RPC endpoint. No codegen. Still in invalidation graph. |
|
||||||
|
| `route` | `str \| None` | `None` | Mizan-owned URL pattern for view-path functions. |
|
||||||
|
| `methods` | `list[str] \| None` | `None` | HTTP methods for route. Default: `['GET']` for context, `['POST']` for mutation. |
|
||||||
|
| `auth` | `bool \| str \| callable \| None` | `None` | Auth requirement: `True`, `'staff'`, `'superuser'`, or `callable(request) -> bool`. |
|
||||||
|
| `websocket` | `bool` | `False` | Enable WebSocket RPC transport. |
|
||||||
|
| `rev` | `int` | `0` | Cache revision. Increment to bust cached entries on deploy. |
|
||||||
|
| `cache` | `int \| False` | (default) | Cache TTL hint. `False` = never cache. Integer = TTL seconds. |
|
||||||
|
|
||||||
|
### Usage Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Global context — auto-mounted at root, SSR-hydrated
|
||||||
|
@client(context=GlobalContext)
|
||||||
|
def current_user(request) -> UserShape:
|
||||||
|
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
||||||
|
|
||||||
|
# Named context — bundled GET, generates typed hooks
|
||||||
|
@client(context=UserContext)
|
||||||
|
def user_profile(request, user_id: int) -> UserShape:
|
||||||
|
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
|
||||||
|
|
||||||
|
@client(context=UserContext)
|
||||||
|
def user_orders(request, user_id: int) -> list[OrderShape]:
|
||||||
|
return OrderShape.query(lambda qs: qs.filter(user_id=user_id))
|
||||||
|
|
||||||
|
# Mutation — auto-scoped invalidation (user_id matches)
|
||||||
|
@client(affects=UserContext)
|
||||||
|
def update_profile(request, user_id: int, name: str) -> dict:
|
||||||
|
request.user.name = name
|
||||||
|
request.user.save()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Function-level affects — only user_profile refetches
|
||||||
|
@client(affects='user_profile')
|
||||||
|
def update_name(request, user_id: int, name: str) -> dict:
|
||||||
|
...
|
||||||
|
|
||||||
|
# View-path context — registered in invalidation graph, no codegen
|
||||||
|
@client(context=UserContext, route='/profile/<user_id>/')
|
||||||
|
def profile_page(request, user_id: int) -> HttpResponse:
|
||||||
|
return render(request, 'profile.html', {...})
|
||||||
|
|
||||||
|
# View-path mutation — invalidation via header on the redirect
|
||||||
|
@client(affects=UserContext, route='/profile/<user_id>/update/', methods=['POST'])
|
||||||
|
def update_profile_view(request, user_id: int) -> HttpResponse:
|
||||||
|
form = ProfileForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect(f'/profile/{user_id}/')
|
||||||
|
|
||||||
|
# Private webhook — not client-callable, emits invalidation
|
||||||
|
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
||||||
|
def stripe_webhook(request) -> HttpResponse:
|
||||||
|
event = json.loads(request.body)
|
||||||
|
process_stripe_event(event)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
# Auth guards
|
||||||
|
@client(auth=True)
|
||||||
|
def secret(request) -> dict: ...
|
||||||
|
|
||||||
|
@client(auth='staff')
|
||||||
|
def admin_action(request) -> dict: ...
|
||||||
|
|
||||||
|
@client(auth=lambda req: req.user.email.endswith('@company.com'))
|
||||||
|
def internal_tool(request) -> dict: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### _meta Dict Structure
|
||||||
|
|
||||||
|
After decoration, the function class has `_meta` with these possible keys:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"context": "user", # context name string (if context=)
|
||||||
|
"affects": [ # normalized affects targets (if affects=)
|
||||||
|
{"type": "context", "name": "user"},
|
||||||
|
{"type": "function", "name": "user_profile", "context": "user"},
|
||||||
|
],
|
||||||
|
"private": True, # if private=True
|
||||||
|
"route": "/webhooks/stripe/", # if route=
|
||||||
|
"methods": ["POST"], # if route= (defaults applied)
|
||||||
|
"view_path": True, # if return type is HttpResponse
|
||||||
|
"websocket": True, # if websocket=True
|
||||||
|
"auth": "required", # "required" | "staff" | "superuser" | callable
|
||||||
|
"rev": 3, # if rev=
|
||||||
|
"cache": 60, # if cache=
|
||||||
|
"form": True, # if form function
|
||||||
|
"form_name": "contact", # form name
|
||||||
|
"form_role": "schema", # "schema" | "validate" | "submit"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache System
|
||||||
|
|
||||||
|
### Required Settings
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
MIZAN_CACHE_SECRET = "your-32-byte-hmac-signing-key" # Required for cache
|
||||||
|
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0" # Required for cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must be set. If either is missing, caching is disabled with a warning.
|
||||||
|
|
||||||
|
### HMAC Key Derivation
|
||||||
|
|
||||||
|
Cache keys are derived from HMAC-SHA256 over a JSON-canonical form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
derive_cache_key(secret, context, params, user_id=None, rev=0) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
**Canonical form** (the HMAC message):
|
||||||
|
```json
|
||||||
|
{"c":"user","p":{"user_id":"5"},"r":0}
|
||||||
|
```
|
||||||
|
With optional `"u":"5"` for user-scoped entries.
|
||||||
|
|
||||||
|
- `c` = context name
|
||||||
|
- `p` = sorted params dict (all values stringified)
|
||||||
|
- `r` = revision number
|
||||||
|
- `u` = user ID (for auth-scoped cache entries)
|
||||||
|
|
||||||
|
**Key format:** `ctx:{context}:{hmac_hex}`
|
||||||
|
- Example: `ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6`
|
||||||
|
|
||||||
|
**Cross-language conformance:** The TypeScript adapter (`mizan-ts/src/cache/keys.ts`) produces identical keys for identical inputs. Pin tests verify this.
|
||||||
|
|
||||||
|
### Cache Operations
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mizan.cache import cache_get, cache_put, cache_purge
|
||||||
|
|
||||||
|
# Store
|
||||||
|
cache_put(secret, backend, "user", {"user_id": "5"}, b'{"name":"Ryth"}')
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
data = cache_get(secret, backend, "user", {"user_id": "5"})
|
||||||
|
|
||||||
|
# Scoped purge (recomputes HMAC, deletes one key)
|
||||||
|
cache_purge(backend, "user", params={"user_id": "5"}, secret=secret)
|
||||||
|
|
||||||
|
# Broad purge (SCAN by prefix "ctx:user:*")
|
||||||
|
cache_purge(backend, "user")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backends
|
||||||
|
|
||||||
|
**MemoryCache** — dict-based, for testing. No persistence.
|
||||||
|
|
||||||
|
**RedisCache** — production backend.
|
||||||
|
- Connection pooling (50 max connections)
|
||||||
|
- 24h default TTL safety net
|
||||||
|
- Key prefix: `mizan:` (configurable)
|
||||||
|
- `delete_by_prefix` uses Redis SCAN (1000 keys per batch)
|
||||||
|
- `delete` uses UNLINK (non-blocking)
|
||||||
|
|
||||||
|
### Cache Integration in Dispatch
|
||||||
|
|
||||||
|
`context_fetch_view` checks origin-side cache before executing functions. On cache miss, executes functions and stores the result. On mutation, purges affected cache entries based on the invalidation targets.
|
||||||
|
|
||||||
|
All HTTP responses emit `Cache-Control: no-store`. Origin-side caching is internal — the HTTP layer never caches at the CDN. Edge caching is managed by Mizan Edge (closed-source Cloudflare Workers) which uses the manifest and MWT tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MWT (Mizan Web Token) and JWT
|
||||||
|
|
||||||
|
### Two Token Systems
|
||||||
|
|
||||||
|
**JWT** — standard user authentication tokens. Access + refresh pair. Session-tied for revocation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
JWT_PRIVATE_KEY = "your-secret-key" # Required
|
||||||
|
JWT_ALGORITHM = "HS256" # Default, or RS256 for asymmetric
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES_IN = 604800 # 7 days
|
||||||
|
JWT_VALIDATE_SESSION = True # Check session exists on use
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT claims: `sub` (user ID), `sid` (session key), `staff`, `super`, `type` (access/refresh), `iat`, `exp`.
|
||||||
|
|
||||||
|
Session validation: on every JWT use, checks that the session still exists. Logging out destroys the session → immediately revokes all tokens tied to it.
|
||||||
|
|
||||||
|
**MWT** — Mizan Web Token. Protocol-owned identity for Edge cache keying. Separate secret from JWT and cache.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
MIZAN_MWT_SECRET = "your-mwt-signing-key" # Separate from JWT_PRIVATE_KEY
|
||||||
|
MIZAN_MWT_TTL = 300 # 5 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
MWT is used by Mizan Edge to derive user-scoped cache keys without exposing the cache secret to the client. The MWT carries claims that Edge needs (user identity, permissions) in a short-lived token that travels on a custom header (`X-Mizan-Token`).
|
||||||
|
|
||||||
|
### Secret Separation
|
||||||
|
|
||||||
|
Three independent secrets, each with its own blast radius:
|
||||||
|
|
||||||
|
| Secret | Setting | Purpose | Compromise Impact |
|
||||||
|
|--------|---------|---------|-------------------|
|
||||||
|
| JWT secret | `JWT_PRIVATE_KEY` | User auth tokens | Auth bypass |
|
||||||
|
| Cache secret | `MIZAN_CACHE_SECRET` | HMAC cache keys | Cache poisoning |
|
||||||
|
| MWT secret | `MIZAN_MWT_SECRET` | Edge identity tokens | Cache key spoofing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSR Implementation
|
||||||
|
|
||||||
|
### Django Template Backend
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||||
|
'OPTIONS': {
|
||||||
|
'worker_path': 'frontend/ssr-worker.tsx',
|
||||||
|
'timeout': 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
def profile_page(request, user_id):
|
||||||
|
profile = get_user_profile(user_id)
|
||||||
|
return render(request, 'ProfilePage', {'profile': profile})
|
||||||
|
```
|
||||||
|
|
||||||
|
`render()` calls `MizanTemplates.get_template('ProfilePage')` which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC to the Bun worker.
|
||||||
|
|
||||||
|
### SSR Bridge (bridge.py)
|
||||||
|
|
||||||
|
- Spawns `bun run <worker_path>` on first render
|
||||||
|
- Persistent subprocess — stays alive across requests
|
||||||
|
- JSON-RPC over stdin/stdout with message ID correlation
|
||||||
|
- Thread-safe: multiple Django workers can call `render()` concurrently
|
||||||
|
- Auto-restarts on crash
|
||||||
|
- Waits for `{"id": 0, "ready": true}` before accepting requests
|
||||||
|
|
||||||
|
### Bun Worker (worker.tsx)
|
||||||
|
|
||||||
|
- Reads newline-delimited JSON from stdin
|
||||||
|
- Component registry: `registerComponent('ProfilePage', ProfilePage)`
|
||||||
|
- Calls `renderToString(createElement(Component, props))`
|
||||||
|
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
|
||||||
|
- Health check: `{"method": "ping"}` → `{"pong": true}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Manifest
|
||||||
|
|
||||||
|
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contexts": {
|
||||||
|
"user": {
|
||||||
|
"functions": [
|
||||||
|
{"name": "user_profile", "path": "rpc"},
|
||||||
|
{"name": "profile_page", "path": "view", "route": "/profile/<user_id>/"}
|
||||||
|
],
|
||||||
|
"endpoints": ["/api/mizan/ctx/user/"],
|
||||||
|
"params": ["user_id"],
|
||||||
|
"user_scoped": true,
|
||||||
|
"render_strategy": "dynamic_cached",
|
||||||
|
"page_routes": ["/profile/<user_id>/"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mutations": {
|
||||||
|
"update_profile": {
|
||||||
|
"affects": ["user"],
|
||||||
|
"auto_scoped_params": ["user_id"]
|
||||||
|
},
|
||||||
|
"stripe_webhook": {
|
||||||
|
"affects": ["subscription"],
|
||||||
|
"private": true,
|
||||||
|
"route": "/webhooks/stripe/",
|
||||||
|
"methods": ["POST"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**render_strategy**: `"psr"` (no user-scoped params) or `"dynamic_cached"` (user-scoped). Derived automatically from whether params overlap with `{user_id, user, owner_id, account_id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mizan/urls.py
|
||||||
|
urlpatterns = [
|
||||||
|
path("session/", session_init_view), # GET — CSRF cookie
|
||||||
|
path("call/", function_call_view), # POST — RPC dispatch
|
||||||
|
path("ctx/<str:context_name>/", context_fetch_view), # GET — bundled context fetch
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Mounted at `/api/mizan/` by convention:
|
||||||
|
```python
|
||||||
|
urlpatterns = [
|
||||||
|
path("api/mizan/", include("mizan.urls")),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codegen — Current State
|
||||||
|
|
||||||
|
The codegen is `protocol/mizan-generate/` — framework-agnostic, two-stage. Stage 1 emits the protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types). Stage 2 emits per-framework hooks/composables/stores that subscribe to the `mizan-base` kernel.
|
||||||
|
|
||||||
|
**What's in place:**
|
||||||
|
|
||||||
|
- Function hooks (`useEcho`, `useUserProfile`, etc.) in the React adapter, subscribing to kernel state via `useSyncExternalStore`
|
||||||
|
- Context hooks for named contexts and `global`
|
||||||
|
- Channel hooks for WebSocket transport
|
||||||
|
- Vue and Svelte equivalents (Stage 2 templates compile, but no live-backend example exercises them — see `ISSUES.md` A4)
|
||||||
|
|
||||||
|
**What's not yet emitted (the wrapper layer):**
|
||||||
|
|
||||||
|
- `<MizanContext>` provider component for React (calls `configure()` and mounts the kernel into the component tree)
|
||||||
|
- `useMizan()` hook for accessing the kernel from React
|
||||||
|
- Framework-named error class (e.g. `DjangoError`) wrapping `MizanError` from the kernel
|
||||||
|
- Vue and Svelte equivalents
|
||||||
|
|
||||||
|
The legacy `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) and the `forms.ts` consumer (~1163 lines) still depend on the pre-kernel API. They're tracked as `ISSUES.md` A1 and A3. Removing them is gated on the wrapper layer above being emitted.
|
||||||
|
|
||||||
|
The SSR pipeline is independent of the codegen — it renders whatever components are registered in the Bun worker.
|
||||||
173
ISSUES.md
Normal file
173
ISSUES.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Mizan — Known Issues
|
||||||
|
|
||||||
|
Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte).
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- ~~C1~~ Scoped cache purge now passes user_id
|
||||||
|
- ~~C2~~ initSession retries 3x, resets on failure
|
||||||
|
- ~~C3~~ SSR backend injects `__MIZAN_SSR_DATA__` script tag
|
||||||
|
- ~~C4~~ SSR bridge uses _write_lock for stdin
|
||||||
|
- ~~C5~~ SSR bridge registers atexit handler
|
||||||
|
- ~~C7~~ View-path mutations now purge origin cache
|
||||||
|
- ~~H1~~ pendingScoped is Array, not Map (no overwrite)
|
||||||
|
- ~~H2~~ stableKey() sorts JSON keys (order-independent)
|
||||||
|
- ~~H3~~ mizanFetch retries 2x on 5xx/network errors
|
||||||
|
- ~~H4~~ Named contexts skip refetch if SSR data exists
|
||||||
|
- ~~H6~~ refreshContext uses GET /ctx/ not POST /call/
|
||||||
|
- ~~H10~~ _meta always fresh dict
|
||||||
|
- ~~H11~~ Python normalizes True→"true" for cross-language HMAC
|
||||||
|
- ~~H13~~ isValid checks all required fields are touched
|
||||||
|
- ~~M11~~ execute_function return type includes HttpResponseBase
|
||||||
|
- ~~M18~~ registerContext cleanup uses ?. (no crash)
|
||||||
|
|
||||||
|
## Remaining Critical
|
||||||
|
|
||||||
|
### C6. No loading/error/stale states in runtime
|
||||||
|
**File:** `mizan-base/src/index.ts`
|
||||||
|
The kernel stores only `{params, refetch}`. No `data`, `status`, `error`. Every adapter reinvents loading tracking. Blocks stale-while-revalidate.
|
||||||
|
|
||||||
|
## Remaining High
|
||||||
|
|
||||||
|
### H5. Mutation hooks expose no loading/error state
|
||||||
|
**File:** `protocol/mizan-generate/generator/lib/adapters/react.mjs`
|
||||||
|
Returns bare `useCallback`. No `isPending`, `error`, `isSuccess`.
|
||||||
|
|
||||||
|
### H7. Redis SCAN blocks request path at scale
|
||||||
|
**File:** `mizan-django/src/mizan/cache/backend.py`
|
||||||
|
Synchronous SCAN at 1M keys: multi-second blocking.
|
||||||
|
|
||||||
|
### H8. Svelte codegen uses Svelte 4 stores
|
||||||
|
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
|
||||||
|
Should use Svelte 5 `$state`/`$derived` runes.
|
||||||
|
|
||||||
|
### H9. Svelte destroy() not auto-called
|
||||||
|
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
|
||||||
|
Memory leak if user forgets `onDestroy`.
|
||||||
|
|
||||||
|
### H12. Forms triggerValidation captures stale data
|
||||||
|
**File:** `mizan-react/src/forms.ts`
|
||||||
|
Debounced validation uses stale closure data.
|
||||||
|
|
||||||
|
## Remaining Medium
|
||||||
|
|
||||||
|
### M1. SSR bridge not fork-safe
|
||||||
|
gunicorn prefork shares file descriptors and Redis connections.
|
||||||
|
|
||||||
|
### M2. cache_purge_user() not implemented
|
||||||
|
No way to purge all cache entries for one user.
|
||||||
|
|
||||||
|
### M3. No garbage collection for context entries
|
||||||
|
Runtime `contexts` Map grows monotonically.
|
||||||
|
|
||||||
|
### M4. No cross-tab invalidation
|
||||||
|
No BroadcastChannel. Logout in tab 1 doesn't affect tab 2.
|
||||||
|
|
||||||
|
### M5. React 18 Strict Mode double-fetch
|
||||||
|
useEffect runs twice in dev mode.
|
||||||
|
|
||||||
|
### M6. No request deduplication
|
||||||
|
Two components mounting same context fire parallel fetches.
|
||||||
|
|
||||||
|
### M7. SSR worker module cache never invalidates
|
||||||
|
Dynamic imports cached forever.
|
||||||
|
|
||||||
|
### M8. Vue injection key not exported
|
||||||
|
Can't inject directly without generated composables.
|
||||||
|
|
||||||
|
### M9. Vue onMounted won't pre-fetch in Vue SSR
|
||||||
|
Needs `onServerPrefetch` for Nuxt.
|
||||||
|
|
||||||
|
### M10. Svelte should use setContext/getContext
|
||||||
|
Module-level stores don't scope to component tree.
|
||||||
|
|
||||||
|
### M12. render_strategy heuristic uses hardcoded param names
|
||||||
|
Misses `member_id`, `customer_id`, non-English names.
|
||||||
|
|
||||||
|
### M13. initSession called for token-auth requests
|
||||||
|
Wastes GET /session/ round-trip for JWT/MWT apps.
|
||||||
|
|
||||||
|
### M14. Vue watch imported but unused
|
||||||
|
Params not watched — reactive param changes don't trigger refetch.
|
||||||
|
|
||||||
|
### M15. Vue mutation composables misleading `use` prefix
|
||||||
|
`export const useXxx = callXxx` — not a real composable.
|
||||||
|
|
||||||
|
### M16. Svelte mutation imports bypass Stage 1 index
|
||||||
|
Should import from `'../index'` consistently.
|
||||||
|
|
||||||
|
### M17. Side effects in React state updater
|
||||||
|
Context listeners called inside `setContextStore()` updater.
|
||||||
|
|
||||||
|
## Architectural / Cleanup Debt
|
||||||
|
|
||||||
|
### A1. Legacy MizanProvider not yet removed
|
||||||
|
**File:** `mizan-react/src/context.tsx` (~750 lines)
|
||||||
|
Superseded by the kernel (`mizan-base`) + generated React adapter (`useSyncExternalStore`). Still exported as `MizanProvider`, `useMizan`, `useMizanContext`, etc. Must be deleted or replaced with thin shims that call `configure()` + delegate to the new generated hooks.
|
||||||
|
|
||||||
|
### A2. Allauth pending extraction
|
||||||
|
**File:** `legacy/allauth/` (44 files)
|
||||||
|
Sitting in `legacy/` since the cleanup pass. Should become its own `mizan-django-allauth` package consuming Mizan's public API. Unblocks v1 mizan-react publishing.
|
||||||
|
|
||||||
|
### A3. Forms codegen not adapted to kernel
|
||||||
|
**File:** `mizan-react/src/forms.ts` (~1163 lines)
|
||||||
|
Still uses `useMizan().call()` from the legacy MizanProvider. Needs rewrite to use `mizanCall` from the kernel. Currently the only consumer of MizanProvider — blocks A1.
|
||||||
|
|
||||||
|
### A4. Codegen for Vue/Svelte not validated end-to-end
|
||||||
|
The Stage 2 templates produce code that compiles, but no example app exercises Vue or Svelte rendering against a live backend. React is the only adapter with full integration verification.
|
||||||
|
|
||||||
|
### A5. ROADMAP.md is stale
|
||||||
|
**File:** `ROADMAP.md`
|
||||||
|
Lists SSR Bridge, Edge Manifest, Codegen Rewrite, etc. as "Next" — all are done. Doesn't reflect:
|
||||||
|
- Two-stage codegen with Vue/Svelte adapters
|
||||||
|
- C6 kernel-owned state (`ContextState<T>`)
|
||||||
|
- mizan-ts cross-language adapter
|
||||||
|
- Cleanup of djarea/Django-specific naming
|
||||||
|
|
||||||
|
### A6. CLAUDE.md may also be stale
|
||||||
|
**File:** `CLAUDE.md`
|
||||||
|
Written before the kernel rewrite. References to MizanProvider responsibilities and the old codegen pattern are likely outdated. Needs audit.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
### T1. No tests for C6 kernel state machine
|
||||||
|
**File:** `mizan-base/` has no `tests/` directory at all
|
||||||
|
The state-owning kernel has zero unit tests. No coverage of:
|
||||||
|
- `registerContext` returning `getState/subscribe/refetch/unregister`
|
||||||
|
- Status transitions: idle → loading → success/error
|
||||||
|
- Subscriber notifications on state change
|
||||||
|
- Refetch reusing the same entry on Strict Mode re-mount
|
||||||
|
- `unregister` clearing listeners
|
||||||
|
|
||||||
|
### T2. No tests for generated Vue adapter output
|
||||||
|
The `vue.mjs` template produces code, but no test verifies it generates valid Vue 3 composables, that `onServerPrefetch` is wired correctly, or that the kernel subscription bridges to Vue reactivity.
|
||||||
|
|
||||||
|
### T3. No tests for generated Svelte adapter output
|
||||||
|
Same as T2. Readable store factory pattern is unverified against actual Svelte components.
|
||||||
|
|
||||||
|
### T4. No tests for view-path cache purge (C7 fix unverified)
|
||||||
|
The fix added `_purge_cache_for_invalidation()` to the view-path branch, but no test asserts that an `HttpResponse`-returning mutation actually purges the origin cache.
|
||||||
|
|
||||||
|
### T5. No tests for SSR thread safety (C4 fix unverified)
|
||||||
|
The `_write_lock` was added but no concurrent-render test exists to prove it prevents JSON interleaving.
|
||||||
|
|
||||||
|
### T6. No tests for SSR atexit cleanup (C5 fix unverified)
|
||||||
|
`atexit.register(self.shutdown)` was added but not exercised — no test that asserts the Bun process is reaped on Python exit.
|
||||||
|
|
||||||
|
### T7. No tests for SSR hydration injection (C3 fix unverified)
|
||||||
|
The `<script>window.__MIZAN_SSR_DATA__=...</script>` was added to template output but no test asserts it appears in rendered HTML or that the JSON is valid/safe.
|
||||||
|
|
||||||
|
### T8. No cross-language HMAC pin test for booleans/None (H11 fix unverified)
|
||||||
|
Python now normalizes True→"true", but there's no test comparing Python's `derive_cache_key(secret, ctx, {flag: True})` against TypeScript's equivalent to prove they produce identical hex output.
|
||||||
|
|
||||||
|
### T9. No tests for retry logic (H3)
|
||||||
|
`fetchWithRetry` retries 5xx/network errors with backoff. No test for: 5xx triggers retry, 4xx does not, mutation calls bypass retry, max retries respected.
|
||||||
|
|
||||||
|
### T10. No end-to-end integration test
|
||||||
|
Nothing exercises the full pipeline: Django function defined → schema exported → codegen runs → generated React mounts → mutation fires → server response includes invalidate → kernel refetches → DOM updates. Each layer is tested in isolation.
|
||||||
|
|
||||||
|
### T11. No tests for `isValid` requiring all required fields touched (H13 fix unverified)
|
||||||
|
The forms fix checks `field.required && !touched` but no test exercises a form with untouched required fields to confirm `isValid === false`.
|
||||||
|
|
||||||
|
### T12. No tests for `_meta` fresh-dict isolation (H10 fix unverified)
|
||||||
|
The shared-dict fix replaced `{**FunctionWrapper._meta, **meta}` with `{**meta}`. No test confirms that mutating one function's `_meta` doesn't leak into others.
|
||||||
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
|
CORE = cores/mizan-python
|
||||||
REACT = packages/mizan-react
|
DJANGO = backends/mizan-django
|
||||||
|
FASTAPI = backends/mizan-fastapi
|
||||||
|
REACT = frontends/mizan-react
|
||||||
|
AFI = tests/afi
|
||||||
|
|
||||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
install:
|
install:
|
||||||
|
cd $(CORE) && uv pip install -e .
|
||||||
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||||
|
cd $(FASTAPI) && uv pip install -e ".[dev]"
|
||||||
cd $(REACT) && npm install
|
cd $(REACT) && npm install
|
||||||
|
|
||||||
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
# ─── 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:
|
test-django:
|
||||||
cd $(DJANGO) && uv run pytest
|
cd $(DJANGO) && uv run pytest
|
||||||
|
|
||||||
|
test-fastapi:
|
||||||
|
cd $(FASTAPI) && uv run pytest
|
||||||
|
|
||||||
test-react:
|
test-react:
|
||||||
cd $(REACT) && npm test
|
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 ──────────────────────────────────────────────────────
|
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test-integration: docker-up
|
test-integration: docker-up
|
||||||
|
|||||||
297
README.md
297
README.md
@@ -1,297 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Django
|
|
||||||
@client(context='global')
|
|
||||||
def current_user(request) -> UserOutput:
|
|
||||||
return UserOutput(email=request.user.email)
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// React (generated)
|
|
||||||
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Path | Install |
|
|
||||||
|---------|------|---------|
|
|
||||||
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
|
|
||||||
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Django setup
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"mizan",
|
|
||||||
"myapp",
|
|
||||||
]
|
|
||||||
|
|
||||||
# urls.py
|
|
||||||
from django.urls import include, path
|
|
||||||
urlpatterns = [
|
|
||||||
path("api/mizan/", include("mizan.urls")),
|
|
||||||
]
|
|
||||||
|
|
||||||
# asgi.py (for WebSocket support)
|
|
||||||
from mizan import wrap_asgi
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Define server functions
|
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
@client
|
|
||||||
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
|
||||||
return EchoOutput(message=text)
|
|
||||||
|
|
||||||
register(echo, "echo")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Register in apps.py
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
name = "myapp"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
import myapp.mizan_clients # noqa: F401
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Generate TypeScript
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# django.config.mjs
|
|
||||||
export default {
|
|
||||||
source: {
|
|
||||||
django: {
|
|
||||||
managePath: '../backend/manage.py',
|
|
||||||
command: ['uv', 'run', 'python'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: 'src/api/generated.ts',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx mizan-generate
|
|
||||||
```
|
|
||||||
|
|
||||||
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
|
|
||||||
|
|
||||||
### 5. Use in React
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// layout.tsx
|
|
||||||
import { DjangoContext } from '@/api'
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
177
ROADMAP.md
177
ROADMAP.md
@@ -1,88 +1,50 @@
|
|||||||
# Mizan Roadmap
|
# Mizan Roadmap
|
||||||
|
|
||||||
## v1 — Django + React
|
## v1 — Django + Multi-Framework (React, Vue, Svelte)
|
||||||
|
|
||||||
### Done
|
### Done
|
||||||
|
|
||||||
- **@client decorator** — `context=`, `affects=`, `auth=`, `websocket=`
|
- **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
|
||||||
- **ReactContext class** — type-safe context/affects references with linting
|
- **`ReactContext` class** — type-safe context/affects references with linting
|
||||||
- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch
|
- **Named contexts** — functions sharing a context name grouped into one provider and one fetch
|
||||||
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
|
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
|
||||||
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
|
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
|
||||||
- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]`
|
- **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
|
||||||
- **Param elevation** — shared params become required provider props, non-shared become optional
|
- **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
|
||||||
- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen
|
- **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form
|
||||||
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
|
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
|
||||||
- **JWT + session auth** — auto-detected, CSRF handled
|
- **JWT + session auth** — auto-detected, CSRF handled
|
||||||
|
- **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
|
||||||
- **Shapes** — Pydantic + django-readers for typed query projections
|
- **Shapes** — Pydantic + django-readers for typed query projections
|
||||||
- **WebSocket channels** — real-time bidirectional communication
|
- **WebSocket channels** — real-time bidirectional communication
|
||||||
- **Codegen** — generates typed React providers, hooks, mutations from schema
|
- **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
|
||||||
- **CDN-ready headers** — `Cache-Control`, deterministic JSON on context GETs, `no-store` on mutations
|
- **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions
|
||||||
|
- **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC
|
||||||
|
- **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
|
||||||
|
- **Two-stage codegen** — Stage 1 emits framework-agnostic protocol layer; Stage 2 emits per-framework hooks (React, Vue, Svelte)
|
||||||
|
- **`mizan-ts`** — TypeScript backend adapter; proves the protocol is language-agnostic
|
||||||
|
|
||||||
### 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 (in progress)
|
||||||
|
|
||||||
- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications`
|
- **React adapter wrapper layer** — codegen emits `MizanContext` provider, `useMizan` hook, `DjangoError` class on top of the `mizan-base` kernel. Equivalent wrapper layers for Vue and Svelte adapters. The harness in `examples/django-react-site` is blocked on this.
|
||||||
- Comma-separated contexts, semicolon-separated params per context
|
- **Legacy `MizanProvider` removal (A1)** — `mizan-react/src/context.tsx` (~750 lines) replaced by codegen-emitted wrappers. Blocks v1 `mizan-react` publishing.
|
||||||
- Decorator auto-adds header to any HttpResponse with `affects=`
|
- **Forms migration to kernel (A3)** — `mizan-react/src/forms.ts` (~1163 lines) currently consumes legacy `MizanProvider`. Rewrite to use `mizanCall` from the kernel. Blocks A1.
|
||||||
- Edge reads this header to purge cached pages
|
- **Allauth extraction (A2)** — `legacy/allauth/` becomes `mizan-django-allauth` package consuming Mizan's public API.
|
||||||
- Runtime also reads it on XHR/fetch responses (htmx path)
|
- **Vue/Svelte e2e validation (A4)** — example apps exercising a live backend end-to-end, like `examples/django-react-site` does for React.
|
||||||
|
- **Test coverage gaps** — T1–T12 in `ISSUES.md` (kernel state machine, view-path purge, SSR thread safety, retry logic, cross-language HMAC pin, etc.)
|
||||||
|
|
||||||
### Next: Return-Type Branching
|
---
|
||||||
|
|
||||||
`@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior:
|
### Quality
|
||||||
|
|
||||||
- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body.
|
- **H5** — Mutation hooks expose no loading/error state
|
||||||
- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header.
|
- **H7** — Redis SCAN blocks request path at scale
|
||||||
|
- **H8** — Svelte codegen uses Svelte 4 stores; should use Svelte 5 runes
|
||||||
Same decorator. Same `affects=`. Same invalidation graph. Two paths.
|
- **H9** — Svelte `destroy()` not auto-called (memory leak)
|
||||||
|
- **H12** — Forms `triggerValidation` captures stale data
|
||||||
### Next: affects_params
|
- Medium issues (M1–M18) per developer judgment
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,7 +54,7 @@ Django renders React components server-side via a persistent Bun subprocess.
|
|||||||
|
|
||||||
Cloudflare Workers for automatic edge caching.
|
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
|
- Context GETs cached at edge, keyed by context name + params
|
||||||
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
|
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
|
||||||
- Reads JSON `invalidate` key from RPC responses for the same purpose
|
- Reads JSON `invalidate` key from RPC responses for the same purpose
|
||||||
@@ -118,74 +80,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
|
- `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
|
||||||
GET /api/mizan/ctx/<name>/?param=value
|
- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge
|
||||||
|
- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer
|
||||||
200 OK
|
- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers
|
||||||
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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
213
backends/mizan-django/README.md
Normal file
213
backends/mizan-django/README.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# 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 `mizan-generate` (in `protocol/mizan-generate/`). From your
|
||||||
|
frontend project, point a config at the Django backend and run the CLI:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/django.config.mjs
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
export default {
|
||||||
|
source: {
|
||||||
|
django: {
|
||||||
|
managePath: path.join(root, "backend/manage.py"),
|
||||||
|
command: ["uv", "run", "python"],
|
||||||
|
env: {
|
||||||
|
PYTHONPATH: path.join(root, "backend"),
|
||||||
|
DJANGO_SETTINGS_MODULE: "myproject.settings",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "src/api",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx mizan-generate --config django.config.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The codegen drives Django's management command (`export_mizan_schema`) under
|
||||||
|
the hood, 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`.
|
||||||
@@ -5,6 +5,7 @@ description = "Django + React server functions framework"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"mizan-core",
|
||||||
"django>=5.0",
|
"django>=5.0",
|
||||||
"django-ninja>=1.0",
|
"django-ninja>=1.0",
|
||||||
"django-readers>=2.0",
|
"django-readers>=2.0",
|
||||||
@@ -12,6 +13,9 @@ dependencies = [
|
|||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cache = [
|
cache = [
|
||||||
"redis>=5.0",
|
"redis>=5.0",
|
||||||
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}")
|
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
|
# Exports
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -400,7 +400,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
- User context from WebSocket session is passed to function
|
- User context from WebSocket session is passed to function
|
||||||
"""
|
"""
|
||||||
from mizan.client.executor import execute_function, FunctionError
|
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")
|
request_id = content.get("id")
|
||||||
fn_name = content.get("fn")
|
fn_name = content.get("fn")
|
||||||
@@ -2,16 +2,24 @@
|
|||||||
mizan.client - Server function implementation.
|
mizan.client - Server function implementation.
|
||||||
|
|
||||||
This subpackage contains everything needed to make server functions work:
|
This subpackage contains everything needed to make server functions work:
|
||||||
- The @client decorator
|
- The @client decorator (lives in mizan_core.client.function)
|
||||||
- ServerFunction base class
|
- ServerFunction base class (mizan_core.client.function)
|
||||||
- Function execution logic
|
- Function execution logic (.executor — Django-specific dispatch)
|
||||||
- JWT authentication (integral to server functions)
|
- JWT authentication (.jwt — Django-specific session integration)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from mizan.client import client, ServerFunction, compose
|
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
|
# Decorator
|
||||||
client,
|
client,
|
||||||
# Context markers
|
# Context markers
|
||||||
@@ -23,12 +23,12 @@ from enum import Enum
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
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 django.views.decorators.csrf import csrf_protect
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||||
from mizan.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
|
from mizan.setup.settings import get_settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -161,6 +161,42 @@ def _check_auth_requirement(
|
|||||||
return None
|
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]:
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
"""
|
"""
|
||||||
Determine whether an affects target is a context name or function name.
|
Determine whether an affects target is a context name or function name.
|
||||||
@@ -310,7 +346,7 @@ def execute_function(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
fn_name: str,
|
fn_name: str,
|
||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> FunctionResult | FunctionError:
|
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||||
"""
|
"""
|
||||||
Execute a registered server function.
|
Execute a registered server function.
|
||||||
|
|
||||||
@@ -444,10 +480,11 @@ def execute_function(
|
|||||||
from django.http import HttpResponseBase
|
from django.http import HttpResponseBase
|
||||||
|
|
||||||
if isinstance(output, 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)
|
invalidate = _resolve_invalidation(view_class, input_data)
|
||||||
if invalidate:
|
if invalidate:
|
||||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||||
|
_purge_cache_for_invalidation(invalidate, request)
|
||||||
output["Cache-Control"] = "no-store"
|
output["Cache-Control"] = "no-store"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@@ -457,6 +494,46 @@ def execute_function(
|
|||||||
return FunctionResult(data=output.model_dump())
|
return FunctionResult(data=output.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempt to authenticate the request using JWT.
|
Attempt to authenticate the request using JWT.
|
||||||
@@ -502,43 +579,45 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
|||||||
return auth_header.startswith("Bearer ")
|
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
|
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
||||||
is legitimate), so CSRF protection is not needed.
|
Both are self-authenticating, so CSRF protection is not needed.
|
||||||
|
|
||||||
Security: If JWT is provided but invalid, reject the request - do NOT
|
Security: If a token is provided but invalid, reject the request - do NOT
|
||||||
fall back to session auth. This prevents attacks where an invalid token
|
fall back to session auth.
|
||||||
is sent alongside a valid session cookie.
|
|
||||||
"""
|
"""
|
||||||
csrf_protected_view = csrf_protect(view_func)
|
csrf_protected_view = csrf_protect(view_func)
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
# Check if JWT header is present
|
# MWT takes priority
|
||||||
has_jwt = _has_jwt_header(request)
|
if _has_mwt_header(request):
|
||||||
|
if _try_mwt_auth(request):
|
||||||
if has_jwt:
|
|
||||||
# JWT header present - try to authenticate
|
|
||||||
if _try_jwt_auth(request):
|
|
||||||
# JWT valid - skip CSRF, proceed
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
else:
|
return FunctionError(
|
||||||
# JWT invalid - reject (do NOT fall back to session)
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
return FunctionError(
|
message="Invalid or expired MWT",
|
||||||
code=ErrorCode.UNAUTHORIZED,
|
).to_response(status=401)
|
||||||
message="Invalid or expired JWT token",
|
|
||||||
).to_response(status=401)
|
# Legacy JWT fallback
|
||||||
else:
|
if _has_jwt_header(request):
|
||||||
# No JWT - use session auth with CSRF
|
if _try_jwt_auth(request):
|
||||||
return csrf_protected_view(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
|
message="Invalid or expired JWT token",
|
||||||
|
).to_response(status=401)
|
||||||
|
|
||||||
|
# No token — session auth with CSRF
|
||||||
|
return csrf_protected_view(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@_csrf_protect_unless_jwt
|
@_csrf_protect_unless_token
|
||||||
def function_call_view(request: HttpRequest) -> JsonResponse:
|
def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||||
"""
|
"""
|
||||||
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
||||||
@@ -659,22 +738,9 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
response = JsonResponse(response_data)
|
response = JsonResponse(response_data)
|
||||||
response["Cache-Control"] = "no-store"
|
response["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
# Always set the header transport too (Edge reads this)
|
|
||||||
if invalidate_contexts:
|
if invalidate_contexts:
|
||||||
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
||||||
|
_purge_cache_for_invalidation(invalidate_contexts, request)
|
||||||
# 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)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -732,20 +798,30 @@ def execute_context(
|
|||||||
|
|
||||||
def _jwt_auth_only(view_func):
|
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)
|
@wraps(view_func)
|
||||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
has_jwt = _has_jwt_header(request)
|
# MWT takes priority
|
||||||
if has_jwt:
|
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):
|
if _try_jwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
else:
|
return FunctionError(
|
||||||
return FunctionError(
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
code=ErrorCode.UNAUTHORIZED,
|
message="Invalid or expired JWT token",
|
||||||
message="Invalid or expired JWT token",
|
).to_response(status=401)
|
||||||
).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 view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -775,22 +851,48 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
|
|
||||||
params = request.GET.dict()
|
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_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()
|
cache_settings = get_settings()
|
||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
|
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
|
||||||
user_id = str(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:
|
try:
|
||||||
cached = cache_get(
|
cached = cache_get(
|
||||||
cache_settings.cache_secret, cache, context_name, params,
|
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||||
user_id=user_id,
|
user_id=user_id, rev=effective_rev,
|
||||||
)
|
)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
response = HttpResponse(cached, content_type="application/json")
|
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"
|
response["X-Mizan-Cache"] = "HIT"
|
||||||
return response
|
return response
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -815,19 +917,16 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||||
|
|
||||||
# CDN-ready headers
|
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
||||||
# max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
|
# The browser and non-Mizan intermediaries must not cache.
|
||||||
# s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
|
response["Cache-Control"] = "no-store"
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Store in origin-side cache
|
# Store in origin-side cache (skip if cache=False)
|
||||||
if cache is not None and cache_settings.cache_secret:
|
if use_cache:
|
||||||
try:
|
try:
|
||||||
cache_put(
|
cache_put(
|
||||||
cache_settings.cache_secret, cache, context_name, params,
|
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||||
response.content, user_id=user_id,
|
response.content, user_id=user_id, rev=effective_rev,
|
||||||
)
|
)
|
||||||
response["X-Mizan-Cache"] = "MISS"
|
response["X-Mizan-Cache"] = "MISS"
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|||||||
from django import forms
|
from django import forms
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
|
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -428,6 +428,12 @@ def generate_edge_manifest(
|
|||||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
page_routes.append(fn_route)
|
page_routes.append(fn_route)
|
||||||
|
|
||||||
|
# Cache protocol metadata
|
||||||
|
if "rev" in meta:
|
||||||
|
fn_entry["rev"] = meta["rev"]
|
||||||
|
if "cache" in meta:
|
||||||
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
|
||||||
functions_meta.append(fn_entry)
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
sorted_params = sorted(param_names)
|
sorted_params = sorted(param_names)
|
||||||
@@ -292,8 +292,8 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import validate_form_instance
|
from .validation_utils import validate_form_instance
|
||||||
from mizan.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
config: mizanFormMeta = form_class.mizan
|
config: mizanFormMeta = form_class.mizan
|
||||||
form_name = config.name
|
form_name = config.name
|
||||||
@@ -484,8 +484,8 @@ def _register_formset_functions(
|
|||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import build_formset_validation
|
from .validation_utils import build_formset_validation
|
||||||
from .formset_utils import forms_to_formset_post_data
|
from .formset_utils import forms_to_formset_post_data
|
||||||
from mizan.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
formset_class = formset_factory(form_class)
|
formset_class = formset_factory(form_class)
|
||||||
|
|
||||||
@@ -630,3 +630,48 @@ def _register_formset_functions(
|
|||||||
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
|
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
|
||||||
FormsetSubmitFunction.Output = FormsetSubmitPass
|
FormsetSubmitFunction.Output = FormsetSubmitPass
|
||||||
register(FormsetSubmitFunction, f"{form_name}.formset.submit")
|
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.
|
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||||
|
from mizan_core.mwt import create_mwt
|
||||||
|
|
||||||
|
|
||||||
class TokenPairOutput(BaseModel):
|
class TokenPairOutput(BaseModel):
|
||||||
@@ -99,3 +100,42 @@ def jwt_refresh(request: HttpRequest, refresh_token: str) -> TokenPairOutput:
|
|||||||
refresh_token=tokens.refresh_token,
|
refresh_token=tokens.refresh_token,
|
||||||
expires_in=tokens.expires_in,
|
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)
|
||||||
@@ -1,36 +1,40 @@
|
|||||||
"""
|
"""
|
||||||
mizan.setup - Integration and registration utilities.
|
mizan.setup - Django integration helpers.
|
||||||
|
|
||||||
This subpackage contains everything developers need to integrate mizan:
|
The function/composition registry now lives in `mizan_core.registry`.
|
||||||
- Registry for server functions and channels
|
Channels register themselves through the channel-specific registry in
|
||||||
- Auto-discovery for apps
|
`mizan.channels`. Forms register through `mizan.forms`. This module
|
||||||
- Configuration settings
|
re-exports the helpers that Django mizan users typically reach for, so
|
||||||
|
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
||||||
Usage:
|
working as a single curated surface.
|
||||||
from mizan.setup import mizan_clients, register, get_function
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .registry import (
|
from mizan_core.registry import (
|
||||||
register,
|
register,
|
||||||
register_as,
|
register_as,
|
||||||
register_form,
|
|
||||||
register_compose,
|
register_compose,
|
||||||
get_function,
|
get_function,
|
||||||
get_channel,
|
|
||||||
get_compose,
|
get_compose,
|
||||||
get_view,
|
|
||||||
get_all_functions,
|
get_all_functions,
|
||||||
get_all_channels,
|
|
||||||
get_all_compositions,
|
get_all_compositions,
|
||||||
get_registry,
|
get_registry,
|
||||||
get_schema,
|
get_schema,
|
||||||
get_contexts,
|
get_contexts,
|
||||||
get_context_groups,
|
get_context_groups,
|
||||||
get_forms,
|
|
||||||
validate_registry,
|
validate_registry,
|
||||||
clear_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 (
|
from .discovery import (
|
||||||
mizan_clients,
|
mizan_clients,
|
||||||
mizan_module,
|
mizan_module,
|
||||||
@@ -52,7 +56,6 @@ __all__ = [
|
|||||||
"get_function",
|
"get_function",
|
||||||
"get_channel",
|
"get_channel",
|
||||||
"get_compose",
|
"get_compose",
|
||||||
"get_view",
|
|
||||||
"get_all_functions",
|
"get_all_functions",
|
||||||
"get_all_channels",
|
"get_all_channels",
|
||||||
"get_all_compositions",
|
"get_all_compositions",
|
||||||
@@ -18,8 +18,8 @@ from typing import Any
|
|||||||
|
|
||||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||||
|
|
||||||
from .registry import register, get_function
|
from mizan_core.registry import register, get_function
|
||||||
from mizan.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
class _RegisterServerFunctions:
|
||||||
@@ -17,12 +17,18 @@ class mizanSettings:
|
|||||||
# Whether to expose function names in DEBUG mode errors
|
# Whether to expose function names in DEBUG mode errors
|
||||||
debug_expose_names: bool
|
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
|
cache_secret: str | None
|
||||||
|
|
||||||
# Redis URL for cache backend (None = cache disabled)
|
# Redis URL for cache backend (None = cache disabled)
|
||||||
cache_redis_url: str | None
|
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
|
@lru_cache
|
||||||
def get_settings() -> mizanSettings:
|
def get_settings() -> mizanSettings:
|
||||||
@@ -38,6 +44,8 @@ def get_settings() -> mizanSettings:
|
|||||||
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
||||||
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
|
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
|
||||||
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", 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,
|
ErrorCode,
|
||||||
)
|
)
|
||||||
from mizan.client import client
|
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
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ from django.test import RequestFactory, TestCase, TransactionTestCase, override_
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
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
|
from mizan.client import client
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
|
|||||||
|
|
||||||
def setup_benchmark_functions():
|
def setup_benchmark_functions():
|
||||||
"""Register benchmark server functions."""
|
"""Register benchmark server functions."""
|
||||||
from mizan.setup.registry import register
|
from mizan_core.registry import register
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
@@ -928,13 +928,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Clear mizan registry
|
# Clear mizan registry
|
||||||
from mizan.setup.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
# Register test functions
|
# Register test functions
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
class EchoOutput(BaseModel):
|
||||||
@@ -964,7 +964,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
register(rpc_auth_required, "rpc_auth_required")
|
register(rpc_auth_required, "rpc_auth_required")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
from mizan.setup.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
@@ -17,15 +17,15 @@ from mizan.client.executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
execute_context,
|
execute_context,
|
||||||
)
|
)
|
||||||
from mizan.setup.registry import (
|
from mizan_core.registry import (
|
||||||
clear_registry,
|
clear_registry,
|
||||||
register,
|
register,
|
||||||
register_as,
|
register_as,
|
||||||
register_form,
|
|
||||||
get_schema,
|
get_schema,
|
||||||
get_contexts,
|
get_contexts,
|
||||||
get_function,
|
get_function,
|
||||||
)
|
)
|
||||||
|
from mizan.forms import register_form
|
||||||
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ class ContextTests(TestCase):
|
|||||||
|
|
||||||
def test_context_groups(self):
|
def test_context_groups(self):
|
||||||
"""Test get_context_groups() groups functions by context name."""
|
"""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")
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
@@ -1019,9 +1019,8 @@ class ServerDrivenInvalidationTests(TestCase):
|
|||||||
self.assertIn("team_info", data)
|
self.assertIn("team_info", data)
|
||||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||||
|
|
||||||
# CDN-ready headers
|
# Mizan handles caching via its protocol; origin emits no-store
|
||||||
self.assertIn("public", response["Cache-Control"])
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
self.assertIn("s-maxage", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_context_error_not_cached(self):
|
def test_context_error_not_cached(self):
|
||||||
"""Context fetch errors must not be cached."""
|
"""Context fetch errors must not be cached."""
|
||||||
@@ -1148,8 +1147,8 @@ class ChannelTests(TestCase):
|
|||||||
|
|
||||||
def test_register_channel(self):
|
def test_register_channel(self):
|
||||||
"""Test channel registration."""
|
"""Test channel registration."""
|
||||||
|
from mizan.channels import register as register_channel, get_channel
|
||||||
|
|
||||||
@register_as("test-channel")
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(ReactChannel):
|
||||||
class DjangoMessage(BaseModel):
|
class DjangoMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
@@ -1157,15 +1156,13 @@ class ChannelTests(TestCase):
|
|||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from mizan.setup.registry import get_channel
|
register_channel(TestChannel, "test-channel")
|
||||||
|
self.assertEqual(get_channel("test-channel"), TestChannel)
|
||||||
channel = get_channel("test-channel")
|
|
||||||
self.assertEqual(channel, TestChannel)
|
|
||||||
|
|
||||||
def test_channel_schema_export(self):
|
def test_channel_schema_export(self):
|
||||||
"""Test channel schema export."""
|
"""Test channel schema export."""
|
||||||
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
@register_as("chat")
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(ReactChannel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: int
|
room: int
|
||||||
@@ -1180,6 +1177,8 @@ class ChannelTests(TestCase):
|
|||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
register_channel(ChatChannel, "chat")
|
||||||
|
|
||||||
schema = get_schema()
|
schema = get_schema()
|
||||||
|
|
||||||
self.assertIn("channels", schema)
|
self.assertIn("channels", schema)
|
||||||
@@ -1194,8 +1193,8 @@ class ChannelTests(TestCase):
|
|||||||
|
|
||||||
def test_server_push_only_channel(self):
|
def test_server_push_only_channel(self):
|
||||||
"""Test channel without ReactMessage (server-push only)."""
|
"""Test channel without ReactMessage (server-push only)."""
|
||||||
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
@register_as("notifications")
|
|
||||||
class NotificationsChannel(ReactChannel):
|
class NotificationsChannel(ReactChannel):
|
||||||
class DjangoMessage(BaseModel):
|
class DjangoMessage(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
@@ -1203,6 +1202,7 @@ class ChannelTests(TestCase):
|
|||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
register_channel(NotificationsChannel, "notifications")
|
||||||
schema = get_schema()
|
schema = get_schema()
|
||||||
notif_schema = schema["channels"]["notifications"]
|
notif_schema = schema["channels"]["notifications"]
|
||||||
|
|
||||||
@@ -1785,9 +1785,8 @@ class HTTPIntegrationTests(TestCase):
|
|||||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||||
self.assertEqual(data["user_orders"]["count"], 50)
|
self.assertEqual(data["user_orders"]["count"], 50)
|
||||||
|
|
||||||
# CDN headers
|
# Mizan handles caching; origin emits no-store
|
||||||
self.assertIn("public", response["Cache-Control"])
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
self.assertIn("s-maxage", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_context_fetch_string_to_int_coercion(self):
|
def test_context_fetch_string_to_int_coercion(self):
|
||||||
"""Query params arrive as strings. Pydantic must coerce to int."""
|
"""Query params arrive as strings. Pydantic must coerce to int."""
|
||||||
@@ -2061,7 +2060,7 @@ class ReturnTypeBranchingTests(TestCase):
|
|||||||
register(profile_page, "profile_page")
|
register(profile_page, "profile_page")
|
||||||
|
|
||||||
# It's in the context groups (for invalidation graph)
|
# 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()
|
groups = get_context_groups()
|
||||||
self.assertIn("user", groups)
|
self.assertIn("user", groups)
|
||||||
self.assertIn("profile_page", groups["user"])
|
self.assertIn("profile_page", groups["user"])
|
||||||
@@ -2142,16 +2141,10 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
|
|
||||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
def test_context_get_is_cacheable(self):
|
def test_context_get_no_store(self):
|
||||||
"""Context GET has Cache-Control that allows CDN caching."""
|
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
||||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
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)
|
|
||||||
|
|
||||||
def test_mutation_post_not_cacheable(self):
|
def test_mutation_post_not_cacheable(self):
|
||||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
"""Mutation POST has no-store. CDN must never cache mutations."""
|
||||||
@@ -2786,100 +2779,44 @@ class PrivateAndRouteTests(TestCase):
|
|||||||
# ── Cache conformance tests ────────────────────────────────────────────────
|
# ── 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):
|
class CacheBackendTests(TestCase):
|
||||||
"""Tests for MemoryCache backend operations."""
|
"""Tests for MemoryCache backend operations."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from mizan.cache.backend import MemoryCache
|
from mizan_core.cache.backend import MemoryCache
|
||||||
self.cache = MemoryCache()
|
self.cache = MemoryCache()
|
||||||
|
|
||||||
def test_get_miss(self):
|
def test_get_miss(self):
|
||||||
"""Empty cache returns None."""
|
"""Empty cache returns None."""
|
||||||
self.assertIsNone(self.cache.get("nonexistent"))
|
self.assertIsNone(self.cache.get("nonexistent"))
|
||||||
|
|
||||||
def test_put_then_get(self):
|
def test_set_then_get(self):
|
||||||
"""Store and retrieve a value."""
|
"""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")
|
result = self.cache.get("key1")
|
||||||
self.assertEqual(result, b'{"data": true}')
|
self.assertEqual(result, b'{"data": true}')
|
||||||
|
|
||||||
def test_index_tracking(self):
|
def test_delete(self):
|
||||||
"""Put adds the key to specified indexes."""
|
"""Delete a key."""
|
||||||
self.cache.put("key1", b"v1", ["mizan:idx:user", "mizan:idx:user:user_id=5"])
|
self.cache.set("k1", b"v1")
|
||||||
self.assertIn("key1", self.cache.get_index("mizan:idx:user"))
|
self.assertTrue(self.cache.delete("k1"))
|
||||||
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)
|
|
||||||
self.assertIsNone(self.cache.get("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):
|
def test_clear(self):
|
||||||
"""Clear removes everything."""
|
"""Clear removes everything."""
|
||||||
self.cache.put("k1", b"v1", ["idx1"])
|
self.cache.set("k1", b"v1")
|
||||||
self.cache.clear()
|
self.cache.clear()
|
||||||
self.assertIsNone(self.cache.get("k1"))
|
self.assertIsNone(self.cache.get("k1"))
|
||||||
self.assertEqual(self.cache.get_index("idx1"), set())
|
|
||||||
|
|
||||||
|
|
||||||
class CachePurgeTests(TestCase):
|
class CachePurgeTests(TestCase):
|
||||||
@@ -2889,7 +2826,7 @@ class CachePurgeTests(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from mizan.cache import cache_put, set_cache
|
from mizan.cache import cache_put, set_cache
|
||||||
from mizan.cache.backend import MemoryCache
|
from mizan_core.cache.backend import MemoryCache
|
||||||
|
|
||||||
self.cache = MemoryCache()
|
self.cache = MemoryCache()
|
||||||
set_cache(self.cache)
|
set_cache(self.cache)
|
||||||
@@ -2903,10 +2840,10 @@ class CachePurgeTests(TestCase):
|
|||||||
reset_cache()
|
reset_cache()
|
||||||
|
|
||||||
def test_scoped_purge(self):
|
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
|
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)
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
# user_id=5 is gone
|
# user_id=5 is gone
|
||||||
@@ -2933,7 +2870,7 @@ class CacheIntegrationTests(TestCase):
|
|||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
from mizan.cache import set_cache
|
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
|
from mizan.setup.settings import clear_settings_cache
|
||||||
|
|
||||||
self.cache = MemoryCache()
|
self.cache = MemoryCache()
|
||||||
@@ -3028,3 +2965,440 @@ class CacheIntegrationTests(TestCase):
|
|||||||
# User 6 should still be cached
|
# User 6 should still be cached
|
||||||
r6 = self._fetch_context("user_id=6")
|
r6 = self._fetch_context("user_id=6")
|
||||||
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")
|
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,
|
FunctionResult,
|
||||||
execute_function,
|
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
|
from mizan.client import ServerFunction, client
|
||||||
|
|
||||||
|
|
||||||
@@ -1179,7 +1179,7 @@ class RegistrationSecurityTests(TestCase):
|
|||||||
But a DIFFERENT function cannot take over an existing name.
|
But a DIFFERENT function cannot take over an existing name.
|
||||||
"""
|
"""
|
||||||
from mizan.client import ServerFunction
|
from mizan.client import ServerFunction
|
||||||
from mizan.setup.registry import register
|
from mizan_core.registry import register
|
||||||
|
|
||||||
# Register first function
|
# Register first function
|
||||||
class OriginalFunc(ServerFunction):
|
class OriginalFunc(ServerFunction):
|
||||||
@@ -29,7 +29,7 @@ from mizan.client.executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
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.client import ServerFunction, client
|
||||||
from mizan.channels import ReactChannel
|
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>")
|
||||||
193
backends/mizan-fastapi/README.md
Normal file
193
backends/mizan-fastapi/README.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# 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 `mizan-generate` (in `protocol/mizan-generate/`). Point a
|
||||||
|
config at your FastAPI app and run the CLI:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/fastapi.config.mjs
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
export default {
|
||||||
|
source: {
|
||||||
|
fastapi: {
|
||||||
|
module: "main", // module to import for @client side effects
|
||||||
|
cwd: path.join(root, "backend"), // python cwd for module resolution
|
||||||
|
command: ["uv", "run", "python"], // optional — defaults to ["python"]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "src/api",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx mizan-generate --config fastapi.config.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The codegen drives `python -m mizan_fastapi.cli <module>` under the hood,
|
||||||
|
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.cli <module>
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports the named module (which must register every `@client` function as
|
||||||
|
import-time side effects), then prints the OpenAPI schema as JSON to stdout.
|
||||||
|
Mirrors mizan-django's `manage.py export_mizan_schema` 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).
|
||||||
32
backends/mizan-fastapi/pyproject.toml
Normal file
32
backends/mizan-fastapi/pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "mizan-fastapi"
|
||||||
|
version = "0.1.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_*"]
|
||||||
56
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
56
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
from .schema import build_schema
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"mizan_exception_handler",
|
||||||
|
"mizan_validation_handler",
|
||||||
|
"execute_function",
|
||||||
|
"compute_invalidation",
|
||||||
|
"build_schema",
|
||||||
|
"ErrorCode",
|
||||||
|
"MizanError",
|
||||||
|
"NotFound",
|
||||||
|
"BadRequest",
|
||||||
|
"ValidationFailed",
|
||||||
|
"Unauthorized",
|
||||||
|
"Forbidden",
|
||||||
|
"NotImplementedYet",
|
||||||
|
"InternalError",
|
||||||
|
]
|
||||||
45
backends/mizan-fastapi/src/mizan_fastapi/cli.py
Normal file
45
backends/mizan-fastapi/src/mizan_fastapi/cli.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Schema-export CLI for codegen consumption.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m mizan_fastapi.cli <module>
|
||||||
|
|
||||||
|
Imports the named module (whose import side effects must register every
|
||||||
|
@client function with mizan_core.registry — typically by `@client` plus
|
||||||
|
`register(...)` calls at module top level), then prints the OpenAPI
|
||||||
|
schema to stdout as JSON.
|
||||||
|
|
||||||
|
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
|
||||||
|
CLI can fetch from either backend the same subprocess way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .schema import build_schema
|
||||||
|
|
||||||
|
|
||||||
|
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.cli <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
|
||||||
|
|
||||||
|
schema = build_schema()
|
||||||
|
json.dump(schema, sys.stdout)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
197
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
197
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
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 pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from mizan_core.registry import get_function
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 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:
|
||||||
|
return result.model_dump(mode="json") if isinstance(result, BaseModel) else result
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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 = view.call(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 _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||||
|
match target.get("type"):
|
||||||
|
case "context":
|
||||||
|
name = target["name"]
|
||||||
|
scope_keys = (target.get("params") or {}).keys()
|
||||||
|
scoped = {k: input_data[k] for k in scope_keys if k in input_data}
|
||||||
|
return {"context": name, "params": scoped} if scoped else name
|
||||||
|
case "function":
|
||||||
|
return {"function": target["name"]}
|
||||||
|
case _:
|
||||||
|
return target
|
||||||
92
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
92
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
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": [...]}`."""
|
||||||
|
result = execute_function(request, body.fn, body.args)
|
||||||
|
invalidate = compute_invalidation(get_function(body.fn), body.args)
|
||||||
|
return _no_store({"result": result, "invalidate": invalidate})
|
||||||
|
|
||||||
|
|
||||||
|
@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: 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,
|
||||||
|
)
|
||||||
209
backends/mizan-fastapi/src/mizan_fastapi/schema.py
Normal file
209
backends/mizan-fastapi/src/mizan_fastapi/schema.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Mizan schema export for FastAPI backends.
|
||||||
|
|
||||||
|
Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
|
||||||
|
the shape mizan-django emits via Django Ninja so the codegen consumes either
|
||||||
|
backend identically.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from mizan_fastapi.schema import build_schema
|
||||||
|
schema = build_schema() # uses globally registered functions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from pydantic import BaseModel, create_model
|
||||||
|
|
||||||
|
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_schema", "snake_to_camel"]
|
||||||
|
|
||||||
|
|
||||||
|
# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
|
||||||
|
def snake_to_camel(name: str) -> str:
|
||||||
|
"""Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
|
||||||
|
components = re.split(r"[._]", name)
|
||||||
|
return components[0] + "".join(c.title() for c in components[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _has_input(input_cls: Any) -> bool:
|
||||||
|
return (
|
||||||
|
input_cls is not None
|
||||||
|
and input_cls is not BaseModel
|
||||||
|
and hasattr(input_cls, "model_fields")
|
||||||
|
and bool(input_cls.model_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation_to_jsonschema_type(annotation: Any) -> str:
|
||||||
|
if annotation is int:
|
||||||
|
return "integer"
|
||||||
|
if annotation is float:
|
||||||
|
return "number"
|
||||||
|
if annotation is bool:
|
||||||
|
return "boolean"
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
|
||||||
|
def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
|
||||||
|
"""Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
|
||||||
|
camel = snake_to_camel(name)
|
||||||
|
meta = getattr(fn_class, "_meta", {})
|
||||||
|
|
||||||
|
input_cls = getattr(fn_class, "Input", None)
|
||||||
|
has_input = _has_input(input_cls)
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"camelName": camel,
|
||||||
|
"hasInput": has_input,
|
||||||
|
"inputType": f"{camel}Input" if has_input else None,
|
||||||
|
"outputType": f"{camel}Output",
|
||||||
|
"transport": "websocket" if meta.get("websocket") else "http",
|
||||||
|
"isContext": meta.get("context", False),
|
||||||
|
# Form metadata — always emitted so the schema shape matches Django's,
|
||||||
|
# even for FastAPI projects that don't use forms (these stay False/None).
|
||||||
|
"isForm": meta.get("form", False),
|
||||||
|
"formName": meta.get("form_name"),
|
||||||
|
"formRole": meta.get("form_role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("affects"):
|
||||||
|
entry["affects"] = meta["affects"]
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
|
||||||
|
"""Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in context_groups.items():
|
||||||
|
param_info: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
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 not _has_input(input_cls):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field_name, field_info in input_cls.model_fields.items():
|
||||||
|
if field_name not in param_info:
|
||||||
|
param_info[field_name] = {
|
||||||
|
"type": _annotation_to_jsonschema_type(field_info.annotation),
|
||||||
|
"sharedBy": [],
|
||||||
|
}
|
||||||
|
param_info[field_name]["sharedBy"].append(fn_name)
|
||||||
|
|
||||||
|
# A param is required iff every function in the context declares it.
|
||||||
|
for p_meta in param_info.values():
|
||||||
|
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
|
||||||
|
|
||||||
|
out[ctx_name] = {
|
||||||
|
"functions": list(fn_names),
|
||||||
|
"params": param_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_schema() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build an OpenAPI 3.0 schema for all registered Mizan functions.
|
||||||
|
|
||||||
|
Drives FastAPI's native OpenAPI generation by registering a stub endpoint
|
||||||
|
per function with the function's Input/Output Pydantic models, then
|
||||||
|
appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
|
||||||
|
extensions.
|
||||||
|
|
||||||
|
Returns a dict in the same shape mizan-django's schema export emits, so
|
||||||
|
the same codegen pipeline consumes either.
|
||||||
|
"""
|
||||||
|
functions = get_all_functions()
|
||||||
|
context_groups = get_context_groups()
|
||||||
|
|
||||||
|
schema_app = FastAPI(
|
||||||
|
title="mizan Server Functions",
|
||||||
|
version="1.0.0",
|
||||||
|
description="Auto-generated schema for mizan server functions",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-function endpoints + renamed Pydantic models so component names are
|
||||||
|
# camelCase + "Input"/"Output" rather than the user's original class names.
|
||||||
|
schema_classes: dict[str, type[BaseModel]] = {}
|
||||||
|
function_metadata: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for name, fn_class in functions.items():
|
||||||
|
camel = snake_to_camel(name)
|
||||||
|
input_cls = getattr(fn_class, "Input", None)
|
||||||
|
output_cls = getattr(fn_class, "Output", None) or BaseModel
|
||||||
|
has_input = _has_input(input_cls)
|
||||||
|
|
||||||
|
input_type_name = f"{camel}Input" if has_input else None
|
||||||
|
output_type_name = f"{camel}Output"
|
||||||
|
|
||||||
|
if has_input:
|
||||||
|
schema_classes[input_type_name] = create_model(
|
||||||
|
input_type_name, __base__=input_cls,
|
||||||
|
)
|
||||||
|
schema_classes[output_type_name] = create_model(
|
||||||
|
output_type_name, __base__=output_cls,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stub endpoint — only exists so FastAPI walks Pydantic types into
|
||||||
|
# components.schemas. Never invoked. Annotations are set explicitly
|
||||||
|
# rather than via closures so forward-ref resolution doesn't trip on
|
||||||
|
# locally-bound type names.
|
||||||
|
if has_input:
|
||||||
|
async def stub(payload):
|
||||||
|
return None
|
||||||
|
|
||||||
|
stub.__annotations__ = {"payload": schema_classes[input_type_name]}
|
||||||
|
else:
|
||||||
|
async def stub():
|
||||||
|
return None
|
||||||
|
|
||||||
|
schema_app.post(
|
||||||
|
f"/mizan/{name}",
|
||||||
|
response_model=schema_classes[output_type_name],
|
||||||
|
operation_id=camel,
|
||||||
|
summary=fn_class.__doc__ or f"Call {name}",
|
||||||
|
)(stub)
|
||||||
|
|
||||||
|
function_metadata.append(_function_metadata(name, fn_class))
|
||||||
|
|
||||||
|
schema = get_openapi(
|
||||||
|
title=schema_app.title,
|
||||||
|
version=schema_app.version,
|
||||||
|
description=schema_app.description,
|
||||||
|
routes=schema_app.routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema["x-mizan-functions"] = function_metadata
|
||||||
|
|
||||||
|
if context_groups:
|
||||||
|
schema["x-mizan-contexts"] = _context_metadata(context_groups)
|
||||||
|
|
||||||
|
# Attach x-mizan operation metadata, mirroring Django.
|
||||||
|
paths = schema.get("paths", {})
|
||||||
|
for fn_meta in function_metadata:
|
||||||
|
op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
|
||||||
|
if op is not None:
|
||||||
|
op["x-mizan"] = {
|
||||||
|
"transport": fn_meta["transport"],
|
||||||
|
"isContext": fn_meta["isContext"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
0
backends/mizan-fastapi/tests/__init__.py
Normal file
0
backends/mizan-fastapi/tests/__init__.py
Normal file
173
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
173
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import clear_registry, register
|
||||||
|
from mizan_fastapi import (
|
||||||
|
MizanError,
|
||||||
|
mizan_exception_handler,
|
||||||
|
mizan_validation_handler,
|
||||||
|
router as mizan_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SumOutput(BaseModel):
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserOutput(BaseModel):
|
||||||
|
email: str
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
register(echo, "echo")
|
||||||
|
register(add, "add")
|
||||||
|
register(current_user, "current_user")
|
||||||
|
register(user_count, "user_count")
|
||||||
|
register(update_email, "update_email")
|
||||||
|
register(whoami, "whoami")
|
||||||
|
|
||||||
|
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"]
|
||||||
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 }
|
||||||
@@ -102,6 +102,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
register(entry)
|
register(entry)
|
||||||
@@ -132,6 +134,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
register(entry)
|
register(entry)
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
|
|
||||||
|
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 {
|
export interface MizanResponse {
|
||||||
status: number
|
status: number
|
||||||
@@ -38,6 +46,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> = {}
|
const results: Record<string, any> = {}
|
||||||
|
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
@@ -67,12 +98,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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: results,
|
body: results,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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 +186,20 @@ export async function handleMutationCall(
|
|||||||
if (invalidate) {
|
if (invalidate) {
|
||||||
responseData.invalidate = invalidate
|
responseData.invalidate = invalidate
|
||||||
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(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 }
|
return { status: 200, body: responseData, headers }
|
||||||
@@ -11,3 +11,7 @@ export type { MizanResponse } from './dispatch'
|
|||||||
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
|
||||||
export { generateManifest } from './manifest'
|
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
|
const { context, params } = entry
|
||||||
if (params && Object.keys(params).length > 0) {
|
if (params && Object.keys(params).length > 0) {
|
||||||
const paramStr = Object.entries(params)
|
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))}`)
|
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
|
||||||
.join(';')
|
.join(';')
|
||||||
parts.push(`${context};${paramStr}`)
|
parts.push(`${context};${paramStr}`)
|
||||||
@@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
|||||||
fnEntry.methods = entry.methods || ['GET']
|
fnEntry.methods = entry.methods || ['GET']
|
||||||
pageRoutes.push(entry.route)
|
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)
|
functions.push(fnEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ export interface ClientOptions {
|
|||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParamDef {
|
export interface ParamDef {
|
||||||
@@ -36,6 +38,8 @@ export interface RegistryEntry {
|
|||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestContext {
|
export interface ManifestContext {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
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')
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
@@ -52,11 +52,9 @@ describe('Edge Compatibility', () => {
|
|||||||
|
|
||||||
// ── Cache-Control correctness ───────────────────────────────────────
|
// ── Cache-Control correctness ───────────────────────────────────────
|
||||||
|
|
||||||
test('context GET is cacheable', async () => {
|
test('context GET emits no-store', async () => {
|
||||||
const r = await handleContextFetch('user', { userId: '5' })
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
expect(r.headers['Cache-Control']).toContain('public')
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
expect(r.headers['Cache-Control']).toContain('s-maxage')
|
|
||||||
expect(r.headers['Cache-Control']).not.toContain('no-store')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mutation POST not cacheable', async () => {
|
test('mutation POST not cacheable', async () => {
|
||||||
@@ -228,4 +226,200 @@ describe('Manifest', () => {
|
|||||||
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
||||||
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
26
cores/mizan-python/pyproject.toml
Normal file
26
cores/mizan-python/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "mizan-core"
|
||||||
|
version = "0.1.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
115
cores/mizan-python/src/mizan_core/cache/backend.py
vendored
Normal file
115
cores/mizan-python/src/mizan_core/cache/backend.py
vendored
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Cache backends — MemoryCache (testing) and RedisCache (production).
|
||||||
|
|
||||||
|
Simple key-value stores. No reverse indexes. Cache keys are derived
|
||||||
|
from HMAC, so scoped purge just recomputes the key and deletes it.
|
||||||
|
Broad purge uses key-prefix scan (rare operation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(Protocol):
|
||||||
|
"""Interface that all Mizan cache backends implement."""
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None: ...
|
||||||
|
def set(self, key: str, value: bytes) -> None: ...
|
||||||
|
def delete(self, key: str) -> bool: ...
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int: ...
|
||||||
|
def clear(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""
|
||||||
|
In-memory cache backend for testing.
|
||||||
|
|
||||||
|
Uses a Python dict. No persistence, no cross-process sharing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None:
|
||||||
|
return self._store.get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: bytes) -> None:
|
||||||
|
self._store[key] = value
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
if key in self._store:
|
||||||
|
del self._store[key]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int:
|
||||||
|
to_delete = [k for k in self._store if k.startswith(prefix)]
|
||||||
|
for k in to_delete:
|
||||||
|
del self._store[k]
|
||||||
|
return len(to_delete)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._store.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
"""
|
||||||
|
Redis-backed cache backend for production.
|
||||||
|
|
||||||
|
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
|
||||||
|
the HMAC key and deletes directly. Broad purge uses SCAN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_TTL = 86400 # 24h safety-net
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_url: str,
|
||||||
|
prefix: str = "mizan:",
|
||||||
|
ttl: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
import redis as redis_lib
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"Redis is required for Mizan's cache backend. "
|
||||||
|
"Install it with: pip install mizan[cache]"
|
||||||
|
)
|
||||||
|
self._client = redis_lib.from_url(
|
||||||
|
redis_url,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=5,
|
||||||
|
health_check_interval=30,
|
||||||
|
retry_on_timeout=True,
|
||||||
|
max_connections=50,
|
||||||
|
)
|
||||||
|
self._prefix = prefix
|
||||||
|
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
|
||||||
|
|
||||||
|
def _key(self, key: str) -> str:
|
||||||
|
return f"{self._prefix}{key}"
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None:
|
||||||
|
return self._client.get(self._key(key))
|
||||||
|
|
||||||
|
def set(self, key: str, value: bytes) -> None:
|
||||||
|
self._client.set(self._key(key), value, ex=self._ttl)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
return self._client.unlink(self._key(key)) > 0
|
||||||
|
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int:
|
||||||
|
pattern = f"{self._prefix}{prefix}*"
|
||||||
|
count = 0
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = self._client.scan(cursor, match=pattern, count=1000)
|
||||||
|
if keys:
|
||||||
|
count += self._client.unlink(*keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
return count
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.delete_by_prefix("")
|
||||||
59
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
Normal file
59
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||||
|
|
||||||
|
Protocol-critical: every Mizan adapter must produce identical output
|
||||||
|
for identical inputs. Cross-language conformance verified by pin tests.
|
||||||
|
|
||||||
|
Scoped purge recomputes the key directly — no reverse index needed.
|
||||||
|
Broad purge uses a context prefix scan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Context prefix for broad purge (SCAN pattern)
|
||||||
|
CONTEXT_KEY_PREFIX = "ctx:"
|
||||||
|
|
||||||
|
|
||||||
|
def derive_cache_key(
|
||||||
|
secret: str,
|
||||||
|
context: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
user_id: str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Derive a deterministic HMAC-SHA256 cache key.
|
||||||
|
|
||||||
|
Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that
|
||||||
|
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||||
|
"""
|
||||||
|
def _normalize(v: Any) -> str:
|
||||||
|
"""Normalize values for cross-language HMAC consistency.
|
||||||
|
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
||||||
|
if v is True:
|
||||||
|
return "true"
|
||||||
|
if v is False:
|
||||||
|
return "false"
|
||||||
|
if v is None:
|
||||||
|
return "null"
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
sorted_params = {k: _normalize(v) for k, v in sorted(params.items())}
|
||||||
|
|
||||||
|
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
|
||||||
|
if user_id is not None:
|
||||||
|
key_data["u"] = str(user_id)
|
||||||
|
|
||||||
|
message = json.dumps(key_data, sort_keys=True, separators=(",", ":"))
|
||||||
|
hmac_hex = hmac.new(
|
||||||
|
secret.encode("utf-8"),
|
||||||
|
message.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return f"{CONTEXT_KEY_PREFIX}{context}:{hmac_hex}"
|
||||||
@@ -35,10 +35,37 @@ from typing import (
|
|||||||
get_type_hints,
|
get_type_hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Framework-response-base hook ───────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# View-path detection — distinguishing functions that return data (RPC path)
|
||||||
|
# from functions that return a framework-native response object (view path) —
|
||||||
|
# requires knowing the framework's response base class. Each backend adapter
|
||||||
|
# registers its base class here at import time.
|
||||||
|
#
|
||||||
|
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
|
||||||
|
# starlette.responses.Response. If unset, all functions are treated as RPC.
|
||||||
|
|
||||||
|
_framework_response_base: type | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_framework_response_base(cls: type) -> None:
|
||||||
|
"""Backends register their framework's response base class for view-path detection."""
|
||||||
|
global _framework_response_base
|
||||||
|
_framework_response_base = cls
|
||||||
|
|
||||||
|
|
||||||
|
def is_framework_response(obj_or_cls: Any) -> bool:
|
||||||
|
"""True if obj_or_cls is, or is a subclass of, the registered framework response base."""
|
||||||
|
if _framework_response_base is None:
|
||||||
|
return False
|
||||||
|
if isinstance(obj_or_cls, type):
|
||||||
|
return issubclass(obj_or_cls, _framework_response_base)
|
||||||
|
return isinstance(obj_or_cls, _framework_response_base)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# REACT CONTEXT - Named context marker
|
# REACT CONTEXT - Named context marker
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -116,8 +143,8 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
|||||||
Input: ClassVar[type[BaseModel]] = BaseModel
|
Input: ClassVar[type[BaseModel]] = BaseModel
|
||||||
Output: ClassVar[type[BaseModel]] = BaseModel
|
Output: ClassVar[type[BaseModel]] = BaseModel
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest):
|
def __init__(self, request: Any):
|
||||||
"""Initialize with the Django request."""
|
"""Initialize with the framework's request object (HttpRequest in Django, Request in FastAPI, etc.)."""
|
||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -187,9 +214,8 @@ class _FunctionWrapper(ServerFunction):
|
|||||||
else:
|
else:
|
||||||
result = self._wrapped_fn(self.request)
|
result = self._wrapped_fn(self.request)
|
||||||
|
|
||||||
# View path — return HttpResponse directly (no serialization)
|
# View path — return a framework-native response directly (no serialization)
|
||||||
from django.http import HttpResponseBase
|
if is_framework_response(result):
|
||||||
if isinstance(result, HttpResponseBase):
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Wrap primitive returns in the generated output model
|
# Wrap primitive returns in the generated output model
|
||||||
@@ -261,6 +287,8 @@ def client(
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
cache: int | bool = True,
|
||||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||||
"""
|
"""
|
||||||
Register a function as a server function.
|
Register a function as a server function.
|
||||||
@@ -336,7 +364,7 @@ def client(
|
|||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Support both @client and @client(...)
|
# Support both @client and @client(...)
|
||||||
@@ -344,7 +372,7 @@ def client(
|
|||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -387,6 +415,8 @@ def _create_server_function(
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | None = None,
|
auth: bool | str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
cache: int | bool = True,
|
||||||
) -> type[ServerFunction]:
|
) -> type[ServerFunction]:
|
||||||
"""Internal helper that creates a ServerFunction from a decorated function."""
|
"""Internal helper that creates a ServerFunction from a decorated function."""
|
||||||
from pydantic import create_model
|
from pydantic import create_model
|
||||||
@@ -427,12 +457,10 @@ def _create_server_function(
|
|||||||
if output_type is None:
|
if output_type is None:
|
||||||
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
||||||
|
|
||||||
# Detect view path: function returns HttpResponse (or has no return annotation
|
# Detect view path: function returns a framework-native response type
|
||||||
# that maps to a model — view functions often just have -> HttpResponse)
|
# (e.g. Django HttpResponse, FastAPI Response). View functions often just
|
||||||
from django.http import HttpResponseBase
|
# have -> HttpResponse with no Pydantic model.
|
||||||
is_view_path = (
|
is_view_path = is_framework_response(output_type)
|
||||||
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_view_path:
|
if is_view_path:
|
||||||
# View path — no Pydantic output wrapping needed
|
# View path — no Pydantic output wrapping needed
|
||||||
@@ -523,8 +551,17 @@ def _create_server_function(
|
|||||||
else:
|
else:
|
||||||
meta["auth"] = auth
|
meta["auth"] = auth
|
||||||
|
|
||||||
if meta:
|
# Revision: bumped by developer when function logic changes.
|
||||||
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
# Part of the HMAC cache key — old entries become unreachable orphans.
|
||||||
|
if rev != 0:
|
||||||
|
meta["rev"] = rev
|
||||||
|
|
||||||
|
# Cache policy: True=forever (default), False=no-store, int=TTL seconds
|
||||||
|
if cache is not True:
|
||||||
|
meta["cache"] = cache
|
||||||
|
|
||||||
|
# Always assign a fresh dict to prevent shared-dict mutation across classes
|
||||||
|
FunctionWrapper._meta = {**meta}
|
||||||
|
|
||||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||||
# This allows the decorator to be used without import-time side effects.
|
# This allows the decorator to be used without import-time side effects.
|
||||||
@@ -652,7 +689,7 @@ def compose(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable) -> ComposedContext:
|
def decorator(fn: Callable) -> ComposedContext:
|
||||||
from mizan.setup.registry import register_compose
|
from mizan_core.registry import register_compose
|
||||||
|
|
||||||
name = fn.__name__
|
name = fn.__name__
|
||||||
|
|
||||||
@@ -763,7 +800,7 @@ class FormSchemaOutput(BaseModel):
|
|||||||
def create_form_functions(
|
def create_form_functions(
|
||||||
form_class: type,
|
form_class: type,
|
||||||
name: str,
|
name: str,
|
||||||
submit_handler: Callable[[HttpRequest, dict], BaseModel] | None = None,
|
submit_handler: Callable[[Any, dict], BaseModel] | None = None,
|
||||||
) -> tuple[type[ServerFunction], type[ServerFunction], type[ServerFunction] | None]:
|
) -> tuple[type[ServerFunction], type[ServerFunction], type[ServerFunction] | None]:
|
||||||
"""
|
"""
|
||||||
Generate server functions for a Django Form.
|
Generate server functions for a Django Form.
|
||||||
169
cores/mizan-python/src/mizan_core/mwt.py
Normal file
169
cores/mizan-python/src/mizan_core/mwt.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
MWT (Mizan Web Token) — Protocol-owned identity layer.
|
||||||
|
|
||||||
|
MWT is a standard JWT (RFC 7519, HMAC-SHA256) with Mizan-specific claims,
|
||||||
|
traveling on the `X-Mizan-Token` header. It provides:
|
||||||
|
|
||||||
|
- `sub`: user_id for HMAC cache key derivation
|
||||||
|
- `pkey`: permission state hash for staleness detection
|
||||||
|
- `kid`: key ID in the JOSE header (per RFC 7515) for secret rotation
|
||||||
|
- `aud`: audience binding to prevent cross-tenant replay
|
||||||
|
- `nbf`: not-before to handle clock skew
|
||||||
|
|
||||||
|
MWT is issued from an authenticated Django session. The app handles
|
||||||
|
authentication (session, social auth, etc.); Mizan issues MWT from
|
||||||
|
the authenticated identity. Edge Workers and the origin-side cache
|
||||||
|
validate MWT to extract user identity for cache operations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from mizan.mwt import create_mwt, decode_mwt, MWTUser
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
MIZAN_MWT_SECRET: MWT signing key (separate from MIZAN_CACHE_SECRET)
|
||||||
|
MIZAN_MWT_TTL: token lifetime in seconds (default: 300)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
logger = logging.getLogger("mizan.mwt")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MWTPayload:
|
||||||
|
"""Decoded MWT claims."""
|
||||||
|
sub: str # user_id
|
||||||
|
staff: bool # is_staff
|
||||||
|
super: bool # is_superuser
|
||||||
|
pkey: str # permission state hash (full SHA-256 hex)
|
||||||
|
kid: str # key ID (from JOSE header)
|
||||||
|
aud: str # audience
|
||||||
|
iat: int # issued at
|
||||||
|
exp: int # expiration
|
||||||
|
|
||||||
|
|
||||||
|
class MWTUser:
|
||||||
|
"""
|
||||||
|
Minimal user object created from MWT claims.
|
||||||
|
|
||||||
|
Used as request.user for MWT-authenticated requests.
|
||||||
|
No database query required — all data comes from the token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, payload: MWTPayload):
|
||||||
|
self.id = int(payload.sub)
|
||||||
|
self.pk = self.id
|
||||||
|
self.is_staff = payload.staff
|
||||||
|
self.is_superuser = payload.super
|
||||||
|
self.is_authenticated = True
|
||||||
|
self.is_anonymous = False
|
||||||
|
self.is_active = True
|
||||||
|
self.pkey = payload.pkey
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"MWTUser(id={self.id})"
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"MWTUser(id={self.id}, pkey={self.pkey[:8]}...)"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_permission_key(user: Any) -> str:
|
||||||
|
"""
|
||||||
|
Compute a deterministic hash of the user's permission state.
|
||||||
|
|
||||||
|
Includes is_staff, is_superuser, and all Django permissions.
|
||||||
|
When the MWT expires and is refreshed, the new pkey reflects
|
||||||
|
any permission changes. The short TTL controls the staleness window.
|
||||||
|
|
||||||
|
Returns the full 64-character SHA-256 hex digest.
|
||||||
|
"""
|
||||||
|
perms = sorted(user.get_all_permissions()) if hasattr(user, "get_all_permissions") else []
|
||||||
|
staff = "1" if getattr(user, "is_staff", False) else "0"
|
||||||
|
superuser = "1" if getattr(user, "is_superuser", False) else "0"
|
||||||
|
blob = f"{staff}:{superuser}:{','.join(perms)}"
|
||||||
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_mwt(
|
||||||
|
user: Any,
|
||||||
|
secret: str,
|
||||||
|
ttl: int = 300,
|
||||||
|
audience: str = "mizan",
|
||||||
|
kid: str = "v1",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create an MWT from an authenticated Django user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Django user object (must have pk, is_staff, is_superuser).
|
||||||
|
secret: MIZAN_MWT_SECRET signing key.
|
||||||
|
ttl: Token lifetime in seconds (default: 300 = 5 minutes).
|
||||||
|
audience: Audience claim for cross-tenant protection.
|
||||||
|
kid: Key ID placed in JOSE header (per RFC 7515) for rotation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded JWT string.
|
||||||
|
"""
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"sub": str(user.pk),
|
||||||
|
"staff": getattr(user, "is_staff", False),
|
||||||
|
"super": getattr(user, "is_superuser", False),
|
||||||
|
"pkey": compute_permission_key(user),
|
||||||
|
"aud": audience,
|
||||||
|
"iat": now,
|
||||||
|
"nbf": now,
|
||||||
|
"exp": now + ttl,
|
||||||
|
}
|
||||||
|
# kid goes in the JOSE header per RFC 7515, not the payload
|
||||||
|
headers = {"kid": kid}
|
||||||
|
return jwt.encode(payload, secret, algorithm="HS256", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_mwt(
|
||||||
|
token: str,
|
||||||
|
secret: str,
|
||||||
|
audience: str = "mizan",
|
||||||
|
) -> MWTPayload | None:
|
||||||
|
"""
|
||||||
|
Decode and validate an MWT.
|
||||||
|
|
||||||
|
Returns MWTPayload on success, None on any failure (expired, invalid
|
||||||
|
signature, wrong audience, not-yet-valid, malformed).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Decode header first to extract kid
|
||||||
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
|
kid = unverified_header.get("kid", "v1")
|
||||||
|
|
||||||
|
data = jwt.decode(
|
||||||
|
token,
|
||||||
|
secret,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
logger.debug("MWT decode failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return MWTPayload(
|
||||||
|
sub=data["sub"],
|
||||||
|
staff=data.get("staff", False),
|
||||||
|
super=data.get("super", False),
|
||||||
|
pkey=data.get("pkey", ""),
|
||||||
|
kid=kid,
|
||||||
|
aud=audience,
|
||||||
|
iat=data["iat"],
|
||||||
|
exp=data["exp"],
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
logger.debug("MWT payload missing required claims", exc_info=True)
|
||||||
|
return None
|
||||||
230
cores/mizan-python/src/mizan_core/registry.py
Normal file
230
cores/mizan-python/src/mizan_core/registry.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Mizan core registry — function and composition registration with an
|
||||||
|
extension hook for backend-specific registries (channels, forms, etc.)
|
||||||
|
to plug into.
|
||||||
|
|
||||||
|
This is the framework-agnostic registry. Backends own their own
|
||||||
|
type-specific registries (channels in Django Channels, forms in Django
|
||||||
|
Forms, websockets in FastAPI, etc.) and register them as extensions
|
||||||
|
here so the unified schema export can include them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Core registries ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_functions: dict[str, Any] = {}
|
||||||
|
_compositions: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Extension hook ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RegistryExtension(Protocol):
|
||||||
|
"""
|
||||||
|
Backend-specific registries plug into core via this Protocol.
|
||||||
|
|
||||||
|
Each extension owns its own registry of backend-shaped registrations
|
||||||
|
(channels, forms, websocket consumers, etc.) and contributes a schema
|
||||||
|
subdict to the unified schema export.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def schema(self) -> dict[str, Any]: ...
|
||||||
|
def clear(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
_extensions: dict[str, RegistryExtension] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_extension(name: str, extension: RegistryExtension) -> None:
|
||||||
|
"""
|
||||||
|
Register a backend extension. The extension contributes to schema
|
||||||
|
output under its name (e.g. 'channels', 'forms').
|
||||||
|
"""
|
||||||
|
_extensions[name] = extension
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Function registration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def register(view_class: Any, name: str) -> Any:
|
||||||
|
"""
|
||||||
|
Register a server function class. Used by the @client decorator.
|
||||||
|
|
||||||
|
Idempotent for the same class (supports module reloads).
|
||||||
|
"""
|
||||||
|
view_class.name = name
|
||||||
|
|
||||||
|
if name in _functions:
|
||||||
|
existing = _functions[name]
|
||||||
|
if existing.__name__ == view_class.__name__:
|
||||||
|
_functions[name] = view_class
|
||||||
|
return view_class
|
||||||
|
raise ValueError(
|
||||||
|
f"Function '{name}' already registered by {existing.__name__}"
|
||||||
|
)
|
||||||
|
_functions[name] = view_class
|
||||||
|
return view_class
|
||||||
|
|
||||||
|
|
||||||
|
def register_as(name: str) -> Callable[[Any], Any]:
|
||||||
|
"""Decorator form of register()."""
|
||||||
|
def decorator(view_class: Any) -> Any:
|
||||||
|
return register(view_class, name)
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def register_compose(composed: Any, name: str) -> Any:
|
||||||
|
"""Register a composed context."""
|
||||||
|
if name in _compositions:
|
||||||
|
existing = _compositions[name]
|
||||||
|
if existing.name == composed.name:
|
||||||
|
_compositions[name] = composed
|
||||||
|
return composed
|
||||||
|
raise ValueError(
|
||||||
|
f"Composition '{name}' already registered by {existing.name}"
|
||||||
|
)
|
||||||
|
_compositions[name] = composed
|
||||||
|
return composed
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Lookups ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_function(name: str) -> Any | None:
|
||||||
|
"""Get a registered server function by name."""
|
||||||
|
return _functions.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_compose(name: str) -> Any | None:
|
||||||
|
"""Get a registered composition by name."""
|
||||||
|
return _compositions.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_functions() -> dict[str, Any]:
|
||||||
|
"""Get all registered functions."""
|
||||||
|
return _functions.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_compositions() -> dict[str, Any]:
|
||||||
|
"""Get all registered compositions."""
|
||||||
|
return _compositions.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_contexts() -> dict[str, Any]:
|
||||||
|
"""Get all server functions marked as contexts (meta.context truthy)."""
|
||||||
|
return {
|
||||||
|
name: cls
|
||||||
|
for name, cls in _functions.items()
|
||||||
|
if getattr(cls, "_meta", {}).get("context")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_groups() -> dict[str, list[str]]:
|
||||||
|
"""Group function names by their context string."""
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for name, cls in _functions.items():
|
||||||
|
ctx = getattr(cls, "_meta", {}).get("context")
|
||||||
|
if ctx:
|
||||||
|
groups.setdefault(ctx, []).append(name)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Full registry organized by type, including extension contributions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"functions": {...},
|
||||||
|
"compositions": {...},
|
||||||
|
"<extension>": {...}, # one per registered extension
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"functions": _functions.copy(),
|
||||||
|
"compositions": _compositions.copy(),
|
||||||
|
}
|
||||||
|
for name, ext in _extensions.items():
|
||||||
|
# Extensions optionally expose their backing dict via .all()
|
||||||
|
# (Protocol doesn't require it; only schema() and clear() are mandatory)
|
||||||
|
if hasattr(ext, "all"):
|
||||||
|
out[name] = ext.all()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Export the unified schema for codegen consumption.
|
||||||
|
|
||||||
|
Aggregates function and composition schemas plus contributions from
|
||||||
|
each registered backend extension under its name.
|
||||||
|
"""
|
||||||
|
schema: dict[str, Any] = {
|
||||||
|
"functions": {
|
||||||
|
name: cls.get_schema_export() for name, cls in _functions.items()
|
||||||
|
},
|
||||||
|
"compositions": {
|
||||||
|
name: {
|
||||||
|
"name": composed.name,
|
||||||
|
"type": "compose",
|
||||||
|
"meta": composed._meta,
|
||||||
|
"children": composed._meta.get("children", []),
|
||||||
|
"leaves": composed._meta.get("leaves", []),
|
||||||
|
}
|
||||||
|
for name, composed in _compositions.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, ext in _extensions.items():
|
||||||
|
schema[name] = ext.schema()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_registry() -> list[str]:
|
||||||
|
"""
|
||||||
|
Check that all `affects` targets resolve to known contexts or functions.
|
||||||
|
|
||||||
|
Emits warnings for unresolved targets (e.g. typos in string-based affects).
|
||||||
|
Returns the list of warning messages (empty if all resolve).
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
groups = get_context_groups()
|
||||||
|
all_fn_names = set(_functions.keys())
|
||||||
|
|
||||||
|
for fn_name, fn_cls in _functions.items():
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
continue
|
||||||
|
for target in affects:
|
||||||
|
target_name = target.get("name", "")
|
||||||
|
target_type = target.get("type", "")
|
||||||
|
if target_type == "context" and target_name not in groups:
|
||||||
|
issues.append(
|
||||||
|
f"@client function '{fn_name}' declares affects='{target_name}', "
|
||||||
|
f"but no context named '{target_name}' is registered."
|
||||||
|
)
|
||||||
|
elif target_type == "function" and target_name not in all_fn_names:
|
||||||
|
issues.append(
|
||||||
|
f"@client function '{fn_name}' targets function '{target_name}', "
|
||||||
|
f"but no function named '{target_name}' is registered."
|
||||||
|
)
|
||||||
|
|
||||||
|
for msg in issues:
|
||||||
|
warnings.warn(msg, stacklevel=2)
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Clear (testing) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear_registry() -> None:
|
||||||
|
"""Clear all registrations, including extension state. For testing."""
|
||||||
|
_functions.clear()
|
||||||
|
_compositions.clear()
|
||||||
|
for ext in _extensions.values():
|
||||||
|
ext.clear()
|
||||||
0
cores/mizan-python/tests/__init__.py
Normal file
0
cores/mizan-python/tests/__init__.py
Normal file
68
cores/mizan-python/tests/test_keys.py
Normal file
68
cores/mizan-python/tests/test_keys.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Unit tests for cache key derivation. Includes the cross-language pin against mizan-ts."""
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from mizan_core.cache.keys import derive_cache_key
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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.assertTrue(key1.startswith("ctx:user:"))
|
||||||
|
self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex
|
||||||
|
|
||||||
|
def test_param_order_irrelevant(self):
|
||||||
|
"""Parameter ordering does not affect the 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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
# "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."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_cross_language_pin(self):
|
||||||
|
"""Pinned HMAC values — must match TypeScript adapter exactly."""
|
||||||
|
pin_secret = "test-pin-secret-that-is-32bytes!"
|
||||||
|
|
||||||
|
public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0)
|
||||||
|
self.assertEqual(
|
||||||
|
public_key,
|
||||||
|
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_scoped_key = derive_cache_key(
|
||||||
|
pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
user_scoped_key,
|
||||||
|
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2",
|
||||||
|
)
|
||||||
97
cores/mizan-python/tests/test_mwt.py
Normal file
97
cores/mizan-python/tests/test_mwt.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Unit tests for MWT creation, decoding, and permission key derivation."""
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from mizan_core.mwt import (
|
||||||
|
MWTUser,
|
||||||
|
compute_permission_key,
|
||||||
|
create_mwt,
|
||||||
|
decode_mwt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(**kwargs):
|
||||||
|
user = MagicMock()
|
||||||
|
user.pk = kwargs.get("pk", 1)
|
||||||
|
user.is_staff = kwargs.get("is_staff", False)
|
||||||
|
user.is_superuser = kwargs.get("is_superuser", False)
|
||||||
|
user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set()))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class MWTCreationTests(TestCase):
|
||||||
|
"""Tests for MWT creation and decoding."""
|
||||||
|
|
||||||
|
SECRET = "test-mwt-secret-that-is-32bytes!"
|
||||||
|
|
||||||
|
def test_create_and_decode(self):
|
||||||
|
"""Create an MWT and decode it successfully."""
|
||||||
|
user = _make_user(pk=42, is_staff=True)
|
||||||
|
token = create_mwt(user, self.SECRET, ttl=300)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
|
||||||
|
self.assertIsNotNone(payload)
|
||||||
|
self.assertEqual(payload.sub, "42")
|
||||||
|
self.assertTrue(payload.staff)
|
||||||
|
self.assertFalse(payload.super)
|
||||||
|
self.assertEqual(payload.kid, "v1")
|
||||||
|
self.assertEqual(len(payload.pkey), 64)
|
||||||
|
|
||||||
|
def test_decode_expired(self):
|
||||||
|
"""Expired MWT returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET, ttl=-1)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_decode_wrong_secret(self):
|
||||||
|
"""MWT signed with wrong secret returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET)
|
||||||
|
payload = decode_mwt(token, "wrong-secret")
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_decode_wrong_audience(self):
|
||||||
|
"""MWT with wrong audience returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET, audience="app1")
|
||||||
|
payload = decode_mwt(token, self.SECRET, audience="app2")
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_mwt_user_has_pkey(self):
|
||||||
|
"""MWTUser carries the permission key."""
|
||||||
|
user = _make_user(pk=5, perms={"app.view_thing"})
|
||||||
|
token = create_mwt(user, self.SECRET)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
mwt_user = MWTUser(payload)
|
||||||
|
|
||||||
|
self.assertEqual(mwt_user.pk, 5)
|
||||||
|
self.assertTrue(mwt_user.is_authenticated)
|
||||||
|
self.assertEqual(len(mwt_user.pkey), 64)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionKeyTests(TestCase):
|
||||||
|
"""Tests for pkey determinism and sensitivity."""
|
||||||
|
|
||||||
|
def test_deterministic(self):
|
||||||
|
"""Same permissions produce same pkey."""
|
||||||
|
user = _make_user(perms={"app.view_thing", "app.add_thing"})
|
||||||
|
pkey1 = compute_permission_key(user)
|
||||||
|
pkey2 = compute_permission_key(user)
|
||||||
|
self.assertEqual(pkey1, pkey2)
|
||||||
|
|
||||||
|
def test_changes_on_permission_change(self):
|
||||||
|
"""Different permissions produce different pkey."""
|
||||||
|
user1 = _make_user(perms={"app.view_thing"})
|
||||||
|
user2 = _make_user(perms={"app.view_thing", "app.add_thing"})
|
||||||
|
self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2))
|
||||||
|
|
||||||
|
def test_changes_on_staff_change(self):
|
||||||
|
"""Staff status change produces different pkey."""
|
||||||
|
user_normal = _make_user(is_staff=False)
|
||||||
|
user_staff = _make_user(is_staff=True)
|
||||||
|
self.assertNotEqual(
|
||||||
|
compute_permission_key(user_normal),
|
||||||
|
compute_permission_key(user_staff),
|
||||||
|
)
|
||||||
92
docs/AFI_ARCHITECTURE.md
Normal file
92
docs/AFI_ARCHITECTURE.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# AFI Architecture
|
||||||
|
|
||||||
|
Mizan is an **Application Framework Interface (AFI)** — the
|
||||||
|
server-client unification layer.
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
|
||||||
|
Tree organized by role.
|
||||||
|
|
||||||
|
```
|
||||||
|
backends/ server protocol adapters
|
||||||
|
mizan-django/ Django adapter
|
||||||
|
mizan-fastapi/ FastAPI adapter (AFI-common scope)
|
||||||
|
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
|
||||||
|
frontends/ client kernel + per-framework adapters
|
||||||
|
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
|
||||||
|
mizan-react/ React contexts + hooks over the kernel
|
||||||
|
mizan-vue/ Vue composables over the kernel
|
||||||
|
mizan-svelte/ Svelte stores/runes over the kernel
|
||||||
|
cores/ shared language-level primitives
|
||||||
|
mizan-python/ @client decorator, registry, MWT, HMAC cache keys
|
||||||
|
protocol/ protocol-level tooling
|
||||||
|
mizan-generate/ codegen — schema in, typed client out
|
||||||
|
workers/ runtime workers / bridges
|
||||||
|
mizan-ssr/ Bun subprocess used by the Django template backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two orthogonal products
|
||||||
|
|
||||||
|
- **RPC** — typed client generation via codegen
|
||||||
|
- **SSR** — server rendering via the Bun bridge
|
||||||
|
|
||||||
|
Independent and composable. Either ships standalone; together they
|
||||||
|
compose.
|
||||||
|
|
||||||
|
## Kernel model
|
||||||
|
|
||||||
|
The client kernel (`mizan-base`) is the one hard thing. Per-
|
||||||
|
framework adapters are thin idiomatic wrappers around it. Codegen
|
||||||
|
emits typed bindings against the framework adapter's surface, not
|
||||||
|
against the raw kernel — so a React developer gets `useEcho()` and
|
||||||
|
`<MizanContext>`, a Vue developer gets `useEcho()` composables, a
|
||||||
|
Svelte developer gets readable stores. Same kernel underneath.
|
||||||
|
|
||||||
|
## KDL is the IR
|
||||||
|
|
||||||
|
The Mizan IR is **KDL** — the LLVM-IR-equivalent of the system. Every
|
||||||
|
backend adapter produces KDL describing its registered functions,
|
||||||
|
contexts, types, and invalidation graph. Every codegen target consumes
|
||||||
|
KDL. KDL is the contract; everything else (REST envelopes, OpenAPI
|
||||||
|
documents, framework idioms) is sediment around it.
|
||||||
|
|
||||||
|
The IR must be validated against multiple adapters before it is
|
||||||
|
considered stable. Single-adapter validation hides assumptions —
|
||||||
|
divergence between adapters is what the IR exists to prevent.
|
||||||
|
|
||||||
|
Forward-direction primitives:
|
||||||
|
|
||||||
|
- `cores/mizan-python` builds the IR from registered functions
|
||||||
|
(`build_ir()` walks `mizan_core.registry`, emits KDL)
|
||||||
|
- A `mizan-schema` package (forthcoming) holds the canonical KDL
|
||||||
|
grammar / type system definition that every adapter targets
|
||||||
|
- Codegen reads KDL directly — no OpenAPI envelope, no
|
||||||
|
`openapi-typescript`, no per-backend converter divergence
|
||||||
|
- Edge manifest, MWT claims, and other protocol artifacts all derive
|
||||||
|
from the same KDL
|
||||||
|
|
||||||
|
**Current implementation is transitional.** Today the codegen consumes
|
||||||
|
OpenAPI 3.0 (`x-mizan-functions` + `x-mizan-contexts` extensions over
|
||||||
|
Pydantic→JSON-Schema), produced via Django Ninja or FastAPI's native
|
||||||
|
generator. That layered indirection is what introduces adapter
|
||||||
|
divergence (see the AFI conformance suite). KDL-as-IR collapses it.
|
||||||
|
|
||||||
|
## Launch surface
|
||||||
|
|
||||||
|
Python (Django) + React. Vue and Svelte ship as v1 alongside React.
|
||||||
|
TypeScript backend (`mizan-ts`) proves the protocol is portable.
|
||||||
|
|
||||||
|
## Why the AFI shape
|
||||||
|
|
||||||
|
Quadratic ecosystem growth (N server adapters × M client adapters)
|
||||||
|
collapses to linear (one adapter per stack) when both sides
|
||||||
|
communicate through a shared protocol.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- All cross-package communication goes through the protocol. No
|
||||||
|
direct cross-package dependencies.
|
||||||
|
- New adapters land as new packages, not as modifications to existing
|
||||||
|
ones.
|
||||||
|
- Framework adapters wrap the kernel in framework idioms — they
|
||||||
|
don't bypass it. Codegen targets the adapter, not the raw kernel.
|
||||||
73
docs/CACHE_KEYING.md
Normal file
73
docs/CACHE_KEYING.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Cache Keying
|
||||||
|
|
||||||
|
*Discovered 2026-04-06.*
|
||||||
|
|
||||||
|
## The gap
|
||||||
|
|
||||||
|
Mizan specified invalidation but never specified cache keying.
|
||||||
|
Without correct cache keying, Edge caching is a **security
|
||||||
|
vulnerability** — it serves User A's content to User B.
|
||||||
|
|
||||||
|
## Why Vary doesn't work
|
||||||
|
|
||||||
|
All major CDNs ignore `Vary` for personalized content. No
|
||||||
|
standardized replacement exists.
|
||||||
|
|
||||||
|
## Resolution: HMAC cache key (JSON-canonical form)
|
||||||
|
|
||||||
|
```
|
||||||
|
HMAC-SHA256(secret, JSON.stringify({
|
||||||
|
"c": context,
|
||||||
|
"p": sorted_params,
|
||||||
|
"r": rev,
|
||||||
|
"u": user_id // omitted for public content
|
||||||
|
}, sort_keys=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key derivation rules
|
||||||
|
|
||||||
|
- **Public content** — URL path + query params (standard CDN).
|
||||||
|
- **User-scoped content** — HMAC key derivation above.
|
||||||
|
- **`@client(auth=...)`** determines whether content is user-scoped.
|
||||||
|
- **`rev` parameter** on `@client` for deploy-time logic
|
||||||
|
invalidation. Bumped by the developer when function logic changes.
|
||||||
|
|
||||||
|
## Identity layer
|
||||||
|
|
||||||
|
MWT (Mizan Web Token) — see [MWT_SPEC.md](MWT_SPEC.md). JWT with
|
||||||
|
Mizan claims on `X-Mizan-Token` header. Replaces the old
|
||||||
|
`JWTUser` + permission key metadata approach.
|
||||||
|
|
||||||
|
## Cache architecture
|
||||||
|
|
||||||
|
*Decided 2026-04-06.*
|
||||||
|
|
||||||
|
**Not a compiled binary ABI. Not a pluggable Python protocol.**
|
||||||
|
|
||||||
|
Each backend adapter (Python, TypeScript, future PHP/C#/Go)
|
||||||
|
implements the cache protocol in its own language, backed by Redis.
|
||||||
|
**Conformance verified by a shared test suite.**
|
||||||
|
|
||||||
|
### Required operations
|
||||||
|
|
||||||
|
- `cache_get`
|
||||||
|
- `cache_put`
|
||||||
|
- `cache_purge`
|
||||||
|
- `cache_purge_user`
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Redis only. Handles persistence, cross-worker sharing, crash
|
||||||
|
recovery.
|
||||||
|
|
||||||
|
## Deploy invalidation
|
||||||
|
|
||||||
|
No full context flush. The `rev` parameter on `@client` is part of
|
||||||
|
the HMAC key. When the developer bumps `rev`, old cache entries
|
||||||
|
become **unreachable orphans**. No purge needed; no thundering herd.
|
||||||
|
|
||||||
|
## Invariant
|
||||||
|
|
||||||
|
All cache-related code must implement *identical* HMAC key
|
||||||
|
derivation. Cross-language conformance tests enforce this. Any
|
||||||
|
divergence is a security vulnerability.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user