Compare commits
11 Commits
adcc027894
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9494549861 | |||
| e00b3a177e | |||
| 3aafec6dd4 | |||
| 398c90fc8b | |||
| b5a95e8dcc | |||
| e0fc46058c | |||
| e9a08d278e | |||
| e4091dfbe8 | |||
| 81ea0cea9f | |||
| 587be8c4ab | |||
| ae684a36cb |
@@ -1,4 +1,4 @@
|
|||||||
name: Publish Django package to Gitea registry
|
name: Publish Django package to PyPI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Publish React package to Gitea registry
|
name: Publish React package to npm
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -19,21 +19,17 @@ target/
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
examples/django-react-site/harness/test-results/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
examples/django-react-desktop-app/frontend/dist/
|
protocol/mizan-generate/bin/mizan-generate-*
|
||||||
examples/django-react-site/harness/src/api/generated.*
|
|
||||||
examples/django-react-site/harness/test-results/
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Agent worktrees (transient scratch — never tracked)
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
107
INVARIANTS.md
Normal file
107
INVARIANTS.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Application Framework Interface Invariants
|
||||||
|
|
||||||
|
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
|
||||||
|
|
||||||
|
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
|
||||||
|
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
|
||||||
|
|
||||||
|
## Backend Adapters
|
||||||
|
|
||||||
|
Django (python)
|
||||||
|
FastAPI (python)
|
||||||
|
Typescript (generic)
|
||||||
|
Rust/Axum (generic)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
## Frontend Adapters
|
||||||
|
|
||||||
|
React (Typescript)
|
||||||
|
Vue (Typescript)
|
||||||
|
Svelte (Typescript)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
### Client Function RPC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
No REST endpoints.
|
||||||
|
|
||||||
|
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
|
||||||
|
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
|
||||||
|
|
||||||
|
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
|
||||||
|
|
||||||
|
### Named Contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
|
||||||
|
|
||||||
|
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
|
||||||
|
|
||||||
|
### Mutation Invalidation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
|
||||||
|
|
||||||
|
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
|
||||||
|
|
||||||
|
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
|
||||||
|
|
||||||
|
### API Shapes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A backend adapter supports the "API Shape" feature to the fullest extent:
|
||||||
|
|
||||||
|
- ORM Integration
|
||||||
|
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
|
||||||
|
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
|
||||||
|
|
||||||
|
### Canonical IR & Codegen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
|
||||||
|
|
||||||
|
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
|
||||||
|
|
||||||
|
### Client Kernel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
|
||||||
|
|
||||||
|
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
|
||||||
|
|
||||||
|
### SSR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
|
||||||
|
|
||||||
|
## Compositions
|
||||||
|
|
||||||
|
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
|
||||||
|
|
||||||
|
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
|
||||||
|
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.
|
||||||
@@ -11,9 +11,8 @@ no longer exist.
|
|||||||
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||||
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||||
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||||
- [ ] **Upload dispatch not wired for Rust/Axum + Tauri.** The `Upload` type is first-class end to end — IR (`upload` KDL node), codegen (TS `File`; the Rust target lowers it to `Vec<u8>`), kernel (auto-multipart), and dispatch+constraint binding on Django and FastAPI. The Rust/Axum and Tauri *adapters* have no upload concept at dispatch — they don't bind multipart file parts yet.
|
|
||||||
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
||||||
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: cross-language stringification of un-normalized value types, and no thundering-herd / single-flight protection.
|
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||||
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||||
|
|
||||||
## Resolved this pass
|
## Resolved this pass
|
||||||
|
|||||||
79
MIZAN.md
79
MIZAN.md
@@ -1,19 +1,12 @@
|
|||||||
# MIZAN — Named Contexts & Mutation Architecture
|
# MIZAN — Named Contexts & Mutation Architecture
|
||||||
|
|
||||||
> **Historical design spec.** The original named-contexts / mutation design
|
The design spec for the named-contexts and mutation-invalidation surface: the
|
||||||
> document from the January 2025 design conversation. Kept as a record of design
|
developer-facing API tiers, param elevation, context bundling, the `affects`
|
||||||
> intent, not as a description of the current build — names and surfaces here
|
invalidation graph, and the `ReactContext` read/write class form. Wire protocol
|
||||||
> predate the implementation (the codegen is the Rust binary
|
and package layout live in `CLAUDE.md`; subsystem architecture lives in `docs/`
|
||||||
> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For
|
(`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`, `CACHE_KEYING.md`,
|
||||||
> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen
|
`MWT_SPEC.md`). The codegen that consumes this surface is the Rust binary
|
||||||
> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`,
|
`protocol/mizan-codegen`.
|
||||||
> `CACHE_KEYING.md`, `MWT_SPEC.md`).
|
|
||||||
|
|
||||||
## For Claude Code
|
|
||||||
|
|
||||||
This plan was written by Ryth's Claude.ai session after an extended design conversation
|
|
||||||
reviewing the full codebase, the original @compose discussion from January 2025, and
|
|
||||||
several rounds of architectural refinement.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,7 +32,7 @@ the class form. `@client` + `affects` covers 95% of cases.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Named Contexts (replacing context='local' and @compose)
|
## 1. Named Contexts
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
Any string passed to `context=` becomes a named context. Functions and classes sharing
|
Any string passed to `context=` becomes a named context. Functions and classes sharing
|
||||||
@@ -176,7 +169,7 @@ GET /api/mizan/ctx/global/
|
|||||||
No params. Fetched once. SSR-hydrated.
|
No params. Fetched once. SSR-hydrated.
|
||||||
|
|
||||||
### Mutation calls
|
### Mutation calls
|
||||||
Non-context `@client` functions (including those with `affects`) use the existing
|
Non-context `@client` functions (including those with `affects`) use the
|
||||||
POST endpoint:
|
POST endpoint:
|
||||||
```
|
```
|
||||||
POST /api/mizan/call/
|
POST /api/mizan/call/
|
||||||
@@ -383,7 +376,7 @@ Mutation is business logic, not automation.
|
|||||||
## 6. Discovery and Registration
|
## 6. Discovery and Registration
|
||||||
|
|
||||||
### @client functions
|
### @client functions
|
||||||
Discovered via `clients.py` convention (DjangoAppVisitor), same as current.
|
Discovered via the `clients.py` convention (DjangoAppVisitor).
|
||||||
|
|
||||||
### ReactContext classes
|
### ReactContext classes
|
||||||
Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are
|
Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are
|
||||||
@@ -394,54 +387,24 @@ are detected at registration time.
|
|||||||
- Duplicate names within same context → error
|
- Duplicate names within same context → error
|
||||||
- Mixed WebSocket transport within context → error
|
- Mixed WebSocket transport within context → error
|
||||||
- `receive` defined without `send` → error
|
- `receive` defined without `send` → error
|
||||||
- `affects` referencing a non-existent context name or function → error (or warning)
|
- `affects` referencing a non-existent context name or function → error
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. What to Remove / Deprecate
|
## 7. Surfaces the named-context design subsumes
|
||||||
|
|
||||||
- `context='local'` → replaced by any non-'global' context string
|
Each of these is expressed by a primitive above rather than by a mechanism of
|
||||||
- `@compose` decorator → replaced by shared context names
|
its own, which is why none of them is part of the public API:
|
||||||
- `ComposedContext` class → remove from public API
|
|
||||||
- `on_server` flag → default behavior (contexts always bundled)
|
- A per-component local scope → any non-`'global'` context string
|
||||||
- `share` prop pattern → replaced by param elevation + `specify`
|
- Composition of several read functions into one fetch → a shared context name
|
||||||
|
- A composed-context class → the shared context name plus `ReactContext`
|
||||||
|
- A per-function "bundle on the server" flag → contexts are always bundled
|
||||||
|
- A prop that shares params down a subtree → param elevation + `specify`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Implementation Order
|
## 8. The Developer's Mental Model
|
||||||
|
|
||||||
### Phase 1: Named contexts (core feature)
|
|
||||||
1. Accept any string for `context=` (not just 'global'/'local')
|
|
||||||
2. Group functions by context name in the registry
|
|
||||||
3. Add context bundling endpoint: `GET /api/mizan/ctx/<name>/`
|
|
||||||
4. Update codegen to produce named providers with param elevation
|
|
||||||
5. Update codegen to produce `specify` prop handling
|
|
||||||
6. Make `context='global'` use the same mechanism, just auto-mounted
|
|
||||||
|
|
||||||
### Phase 2: affects invalidation
|
|
||||||
1. Add `affects` parameter to `@client` decorator
|
|
||||||
2. Accept string (context name), function reference, or list
|
|
||||||
3. Store affects metadata in the function's `_meta` dict
|
|
||||||
4. Export affects relationships in the schema
|
|
||||||
5. Update codegen: mutation hooks auto-invalidate after success
|
|
||||||
6. Frontend: invalidation checks if affected context is mounted before refetching
|
|
||||||
|
|
||||||
### Phase 3: ReactContext classes
|
|
||||||
1. Implement `ReactContext` base class with metaclass magic for the string arg
|
|
||||||
2. `send` method registered as a context function (same as @client with context)
|
|
||||||
3. `receive` method registered as a commit handler
|
|
||||||
4. Commit endpoint: `POST /api/mizan/ctx/<name>/commit/`
|
|
||||||
5. Update codegen: produce commit hooks for classes with `receive`
|
|
||||||
6. Auto-refetch after commit, with optional fresh-data-from-receive optimization
|
|
||||||
|
|
||||||
### Phase 4: Cleanup
|
|
||||||
1. Remove `@compose` from public API and docs
|
|
||||||
2. Remove `context='local'` (accept for backwards compat with deprecation warning)
|
|
||||||
3. Update README and all examples
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. The Developer's Mental Model
|
|
||||||
|
|
||||||
Write functions. Name your contexts. Declare what affects what.
|
Write functions. Name your contexts. Declare what affects what.
|
||||||
The framework generates the client, handles the caching, and runs the invalidation.
|
The framework generates the client, handles the caching, and runs the invalidation.
|
||||||
|
|||||||
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install test test-core test-django test-fastapi test-react test-afi parity-table parity-check test-integration docker-up docker-down clean
|
.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
|
||||||
|
|
||||||
CORE = cores/mizan-python
|
CORE = cores/mizan-python
|
||||||
DJANGO = backends/mizan-django
|
DJANGO = backends/mizan-django
|
||||||
@@ -30,24 +30,11 @@ test-fastapi:
|
|||||||
test-react:
|
test-react:
|
||||||
cd $(REACT) && npm test
|
cd $(REACT) && npm test
|
||||||
|
|
||||||
# AFI conformance — two gates, substrate-level, not e2e:
|
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
|
||||||
# test_codegen_parity.py — Django/FastAPI/Rust emit byte-identical KDL IR.
|
# schemas for the same @client fixture. Substrate-level gate, not e2e.
|
||||||
# test_capability_parity.py — every (capability, applicable adapter) pair is
|
|
||||||
# probed for its wiring. RED on every unwired gap
|
|
||||||
# by design: that board is the owed work, itemized.
|
|
||||||
test-afi:
|
test-afi:
|
||||||
cd $(AFI) && uv run pytest
|
cd $(AFI) && uv run pytest
|
||||||
|
|
||||||
# Regenerate the README parity table from the live conformance probes. The table
|
|
||||||
# is generated output — never hand-edited.
|
|
||||||
parity-table:
|
|
||||||
cd $(AFI) && uv run python parity_table.py --write
|
|
||||||
|
|
||||||
# CI gate: the committed README parity table matches what the probes report.
|
|
||||||
# Fails on any hand-edit, the same forcing function as the codegen byte-parity.
|
|
||||||
parity-check:
|
|
||||||
cd $(AFI) && uv run python parity_table.py --check
|
|
||||||
|
|
||||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test-integration: docker-up
|
test-integration: docker-up
|
||||||
|
|||||||
482
OWED_SURFACE.md
Normal file
482
OWED_SURFACE.md
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
# Owed Surface
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
This document declares, per crate/package unit, the behavioral mechanisms the unit owes to substantiate the documentation's claims.
|
||||||
|
|
||||||
|
- The owed surface is derived from the documentation's CLAIMS. For each non-trivial claim, it enumerates the behaviors the code must exhibit to prove the claim — to a hostile auditor, an IP lawyer, and a paying customer — at maximal performance, efficiency, and hygiene, never a minimal technicality.
|
||||||
|
- A mechanism is stated as observable behavior with the criterion that distinguishes its maximal realization from a degenerate stub, observably enough that a skeptic can check it.
|
||||||
|
- A mechanism is owed by the unit's charter and claims. The enumeration is derived from the claims, never from an inventory of the source, so it does not move when the source does. Whether a given mechanism holds right now is answered by the test suite, CI, git history, and the issue tracker.
|
||||||
|
- A **unit** is one crate/package/build target, identified by its root path, sized to emit whole in one `.pack`. A `.pack` targets the units whose roots contain its files.
|
||||||
|
- This is a declared contract the authoring agent holds and honors when it emits a pack. The PreToolUse gate enforces only that a stance is declared (this document, or an exemption) before code is authored; it does not test the packs.
|
||||||
|
|
||||||
|
The AFI's single load-bearing thesis (README.md, docs/AFI_ARCHITECTURE.md § Why the AFI shape): the backends × frontends quadratic collapses to linear because **one KDL IR is the only contract that crosses the backend↔frontend boundary**. Every mechanism below is, in the end, in service of that: N backends emit byte-identical KDL for the same registered functions, and M frontends are generated from it, so a bug can only live in the KDL contract or its edges — nowhere in between.
|
||||||
|
|
||||||
|
`backends/mizan-django/src/mizan` exceeds the single-emit budget (137K est. tokens); it is decomposed below into sub-units along the documented feature seams (dispatch, cache, channels, forms, shapes, ssr, jwt, registration/export) plus its verification harness, which itself exceeds budget and is cut into two test sub-units at the protocol-vs-adversarial seam. Every other unit emits whole.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan_core (`cores/mizan-python/src/mizan_core`)
|
||||||
|
|
||||||
|
**Charter.** The framework-agnostic Python substrate every Python backend adapter stands on: the `@client` decorator and the function-to-IR machinery, the registry, canonical KDL IR emission, HMAC cache-key derivation, cache backends, MWT identity, and the type-introspection helpers the adapters share. It owns *language-level* primitives; it does not own transport, dispatch, or any Django/FastAPI mechanics.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Client Function RPC — decorated functions carrying the full variadic/kwarg set (INVARIANTS.md § Client Function RPC).
|
||||||
|
- Named Contexts — functions sharing a context name grouped at registration into one provider/one fetch (INVARIANTS.md § Named Contexts; MIZAN.md §1–2).
|
||||||
|
- Mutation Invalidation & merge — `affects=`/`merge=` carried in the IR, never middleware (INVARIANTS.md § Mutation Invalidation; MIZAN.md §4).
|
||||||
|
- Auth as a property of the declared function, carried in the IR (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule).
|
||||||
|
- Canonical KDL IR — every backend emits KDL describing functions/contexts/types/invalidation graph; the IR is the only contract (INVARIANTS.md § Canonical IR & Codegen; docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
- HMAC cache keying with cross-language conformance (docs/CACHE_KEYING.md § Invariant).
|
||||||
|
- MWT identity layer (docs/MWT_SPEC.md).
|
||||||
|
- Free origin-side cache implementing the full protocol locally (docs/PRODUCT_ARCHITECTURE.md § Origin-side cache).
|
||||||
|
- File Uploads — `Upload` first-class end to end through IR (INVARIANTS.md § File Uploads).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Client Function RPC / decorator:
|
||||||
|
- `@client` accepts the full declared set (`context`, `affects`, `merge`, `private`, `route`, `methods`, `websocket`, `auth`, `rev`, `cache`) and synthesizes a Pydantic `Input` model from the function signature (skipping the request param) — observable: a decorated fn with `(request, a: int, b: int)` yields an `Input` with two typed fields; input validation rejects `a="x"` before the body runs.
|
||||||
|
- the return annotation decides wire shape: a primitive/dict return is wrapped as `{result: …}`, while `BaseModel` / `list[BaseModel]` / `Optional[BaseModel]` pass through bare — observable: `-> list[Item]` reaches the wire as a bare JSON array, `-> int` as `{"result": n}`; a missing return annotation raises `TypeError` at decoration (not a silent `Any`).
|
||||||
|
- `context=` and `affects=` (and `merge=`) are enforced mutually exclusive at decoration — observable: `@client(context=X, affects=Y)` raises `ValueError`, so a function cannot be simultaneously a reader and a mutation.
|
||||||
|
- `auth=` is normalized and validated at decoration (`True`→`"required"`, callables kept, `"staff"/"superuser"` allowed) — observable: `@client(auth="admin")` raises `ValueError` naming the valid set, not a runtime surprise at dispatch.
|
||||||
|
|
||||||
|
Named Contexts grouping (the "one provider, one fetch" invariant's registry half):
|
||||||
|
- the registry groups every function by its context string so a named context is a single fetch unit, never N callables — observable: two `@client(context="user")` functions produce `get_context_groups()["user"] == [both names]`; `"global"` is just a reserved name in the same map, not a separate mechanism.
|
||||||
|
- mixing socket and non-socket transport within one context is a registration-time error (INVARIANTS.md § WebSocket Support) — observable: registering a `websocket=True` fn and a plain fn under the same `context=` raises at registration rather than producing a context half of whose members are unreachable over the bundle fetch.
|
||||||
|
- `receive` defined without `send`, and `affects` referencing a non-existent context or function, are registration-time errors (MIZAN.md §6) — observable: `validate_registry()` raises on an `affects` target that resolves to neither a registered context nor a registered function, and on a `receive` with no paired `send`, so a typo cannot reach codegen as a silently-dead invalidation edge.
|
||||||
|
|
||||||
|
Canonical KDL IR (`build_ir`) — the contract every codegen target reads:
|
||||||
|
- IR is emitted in a canonical order independent of registration order (functions alphabetical by wire name, contexts alphabetical, params alphabetical, `shared-by` sorted) — observable: registering the same functions in two different orders yields byte-identical KDL; this is the property the three-way parity test rests on.
|
||||||
|
- types are introspected from the Pydantic models directly (never routed through JSON-Schema `$ref`), producing `struct` / `alias{list}` / `enum` / `optional` / `union` shapes under canonical `<camelName>Input` / `<camelName>Output` names, with `Vec`-element sub-types surfaced — observable: a `-> list[OrderOutput]` fn emits `type "userOrdersOutput" { alias { list { ref "OrderOutput" } } }` AND a `type "OrderOutput" { struct … }`; `-> Model | None` sets `output-nullable #true`.
|
||||||
|
- context param elevation is computed in the IR: a param is `required #true` iff every member of the context declares it, with `shared-by` naming the declarers — observable: a two-function `user` context where both take `user_id` emits `param "user_id" { type "integer"; required #true; shared-by … }`; if only one declares `page`, `page` is `required #false`.
|
||||||
|
- `private` and view-path functions are omitted from the emitted `function` set, and channels are emitted from the `channels` registry extension — observable: `@client(private=True)` never appears in the KDL (so it can carry invalidation without being client-callable); a registered channel emits a `channel` node with its pascal-name and message-type refs.
|
||||||
|
- channel slot names in the IR are backend-neutral and named from the client's side: `params`, `client-message`, `server-message`, in that order — observable: every emitter and parser iterates the three slots in that order, so a channel declared on Django and the same channel declared on FastAPI emit the identical `channel` node.
|
||||||
|
- `wire_to_pascal` is the single derivation of a channel's emitted type names — the wire name split on `[._-]`, each part title-cased and joined, yielding `<Pascal>Params` / `<Pascal>ClientMessage` / `<Pascal>ServerMessage` — observable: a backend that also publishes an OpenAPI slot table names each type through this same function, so the two documents cannot disagree about what one type is called.
|
||||||
|
|
||||||
|
HMAC cache keying (protocol-critical cross-language identity):
|
||||||
|
- `derive_cache_key` produces `ctx:{context}:{hmac_hex}` over a JSON-canonical sorted form with param values normalized to JSON-native strings (`True`→`"true"`, `None`→`"null"`) and `user_id` omitted for public content — observable: the pinned test vectors (`ctx:user:605a1ca5…` public, `ctx:user:30fc08eb…` user-scoped) match the TypeScript adapter byte-for-byte; param ordering does not change the key; the `ctx:` prefix supports broad SCAN.
|
||||||
|
- key derivation resists delimiter collision and versions on `rev` — observable: `context="user", user_id="12"` and `context="user1", user_id="2"` produce different keys; bumping `rev` produces a new key, so old entries become unreachable orphans without a purge.
|
||||||
|
|
||||||
|
MWT identity layer:
|
||||||
|
- `create_mwt` places `kid` in the JOSE header per RFC 7515 (not the payload) and computes `pkey` as `sha256` over `sorted(get_all_permissions())` plus staff/super flags, with `aud` and `nbf` claims — observable: `decode_mwt` reads `kid` from the header; a token minted for one audience decodes to `None` under another; `pkey` is deterministic for identical permission state and changes the instant a permission is added.
|
||||||
|
- `MWTUser` is built entirely from claims with no DB query — observable: constructing `MWTUser(payload)` sets `pk`/`is_staff`/`is_superuser`/`pkey` from the token alone; an expired token decodes to `None`.
|
||||||
|
|
||||||
|
Cache backends:
|
||||||
|
- `MemoryCache` and `RedisCache` both implement get/set/delete plus prefix-scoped purge; the Redis broad purge SCANs `ctx:{context}:*` and UNLINKs, never a full flush — observable: `delete_by_prefix("ctx:user:")` removes only `user` entries and leaves `ctx:products:*` and foreign-prefixed keys intact; `RedisCache` applies a TTL safety-net on every `set`.
|
||||||
|
|
||||||
|
Type-introspection helpers (shared so backend parity cannot drift):
|
||||||
|
- `is_structured_output` recognizes `BaseModel` / `Optional[BaseModel]` / container-of-`BaseModel` as no-wrap, and `types_match_for_merge` accepts direct / list-upsert / list-replace shape matches — observable: a slot typed `list[T]` matches a value typed `T` (upsert-by-id), and a multi-arm `A | B | None` union is returned as-is by `extract_optional`, not silently narrowed to one arm.
|
||||||
|
|
||||||
|
File Uploads:
|
||||||
|
- an `Upload` type is a first-class argument carried through IR, codegen, and dispatch binding, bound from multipart over HTTP and from the envelope over IPC (INVARIANTS.md § File Uploads) — observable: a function declaring an `Upload` parameter emits a distinguished IR shape rather than degrading to an opaque string, and dispatch binds a real file object the body can read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust core (`cores/mizan-rust`)
|
||||||
|
|
||||||
|
**Charter.** The Rust analog of `mizan_core`: the IR data model, a KDL emitter that is byte-equivalent to the Python emitter, the compile-time (linkme) registry, the runtime invalidation/merge resolvers the HTTP and Tauri adapters call, and the cross-function graph checks. It owns the Rust side of the *same* IR contract; it does not own transport.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Canonical KDL IR — "the IR must be validated against multiple adapters"; Rust is an IR authority (docs/AFI_ARCHITECTURE.md § KDL is the IR; README.md note 6).
|
||||||
|
- Mutation invalidation auto-scoping (three-tier) and merge on the Rust adapters (README.md § Adapters; § Merge via `mizan-tauri`/`mizan-rust-axum`).
|
||||||
|
- The IR is the only contract — divergence between adapters is what it exists to prevent (docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Byte-equivalent KDL emission:
|
||||||
|
- `build_ir()` produces KDL byte-identical to the Python emitter against the same registered functions/types/contexts — observable: `cores/mizan-rust/tests/afi_parity.rs` and the three-way `tests/afi/test_codegen_parity.py` diff Rust output against the canonical Python-emitted `afi_ir.kdl` and require exact equality (line-by-line failure on any drift).
|
||||||
|
- the emitter reproduces the Python emitter's canonicalization exactly: alphabetical functions/contexts, sorted params, `shared-by`, snake→camel conversion, primitive-alias/enum inlining, and tree-shaking to types reachable from a registered function's input/output — observable: a `#[derive(Mizan)]` type not referenced by any function is omitted; an `Alias(Primitive)` or `Enum` named type inlines at its reference site instead of emitting a standalone `type` node, matching the Python output.
|
||||||
|
- channel nodes emit the same three client-named slots in the same order as the Python emitter (`params`, `client-message`, `server-message`) with the same pascal derivation — observable: the three-way parity fixture carrying a channel diffs byte-identical across Django, FastAPI, and Rust, so no backend can reintroduce a backend-shaped slot name into a backend-neutral IR.
|
||||||
|
|
||||||
|
Compile-time registry:
|
||||||
|
- `TYPES` / `CONTEXTS` / `FUNCTIONS` are linkme distributed slices populated at the consumer crate's expansion sites, and `lookup_function` / `context_members` resolve against them — observable: an IR-export bin that references one symbol per module force-links its registrations; dropping the reference drops the function from the emitted IR (the documented force-link requirement is real, not decorative).
|
||||||
|
|
||||||
|
Runtime invalidation & merge (must match the Python executor's semantics):
|
||||||
|
- `compute_invalidation` auto-scopes by matching mutation arg names against the affected context's declared Input params — observable: a mutation carrying `user_id` against a `user` context whose members declare `user_id` emits `{context:"user", params:{user_id:…}}`, while a non-matching arg emits the bare context string.
|
||||||
|
- `compute_merges` resolves the slot by structural return-type match against context members (via `types_match`), emitting `{context, slot, value}` only on a unique match and dropping ambiguous/no-match — observable: with two context members of different output shapes, a mutation's value routes to the single member whose type matches; two matching members drop the merge (fall back to refetch), never a bundle-order guess.
|
||||||
|
|
||||||
|
Cross-function graph checks (fail at IR-build time, before any client is emitted):
|
||||||
|
- `verify_invariants` panics with a structured message when an `affects`/`merge` target names an unregistered context, when a `merge` target has no unique matching member, or when a shared context param's type diverges across members — observable: an `affects = "ghost"` fails codegen with a named error; a `merge` whose context has two same-type members fails naming both; this is the whole-graph consistency the "IR prevents divergence" claim rests on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-macros (`cores/mizan-rust-macros`)
|
||||||
|
|
||||||
|
**Charter.** The proc macros — `#[derive(Mizan)]`, `#[mizan::context]`, `#[mizan::client]` — that make the Rust consumer surface author the same registry and IR shapes the Python decorator produces. It owns the compile-time codegen that emits `MizanType`/`FunctionSpec` impls and linkme registrations; it does not own runtime behavior.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Rust/Tauri are "the IR authority via the `#[mizan::client]` macro + linkme registry" (README.md note 6).
|
||||||
|
- The `#[mizan::client]` surface mirrors the Python `@client` parameter set (backends/mizan-tauri/README.md § Define server functions; backends/mizan-rust-axum README).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `#[derive(Mizan)]` emits a `MizanType::shape()` matching the Python type introspection, honoring serde `rename_all`/`rename` so wire names match serialization, and registers a `TypeEntry` — observable: an enum with `#[serde(rename_all="snake_case")]` emits IR enum variants in snake form; a struct field `r#type` emits IR field name `type`.
|
||||||
|
- `#[mizan::client]` synthesizes a `<camelName>Input` struct + `MizanType` impl, registers the canonical `<camelName>Input`/`<camelName>Output` type entries (and the `Vec` element type for list outputs), and implements `FunctionSpec::dispatch` that deserializes JSON args into the typed input, awaits the body, and serializes the result — observable: `async fn user_orders(req, user_id: i64) -> Vec<OrderOutput>` registers `userOrdersOutput` as a list alias plus `OrderOutput`, and dispatch round-trips typed args; a `Result<T, MizanError>` return `?`-unwraps so user errors surface as the standard envelope, while the IR still sees only the `T` shape.
|
||||||
|
- `#[mizan::client]` enforces the same mutual-exclusion as Python (`context` vs `affects`/`merge`) and requires an `async fn` with an explicit return type — observable: `#[mizan::client(context = X, affects = Y)]` is a compile error; a non-async or return-typeless fn is a compile error.
|
||||||
|
- `#[mizan::context]` emits a `ContextMarker` with a snake_case (or explicit) name and registers a `ContextEntry` — observable: `#[mizan::context("user")]` and `#[mizan::context] struct UserCtx` both yield `NAME == "user"`; a non-unit struct is a compile error.
|
||||||
|
- input-param wire names strip the Rust `_`-underscore convention and bridge it with `#[serde(rename)]` — observable: `_user_id: i64` emits IR param name `user_id` and the synthesized Input renames the JSON key so dispatch deserializes the wire form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-ssr (`cores/mizan-rust-ssr`)
|
||||||
|
|
||||||
|
**Charter.** The embedded-V8 SSR engine, its PyO3 binding, and the anti-RSC guard. It owns rendering a build-time JS bundle to HTML in-process via `deno_core`, exposing that engine to the Python side across an in-process FFI boundary, and the structural guarantee that the SSR surface never imports an RSC/Flight runtime. It does not own the Django template backend (that is `mizan-django/ssr`) or the bundling that produces the JS bundle (that is `mizan-generate`).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- SSR is hand-rolled; no frontend adapter imports an SSR runtime or meta-framework (Next/Nuxt/SvelteKit/RSC/Flight) — the CVE-2025-55182 pre-auth-RCE deserialization class (HOLOMORPHICS/Mizan project note; MEMORY: mizan-ssr-no-framework-runtimes; enforced by `cores/mizan-rust-ssr/tests/no_rsc.rs`).
|
||||||
|
- SSR renders synchronously from props, injected as validated data (the AFI provides the typed one-way version).
|
||||||
|
- The Python side binds the engine through PyO3 — in-process FFI, no subprocess, no JSON-RPC framing; behavior lives in the binary, languages bind to it (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the engine composes a real `deno_web` web-platform layer (TextEncoder/Decoder, MessagePort, timers) rather than a partial shim, evals the trusted bundle once, and renders per request — observable: the fixture bundle renders `Hello, World!`; a missing global fails loudly at render, not silently (a partial polyfill is silent-failure-shaped, which is why deno_web's real impls carry this).
|
||||||
|
- props cross as a `v8::json::parse`d value passed as a function argument, never spliced into evaluated source — observable: the injection test feeds a prop string crafted to break out of a string-built call; it renders as inert text and does not set a global, so code injection is structurally absent.
|
||||||
|
- the no-RSC guard scans authored SSR source and dependencies for the forbidden token set (`react-server-dom`, `renderToReadableStream`, `renderToPipeableStream`, `createFromReadableStream`/`Fetch`, `use server`, `next/`, `nuxt`, `@sveltejs/kit`) and fails on presence — observable: adding any RSC/Flight/meta-framework import to the scanned fixtures turns `no_rsc.rs` red; absence alone is not the guarantee — re-entry is loud.
|
||||||
|
- a PyO3 binding exposes `SsrEngine` to the Python side as an in-process extension: construct-from-bundle and `render(props_json) -> HTML` cross the FFI boundary with no process spawn, honoring V8's one-isolate-per-engine / non-`Send` constraint so the caller holds one engine per (worker thread, bundle) — observable: the Django backend imports the engine and calls `render(props)` in-process, and a prop still crosses as a parsed value, so the injection guarantee survives the FFI hop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django dispatch (`backends/mizan-django/src/mizan/client`)
|
||||||
|
|
||||||
|
**Charter.** The Django HTTP/RPC dispatch surface: the executor that validates input, enforces auth, runs the function, and branches RPC-vs-view; the invalidation and merge resolvers; the context-bundle fetch; JWT/MWT request authentication. It owns per-request Django dispatch semantics; it does not own the registry, the IR, or the cache implementation (it calls them).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch returning `{result, invalidate}` and `merge` (README.md; MIZAN.md §4).
|
||||||
|
- Named-context bundle fetch — one GET returns all functions in the context, never N round-trips (INVARIANTS.md § Named Contexts; MIZAN.md §3).
|
||||||
|
- Mutation invalidation with three-tier auto-scoping; on failure nothing invalidates; developer writes no cache key (INVARIANTS.md § Mutation Invalidation).
|
||||||
|
- Invalidation travels two transports — the JSON body and the `X-Mizan-Invalidate` header — because a view-path response has no JSON body to carry it (INVARIANTS.md § Mutation Invalidation).
|
||||||
|
- Return-type branching: a data return takes the RPC path, an `HttpResponse` return takes the view path (backends/mizan-django/README.md § `@client` parameters).
|
||||||
|
- Auth enforced at dispatch, rejecting before the body runs, identically across transports (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule).
|
||||||
|
- Origin-side HMAC cache read/write on context fetch; `cache=False`/`rev` policy (docs/CACHE_KEYING.md; docs/PRODUCT_ARCHITECTURE.md § Spec surface).
|
||||||
|
- MWT/JWT server-side auth enforcement in the executor (`_check_auth_requirement`) (docs/MWT_SPEC.md § Usage rule).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Dispatch & validation:
|
||||||
|
- `execute_function` validates input against the function's Pydantic `Input` before invoking the body, and rejects private functions from RPC — observable: a missing required field returns `VALIDATION_ERROR` with per-field detail and the body never runs; a `private=True` function returns `FORBIDDEN` when called over `/call/`.
|
||||||
|
- output serialization walks `BaseModel`/`list`/`dict` recursively via `to_jsonable_python` so `list[BaseModel]` reaches the wire as a bare array — observable: a `-> list[Item]` function returns `[{…},{…}]`, not `{"result":[…]}`; an `Optional[Model]` returning `None` serializes to `null` not `{"result":null}`.
|
||||||
|
|
||||||
|
Named-context bundle fetch (single request, param-filtered):
|
||||||
|
- `execute_context` runs every function in the group in one request, passing each only the params it declares, and fails the whole bundle if any member fails auth/validation — observable: `GET /ctx/user/?user_id=5&page=3` returns `{user_profile:…, user_orders:…}` where `user_profile` never sees `page`; if one member requires auth and the request is anonymous, the whole fetch returns the auth error, not a partial bundle.
|
||||||
|
|
||||||
|
Three-tier invalidation (the invariant that separates the AFI from typed RPC):
|
||||||
|
- `_resolve_invalidation` auto-scopes by matching mutation args against context param names (Tier 1), falling back to the bare context (Tier 3), and resolves function-level `affects` to the function name — observable: `update_profile(user_id=5,…)` against a `user` context emits `[{context:"user", params:{user_id:5}}]`; a mutation whose args don't overlap emits `["user"]`; `affects="user_profile"` emits the function name as the key.
|
||||||
|
- invalidation is emitted on both transports and only on success — observable: a successful mutation carries both `response["invalidate"]` (JSON body) and `X-Mizan-Invalidate: user;user_id=5` (header, URL-encoded so `q=hello world`→`q=hello%20world` and semicolons survive a parse round-trip); a mutation that raises emits neither.
|
||||||
|
- `_resolve_merges` resolves the merge slot server-side by matching the mutation's Output type against context members' Output types (`types_match_for_merge`), emitting `{context, slot, value, params?}` only on a unique match — observable: with `morph_groups: list[MorphGroupMeta]` and `morph_layers: list[MorphLayer]` in one context, a mutation returning `MorphLayer` merges into `morph_layers` only; the kernel does no shape inference.
|
||||||
|
|
||||||
|
Auth enforced before the body:
|
||||||
|
- `_check_auth_requirement` runs before `view.call`, handling `required`/`staff`/`superuser`/callable and mapping to `UNAUTHORIZED`/`FORBIDDEN` — observable: an anonymous call to `@client(auth=True)` returns `UNAUTHORIZED` and the function body never executes; a callable raising `PermissionError` surfaces its message as `FORBIDDEN`.
|
||||||
|
- MWT is checked first (`X-Mizan-Token`), then JWT (`Authorization: Bearer`), then session+CSRF; a present-but-invalid token is rejected (never a silent fall-through to session) — observable: an invalid `X-Mizan-Token` returns 401 without trying session auth; a valid MWT sets `request.user = MWTUser` with no DB query; CSRF is enforced only on the session path.
|
||||||
|
|
||||||
|
Return-type branching + origin cache:
|
||||||
|
- a function returning an `HttpResponse` takes the view path (invalidation rides the header, `Cache-Control: no-store`), while a data return takes the RPC path — observable: a `-> HttpResponseRedirect` mutation returns the 302 with `X-Mizan-Invalidate` set; the same-decorated `-> Shape` mutation returns JSON with `invalidate` in the body.
|
||||||
|
- context fetch consults the origin cache keyed by the effective `rev` (max across members) and effective cache policy (`False` short-circuits), stores deterministic (sorted-key) JSON on miss, and purges scoped/broad on mutation — observable: two identical fetches return byte-identical bodies and the second carries `X-Mizan-Cache: HIT`; a scoped mutation for `user_id=5` purges only that entry and leaves `user_id=6` a HIT; a context with any `cache=False` member emits `no-store`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django cache (`backends/mizan-django/src/mizan/cache`)
|
||||||
|
|
||||||
|
**Charter.** The Django-side origin cache facade over `mizan_core`'s backends and key derivation — the free, unit-testable local cache that implements the same HMAC key and purge semantics as the paid Edge. It owns cache lifecycle/config resolution and the scoped-vs-broad purge dispatch; it does not own key derivation (delegates to core).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Free framework origin-side cache implementing the full cache protocol locally, same HMAC key and purge as Edge (docs/PRODUCT_ARCHITECTURE.md § Origin-side cache; docs/CACHE_KEYING.md § Cache architecture).
|
||||||
|
- Scoped purge recomputes the key and deletes directly; broad purge SCANs the `ctx:{context}:*` prefix (docs/CACHE_KEYING.md § Required operations).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `cache_purge` recomputes the exact HMAC key for a scoped purge (one DELETE) and prefix-scans for a broad purge, so scoped invalidation touches exactly one entry — observable: `cache_purge(ctx, {user_id:5}, secret)` deletes only user 5's entry (returns 1) and leaves user 6; `cache_purge(ctx)` with no params removes every entry under the prefix.
|
||||||
|
- cache enablement is gated on both `cache_secret` and `cache_redis_url` present, thread-safe and lazily initialized — observable: with only one configured, caching is disabled and logged; concurrent `get_cache()` calls initialize once.
|
||||||
|
- the operability obligations the "same protocol as Edge, security-critical" claim rests on hold under concurrency and across languages: purge atomicity, cross-language stringification for every value type rather than bool/None alone, per-param sub-index cleanup on broad purge, single-flight protection against a thundering herd, one argument shape shared by `cache_get`/`cache_put`, and RedisCache exercised by the same suite as MemoryCache — observable: an index read racing a delete cannot resurrect a purged key; a broad purge leaves no orphaned per-param sub-index behind; N simultaneous misses on one key issue one origin fetch; a float or nested value stringifies identically in Python and TypeScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django channels (`backends/mizan-django/src/mizan/channels`)
|
||||||
|
|
||||||
|
**Charter.** The WebSocket transport: the `Channel` base + registry, the multiplexed consumer that handles channel subscribe/message and RPC-over-WS, server push, and channel schema export. It owns real-time bidirectional messaging and WS-transported RPC; it does not own HTTP dispatch (reuses the executor).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- WebSocket support: `websocket=` dispatched over a persistent connection; server-initiated messages reach subscribed contexts; declaration and wire semantics uniform across adapters (INVARIANTS.md § WebSocket Support).
|
||||||
|
- A channel is typed in both directions — typed params, a typed client→server message, a typed server→client message — so real-time traffic carries the same type contract RPC does (INVARIANTS.md § WebSocket Support; backends/mizan-django/README.md § Channels).
|
||||||
|
- Channels compose into the IR channel section (docs/AFI_ARCHITECTURE.md — codegen channels target consumes the channel nodes).
|
||||||
|
- Auth/authorization checked before any channel or RPC body runs (INVARIANTS.md § Auth; consumer security).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the consumer multiplexes many channel subscriptions and RPC calls over one socket, keyed by `(channel, params_json)`, and validates Pydantic params/messages before `authorize`/`receive` — observable: subscribing with a wrong-typed param returns an error before authorization; a duplicate subscription to the same `(channel, params)` is rejected; unsubscribe leaves zero lingering subscriptions after rapid subscribe/unsubscribe cycles.
|
||||||
|
- WS-RPC only dispatches functions explicitly marked `websocket=True`, running the same `execute_function` (so validation/auth are identical to HTTP) — observable: an RPC call to an HTTP-only function returns `FORBIDDEN` ("use POST /call/"); a WS call to a `websocket=True` fn returns the same envelope shape as HTTP; a missing `id`/`fn` returns a structured error.
|
||||||
|
- `authorize()` gates every subscription and exceptions in it are contained — observable: `authorize` returning `False` blocks the subscribe with "Not authorized"; an `authorize` that raises returns an error rather than crashing the socket; room-level authorization enforces per-param access (room 1 allowed, room 999 rejected).
|
||||||
|
- server push (`Channel.push`) broadcasts to the channel-layer group, converting Pydantic to JSON, so a server function can reach subscribers — observable: `ChatChannel.push(room="general", message=…)` sends to `chat_general` with the message body; push with no channel layer configured warns rather than raising.
|
||||||
|
- a channel declares its wire types under the backend-neutral slot names the `Channel` base reads — `Params`, `ClientMessage` (travels client → server), `ServerMessage` (travels server → client) — and any slot left undeclared makes that direction unavailable — observable: a model declared under any other attribute name is invisible to the registry extension and to the IR, so the channel is exported as though that direction were absent.
|
||||||
|
- channel schema is exported into the registry's `channels` extension carrying the `params` / `client_message` / `server_message` shapes the channel declares plus a `bidirectional` flag — observable: a channel declaring a `ClientMessage` reports `bidirectional: true`; a push-only channel reports `false` and omits `client_message` while still carrying `server_message`; the KDL `channel` node names the same three slots as `params` / `client-message` / `server-message`, and the codegen channels target emits the matching typed envelopes and `useXChannel` hook.
|
||||||
|
- the OpenAPI channel document names each slot type through `mizan_core.ir.wire_to_pascal`, the same derivation the IR uses, and tabulates the per-channel slots under `x-mizan-channels` — observable: the `paramsType` / `clientMessageType` / `serverMessageType` entries and the `hasParams` / `hasClientMessage` / `hasServerMessage` flags name exactly the types the KDL `channel` node refers to, so the OpenAPI view and the IR view of one channel cannot disagree.
|
||||||
|
- JWT auth over the WS handshake authenticates from the `?token=` query param without a DB query, taking precedence over session — observable: a valid access token sets `scope["user"]` to a `JWTUser` from claims; an invalid token falls back to session rather than rejecting the socket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django forms (`backends/mizan-django/src/mizan/forms`)
|
||||||
|
|
||||||
|
**Charter.** The Forms composition: `mizanFormMixin`/`mizanFormMeta` turning a Django Form into the three role-tagged server functions (schema/validate/submit), plus formsets, and the field schema/validation projection. It owns Django-Form-to-server-function translation; it does not own generic RPC dispatch. Auth-provider (django-allauth) forms are **out of scope** — the docs place them in a dedicated external `mizan-allauth` repository built on this mixin; this unit owes only the primitive they build on.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Forms are three role-tagged client functions (schema / validate / submit) plus field validation, composed from RPC + validation (INVARIANTS.md § Compositions — Forms).
|
||||||
|
- A formset is the same three-role composition applied to a collection, so it introduces no fourth role (backends/mizan-django/README.md § Forms).
|
||||||
|
- Auto-registers `{name}.schema` / `.validate` / `.submit`; frontend gets `useXForm()` (backends/mizan-django/README.md § Forms).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `mizanFormMixin.__init_subclass__` auto-registers exactly three role-tagged server functions per concrete form (and formset variants when enabled), carrying `form`/`form_name`/`form_role` meta — observable: defining a `ContactForm` with a `mizanFormMeta(name="contact")` registers `contact.schema`, `contact.validate`, `contact.submit`; a form without a `mizan` attribute registers nothing; enabling `enable_formset` adds `contact.formset.{schema,validate,submit}`.
|
||||||
|
- the schema function projects each Django field into a typed `FieldSchema` (mapping field classes to Python types, extracting choices from `ModelChoiceField` safely, serializing initial values) and carries the `mizanFormMeta` display/behavior settings — observable: a `CharField`/`EmailField`/`Textarea` form yields three typed fields with correct `type`/`widget`; a `ModelChoiceField` yields JSON-serializable `{value,label}` choices (no `ModelChoiceIteratorValue` leak).
|
||||||
|
- validate runs the real Django form validation and returns structured per-field errors; submit branches multipart-vs-JSON, calls the form's `on_submit_success`/`on_submit_failure`, and returns pass/fail with data — observable: submitting an invalid email returns field errors and `success: false`; a valid submit runs `on_submit_success` and returns its data; a multipart submit binds files.
|
||||||
|
- `create_form_instance` threads `request`/`user`/`instance` init kwargs into the Django form and gracefully drops any the form doesn't accept, so the mixin is a reusable primitive for forms that need request context (the base the external `mizan-allauth` repo builds on) — observable: a form declaring a `request` kwarg receives it; a form that doesn't accept `request` still instantiates rather than raising `TypeError`.
|
||||||
|
- a forms codegen target emits the form clients against `mizanCall` from the kernel, so the frontend form surface stands on no hand-written provider — observable: `useXForm()` is generated from the IR's `is-form`/`form-name`/`form-role` fields and reaches the server through the kernel, exactly as every other generated client does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django shapes (`backends/mizan-django/src/mizan/shapes`)
|
||||||
|
|
||||||
|
**Charter.** The "API Shapes" primitive: Pydantic-typed queryset projection over django-readers, PK-keyed structural diffing (add/modify/delete) across nested relations. It owns ORM projection and diff derivation; it does not own dispatch.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- API Shapes to the fullest extent: ORM integration, auto-diffing by primary key (add/modify/delete, Django as reference), authorable near the used function (INVARIANTS.md § API Shapes).
|
||||||
|
- A Shape is one declaration serving as both the wire type and the query plan — the Pydantic field set compiles to a django-readers projection (INVARIANTS.md § API Shapes).
|
||||||
|
- Context classes send/receive with Shape diffing (INVARIANTS.md § Compositions — Context classes; MIZAN.md §5).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `Shape.query` compiles a django-readers projection from the Pydantic field set + nested Shapes, executing minimal queries (single query for flat, prefetch for nested) and validating each row — observable: a flat shape query runs one SQL query; a nested `AuthorCardShape` with `books` runs two (prefetch), not N+1; per-relation querysets filter nested rows (`books=lambda qs: qs.filter(is_published=True)`).
|
||||||
|
- diffing computes add/modify/delete by primary key across nested relations, using a single batched query for existing rows and strict access to nested diffs — observable: `diff_many` of mixed new+existing items runs one query for the existing set; a nested diff reports `created`/`updated`/`deleted` by child PK; accessing a mistyped nested name raises (KeyError/AttributeError) rather than silently returning empty.
|
||||||
|
- PK/type resolution handles integer, slug, and UUID primary keys, two FKs to the same model, self-referential and nullable FKs, and treats `False`/`0`/`""` as present values — observable: a UUID-PK `Section` shape diffs correctly; `is_published=False` is not treated as missing; a nullable editor FK returns `None` rather than erroring.
|
||||||
|
- the `ReactContext('name')` class form carries `send`/`receive` and a `POST /ctx/<name>/commit/` endpoint that routes committed shape data to `receive`, with auto-refetch-or-fresh-return after commit (INVARIANTS.md § Compositions; MIZAN.md §5) — observable: a class defining `send`/`receive` generates a read hook and a commit function; committing runs `receive` and either refetches the context or splices the Shape `receive` returned, so the client never holds a post-commit stale bundle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django SSR (`backends/mizan-django/src/mizan/ssr`)
|
||||||
|
|
||||||
|
**Charter.** The SSR product's Django half: a Django template backend that renders `.tsx`/`.jsx` component files by resolving each to its built bundle and driving the PyO3-bound `SsrEngine` in-process, wrapping output with a hydration payload. It owns the Django-template-engine integration, component-to-bundle resolution, prop gathering, and the per-(worker-thread, bundle) engine lifecycle; it does not own the V8 render (that is `cores/mizan-rust-ssr`'s `SsrEngine`) or the bundling that produces the render bundle (that is `mizan-generate`).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- SSR is a Django template backend replacing the rendering engine; the template name IS a `.tsx`/`.jsx` file path; context dict becomes props; output wrapped in `<div id="mizan-root">` + `window.__MIZAN_SSR_DATA__` hydration (docs/SSR_ARCHITECTURE.md).
|
||||||
|
- The render engine is embedded-V8 inside the Mizan Rust binary, bound via PyO3 — in-process FFI, no external JS runtime serving requests, no subprocess, no JSON-RPC framing (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary).
|
||||||
|
- The component path resolves against `DIRS` to its built bundle in `OPTIONS['bundles']` (produced by mizan-generate's SSR bundling step); the backend gathers props and wraps output for hydration (docs/SSR_ARCHITECTURE.md § AFI boundary; the `TEMPLATES` config block).
|
||||||
|
- One engine per (worker thread, bundle) — V8's Locker constraint makes an engine non-`Send`, so the Django side never shares one across threads (docs/SSR_ARCHITECTURE.md § The engine).
|
||||||
|
- SSR is orthogonal to RPC and composable; first paint carries data (INVARIANTS.md § SSR).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `MizanTemplates` implements Django's template-backend interface: `get_template(name)` resolves `name` as a `.tsx`/`.jsx` file path under `DIRS` and returns a `MizanTemplate` for the resolved component; `render` strips `request`/`csrf_token` and passes the remaining context as props — observable: `render(request, 'components/Hello.tsx', ctx)` renders that component with `ctx` as props; `from_string` raises (it renders files, not strings); a missing file raises `TemplateDoesNotExist`.
|
||||||
|
- component-to-bundle resolution: the backend resolves each component file to its self-contained render bundle in `OPTIONS['bundles']` (the `mizan-generate`-produced bundle assigning `globalThis.renderApp`) and hands the bundle to the engine, rather than reading component source directly — observable: `render` of `components/Hello.tsx` loads that component's built bundle from the configured `bundles` directory; a component with no built bundle raises rather than rendering stale or empty HTML.
|
||||||
|
- rendered output is wrapped for client hydration — observable: output contains `<div id="mizan-root">…</div>` plus `<script>window.__MIZAN_SSR_DATA__={sorted-json}</script>`, so first paint carries the props the client hydrates from.
|
||||||
|
- engine lifecycle: the backend constructs one `SsrEngine` per (worker thread, bundle) pair and reuses it across requests, never sharing a single engine across threads (the engine is non-`Send`) — observable: concurrent renders on different worker threads each use their own thread-local engine and return correct results with no interleaving; the engine is built once per bundle, not per render.
|
||||||
|
- the Django backend binds the engine through PyO3 and renders in-process: no external JS runtime process, no newline-delimited JSON-RPC correlation, no ready-signal or auto-restart machinery, and `OPTIONS` carries `bundles` (a directory of built bundles) rather than `worker` (a JS entry file) — observable: a render spawns no subprocess and calls the PyO3-bound `SsrEngine.render(props)` directly, so a render failure is a Python exception rather than a lost correlation id.
|
||||||
|
- render-on-mutation orchestration (mutation → trigger local render → store HTML) is driven by the manifest's `render_strategy`, wiring the engine to the PSR path (docs/PSR_VS_EDGE.md) — observable: a mutation against a public context triggers a local re-render and stores the HTML, so the next request for that page is served pre-rendered rather than rendered on demand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django JWT/MWT (`backends/mizan-django/src/mizan/jwt`)
|
||||||
|
|
||||||
|
**Charter.** The Django identity layer: JWT access/refresh tokens tied to sessions, the MWT-mint server functions, JWT settings/algorithm resolution, and the Ninja security class. It owns Django-session-bound token issuance and validation; the MWT format itself lives in `mizan_core.mwt`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- JWT auth is session-bound: the access/refresh pair is auto-detected at dispatch, and CSRF is handled on the session path only (backends/mizan-django/README.md § Setup; INVARIANTS.md § Auth).
|
||||||
|
- MWT is issued from an authenticated identity; `create_mwt(user, secret, ttl, audience, kid)`; a separate JWT module carries user-auth tokens (docs/MWT_SPEC.md § Key decisions).
|
||||||
|
- MWT is the cache-keying identity, not a replacement for JWT auth (docs/MWT_SPEC.md).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- JWT tokens carry `sub`/`sid`/`staff`/`super`/`type`/`iat`/`exp` and are tied to a session key so logout revokes them — observable: a refresh whose underlying session was destroyed returns `None` (immediate revocation); an access `JWTUser` is built from claims with no DB query; `decode_token` enforces the expected token type.
|
||||||
|
- settings auto-detect algorithm from key shape (PEM→RS256 else HS256) and derive the public key from the private RSA key when absent — observable: an HS256 secret works with `public_key == private_key`; a PEM private key auto-selects RS256 and extracts the public key.
|
||||||
|
- `mwt_obtain` mints an MWT from the authenticated session via `create_mwt`, requiring `MIZAN_MWT_SECRET`, and `jwt_obtain`/`jwt_refresh` issue/rotate the JWT pair carrying user claims — observable: `mwt_obtain` on an anonymous request raises; with no secret configured it raises a clear config error; the JWT pair includes `is_staff`/`is_superuser` so downstream auth needs no DB query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django registration & export (`backends/mizan-django/src/mizan/export`, `.../management`, `.../setup`, `.../__init__.py`, `.../urls.py`, `.../_vendor`)
|
||||||
|
|
||||||
|
**Charter.** The Django discovery/registration glue and the two protocol export surfaces: the Edge manifest generator and the KDL IR management command, plus URL wiring, session-init, and the ASGI/channels wrapper. It owns clients.py auto-discovery and the manifest/IR export commands; it delegates registry and IR shape to `mizan_core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Codegen IR export (KDL) via `python manage.py export_mizan_ir` (backends/mizan-django/README.md § Generate the frontend; docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
- The Edge manifest is a deterministic (sorted) derivation of the registry covering both RPC and view-path functions, and records each context's `render_strategy` (docs/PSR_VS_EDGE.md § PSR — Preemptive Static Rendering).
|
||||||
|
- Function discovery / registration via the clients.py convention (backends/mizan-django/README.md § Setup; MIZAN.md §6).
|
||||||
|
- Session / CSRF init endpoint; `wrap_asgi` WebSocket routing (backends/mizan-django/README.md § Setup).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `export_mizan_ir` populates the registry via discovery, then writes canonical KDL from `mizan_core.ir.build_ir` — observable: the Django-emitted KDL is byte-identical to the FastAPI and Rust emissions for the same fixture (`tests/afi/test_codegen_parity.py`); this is the "IR is the only contract, validated against multiple adapters" claim made checkable.
|
||||||
|
- `generate_edge_manifest` emits a deterministic (sorted contexts and mutations) JSON mapping contexts to endpoints/params/functions, distinguishing rpc vs view path, marking `user_scoped` and `render_strategy` (`dynamic_cached` for user-scoped, `psr` for public), and mutations with auto-scoped params + private/route — observable: two exports are byte-identical regardless of registration order; a context with `user_id` is `user_scoped`+`dynamic_cached`; a view-path function's `route` populates `page_routes`; a mutation whose args match context params lists them under `auto_scoped_params`.
|
||||||
|
- `mizan_clients` discovers `ServerFunction` subclasses under each app's `clients.py`/`clients/` layer and registers them idempotently — observable: re-running discovery does not double-register; a class already registered under a different name is skipped rather than clobbered.
|
||||||
|
- the session-init view sets the CSRF cookie and returns the token, and `wrap_asgi` routes `/ws/` to the channels consumer — observable: `GET /session/` returns `{csrfToken}` and a `Set-Cookie: csrftoken=…`, so SSR/clients can establish CSRF before an authenticated call; `wrap_asgi(get_asgi_application())` produces a ProtocolTypeRouter dispatching http vs websocket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django protocol tests (`backends/mizan-django/src/mizan/tests` — test_core.py, test_auth.py, test_ssr.py, test_benchmarks.py)
|
||||||
|
|
||||||
|
**Charter.** The Django backend's protocol-and-integration verification: the executor/registry/invalidation/merge/cache/manifest/edge-compatibility/auth/SSR/throughput suites. It holds the evidence that the dispatch, invalidation, cache, auth, and SSR mechanisms above behave as claimed against the real HTTP stack; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The dispatch, invalidation, merge, cache, auth, and SSR claims of the mizan-django dispatch/cache/ssr/jwt sub-units are *verified* here.
|
||||||
|
- Edge caching is provable without an Edge — deterministic JSON, correct Cache-Control, header round-trip, auth-differentiated responses (test_core.py § EdgeCompatibilityTests, a doc-shaped claim carried in the suite).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the suite exercises the real HTTP stack (Django test client / LiveServer) — not just RequestFactory — for dispatch, three-tier invalidation, merge, view-path branching, and cache HIT/MISS/scoped-purge — observable: `HTTPIntegrationTests` and `CacheIntegrationTests` assert the JSON body and `X-Mizan-Invalidate` header agree, that a scoped mutation preserves other users' cached entries, and that a second identical fetch is a HIT.
|
||||||
|
- the auth suite covers every axis (JWT valid/invalid/expired, MWT, session, staff/superuser/callable/PermissionError) and asserts the body never runs on failure — observable: an invalid token returns 401 without session fall-through; an anonymous call to an auth-required function returns before the body; a callable's `PermissionError` message surfaces verbatim.
|
||||||
|
- the Edge-compatibility suite asserts the properties a CDN cares about (deterministic byte-identical bodies, sorted JSON keys, URL-encoded delimiter-safe headers, `no-store` on errors/mutations, header↔body invalidation agreement, auth-differentiated responses for the same URL) — observable: these tests go red if any of those properties regress, so "Edge caching is possible" is checkable without a CDN.
|
||||||
|
- the SSR suite verifies the engine-based render path — template-backend resolution, bundle-driven in-process render, the hydration wrapper, and concurrent renders — and asserts that no external JS runtime process is spawned during the suite (the PyO3-bound `SsrEngine` renders in-process) — observable: a render produces the `<div id="mizan-root">` + `__MIZAN_SSR_DATA__` wrapper from the resolved bundle, concurrent renders across worker threads each use their own engine and return correct results, and the suite spawns no `bun`/`node` subprocess.
|
||||||
|
- the benchmark suite measures HTTP-vs-executor overhead and throughput with correctness assertions on each path — observable: every benchmark also asserts the function's numeric output, so a green benchmark run is also a correctness run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django adversarial & feature tests (`backends/mizan-django/src/mizan/tests` — test_pentest.py, test_security.py, test_channels.py, test_shapes.py)
|
||||||
|
|
||||||
|
**Charter.** The Django backend's adversarial and feature-specific verification: the penetration/security suites (attacker-shaped defenses) and the channels/shapes suites (feature behavior). It holds the evidence that validation, authorization, channel subscription, and shape diffing behave as claimed against hostile and edge inputs; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The auth-guard, input-validation, and no-info-disclosure claims (INVARIANTS.md § Auth; executor validation) are verified adversarially here.
|
||||||
|
- The WebSocket channel authorization/subscription and API Shapes diff/query claims (INVARIANTS.md § WebSocket Support, § API Shapes) are verified here.
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the pentest and security suites assert the properties an attacker probes: validation-runs-before-execution, private/internal functions unreachable over RPC, no sensitive detail in production error messages, injection strings (SQL/command/template/prototype-pollution/unicode-lookalike/zero-width) treated as inert data, and no function-existence timing leak — observable: these tests go red if the executor ever runs a body before validation, leaks a secret in a 500, or executes an injection payload.
|
||||||
|
- the channels suite verifies subscription lifecycle and authorization: param validation before `authorize`, `authorize`-false and `authorize`-raise both blocking cleanly, duplicate-subscription rejection, room-level per-param authorization, and WS-RPC gated to `websocket=True` functions — observable: subscribing to a room the user cannot access is rejected; an RPC to an HTTP-only function returns FORBIDDEN over the socket.
|
||||||
|
- every channel fixture these suites register subclasses the `Channel` base and declares its wire models under the `Params` / `ClientMessage` / `ServerMessage` slot names that base reads — observable: a fixture's declared message model reaches the exported schema and the IR; a model declared under any other attribute name is invisible to both, so the suite would be asserting against a channel the contract sees as slotless.
|
||||||
|
- the extension-schema tests pin both directions of the `bidirectional` flag — observable: a fixture declaring `ClientMessage` asserts `bidirectional` true with both `client_message` and `server_message` present, and a push-only fixture asserts `bidirectional` false with `client_message` absent, so neither the flag nor the slot keys can be renamed without turning the suite red.
|
||||||
|
- the shapes suite verifies query efficiency and diff correctness across the hard cases: single-query flat, prefetch nested (no N+1), UUID/slug/int PKs, two-FKs-to-same-model, self-referential and nullable FKs, `False`/`0`/`""` treated as present, batched `diff_many`, and strict nested-diff access raising on typos — observable: a nested query asserts exactly the prefetch count; a mistyped nested-diff name raises rather than silently returning empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-fastapi (`backends/mizan-fastapi/src/mizan_fastapi`)
|
||||||
|
|
||||||
|
**Charter.** The FastAPI adapter targeting the AFI-common subset: RPC dispatch, context bundling, JSON-body invalidation + merge, auth gating, the error envelope, the channel registry extension and socket handler, and the KDL IR CLI. It owns the FastAPI transport surface over `mizan_core`; Forms/Shapes/SSR are explicitly out of scope.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, function registration, KDL IR export (README.md § Adapters; backends/mizan-fastapi/README.md § Scope).
|
||||||
|
- Auth-guard enforcement (`auth=` rejects) (backends/mizan-fastapi/README.md § Auth integration).
|
||||||
|
- The same core primitives as Django, proving the protocol is not Django-specific; IR-shape parity with Django and Rust (README.md § Conformance; docs/AFI_ARCHITECTURE.md).
|
||||||
|
- Every error path renders through the Mizan envelope; `GET /session/` returns a null CSRF token for wire parity (backends/mizan-fastapi/README.md § Setup; README.md § Adapters note 7).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `execute_function` looks up the registered function, enforces `auth` before running (matching Django's semantics: `True`/`required`/`staff`/`superuser`/callable), validates input against the Pydantic `Input`, awaits `view.acall` (async handlers on the loop, sync in a threadpool), and serializes via `jsonable_encoder` — observable: an anonymous call to `@client(auth=True)` returns 401 before the body; an `async def` handler runs on the loop (a real `await` inside completes); `list[BaseModel]`/`Optional[BaseModel]` reach the wire bare.
|
||||||
|
- `compute_invalidation` auto-scopes by matching args against the context's declared Input fields, emitting a bare context or a `{context, params}` object — observable: a mutation with a matching arg emits the scoped form, a non-matching arg the bare context string; identical to the Django resolver's output.
|
||||||
|
- `compute_merges` resolves the slot by unique return-type match (`types_match_for_merge`) and emits `{context, slot, value, params?}`, dropping ambiguous — observable: the `morph_groups`/`morph_layers` fixture routes a `MorphLayer` mutation to `morph_layers` only; a merge-only mutation emits `merge` with empty `invalidate`.
|
||||||
|
- the router exposes `POST /call/`, `GET /ctx/{name}/`, `GET /session/` and both exception handlers render every failure through `{"error":{code,message,details?}}` with `Cache-Control: no-store` — observable: an unknown function returns 404 in the envelope; a malformed body returns `BAD_REQUEST`; a validation failure returns 422; `/session/` returns `{csrfToken: null}` (parity, since CSRF is Django-only).
|
||||||
|
- the `Channel` base declares the same three backend-neutral slots as the Django base (`Params`, `ClientMessage`, `ServerMessage`) and its `channels` registry extension exports the same key set (`name`, `type`, `bidirectional`, plus the declared `params`/`client_message`/`server_message` schemas) — observable: a channel declaring `ClientMessage` reports `bidirectional: true` and a `client_message` schema whose entry is comparable key-for-key with Django's entry for an identically-declared channel, so the slot names carry on a backend with no Django in it.
|
||||||
|
- the socket handler fans a message out to exactly the subscribers whose params key the group, dropping a socket that fails to take a frame — observable: two subscribers with the same params receive one push; a departed socket is discarded from the group with the failure surfaced rather than swallowed, so one dead client cannot wedge the broadcast.
|
||||||
|
- `python -m mizan_fastapi.ir <module>` imports the module (triggering registration) and writes canonical KDL — observable: its output equals the Django management command's output for the same fixture (three-way parity).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-axum (`backends/mizan-rust-axum`)
|
||||||
|
|
||||||
|
**Charter.** The Rust/Axum HTTP adapter: the `/call/`, `/ctx/:name/`, `/session/` handlers, the error envelope, and app-state threading, dispatching through `mizan-core`'s `FUNCTIONS` registry. It owns the Axum wire surface; dispatch/invalidation/merge logic is `mizan-core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, KDL IR export (README.md § Adapters; note 6).
|
||||||
|
- Axum error envelope mirrors FastAPI's with `Cache-Control: no-store` (backends/mizan-rust-axum/src/errors.rs).
|
||||||
|
- Query params are coerced to typed JSON via the per-function input params (handlers.rs).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `function_call` dispatches through `lookup_function` + `FunctionSpec::dispatch`, then attaches `compute_invalidation` and `compute_merges` output, mirroring the FastAPI response shape `{result, invalidate, merge?}` — observable: the wire-parity drivers (`tests/rust/drive_kernel.rs`, `drive_emitted.rs`) run the same probes against the Axum server and FastAPI and require the same JSON shapes and invalidate/merge semantics.
|
||||||
|
- `context_fetch` bundles every registered member of the context and coerces string query params to typed JSON via each function's `input_params` primitive table — observable: `GET /ctx/user/?user_id=5` returns the flat bundle with `user_id` coerced to an integer before dispatch; an unknown context returns the envelope 404.
|
||||||
|
- app state is type-erased into the handle and downcast in user functions — observable: a handler downcasts `RequestHandle` to the concrete state type; the stateless router variant threads a unit handle.
|
||||||
|
- the adapter honors every declaration it accepts, so no function reaches the wire with a declared property the transport ignores (README.md § Caveat, notes 2/3/5; INVARIANTS.md § Auth): a `Transport::Websocket` function is routed through a WebSocket handler, an `is_form`/`form_role` function is reachable through validate/submit endpoints, and `auth=` is enforced in the dispatch path — observable: an `auth=True` function is rejected for an anonymous caller on this adapter exactly as it is on Django and FastAPI, which is what makes "auth enforced on every adapter" a single claim rather than a per-adapter one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-tauri (`backends/mizan-tauri`)
|
||||||
|
|
||||||
|
**Charter.** The Tauri adapter: a plugin exposing a single `mizan_invoke` command that routes op-tagged call/fetch envelopes through the shared `mizan-core` registry over Tauri IPC. It owns the IPC wire surface; dispatch/invalidation/merge are `mizan-core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, invalidation (JSON body only), three-tier auto-scoping (README.md § Adapters; note 1).
|
||||||
|
- Transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP; invalidation rides the response body; no header channel (README.md note 1; backends/mizan-tauri/README.md § Wire protocol).
|
||||||
|
- `RequestHandle` wraps `AppHandle` so functions can access managed state; `Result<T, MizanError>` supported (backends/mizan-tauri/README.md § App-state access).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the plugin registers exactly one command (`plugin:mizan|mizan_invoke`) that deserializes the op-tagged envelope and dispatches `call`/`fetch` through the same `FUNCTIONS`/`CONTEXTS` slices the HTTP adapter uses — observable: `{op:"call", fn, args}` returns `{result, invalidate, merge?}` and `{op:"fetch", context, params}` returns the flat bundle, identical shapes to the axum adapter minus the header channel; there is no per-function `#[tauri::command]`.
|
||||||
|
- errors flow through Tauri's reject path re-wrapped into the `{code, message, details?}` shape — observable: a `MizanError::ValidationFailed` reaches the JS transport as the same envelope an HTTP 422 would carry, so consumer error handling is transport-agnostic.
|
||||||
|
- `RequestHandle::new(app)` lets a function downcast to `tauri::AppHandle` for managed state / event emission — observable: a function calling `req.downcast::<tauri::AppHandle>()` reaches Tauri state; stateless functions ignore the handle.
|
||||||
|
- the `auth`/`private` fields Tauri's `FunctionSpec` carries are enforced in the dispatch path (README.md § Caveat, note 5; INVARIANTS.md § Auth) — observable: an `auth=`-declared function is rejected for an unauthorized caller over IPC, and a `private=True` function is unreachable through `mizan_invoke`, so a desktop build cannot be the one transport where a declared guard is decorative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust client kernel (`frontends/mizan-rust`)
|
||||||
|
|
||||||
|
**Charter.** The Rust port of the shared client kernel: the reconciled cache (context registry + state), transport (HTTP with retry, CSRF), merge splicing, the debounced invalidation queue, error-envelope parsing, and the PyO3 bridge that exposes the kernel to Python. It owns the client-side reconciled view; framework rendering lives in adapters.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The client kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — reached through a pluggable transport; no adapter keeps its own copy of the truth (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Mutation invalidation auto-refetches affected contexts; on failure nothing invalidates (INVARIANTS.md § Mutation Invalidation).
|
||||||
|
- Merge splices the return value into the cached entry rather than refetching (the `merge=` path; MIZAN.md §5 fresh-return optimization generalized).
|
||||||
|
- Transports are pluggable (HTTP, Tauri IPC, webview) via `configure` (docs/AFI_ARCHITECTURE.md § Kernel model; frontends/mizan-tauri-transport/README.md).
|
||||||
|
- The Python client is a typed facade over this kernel via PyO3 (protocol/mizan-codegen python target; baselines/python/client.py).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the context registry keys entries by context name + `stable_key(params)`, holds one `ContextState {data, status, error}` per entry, and notifies subscribers via a watch channel that coalesces to the latest state — observable: `stable_key({b,a})` == `stable_key({a,b})` (byte-identical to `JSON.stringify` with sorted keys), so the same params hit the same cache entry regardless of key order; a refetch advances the entry through Loading→Success visible to subscribers.
|
||||||
|
- `mizan_call` applies the response's `merge` entries first, then queues `invalidate` entries, then returns `result` — observable: a mutation response `{result, merge, invalidate}` splices the merged slot into the cached bundle AND schedules refetch; a failed call (4xx) surfaces the error and invalidates nothing.
|
||||||
|
- `splice_slot` upserts by `id` into an array slot, replaces an array slot with a new array, replaces a scalar, and no-ops a merge into a slot absent from the bundle — observable: merging `{id:1,name:"A"}` into `[{id:1,…},{id:2,…}]` replaces entry 1 in place; merging into a missing slot leaves the bundle untouched (no fabricated slot on a stale cache).
|
||||||
|
- the invalidation queue debounces within one async tick, and broad invalidations subsume scoped ones for the same context — observable: two invalidations queued in the same tick flush once; a broad invalidate refetches every param variant while a scoped invalidate refetches only the matching entry.
|
||||||
|
- transport is HTTP-with-retry (3 attempts, linear backoff, retry on 5xx/network, surface 4xx immediately), reads the CSRF cookie into the configured header per call, and is swappable — observable: a 5xx retries then errors; a 4xx returns immediately; swapping the transport (Tauri/webview) leaves the generated call/fetch code unchanged (transport read from config).
|
||||||
|
- the error envelope parses both the FastAPI nested shape and the Django flat shape, falling back to `HTTP_<status>` — observable: `{"error":{"code":…}}` and `{"error":true,"code":…}` both yield the correct `code`; an unparseable body yields `HTTP_500` with the raw body.
|
||||||
|
- the PyO3 bridge exposes `call`/`fetch_context`/`subscribe_context`/`invalidate` with the GIL released across the network round-trip, and fires the Python subscription callback on each watch change with a `{data,status,error}` dict — observable: `py.allow_threads` wraps the blocking call; a subscription callback fires with `status: "success"` and the decoded data; cancelling ends the watcher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-base and framework adapters (`frontends/mizan-base`, `frontends/mizan-react`, `frontends/mizan-vue`, `frontends/mizan-svelte`)
|
||||||
|
|
||||||
|
**Charter.** The TypeScript client kernel (`@mizan/base`) and the per-framework idiomatic adapters (React hooks, Vue composables, Svelte stores) that subscribe to it. `@mizan/base` is the authoritative kernel the `frontends/mizan-rust` unit ports. The `mizan-ts` cross-language HMAC pin (`deriveCacheKey`) also lives on the TS side.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Every frontend adapter is a thin idiomatic wrapper over one shared kernel; the kernel owns `ContextState<T> = {data,status,error}`, `registerContext`, `mizanCall`/`mizanFetch`, server-driven merge/invalidate, `initSession`, and a pluggable `MizanTransport` (HTTP default, Tauri/webview swap via `configure`) (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override (INVARIANTS.md § Named Contexts; MIZAN.md §2 param elevation / `specify` resolution order).
|
||||||
|
- Codegen targets the adapter surface, never the raw kernel; React devs get hooks, Vue composables, Svelte stores, same kernel underneath (docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Vue and Svelte are co-equal codegen targets over the same kernel, not React-derived (docs/AFI_ARCHITECTURE.md § Authoring surface).
|
||||||
|
- Cross-language HMAC pin: `deriveCacheKey` in `mizan-ts` matches the Python key byte-for-byte (docs/CACHE_KEYING.md; README.md § Adapters — TypeScript is the protocol-reference adapter).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `@mizan/base` owns the single reconciled view: `ContextState`, the context registry, `mizanCall`/`mizanFetch`, server-driven `merge`/`invalidate`, `initSession`, over a `MizanTransport` interface — observable: the same behaviors the `mizan-rust` port pins (stable-key cache identity, merge-splice, scoped-vs-broad refetch, retry, dual-envelope error parse) hold in TS; the Rust port mirrors this file behavior-for-behavior.
|
||||||
|
- the generated provider elevates a context's shared params to required props and its non-shared params to optional props, and resolves each member's effective params by overlaying per-function overrides onto the provider props at fetch time (INVARIANTS.md § Named Contexts; MIZAN.md §2 resolution order) — observable: a two-function `user` context where both take `user_id` and only one takes `page` generates a provider with required `user_id` and optional `page`; a per-function override supplies a different `page` for that one member while the shared `user_id` still covers both, and a member still missing a required param at fetch time is a runtime error, not a silent undefined.
|
||||||
|
- `deriveCacheKey` (mizan-ts) reproduces the Python HMAC key byte-for-byte — observable: the pinned vectors in `cores/mizan-python/tests/test_keys.py::test_cross_language_pin` (`ctx:user:605a1ca5…`, `ctx:user:30fc08eb…`) are asserted against the TS output; any normalization drift (bool/None stringification, key ordering) breaks the pin, which the doc marks a security vulnerability.
|
||||||
|
- adapters subscribe to the kernel and render in their own idiom without keeping a parallel copy of the truth — observable: a React hook and a Vue composable over the same context read the same kernel entry; mutating in one path updates both because the truth lives once in the kernel.
|
||||||
|
- the channel subscription surface (`useChannel` and the `ChannelSubscription<Params, Server, Client>` type) orders its type parameters by direction, so a hook's inbound and outbound message types cannot be transposed — observable: a push-only channel's generated hook types its outbound message as `never`, making a client-side send on that channel a compile error rather than a runtime drop.
|
||||||
|
- `frontends/mizan-vue` and `frontends/mizan-svelte` are runtime kernel-adapter packages, not codegen output alone — observable: a Vue composable and a Svelte store each subscribe to `@mizan/base` and refresh on invalidation against a live backend, so co-equality is checkable at the runtime layer and not only at the byte-parity layer.
|
||||||
|
- the Svelte codegen target emits Svelte 5 `$state`/`$derived` runes (INVARIANTS.md § Client Kernel — "Svelte runes") — observable: the emitted Svelte client reads context state through runes rather than through `readable` stores.
|
||||||
|
- every React provider that ships subscribes to the kernel, so no adapter module holds context state of its own (INVARIANTS.md § Client Kernel) — observable: the desktop example imports the codegen-emitted kernel-subscribing `MizanContext`, and `mizan-react` exposes no second provider keeping a parallel copy of the truth.
|
||||||
|
- the generated channel client in the example harness names the channel slots from the client's side — observable: `examples/django-react-site/harness/src/api/channels.ts` declares `<Pascal>ClientMessage`/`<Pascal>ServerMessage` types with `hasClientMessage`/`hasServerMessage` flags and `clientMessageType`/`serverMessageType` keys, matching what `protocol/mizan-codegen`'s channels target emits from the IR, so the harness is regenerable rather than divergent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-codegen (`protocol/mizan-codegen`)
|
||||||
|
|
||||||
|
**Charter.** The single Rust codegen binary that reads KDL IR and emits typed clients for every target (stage1, react, vue, svelte, channels, python, rust), plus the source-fetching that spawns each backend's IR-export command and the Pydantic-pre-step, plus the SSR bundling step that compiles each SSR entry component into a self-contained render bundle. It owns the IR→client transform and the SSR bundle production; it does not emit IR (backends do) or run the render engine (that is `cores/mizan-rust-ssr`).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Codegen reads KDL directly — no OpenAPI envelope, no `openapi-typescript`, no per-backend converter (docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
- Every frontend client is generated from the IR; each target is byte-parity-tested (INVARIANTS.md § Canonical IR & Codegen).
|
||||||
|
- The codegen drives the backend's IR-export command as a subprocess and parses the KDL it writes (docs/AFI_ARCHITECTURE.md; backends/*/README.md § Generate the frontend).
|
||||||
|
- Stage 1 (typed `callXxx`/`fetchXxx`) + Stage 2 (`<MizanContext>` provider, per-context providers, `use{Hook}()`) emission (backends/*/README.md § Generate the frontend).
|
||||||
|
- Pydantic + Rust DX: a decoru pre-step authors Rust types from Pydantic before the cargo IR bin runs; a generic `[source.script]` source spawns any command emitting KDL (backends/mizan-tauri/README.md § Generate the frontend; config.rs).
|
||||||
|
- mizan-generate's SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle assigning `globalThis.renderApp`, written to the `bundles` directory — the only place node/bun run in the SSR path (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `fetch.rs` spawns the configured source's export command (FastAPI `-m mizan_fastapi.ir`, Django `manage.py export_mizan_ir`, Rust `cargo run --bin`, or a generic script) and parses stdout as KDL — no OpenAPI/converter anywhere in the path — observable: a codegen run against a live FastAPI backend consumes only the KDL the CLI writes; the Rust source runs the cargo bin and the optional decoru pre-step first.
|
||||||
|
- the KDL parser reconstructs the full typed IR (types with struct/list/enum/alias shapes, functions with input/output/nullable/context/affects/merge/form, contexts with param elevation, channels) — observable: `ir_deserialization.rs` reads the AFI fixture back into typed structs and asserts the function set, per-function fields, param elevation, and named-type presence.
|
||||||
|
- the channel half of the parser reads the `params` / `client-message` / `server-message` slots in that order and the emitter renders them as `paramsType` / `clientMessageType` / `serverMessageType` — observable: a KDL `channel` node carrying only `server-message` yields a channel view whose client slot is absent, and the emitted `use{Pascal}Channel` hook types its outbound message as `never`.
|
||||||
|
- each target emits deterministically and is byte-parity-tested against a committed baseline — observable: `stage1_parity.rs`, `react_parity.rs`, `rust_parity.rs`, `python_parity.rs`, `vue_svelte_parity.rs`, and `channels_smoke.rs` diff emitter output against baselines and fail on any byte drift; two different runs produce identical output.
|
||||||
|
- the emitters produce genuinely different, correct artifacts per target — not one shape behind distinct labels — observable: the react target emits `<MizanContext>` + per-context providers + `use{Hook}()` reading React context (Stage 2), Stage 1 emits the framework-agnostic typed `callXxx`/`fetchXxx`; vue emits composables; svelte emits stores; python emits a Pydantic-typed facade over the PyO3 kernel; rust emits a full crate depending on `mizan-rust` — each byte-checked against its own baseline, and stage1 is auto-included whenever a framework target is requested.
|
||||||
|
- the codegen tree-shakes and canonicalizes types to match the backend emitters, and hoists inline enums into named Rust/TS types — observable: an unreferenced type is not emitted; an inline `field { enum … }` becomes a top-level Rust enum the struct field references; the channels target emits zero files when the IR carries no channels.
|
||||||
|
- an SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle that assigns `globalThis.renderApp`, emitted into the configured `bundles` directory — the only place node/bun run in the SSR path — observable: a `mizan-generate` run produces one render bundle per SSR entry component, each evaluable standalone by the embedded-V8 engine (assigning `renderApp` at eval time and rendering from a JSON-parsed props argument).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: AFI conformance (`tests/afi`)
|
||||||
|
|
||||||
|
**Charter.** The cross-adapter conformance gate: one fixture registered identically in Django, FastAPI, and a Rust app, asserting all three emit byte-identical KDL. It is the executable form of "the IR is the only contract"; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Adapter parity is gated by the AFI conformance suite asserting IR-shape parity — the same fixture through Django, FastAPI, and Rust emits byte-identical KDL (README.md § Conformance; docs/AFI_ARCHITECTURE.md § KDL is the IR — "divergence between adapters is what the IR exists to prevent").
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- one shared fixture (`fixture.py` and its Rust twin `rust_app`) registers the same 7 functions / 5 types / context+affects+merge graph across all three backends, and the parity test diffs the three KDL emissions requiring exact three-way equality — observable: `test_codegen_parity.py` fails (naming the divergent pair) the instant any adapter's type introspection, ordering, or param elevation drifts; the fixture spans the AFI axes (plain fn, no-input fn, shared-param context, affects mutation, optional return, merge mutation) so the gate is not a degenerate single-shape check.
|
||||||
|
- the fixture spans the channel axis as well as the function axes, so the client-named slots are gated by the same three-way equality — observable: a channel declared once per backend emits an identical `channel` node with identical `<Pascal>Params` / `<Pascal>ClientMessage` / `<Pascal>ServerMessage` refs, so a backend-shaped slot name cannot re-enter the IR through one adapter without turning this gate red.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: wire-parity drivers (`tests/rust`, `tests/rust/fixture_client`)
|
||||||
|
|
||||||
|
**Charter.** The runtime wire-contract gate: Rust drivers (`drive_kernel`, `drive_emitted`) that hit a live FastAPI fixture and a live Rust/Axum fixture and assert the same JSON shapes and invalidate/merge semantics, plus the codegen-emitted `fixture_client` crate they exercise. It proves the runtime wire equivalence the static IR parity does not, and authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The Rust adapters honor the same wire contract as FastAPI beyond static IR equivalence — same JSON shapes, same invalidate/merge semantics (README.md § Adapters; the "IR prevents divergence" claim taken to the runtime).
|
||||||
|
- The codegen-emitted typed client round-trips cleanly through the kernel (protocol/mizan-codegen rust target).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `run_wire_parity.py` boots each backend, probes the readiness surface `/api/mizan/session/` (Mizan-protocol-shaped, so the harness reads the same surface across backends), then runs both the raw-kernel and emitted-typed drivers against each, propagating any non-zero exit — observable: the drivers hit every fixture endpoint (plain functions, the two-function context, the optional-return path, the merge mutation) against both FastAPI and Rust/Axum and require the same responses; a wire drift on either backend turns the harness red.
|
||||||
|
- `drive_emitted` exercises the codegen-emitted `fixture_client` typed functions (`call_echo`, `fetch_user_context`, `call_update_profile`, the optional `call_find_user`, the merge `call_rename_user`) so the generated crate is proven to round-trip, not merely to compile — observable: `call_find_user(99999)` returns `None`, `fetch_user_context(5)` returns the bundled `user_profile`+`user_orders`, and any deserialization mismatch fails the driver.
|
||||||
127
README.md
127
README.md
@@ -20,99 +20,50 @@ def update_profile(request, user_id: int, name: str) -> dict:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
|
## Adapters
|
||||||
reference implementation; per-adapter support is inventoried below.
|
|
||||||
|
|
||||||
> **Status:** Mizan is not production-tested. It passes its own test suites but has not
|
Backends: Django (`backends/mizan-django`, the reference implementation), FastAPI
|
||||||
> been run in a production deployment. Treat it as pre-release.
|
(`backends/mizan-fastapi`), Rust/Axum (`backends/mizan-rust-axum`), Tauri
|
||||||
|
(`backends/mizan-tauri`), and TypeScript (`backends/mizan-ts`). Frontends are generated
|
||||||
|
from the KDL IR over the `@mizan/base` kernel; `frontends/` holds the kernel, the
|
||||||
|
per-framework adapters, and the transports.
|
||||||
|
|
||||||
|
Per-adapter transport shape:
|
||||||
|
|
||||||
|
- Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||||
|
Invalidation rides in the JSON response body; there is no header channel.
|
||||||
|
- Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
||||||
|
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
||||||
|
rather than fetching over HTTP.
|
||||||
|
- "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
||||||
|
adapter carries typed input/output through the KDL IR; the projection primitive
|
||||||
|
itself is Django-only.
|
||||||
|
- FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||||
|
parity; CSRF is Django-only.
|
||||||
|
- TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||||
|
codegen source — it demonstrates the cache + invalidation protocol is
|
||||||
|
language-agnostic.
|
||||||
|
|
||||||
|
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but their dispatch
|
||||||
|
> paths do not enforce it — do not rely on `auth=` for access control on those
|
||||||
|
> adapters.
|
||||||
|
|
||||||
|
Auth-provider integration (django-allauth) lives in its own repository,
|
||||||
|
`mizan-allauth` — a dedicated Django system built on mizan-django's forms and
|
||||||
|
context primitives.
|
||||||
|
|
||||||
|
## Conformance
|
||||||
|
|
||||||
|
Per-adapter capability support is measured by the AFI conformance suite in
|
||||||
|
[`tests/afi/`](tests/afi/), not maintained as prose — the suite asserts IR-shape
|
||||||
|
parity: the same fixture through Django, FastAPI, and the Rust adapter emits
|
||||||
|
byte-identical KDL (`test_codegen_parity.py`).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
||||||
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
|
- [`INVARIANTS.md`](INVARIANTS.md) — the AFI invariants every adapter satisfies
|
||||||
|
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md)
|
||||||
## Backend adapters
|
|
||||||
|
|
||||||
Every adapter implements the same AFI wire protocol. The matrix below is **generated**
|
|
||||||
from the conformance probes in [`tests/afi/`](tests/afi/) by `make parity-table` — it is
|
|
||||||
output, not prose. A cell goes `✅` only when that adapter wires the capability into its
|
|
||||||
own dispatch surface; it cannot be set to "supported" or "Django-only" by editing this
|
|
||||||
file (a hand-edit fails `python tests/afi/parity_table.py --check` in CI, the same
|
|
||||||
forcing function the codegen byte-parity tests use).
|
|
||||||
|
|
||||||
Every capability in the matrix is **AFI-common** — each adapter owes a binding, and a
|
|
||||||
`❌` is a gap on the owed-work board, never a "this framework doesn't do that." The line
|
|
||||||
between AFI-common and genuinely backend-bound lives in
|
|
||||||
[`tests/afi/manifest.py`](tests/afi/manifest.py): what sits *outside* the matrix by
|
|
||||||
design is the `allauth` integration (a Django-ecosystem package) and the per-stack
|
|
||||||
*bindings* of common capabilities (`django-readers` is Django's Shapes binding; Django
|
|
||||||
Forms is Django's Forms binding) — the capability is common; the binding is not.
|
|
||||||
|
|
||||||
<!-- MIZAN:PARITY:START — generated by tests/afi/parity_table.py; do not edit by hand -->
|
|
||||||
Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · — not applicable to this adapter's transport
|
|
||||||
|
|
||||||
Every capability below is **AFI-common**: each adapter owes a binding, and a ❌ is a gap on the owed-work board (`tests/afi/`), never a category. Backend-specific *bindings* of common capabilities (django-readers for Shapes, Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are out of this matrix by design — see `tests/afi/manifest.py` for the line.
|
|
||||||
|
|
||||||
### Protocol core
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| File uploads (`Upload` type) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
### Edge, cache & enforcement
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Origin-side HMAC cache | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Edge manifest export | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| PSR (`render_strategy` in manifest) | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
|
|
||||||
### Extension points
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| WebSocket transport (`websocket=` declared) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| SSR bridge (subprocess renderer) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| JWT auth (access / refresh) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| MWT (edge identity token) | ✅ | ✅ | ✅ | — | ✅ |
|
|
||||||
| Typed query projection (Shapes) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Forms (schema / validate / submit) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
**Notes**
|
|
||||||
|
|
||||||
- **Invalidation — `X-Mizan-Invalidate` header** — The header channel is co-equal with the body channel in the spec. IPC transports carry invalidation in the response envelope instead.
|
|
||||||
- **Edge manifest export** — The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge.
|
|
||||||
- **MWT (edge identity token)** — MWT exists to key an edge cache; without an edge there is nothing to key.
|
|
||||||
- **Typed query projection (Shapes)** — The capability is AFI-common; the binding is per-ORM (django-readers on Django, the project's ORM elsewhere).
|
|
||||||
- **Forms (schema / validate / submit)** — The capability is AFI-common; the binding is per-framework (Django Forms on Django, Pydantic-or-equivalent elsewhere).
|
|
||||||
<!-- MIZAN:PARITY:END -->
|
|
||||||
|
|
||||||
## Conformance
|
|
||||||
|
|
||||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/), at
|
|
||||||
two layers:
|
|
||||||
|
|
||||||
- **IR-shape parity** (`test_codegen_parity.py`) — Django, FastAPI, and the Rust adapter
|
|
||||||
emit byte-identical KDL for the same registered fixture. The IR is the contract; the
|
|
||||||
language that wrote the backend is irrelevant to the codegen-facing artifact.
|
|
||||||
- **Capability parity** (`test_capability_parity.py`) — every `(capability, applicable
|
|
||||||
adapter)` pair declared in `manifest.py` is probed for its actual wiring (`probes.py`).
|
|
||||||
A gap is a **red test that names the owed binding**, not a footnote. The suite is
|
|
||||||
intentionally red wherever a capability is unwired: that redness is the owed-work
|
|
||||||
board, itemized and loud, and a gap turns green by being *wired*, never by being
|
|
||||||
*described*. This is the per-capability gate the roadmap previously deferred.
|
|
||||||
|
|
||||||
The generated table above is rendered from the capability layer, and the `--check`
|
|
||||||
diff keeps the README honest to the probes on every CI run.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@@ -34,11 +34,21 @@
|
|||||||
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
||||||
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
||||||
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
||||||
- [ ] **Cache hardening** — thundering-herd / single-flight protection, and pinning cross-language stringification of un-normalized value types (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
- [ ] **Cache hardening** — purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
||||||
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Core Consolidation — Rust Binary
|
||||||
|
|
||||||
|
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
|
||||||
|
|
||||||
|
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
|
||||||
|
|
||||||
|
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mizan Cloud (closed-source)
|
## Mizan Cloud (closed-source)
|
||||||
|
|
||||||
### Mizan Edge
|
### Mizan Edge
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ function. Typed React client generated. Invalidation automatic.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "mizan[channels]"
|
uv add "mizan[channels]"
|
||||||
# or with allauth integration:
|
|
||||||
uv add "mizan[channels,allauth]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -116,20 +114,29 @@ class ContactForm(mizanFormMixin, forms.Form):
|
|||||||
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
||||||
gets `useContactForm()`.
|
gets `useContactForm()`.
|
||||||
|
|
||||||
|
Auth-provider forms (django-allauth login, signup, MFA, WebAuthn) live in the
|
||||||
|
dedicated `mizan-allauth` repository, built on this mixin.
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
WebSocket-native RPC via a flag flip:
|
WebSocket-native RPC via a flag flip. The message slots are named from the
|
||||||
|
client's point of view: `ClientMessage` travels client → server,
|
||||||
|
`ServerMessage` travels server → client. Declare only the directions the
|
||||||
|
channel uses.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
|
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
user: str
|
user: str
|
||||||
|
|
||||||
@@ -138,10 +145,19 @@ class ChatChannel(ReactChannel):
|
|||||||
|
|
||||||
def group(self, params):
|
def group(self, params):
|
||||||
return f"chat_{params.room}"
|
return f"chat_{params.room}"
|
||||||
|
|
||||||
|
def receive(self, params, msg):
|
||||||
|
return self.ServerMessage(text=msg.text, user=self.user.email)
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend gets `useChatChannel({ room })`.
|
Frontend gets `useChatChannel({ room })`.
|
||||||
|
|
||||||
|
Server code outside a subscription broadcasts with `push()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await ChatChannel.push(room="general", message=ChatChannel.ServerMessage(...))
|
||||||
|
```
|
||||||
|
|
||||||
## Generate the frontend
|
## Generate the frontend
|
||||||
|
|
||||||
The codegen is the `mizan-generate` Rust binary (source at
|
The codegen is the `mizan-generate` Rust binary (source at
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ channels = [
|
|||||||
"channels>=4.0",
|
"channels>=4.0",
|
||||||
"channels-redis>=4.0",
|
"channels-redis>=4.0",
|
||||||
]
|
]
|
||||||
allauth = [
|
|
||||||
"django-allauth>=65.0",
|
|
||||||
]
|
|
||||||
webauthn = [
|
|
||||||
"fido2>=2.0",
|
|
||||||
]
|
|
||||||
shapes = [
|
shapes = [
|
||||||
"django-readers>=2.0",
|
"django-readers>=2.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,99 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
mizan - Django + React unified framework
|
The mizan package surface: the `client` decorator with its context types, the
|
||||||
|
`Channel` base and its registry, the form/shape/export submodules, and
|
||||||
|
`wrap_asgi`, which mounts the WebSocket consumer alongside an HTTP application.
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
`urls` and `Shape` resolve through `__getattr__` rather than at import time.
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. urls.py - HTTP endpoint
|
|
||||||
```python
|
|
||||||
from mizan import urls as mizan_urls
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('api/mizan/', include(mizan_urls)),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. asgi.py - WebSocket support (optional)
|
|
||||||
```python
|
|
||||||
from mizan import wrap_asgi
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Define server functions
|
|
||||||
```python
|
|
||||||
# apps/myapp/clients.py
|
|
||||||
from mizan import client
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
# HTTP-only function (default)
|
|
||||||
@client
|
|
||||||
def echo(request, text: str) -> EchoOutput:
|
|
||||||
return EchoOutput(message=f"Echo: {text}")
|
|
||||||
|
|
||||||
# Global context (singleton, SSR-hydrated)
|
|
||||||
@client(context='global')
|
|
||||||
def current_user(request) -> UserOutput:
|
|
||||||
return UserOutput(email=request.user.email)
|
|
||||||
|
|
||||||
# WebSocket-enabled for real-time
|
|
||||||
@client(websocket=True)
|
|
||||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
|
||||||
return MessageOutput(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Auto-discover in apps.py
|
|
||||||
```python
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
from mizan.setup import mizan_clients
|
|
||||||
mizan_clients('apps')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Frontend - generate types and use
|
|
||||||
```bash
|
|
||||||
npm run schemas
|
|
||||||
```
|
|
||||||
```tsx
|
|
||||||
import { useEcho, useCurrentUser } from '@/api'
|
|
||||||
|
|
||||||
const user = useCurrentUser()
|
|
||||||
const echo = useEcho()
|
|
||||||
await echo({ text: 'hello' })
|
|
||||||
```
|
|
||||||
|
|
||||||
## What You Get
|
|
||||||
|
|
||||||
| Backend | Frontend | Transport |
|
|
||||||
|------------------------------------|-----------------------|------------|
|
|
||||||
| `@client` | `useXxx()` hook | HTTP |
|
|
||||||
| `@client(context='global')` | `useXxx()` + SSR | HTTP |
|
|
||||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
|
||||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
|
||||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
|
||||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
|
||||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# All imports at module level (sorted)
|
# All imports at module level (sorted)
|
||||||
from . import channels
|
from mizan import channels
|
||||||
from . import client as client_module
|
from mizan import client as client_module
|
||||||
from . import export
|
from mizan import export
|
||||||
from . import forms
|
from mizan import forms
|
||||||
from . import setup
|
from mizan import setup
|
||||||
from .channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
from .channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
from mizan.client import (
|
||||||
from mizan_core.upload import File, Upload, UploadedFile
|
ComposedContext,
|
||||||
|
GlobalContext,
|
||||||
|
ReactContext,
|
||||||
|
ServerFunction,
|
||||||
|
client,
|
||||||
|
compose,
|
||||||
|
)
|
||||||
|
|
||||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
from .setup import (
|
from mizan.setup import (
|
||||||
mizan_clients,
|
mizan_clients,
|
||||||
mizan_module,
|
mizan_module,
|
||||||
get_channel,
|
get_channel,
|
||||||
@@ -106,11 +38,11 @@ from .setup import (
|
|||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
"""Lazy loading for modules that can't be imported at app load time."""
|
"""Lazy loading for modules that can't be imported at app load time."""
|
||||||
if name == "urls":
|
if name == "urls":
|
||||||
from .urls import urlpatterns as mizan_patterns
|
from mizan.urls import urlpatterns as mizan_patterns
|
||||||
|
|
||||||
return mizan_patterns
|
return mizan_patterns
|
||||||
if name == "Shape":
|
if name == "Shape":
|
||||||
from .shapes import Shape
|
from mizan.shapes import Shape
|
||||||
|
|
||||||
return Shape
|
return Shape
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
@@ -118,17 +50,8 @@ def __getattr__(name):
|
|||||||
|
|
||||||
def wrap_asgi(http_application):
|
def wrap_asgi(http_application):
|
||||||
"""
|
"""
|
||||||
Wrap an ASGI application with mizan WebSocket support.
|
Route HTTP to `http_application` and /ws/ to the mizan consumer, with the
|
||||||
|
channels auth middleware supplying `scope["user"]` on the socket branch.
|
||||||
Usage in asgi.py:
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
from mizan import wrap_asgi
|
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
|
||||||
|
|
||||||
This adds:
|
|
||||||
- WebSocket routing at /ws/ for RPC and channels
|
|
||||||
- Authentication middleware for WebSocket connections
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
@@ -141,7 +64,7 @@ def wrap_asgi(http_application):
|
|||||||
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
||||||
)
|
)
|
||||||
|
|
||||||
from .channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
return ProtocolTypeRouter(
|
return ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
@@ -165,10 +88,6 @@ __all__ = [
|
|||||||
"GlobalContext",
|
"GlobalContext",
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
# File uploads
|
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
# Setup
|
# Setup
|
||||||
"mizan_clients",
|
"mizan_clients",
|
||||||
"mizan_module",
|
"mizan_module",
|
||||||
@@ -179,7 +98,7 @@ __all__ = [
|
|||||||
# ASGI
|
# ASGI
|
||||||
"wrap_asgi",
|
"wrap_asgi",
|
||||||
# Channels
|
# Channels
|
||||||
"ReactChannel",
|
"Channel",
|
||||||
"register_channel",
|
"register_channel",
|
||||||
# Shapes
|
# Shapes
|
||||||
"Shape",
|
"Shape",
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import sys
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
from typing import Protocol, Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def get_members(path):
|
def get_members(path):
|
||||||
try:
|
try:
|
||||||
module = import_module(path)
|
module = import_module(path)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError as exc:
|
||||||
print('Could not import module "{}"'.format(path))
|
# Callers of this module write machine-read output to stdout, so the
|
||||||
|
# diagnostic must not share that stream.
|
||||||
|
print(f'Could not import module "{path}": {exc}', file=sys.stderr)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
members = [
|
members = [
|
||||||
@@ -21,7 +26,8 @@ def get_members(path):
|
|||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
class DjangoAppVisitorHandler(Protocol):
|
class DjangoAppVisitorHandler(ABC):
|
||||||
|
@abstractmethod
|
||||||
def on_module(
|
def on_module(
|
||||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
@@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol):
|
|||||||
|
|
||||||
class DjangoAppVisitor:
|
class DjangoAppVisitor:
|
||||||
"""
|
"""
|
||||||
Discovers Python modules under each Django app following conventions:
|
Walks each installed app for modules named after `layer`:
|
||||||
- <app>/<module>.py -> url_prefix "<renamed>/"
|
<app>/<layer>.py -> path_parts []
|
||||||
- <app>/<module>/**/*.py -> url_prefix "<renamed>/<subdirs...>/<module>/"
|
<app>/<layer>/**/*.py -> path_parts [<subdirs...>, <stem>]
|
||||||
|
|
||||||
Example:
|
`apps_root` is the dotted package the apps live under, relative to
|
||||||
<app>/<module>/forms/nksn.py -> url_prefix "<renamed>/forms/nksn/"
|
BASE_DIR; "" means the apps sit directly at BASE_DIR.
|
||||||
module_path "<app>.module.forms.nksn"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -66,7 +71,6 @@ class DjangoAppVisitor:
|
|||||||
|
|
||||||
app_module = f"{module_prefix}{app_name}"
|
app_module = f"{module_prefix}{app_name}"
|
||||||
|
|
||||||
# 1) Visit package: <app>/<module>/**/*.py
|
|
||||||
layer_dir = app_dir / self.layer
|
layer_dir = app_dir / self.layer
|
||||||
if layer_dir.is_dir():
|
if layer_dir.is_dir():
|
||||||
for py_file in layer_dir.rglob("*.py"):
|
for py_file in layer_dir.rglob("*.py"):
|
||||||
@@ -83,7 +87,6 @@ class DjangoAppVisitor:
|
|||||||
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2) Visit module module file: <app>/module.py
|
|
||||||
layer_file = app_dir / f"{self.layer}.py"
|
layer_file = app_dir / f"{self.layer}.py"
|
||||||
if layer_file.is_file():
|
if layer_file.is_file():
|
||||||
handler.on_module(
|
handler.on_module(
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# Cache Module — Known Issues
|
# Cache Module — Known Issues
|
||||||
|
|
||||||
Open issues against the current cache implementation. The cache uses
|
Open issues against the current cache implementation. Resolved items are
|
||||||
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key;
|
removed once their fix lands.
|
||||||
broad purge is a prefix SCAN+UNLINK), so there are no index/sub-index races to
|
|
||||||
track. Resolved items are removed once their fix lands.
|
|
||||||
|
|
||||||
## Correctness
|
## Correctness
|
||||||
|
|
||||||
|
### Purge race condition (non-atomic index operations)
|
||||||
|
`cache_purge` reads the index and deletes as separate operations. A
|
||||||
|
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||||
|
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||||
|
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||||
|
|
||||||
### Cross-language stringification divergence
|
### Cross-language stringification divergence
|
||||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||||
@@ -15,6 +19,22 @@ TypeScript HMAC keys can still diverge on an un-normalized type.
|
|||||||
|
|
||||||
## Performance / Operability
|
## Performance / Operability
|
||||||
|
|
||||||
|
### Broad purge leaves per-param sub-indexes
|
||||||
|
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||||
|
sub-indexes — a slow Redis memory leak.
|
||||||
|
|
||||||
### No thundering-herd protection
|
### No thundering-herd protection
|
||||||
Concurrent cold misses on the same key all execute and write. No
|
Concurrent cold misses on the same key all execute and write. No
|
||||||
single-flight / request-coalescing.
|
single-flight / request-coalescing.
|
||||||
|
|
||||||
|
## API shape
|
||||||
|
|
||||||
|
### cache_get / cache_put argument inconsistency
|
||||||
|
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||||
|
inputs from module globals — two access patterns for one concern.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
### RedisCache lacks test coverage
|
||||||
|
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||||
|
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
Origin-side cache keyed by HMAC digests of (context, params, user, rev).
|
||||||
|
|
||||||
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
There are no reverse indexes: a scoped purge recomputes the one key it needs
|
||||||
Scoped purge recomputes the key and deletes directly.
|
and deletes it, and a purge with no params falls back to a key-prefix scan.
|
||||||
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
|
from __future__ import annotations
|
||||||
@@ -28,7 +24,7 @@ _init_lock = threading.Lock()
|
|||||||
def get_cache() -> CacheBackend | None:
|
def get_cache() -> CacheBackend | None:
|
||||||
"""
|
"""
|
||||||
Get the configured cache backend, or None if caching is disabled.
|
Get the configured cache backend, or None if caching is disabled.
|
||||||
Thread-safe.
|
Thread-safe; the backend is built once on first call.
|
||||||
"""
|
"""
|
||||||
global _cache_instance, _initialized
|
global _cache_instance, _initialized
|
||||||
if _initialized:
|
if _initialized:
|
||||||
@@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None:
|
|||||||
from mizan.setup.settings import get_settings
|
from mizan.setup.settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Both settings are required; one without the other is a
|
||||||
|
# misconfiguration worth naming rather than silently ignoring.
|
||||||
if settings.cache_secret and settings.cache_redis_url:
|
if settings.cache_secret and settings.cache_redis_url:
|
||||||
_cache_instance = RedisCache(settings.cache_redis_url)
|
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||||
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||||
@@ -113,13 +111,11 @@ def cache_purge(
|
|||||||
rev: int = 0,
|
rev: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Purge cached entries for a context.
|
Purge cached entries for a context and return how many were removed.
|
||||||
|
|
||||||
Scoped purge (params provided): recomputes the HMAC key and deletes
|
With params and a secret, the exact key is recomputed and deleted — one
|
||||||
it directly. One DELETE, no index needed.
|
DELETE. Without them, every key under the prefix "ctx:{context}:" is
|
||||||
|
scanned and removed.
|
||||||
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:
|
if params is not None and len(params) > 0 and secret:
|
||||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
|
|||||||
@@ -1,81 +1,15 @@
|
|||||||
"""
|
"""WebSocket channels: the Channel base class, the channel registry, and
|
||||||
mizan.channels - Real-time WebSocket communication.
|
the schema exports built from it."""
|
||||||
|
|
||||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
|
||||||
Hooks are auto-generated with full TypeScript types.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```python
|
|
||||||
# channels.py
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
class ChatChannel(channels.ReactChannel):
|
|
||||||
|
|
||||||
class Params(BaseModel):
|
|
||||||
room: str
|
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
|
||||||
text: str
|
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
|
||||||
user: str
|
|
||||||
text: str
|
|
||||||
timestamp: datetime
|
|
||||||
|
|
||||||
def authorize(self, params: Params) -> bool:
|
|
||||||
return self.user.is_authenticated
|
|
||||||
|
|
||||||
def group(self, params: Params) -> str:
|
|
||||||
return f'chat_{params.room}'
|
|
||||||
|
|
||||||
def receive(self, params: Params, msg: ReactMessage) -> DjangoMessage | None:
|
|
||||||
return self.DjangoMessage(
|
|
||||||
user=self.user.email,
|
|
||||||
text=msg.text,
|
|
||||||
timestamp=now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
channels.register(ChatChannel, 'chat')
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
# asgi.py
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
|
||||||
"http": get_asgi_application(),
|
|
||||||
"websocket": channels.get_websocket_application(),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Usage (auto-generated)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useChatChannel } from '@/api/generated.channels'
|
|
||||||
|
|
||||||
function Chat({ room }) {
|
|
||||||
const chat = useChatChannel({ room })
|
|
||||||
|
|
||||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
|
||||||
chat.messages // DjangoMessage[]
|
|
||||||
chat.send({ text: 'Hello' }) // ReactMessage
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Push
|
|
||||||
|
|
||||||
```python
|
|
||||||
await ChatChannel.push(room='general', message=ChatChannel.DjangoMessage(...))
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import abc
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.ir import wire_to_pascal
|
||||||
|
from mizan_core.registry import RegistryExtension, register_extension
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
@@ -84,36 +18,25 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
class Channel(abc.ABC):
|
||||||
# Base Classes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ReactChannel:
|
|
||||||
"""
|
"""
|
||||||
Base class for WebSocket channels.
|
A WebSocket channel.
|
||||||
|
|
||||||
Define nested Pydantic classes for typed messaging:
|
Subclasses declare the wire types as nested Pydantic models:
|
||||||
- Params: Query parameters for subscribing (optional)
|
Params (subscription query parameters), ClientMessage (travels
|
||||||
- ReactMessage: Messages from browser to server (optional)
|
client -> server), ServerMessage (travels server -> client). Any
|
||||||
- DjangoMessage: Messages from server to browser (optional)
|
slot left undeclared stays None and that direction is unavailable.
|
||||||
|
|
||||||
Implement required methods:
|
authorize() and group() are abstract. receive(), on_connect() and
|
||||||
- authorize(): Permission check for connection
|
on_disconnect() are the override points; each definition here records
|
||||||
- group(): Which group to broadcast to
|
what happened and a subclass replaces or extends it.
|
||||||
|
|
||||||
Optionally implement:
|
|
||||||
- receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast
|
|
||||||
- on_connect(): Called after successful connection
|
|
||||||
- on_disconnect(): Called when connection closes
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Nested classes (optional, defined by subclasses)
|
|
||||||
Params: ClassVar[Type[BaseModel] | None] = None
|
Params: ClassVar[Type[BaseModel] | None] = None
|
||||||
ReactMessage: ClassVar[Type[BaseModel] | None] = None
|
ClientMessage: ClassVar[Type[BaseModel] | None] = None
|
||||||
DjangoMessage: ClassVar[Type[BaseModel] | None] = None
|
ServerMessage: ClassVar[Type[BaseModel] | None] = None
|
||||||
|
|
||||||
# Set by the framework when handling a connection
|
# Set by the consumer when it builds an instance for a subscription.
|
||||||
user: "AbstractBaseUser | AnonymousUser"
|
user: "AbstractBaseUser | AnonymousUser"
|
||||||
_channel_layer: Any = None
|
_channel_layer: Any = None
|
||||||
_channel_name: str = ""
|
_channel_name: str = ""
|
||||||
@@ -125,64 +48,58 @@ class ReactChannel:
|
|||||||
self._groups = set()
|
self._groups = set()
|
||||||
self._params_dict = {}
|
self._params_dict = {}
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||||
"""
|
"""Return True to allow the connection, False to reject it."""
|
||||||
Permission check. Return True to allow connection, False to reject.
|
|
||||||
|
|
||||||
Override this to implement custom authorization logic.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
f"{self.__class__.__name__} must implement authorize()"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
def group(self, params: BaseModel | None = None) -> str:
|
def group(self, params: BaseModel | None = None) -> str:
|
||||||
"""
|
"""Return the channel-layer group name this subscription broadcasts to."""
|
||||||
Return the group name for broadcasting.
|
|
||||||
|
|
||||||
Messages returned from receive() are broadcast to this group.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
|
||||||
|
|
||||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||||
"""
|
"""
|
||||||
Handle incoming ReactMessage.
|
Handle one ClientMessage; a returned ServerMessage is broadcast to the
|
||||||
|
group. A channel that accepts inbound frames overrides this — reaching
|
||||||
Return a DjangoMessage to broadcast to the group, or None to skip.
|
the definition here means the frame has nowhere to go.
|
||||||
Override this to implement message handling.
|
|
||||||
"""
|
"""
|
||||||
|
logger.warning(
|
||||||
|
"%s does not handle inbound %s; the frame is dropped",
|
||||||
|
type(self).__name__,
|
||||||
|
type(msg).__name__,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def on_connect(self, params: BaseModel | None = None) -> None:
|
async def on_connect(self, params: BaseModel | None = None) -> None:
|
||||||
"""Called after successful connection and group join."""
|
"""Runs after the group join; a subclass extends it via super()."""
|
||||||
pass
|
logger.debug(
|
||||||
|
"%s subscription opened on %s",
|
||||||
|
type(self).__name__,
|
||||||
|
self._channel_name or "<no channel name>",
|
||||||
|
)
|
||||||
|
|
||||||
async def on_disconnect(self) -> None:
|
async def on_disconnect(self) -> None:
|
||||||
"""Called when the connection closes."""
|
"""Runs as the subscription closes; a subclass extends it via super()."""
|
||||||
pass
|
logger.debug(
|
||||||
|
"%s subscription closed, leaving %d group(s)",
|
||||||
# -------------------------------------------------------------------------
|
type(self).__name__,
|
||||||
# Internal Methods (used by the consumer)
|
len(self._groups),
|
||||||
# -------------------------------------------------------------------------
|
)
|
||||||
|
|
||||||
async def _join_group(self, group_name: str) -> None:
|
async def _join_group(self, group_name: str) -> None:
|
||||||
"""Join a channel layer group."""
|
|
||||||
if self._channel_layer:
|
if self._channel_layer:
|
||||||
await self._channel_layer.group_add(group_name, self._channel_name)
|
await self._channel_layer.group_add(group_name, self._channel_name)
|
||||||
self._groups.add(group_name)
|
self._groups.add(group_name)
|
||||||
|
|
||||||
async def _leave_group(self, group_name: str) -> None:
|
async def _leave_group(self, group_name: str) -> None:
|
||||||
"""Leave a channel layer group."""
|
|
||||||
if self._channel_layer and group_name in self._groups:
|
if self._channel_layer and group_name in self._groups:
|
||||||
await self._channel_layer.group_discard(group_name, self._channel_name)
|
await self._channel_layer.group_discard(group_name, self._channel_name)
|
||||||
self._groups.discard(group_name)
|
self._groups.discard(group_name)
|
||||||
|
|
||||||
async def _leave_all_groups(self) -> None:
|
async def _leave_all_groups(self) -> None:
|
||||||
"""Leave all joined groups."""
|
|
||||||
for group_name in list(self._groups):
|
for group_name in list(self._groups):
|
||||||
await self._leave_group(group_name)
|
await self._leave_group(group_name)
|
||||||
|
|
||||||
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
||||||
"""Broadcast a message to a group."""
|
|
||||||
if self._channel_layer:
|
if self._channel_layer:
|
||||||
await self._channel_layer.group_send(
|
await self._channel_layer.group_send(
|
||||||
group_name,
|
group_name,
|
||||||
@@ -195,20 +112,11 @@ class ReactChannel:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Class Methods for Server Push
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def push(cls, message: BaseModel, **params) -> None:
|
async def push(cls, message: BaseModel, **params) -> None:
|
||||||
"""
|
"""
|
||||||
Push a message from server code (views, tasks, signals).
|
Send a ServerMessage to every subscriber of the group named by the
|
||||||
|
given params, from outside a subscription (views, tasks, signals).
|
||||||
Usage:
|
|
||||||
await ChatChannel.push(
|
|
||||||
room='general',
|
|
||||||
message=ChatChannel.DjangoMessage(user='system', text='Hello')
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
@@ -219,16 +127,13 @@ class ReactChannel:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build params model if defined
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if cls.Params:
|
if cls.Params:
|
||||||
params_obj = cls.Params(**params)
|
params_obj = cls.Params(**params)
|
||||||
|
|
||||||
# Get group name
|
|
||||||
instance = cls()
|
instance = cls()
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
|
|
||||||
# Send to group
|
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
group_name,
|
group_name,
|
||||||
{
|
{
|
||||||
@@ -241,63 +146,31 @@ class ReactChannel:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
_registry: dict[str, Type[Channel]] = {}
|
||||||
# Registry
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_registry: dict[str, Type[ReactChannel]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register(channel_class: Type[ReactChannel], name: str) -> None:
|
def register(channel_class: Type[Channel], name: str) -> None:
|
||||||
"""
|
"""Register a channel class under a URL-friendly wire name."""
|
||||||
Register a channel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
channel_class: The ReactChannel subclass to register
|
|
||||||
name: URL-friendly name (used in subscriptions)
|
|
||||||
"""
|
|
||||||
if name in _registry:
|
if name in _registry:
|
||||||
raise ValueError(f"Channel '{name}' is already registered")
|
raise ValueError(f"Channel '{name}' is already registered")
|
||||||
|
|
||||||
channel_class._registered_name = name
|
channel_class._registered_name = name
|
||||||
|
|
||||||
# Validate the channel class
|
|
||||||
if not hasattr(channel_class, "authorize"):
|
|
||||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
|
||||||
if not hasattr(channel_class, "group"):
|
|
||||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
|
||||||
|
|
||||||
_registry[name] = channel_class
|
_registry[name] = channel_class
|
||||||
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
||||||
|
|
||||||
|
|
||||||
def get_channel(name: str) -> Type[ReactChannel] | None:
|
def get_channel(name: str) -> Type[Channel] | None:
|
||||||
"""Get a registered channel class by name."""
|
"""Get a registered channel class by name."""
|
||||||
return _registry.get(name)
|
return _registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
def get_registered_channels() -> dict[str, Type[Channel]]:
|
||||||
"""Get all registered channel classes."""
|
"""Get a copy of the name -> channel-class registry."""
|
||||||
return dict(_registry)
|
return dict(_registry)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# WebSocket Consumer
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_websocket_application():
|
def get_websocket_application():
|
||||||
"""
|
"""Build the ASGI application that serves every registered channel."""
|
||||||
Get the WebSocket application for ASGI.
|
|
||||||
|
|
||||||
Usage in asgi.py:
|
|
||||||
from mizan import channels
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
|
||||||
"http": get_asgi_application(),
|
|
||||||
"websocket": channels.get_websocket_application(),
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
from channels.routing import URLRouter
|
from channels.routing import URLRouter
|
||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
@@ -308,7 +181,7 @@ def get_websocket_application():
|
|||||||
"Install it with: pip install channels channels-redis"
|
"Install it with: pip install channels channels-redis"
|
||||||
)
|
)
|
||||||
|
|
||||||
from .connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
return AuthMiddlewareStack(
|
return AuthMiddlewareStack(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
@@ -319,42 +192,30 @@ def get_websocket_application():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Schema Export (for TypeScript generation)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_channels_schema() -> dict:
|
def get_channels_schema() -> dict:
|
||||||
"""
|
"""JSON-schema per registered channel, keyed by wire name."""
|
||||||
Get schema for all registered channels (for TypeScript generation).
|
|
||||||
|
|
||||||
Returns a dict suitable for the frontend code generator.
|
|
||||||
"""
|
|
||||||
schema = {"channels": {}}
|
schema = {"channels": {}}
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
channel_schema = {
|
channel_schema = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"params": None,
|
"params": None,
|
||||||
"reactMessage": None,
|
"clientMessage": None,
|
||||||
"djangoMessage": None,
|
"serverMessage": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract Params schema
|
if channel_class.Params:
|
||||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
|
|
||||||
# Extract ReactMessage schema
|
if channel_class.ClientMessage:
|
||||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
|
||||||
channel_schema[
|
channel_schema[
|
||||||
"reactMessage"
|
"clientMessage"
|
||||||
] = channel_class.ReactMessage.model_json_schema()
|
] = channel_class.ClientMessage.model_json_schema()
|
||||||
|
|
||||||
# Extract DjangoMessage schema
|
if channel_class.ServerMessage:
|
||||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
|
||||||
channel_schema[
|
channel_schema[
|
||||||
"djangoMessage"
|
"serverMessage"
|
||||||
] = channel_class.DjangoMessage.model_json_schema()
|
] = channel_class.ServerMessage.model_json_schema()
|
||||||
|
|
||||||
schema["channels"][name] = channel_schema
|
schema["channels"][name] = channel_schema
|
||||||
|
|
||||||
@@ -369,34 +230,37 @@ def _register_channel_schema_endpoint(
|
|||||||
input_cls: type | None,
|
input_cls: type | None,
|
||||||
output_cls: type,
|
output_cls: type,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
"""
|
||||||
|
Attach one operation to `api` whose annotations name `input_cls` and
|
||||||
|
`output_cls`, so Ninja emits both into `components.schemas`.
|
||||||
|
"""
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
|
|
||||||
def endpoint(request, data):
|
def schema_carrier(request, data):
|
||||||
pass
|
return output_cls.model_json_schema()
|
||||||
|
|
||||||
endpoint.__annotations__ = {"data": input_cls}
|
schema_carrier.__annotations__ = {"data": input_cls}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def endpoint(request):
|
def schema_carrier(request):
|
||||||
pass
|
return output_cls.model_json_schema()
|
||||||
|
|
||||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||||
endpoint
|
schema_carrier
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_channels_openapi_schema() -> dict:
|
def get_channels_openapi_schema() -> dict:
|
||||||
"""
|
"""
|
||||||
Get OpenAPI schema for all registered channels.
|
OpenAPI document covering every registered channel's wire types, with the
|
||||||
|
per-channel slot table under the `x-mizan-channels` extension key.
|
||||||
|
|
||||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation
|
||||||
This schema is consumed by openapi-typescript for type generation.
|
the Mizan IR emits, so the two documents name one type identically.
|
||||||
"""
|
"""
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Create temporary Ninja API for schema generation only
|
|
||||||
schema_api = NinjaAPI(
|
schema_api = NinjaAPI(
|
||||||
title="mizan Channels",
|
title="mizan Channels",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store dynamically created classes
|
|
||||||
schema_classes: dict[str, type] = {}
|
schema_classes: dict[str, type] = {}
|
||||||
channel_metadata: list[dict] = []
|
channel_metadata: list[dict] = []
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
pascal_name = name.replace("_", " ").title().replace(" ", "")
|
pascal_name = wire_to_pascal(name)
|
||||||
|
|
||||||
channel_meta = {
|
channel_meta = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"pascalName": pascal_name,
|
"pascalName": pascal_name,
|
||||||
"hasParams": False,
|
"hasParams": False,
|
||||||
"hasReactMessage": False,
|
"hasClientMessage": False,
|
||||||
"hasDjangoMessage": False,
|
"hasServerMessage": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register Params type
|
if channel_class.Params:
|
||||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
|
||||||
params_name = f"{pascal_name}Params"
|
params_name = f"{pascal_name}Params"
|
||||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||||
channel_meta["hasParams"] = True
|
channel_meta["hasParams"] = True
|
||||||
channel_meta["paramsType"] = params_name
|
channel_meta["paramsType"] = params_name
|
||||||
|
|
||||||
# Create dummy endpoint to include in schema
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/params",
|
path=f"/channels/{name}/params",
|
||||||
@@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
output_cls=BaseModel,
|
output_cls=BaseModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register ReactMessage type
|
if channel_class.ClientMessage:
|
||||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
client_name = f"{pascal_name}ClientMessage"
|
||||||
react_name = f"{pascal_name}ReactMessage"
|
schema_classes[client_name] = type(
|
||||||
schema_classes[react_name] = type(
|
client_name, (channel_class.ClientMessage,), {}
|
||||||
react_name, (channel_class.ReactMessage,), {}
|
|
||||||
)
|
)
|
||||||
channel_meta["hasReactMessage"] = True
|
channel_meta["hasClientMessage"] = True
|
||||||
channel_meta["reactMessageType"] = react_name
|
channel_meta["clientMessageType"] = client_name
|
||||||
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/react",
|
path=f"/channels/{name}/client",
|
||||||
operation_id=f"{name}ReactMessage",
|
operation_id=f"{name}ClientMessage",
|
||||||
summary=f"{pascal_name} React→Django message",
|
summary=f"{pascal_name} client→server message",
|
||||||
input_cls=schema_classes[react_name],
|
input_cls=schema_classes[client_name],
|
||||||
output_cls=BaseModel,
|
output_cls=BaseModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register DjangoMessage type
|
if channel_class.ServerMessage:
|
||||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
server_name = f"{pascal_name}ServerMessage"
|
||||||
django_name = f"{pascal_name}DjangoMessage"
|
schema_classes[server_name] = type(
|
||||||
schema_classes[django_name] = type(
|
server_name, (channel_class.ServerMessage,), {}
|
||||||
django_name, (channel_class.DjangoMessage,), {}
|
|
||||||
)
|
)
|
||||||
channel_meta["hasDjangoMessage"] = True
|
channel_meta["hasServerMessage"] = True
|
||||||
channel_meta["djangoMessageType"] = django_name
|
channel_meta["serverMessageType"] = server_name
|
||||||
|
|
||||||
_register_channel_schema_endpoint(
|
_register_channel_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/channels/{name}/django",
|
path=f"/channels/{name}/server",
|
||||||
operation_id=f"{name}DjangoMessage",
|
operation_id=f"{name}ServerMessage",
|
||||||
summary=f"{pascal_name} Django→React message",
|
summary=f"{pascal_name} server→client message",
|
||||||
input_cls=None,
|
input_cls=None,
|
||||||
output_cls=schema_classes[django_name],
|
output_cls=schema_classes[server_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
channel_metadata.append(channel_meta)
|
channel_metadata.append(channel_meta)
|
||||||
|
|
||||||
# Get OpenAPI schema from Ninja
|
|
||||||
# path_prefix="" avoids URL reverse() — this API is never mounted
|
# path_prefix="" avoids URL reverse() — this API is never mounted
|
||||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||||
|
|
||||||
# Add channel metadata extension
|
|
||||||
schema["x-mizan-channels"] = channel_metadata
|
schema["x-mizan-channels"] = channel_metadata
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Schema Endpoint (for TypeScript generation)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_schema_router = None
|
_schema_router = None
|
||||||
|
|
||||||
|
|
||||||
def _get_schema_router():
|
def _get_schema_router():
|
||||||
"""Get the Ninja router for the channels schema endpoint."""
|
|
||||||
global _schema_router
|
global _schema_router
|
||||||
if _schema_router is None:
|
if _schema_router is None:
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
@@ -502,17 +354,16 @@ def _get_schema_router():
|
|||||||
|
|
||||||
@_schema_router.get("/schema/")
|
@_schema_router.get("/schema/")
|
||||||
def channels_schema(request):
|
def channels_schema(request):
|
||||||
"""Get schema for all registered channels (for TypeScript generation)."""
|
|
||||||
return get_channels_schema()
|
return get_channels_schema()
|
||||||
|
|
||||||
return _schema_router
|
return _schema_router
|
||||||
|
|
||||||
|
|
||||||
def get_urls():
|
def get_urls():
|
||||||
"""Get URL patterns for channels schema endpoint."""
|
"""URL patterns serving the channels schema endpoint."""
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
api = NinjaAPI(urls_namespace="django_react_channels")
|
api = NinjaAPI(urls_namespace="mizan_channels")
|
||||||
api.add_router("/", _get_schema_router())
|
api.add_router("/", _get_schema_router())
|
||||||
return api.urls
|
return api.urls
|
||||||
|
|
||||||
@@ -523,17 +374,8 @@ 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}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
class _ChannelsExtension(RegistryExtension):
|
||||||
# Core Registry Extension
|
"""Exposes the channel registry to mizan_core under the 'channels' key."""
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
def all(self) -> dict:
|
||||||
return dict(_registry)
|
return dict(_registry)
|
||||||
@@ -546,13 +388,17 @@ class _ChannelsExtension:
|
|||||||
"type": "channel",
|
"type": "channel",
|
||||||
"bidirectional": False,
|
"bidirectional": False,
|
||||||
}
|
}
|
||||||
if getattr(channel_class, "Params", None):
|
if channel_class.Params:
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
if getattr(channel_class, "ReactMessage", None):
|
if channel_class.ClientMessage:
|
||||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"client_message"
|
||||||
|
] = channel_class.ClientMessage.model_json_schema()
|
||||||
channel_schema["bidirectional"] = True
|
channel_schema["bidirectional"] = True
|
||||||
if getattr(channel_class, "DjangoMessage", None):
|
if channel_class.ServerMessage:
|
||||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"server_message"
|
||||||
|
] = channel_class.ServerMessage.model_json_schema()
|
||||||
out[name] = channel_schema
|
out[name] = channel_schema
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -560,25 +406,15 @@ class _ChannelsExtension:
|
|||||||
_registry.clear()
|
_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
from mizan_core.registry import register_extension as _register_extension
|
register_extension("channels", _ChannelsExtension())
|
||||||
_register_extension("channels", _ChannelsExtension())
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Exports
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# URLs
|
|
||||||
"urls",
|
"urls",
|
||||||
# Base class
|
"Channel",
|
||||||
"ReactChannel",
|
|
||||||
# Registration
|
|
||||||
"register",
|
"register",
|
||||||
"get_channel",
|
"get_channel",
|
||||||
"get_registered_channels",
|
"get_registered_channels",
|
||||||
# ASGI application
|
|
||||||
"get_websocket_application",
|
"get_websocket_application",
|
||||||
# Schema export
|
|
||||||
"get_channels_schema",
|
"get_channels_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket consumer for mizan.channels.
|
WebSocket consumer multiplexing channel subscriptions and RPC calls over one
|
||||||
|
socket.
|
||||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
Browser sends:
|
Browser sends:
|
||||||
@@ -12,27 +11,21 @@ Protocol:
|
|||||||
|
|
||||||
# RPC calls (server functions)
|
# RPC calls (server functions)
|
||||||
{"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
{"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||||
|
{"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||||
|
|
||||||
Server sends:
|
Server sends:
|
||||||
# Channel messages
|
# Channel messages
|
||||||
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
|
{"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}}
|
||||||
|
|
||||||
# RPC responses
|
# RPC responses
|
||||||
{"id": "request-id", "ok": true, "data": {...}}
|
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
||||||
{"id": "request-id", "ok": false, "error": {...}}
|
{"id": "request-id", "ok": false, "error": {...}}
|
||||||
|
|
||||||
{"error": "..."}
|
{"error": "..."}
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
Supports both session (cookie) and JWT authentication:
|
Session cookies arrive through AuthMiddlewareStack during the handshake;
|
||||||
- Session: Handled automatically via AuthMiddlewareStack (cookies in handshake)
|
a JWT arrives as a query parameter: ws://localhost/ws/?token=<access_token>
|
||||||
- JWT: Pass token as query parameter: ws://...?token=<jwt>
|
|
||||||
|
|
||||||
The WebSocket URL for JWT auth would be: ws://localhost/ws/?token=<access_token>
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Functions must be explicitly registered (no arbitrary code execution)
|
|
||||||
- Pydantic validation runs BEFORE any function code
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -42,7 +35,8 @@ from urllib.parse import parse_qs
|
|||||||
|
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from . import get_channel
|
|
||||||
|
from mizan.channels import get_channel
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -50,27 +44,23 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class WebSocketRequest:
|
class WebSocketRequest:
|
||||||
"""
|
"""
|
||||||
Minimal request adapter for WebSocket context.
|
The request surface ServerFunction reads, backed by a WebSocket scope
|
||||||
|
instead of an HttpRequest.
|
||||||
Provides the interface expected by ServerFunction without full HttpRequest.
|
|
||||||
This is intentionally minimal - only expose what's needed.
|
|
||||||
|
|
||||||
Note: Some Django libraries (e.g., allauth rate limiting) check request.method.
|
|
||||||
We set method="POST" since WebSocket RPC calls are semantically similar to POST.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# WebSocket RPC is semantically similar to POST (sends data, expects response)
|
# Some Django libraries (allauth rate limiting) branch on request.method;
|
||||||
|
# an RPC call carries data and expects a response, so POST is the match.
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
def __init__(self, scope: dict, channel_name: str = None):
|
def __init__(self, scope: dict, channel_name: str = None):
|
||||||
self.user = scope.get("user")
|
self.user = scope.get("user")
|
||||||
self.session = scope.get("session", {})
|
self.session = scope.get("session", {})
|
||||||
self.channel_name = channel_name # For push subscriptions
|
self.channel_name = channel_name
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def META(self) -> dict:
|
def META(self) -> dict:
|
||||||
"""HTTP headers from WebSocket handshake."""
|
"""HTTP headers from the WebSocket handshake, in WSGI key form."""
|
||||||
headers = dict(self._scope.get("headers", []))
|
headers = dict(self._scope.get("headers", []))
|
||||||
return {
|
return {
|
||||||
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
||||||
@@ -79,24 +69,15 @@ class WebSocketRequest:
|
|||||||
|
|
||||||
|
|
||||||
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||||
"""
|
"""Holds every channel subscription opened over one WebSocket connection."""
|
||||||
Multiplexed WebSocket consumer for django_react channels.
|
|
||||||
|
|
||||||
Manages multiple channel subscriptions over a single WebSocket connection.
|
|
||||||
|
|
||||||
Authentication:
|
|
||||||
- Session auth via cookies (handled by AuthMiddlewareStack)
|
|
||||||
- JWT auth via query parameter: ws://...?token=<jwt>
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Track subscriptions: {(channel_name, params_json): channel_instance}
|
# {(channel_name, params_json): channel_instance}
|
||||||
self._subscriptions: dict[tuple[str, str], Any] = {}
|
self._subscriptions: dict[tuple[str, str], Any] = {}
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
||||||
# Check for JWT token in query parameters
|
|
||||||
await self._try_jwt_auth()
|
await self._try_jwt_auth()
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
@@ -106,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def _try_jwt_auth(self):
|
async def _try_jwt_auth(self):
|
||||||
"""
|
"""
|
||||||
Attempt JWT authentication from query parameter.
|
Authenticate from a ?token=<jwt> query parameter, building a JWTUser
|
||||||
|
from the token claims with no database query.
|
||||||
|
|
||||||
If a valid JWT token is provided via ?token=<jwt>, authenticate the user
|
An invalid token leaves the scope untouched so session auth still
|
||||||
using JWTUser (no database query).
|
applies; a valid one overwrites whatever session auth resolved.
|
||||||
|
|
||||||
Security: If JWT is provided but invalid, we log it but don't reject
|
|
||||||
the connection - the session auth may still be valid. However, if JWT
|
|
||||||
IS valid, it takes precedence over session auth.
|
|
||||||
"""
|
"""
|
||||||
# Parse query string for token
|
|
||||||
query_string = self.scope.get("query_string", b"").decode()
|
query_string = self.scope.get("query_string", b"").decode()
|
||||||
params = parse_qs(query_string)
|
params = parse_qs(query_string)
|
||||||
token_list = params.get("token", [])
|
token_list = params.get("token", [])
|
||||||
|
|
||||||
if not token_list:
|
if not token_list:
|
||||||
return # No JWT provided, use session auth
|
return
|
||||||
|
|
||||||
token = token_list[0]
|
token = token_list[0]
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate JWT and create JWTUser (no DB query)
|
|
||||||
try:
|
try:
|
||||||
from mizan.client.jwt import decode_token
|
from mizan.client.jwt import decode_token
|
||||||
from mizan.jwt.tokens import JWTUser
|
from mizan.jwt.tokens import JWTUser
|
||||||
@@ -135,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||||
if payload is None:
|
if payload is None:
|
||||||
logger.debug("JWT token invalid or expired")
|
logger.debug("JWT token invalid or expired")
|
||||||
return # Fall back to session auth
|
return
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
self.scope["user"] = JWTUser(payload)
|
self.scope["user"] = JWTUser(payload)
|
||||||
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -156,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
||||||
|
|
||||||
async def receive_json(self, content: dict):
|
async def receive_json(self, content: dict):
|
||||||
"""Handle incoming JSON messages."""
|
"""Route one incoming frame by its "action" field."""
|
||||||
action = content.get("action")
|
action = content.get("action")
|
||||||
|
|
||||||
if action == "subscribe":
|
if action == "subscribe":
|
||||||
@@ -167,6 +142,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
await self._handle_message(content)
|
await self._handle_message(content)
|
||||||
elif action == "rpc":
|
elif action == "rpc":
|
||||||
await self._handle_rpc(content)
|
await self._handle_rpc(content)
|
||||||
|
elif action == "ctx":
|
||||||
|
await self._handle_ctx(content)
|
||||||
else:
|
else:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -175,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_subscribe(self, content: dict):
|
async def _handle_subscribe(self, content: dict):
|
||||||
"""Handle subscription request."""
|
"""Authorize, join the group, and record the subscription."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
|
|
||||||
# Get channel class
|
|
||||||
channel_class = get_channel(channel_name)
|
channel_class = get_channel(channel_name)
|
||||||
if not channel_class:
|
if not channel_class:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -189,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create subscription key
|
|
||||||
params_json = json.dumps(params_dict, sort_keys=True)
|
params_json = json.dumps(params_dict, sort_keys=True)
|
||||||
sub_key = (channel_name, params_json)
|
sub_key = (channel_name, params_json)
|
||||||
|
|
||||||
# Check if already subscribed
|
|
||||||
if sub_key in self._subscriptions:
|
if sub_key in self._subscriptions:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -204,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create channel instance
|
|
||||||
instance = channel_class()
|
instance = channel_class()
|
||||||
instance.user = self.scope.get("user")
|
instance.user = self.scope.get("user")
|
||||||
instance._channel_layer = self.channel_layer
|
instance._channel_layer = self.channel_layer
|
||||||
@@ -212,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
instance._registered_name = channel_name
|
instance._registered_name = channel_name
|
||||||
instance._params_dict = params_dict
|
instance._params_dict = params_dict
|
||||||
|
|
||||||
# Parse params
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if channel_class.Params:
|
if channel_class.Params:
|
||||||
try:
|
try:
|
||||||
@@ -226,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check authorization
|
|
||||||
try:
|
try:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
authorized = instance.authorize(params_obj)
|
authorized = instance.authorize(params_obj)
|
||||||
@@ -251,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get group and join
|
|
||||||
try:
|
try:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
@@ -268,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store subscription
|
|
||||||
self._subscriptions[sub_key] = instance
|
self._subscriptions[sub_key] = instance
|
||||||
|
|
||||||
# Call on_connect hook
|
|
||||||
try:
|
try:
|
||||||
await instance.on_connect(params_obj)
|
await instance.on_connect(params_obj)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||||
|
|
||||||
# Confirm subscription
|
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"subscribed": True,
|
"subscribed": True,
|
||||||
@@ -289,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||||
|
|
||||||
async def _handle_unsubscribe(self, content: dict):
|
async def _handle_unsubscribe(self, content: dict):
|
||||||
"""Handle unsubscription request."""
|
"""Drop the subscription and leave its groups."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
|
|
||||||
@@ -315,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.debug(f"Unsubscribed from {channel_name}")
|
logger.debug(f"Unsubscribed from {channel_name}")
|
||||||
|
|
||||||
async def _handle_message(self, content: dict):
|
async def _handle_message(self, content: dict):
|
||||||
"""Handle incoming message from browser."""
|
"""Validate a ClientMessage, hand it to receive(), broadcast what comes back."""
|
||||||
channel_name = content.get("channel")
|
channel_name = content.get("channel")
|
||||||
params_dict = content.get("params", {})
|
params_dict = content.get("params", {})
|
||||||
data = content.get("data", {})
|
data = content.get("data", {})
|
||||||
@@ -335,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
channel_class = instance.__class__
|
channel_class = instance.__class__
|
||||||
|
|
||||||
# Check if channel accepts messages
|
if not channel_class.ClientMessage:
|
||||||
if not channel_class.ReactMessage:
|
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"error": f"Channel {channel_name} does not accept messages",
|
"error": f"Channel {channel_name} does not accept messages",
|
||||||
@@ -345,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse message
|
|
||||||
try:
|
try:
|
||||||
msg = channel_class.ReactMessage(**data)
|
msg = channel_class.ClientMessage(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -357,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse params
|
|
||||||
params_obj = None
|
params_obj = None
|
||||||
if channel_class.Params:
|
if channel_class.Params:
|
||||||
params_obj = channel_class.Params(**params_dict)
|
params_obj = channel_class.Params(**params_dict)
|
||||||
|
|
||||||
# Handle message
|
|
||||||
try:
|
try:
|
||||||
response = instance.receive(params_obj, msg)
|
response = instance.receive(params_obj, msg)
|
||||||
|
|
||||||
# If handler returned a message, broadcast it
|
|
||||||
if response is not None:
|
if response is not None:
|
||||||
if params_obj:
|
if params_obj:
|
||||||
group_name = instance.group(params_obj)
|
group_name = instance.group(params_obj)
|
||||||
@@ -386,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def _handle_rpc(self, content: dict):
|
async def _handle_rpc(self, content: dict):
|
||||||
"""
|
"""
|
||||||
Handle RPC (server function) call.
|
Run a registered server function.
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||||
Response: {"id": "request-id", "ok": true, "data": {...}}
|
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
||||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||||
|
|
||||||
Security:
|
Only functions registered with @client(websocket=True) are reachable,
|
||||||
- Only functions with @client(websocket=True) are allowed
|
and execute_function validates args against the function's Input model
|
||||||
- Pydantic validation happens BEFORE any function code runs
|
before any function body runs.
|
||||||
- Function must be explicitly registered (no arbitrary code execution)
|
|
||||||
- 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_core.registry import get_function
|
from mizan_core.registry import get_function
|
||||||
@@ -406,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
fn_name = content.get("fn")
|
fn_name = content.get("fn")
|
||||||
args = content.get("args", {})
|
args = content.get("args", {})
|
||||||
|
|
||||||
# Validate request structure
|
|
||||||
if not request_id:
|
if not request_id:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -428,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if function exists and has websocket=True
|
|
||||||
fn_class = get_function(fn_name)
|
fn_class = get_function(fn_name)
|
||||||
if fn_class is None:
|
if fn_class is None:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -443,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only allow functions explicitly marked with websocket=True
|
|
||||||
fn_meta = getattr(fn_class, "_meta", {})
|
fn_meta = getattr(fn_class, "_meta", {})
|
||||||
if not fn_meta.get("websocket"):
|
if not fn_meta.get("websocket"):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
@@ -458,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create request adapter from WebSocket scope
|
|
||||||
ws_request = WebSocketRequest(
|
ws_request = WebSocketRequest(
|
||||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute function (Pydantic validation happens inside execute_function)
|
# execute_function is sync, so it runs in a thread pool
|
||||||
# This is sync, so we need to run it in a thread pool
|
|
||||||
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
||||||
ws_request,
|
ws_request,
|
||||||
fn_name,
|
fn_name,
|
||||||
args,
|
args,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send response
|
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -485,20 +439,78 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# the {result, invalidate, merge} envelope the HTTP RPC path builds
|
||||||
|
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
||||||
|
|
||||||
|
data = {"result": result.data}
|
||||||
|
invalidate = await sync_to_async(_resolve_invalidation, thread_sensitive=True)(
|
||||||
|
fn_class, args
|
||||||
|
)
|
||||||
|
merges = await sync_to_async(_resolve_merges, thread_sensitive=True)(
|
||||||
|
fn_class, args, result.data
|
||||||
|
)
|
||||||
|
if invalidate:
|
||||||
|
data["invalidate"] = invalidate
|
||||||
|
if merges:
|
||||||
|
data["merge"] = merges
|
||||||
|
|
||||||
|
await self.send_json({"id": request_id, "ok": True, "data": data})
|
||||||
|
|
||||||
|
async def _handle_ctx(self, content: dict):
|
||||||
|
"""
|
||||||
|
Fetch a context bundle through execute_context.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||||
|
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
|
||||||
|
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||||
|
"""
|
||||||
|
from mizan.client.executor import execute_context, FunctionError
|
||||||
|
|
||||||
|
request_id = content.get("id")
|
||||||
|
context_name = content.get("context")
|
||||||
|
|
||||||
|
if not request_id:
|
||||||
|
await self.send_json({"error": "ctx request missing 'id' field"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not context_name:
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": True,
|
"ok": False,
|
||||||
"data": result.data,
|
"error": {"code": "BAD_REQUEST", "message": "Missing 'context' field"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ws_request = WebSocketRequest(
|
||||||
|
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||||
|
)
|
||||||
|
result = await sync_to_async(execute_context, thread_sensitive=True)(
|
||||||
|
ws_request, context_name, content.get("params") or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, FunctionError):
|
||||||
|
await self.send_json(
|
||||||
|
{
|
||||||
|
"id": request_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": {
|
||||||
|
"code": result.code.value,
|
||||||
|
"message": result.message,
|
||||||
|
**({"details": result.details} if result.details else {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send_json({"id": request_id, "ok": True, "data": result.data})
|
||||||
|
|
||||||
async def channel_message(self, event: dict):
|
async def channel_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
Handle messages broadcast to a group.
|
Forward a group broadcast down the socket, carrying the channel name
|
||||||
|
and params the client routes on.
|
||||||
Called when channel_layer.group_send() is used.
|
|
||||||
Includes channel name and params so the client can route the message.
|
|
||||||
"""
|
"""
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
@@ -511,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def push_message(self, event: dict):
|
async def push_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
Handle push messages from server functions.
|
Forward a topic push down the socket.
|
||||||
|
|
||||||
Called when push("topic", data) is used from a server function.
|
Wire shape: {"type": "push", "topic": "room:42", "data": {...}}
|
||||||
The client receives this to update its local state.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
|
||||||
"""
|
"""
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,68 +1,47 @@
|
|||||||
"""
|
"""
|
||||||
mizan Push - Server-initiated messages to clients.
|
Topic-based server-initiated messages.
|
||||||
|
|
||||||
Simple API for pushing data to subscribed WebSocket connections.
|
A topic string ("room:42", "user:123:notifications") maps onto one channel
|
||||||
|
layer group; subscribing a connection adds its channel name to that group,
|
||||||
Usage:
|
and pushing sends a "push.message" event to every member.
|
||||||
# In a server function - push to all subscribers
|
|
||||||
from mizan.push import push
|
|
||||||
|
|
||||||
push("room:42", {"type": "new_message", "data": {...}})
|
|
||||||
|
|
||||||
# Subscribe a connection to a topic (call during context fetch)
|
|
||||||
from mizan.push import subscribe
|
|
||||||
|
|
||||||
subscribe(request, "room:42")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
import logging
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Lazy import to avoid import errors when channels is not installed
|
logger = logging.getLogger(__name__)
|
||||||
# (e.g., during schema generation)
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from channels.layers import BaseChannelLayer
|
|
||||||
|
|
||||||
|
|
||||||
def _get_channel_layer() -> "BaseChannelLayer | None":
|
def _get_channel_layer():
|
||||||
"""Get channel layer, returning None if channels is not installed."""
|
"""The configured channel layer, or None when django-channels is absent."""
|
||||||
try:
|
try:
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
except ImportError as e:
|
||||||
return get_channel_layer()
|
logger.warning("django-channels is not installed, push is inert: %s", e)
|
||||||
except ImportError:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return get_channel_layer()
|
||||||
def _async_to_sync(coro):
|
|
||||||
"""Wrapper for async_to_sync that handles missing channels."""
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
|
|
||||||
return async_to_sync(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def get_topic_group_name(topic: str) -> str:
|
def get_topic_group_name(topic: str) -> str:
|
||||||
"""Convert a topic string to a valid channel layer group name."""
|
"""
|
||||||
# Channel layer group names must be valid ASCII alphanumeric + hyphens/underscores/periods
|
Convert a topic to a channel layer group name. Group names allow ASCII
|
||||||
# Replace colons with underscores
|
alphanumerics plus hyphens, underscores and periods, so the topic
|
||||||
|
separator becomes an underscore.
|
||||||
|
"""
|
||||||
return topic.replace(":", "_")
|
return topic.replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
def subscribe(request, topic: str) -> None:
|
def subscribe(request, topic: str) -> None:
|
||||||
"""
|
"""
|
||||||
Subscribe this WebSocket connection to a topic.
|
Add this WebSocket connection to a topic's group.
|
||||||
|
|
||||||
Call this in a context or server function to register the connection
|
An HTTP request carries no channel_name, so there is nothing to add.
|
||||||
for push notifications on the given topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django request (must have channel_name attribute from WebSocket)
|
|
||||||
topic: Topic string, e.g., "room:42", "user:123:notifications"
|
|
||||||
"""
|
"""
|
||||||
channel_name = getattr(request, "channel_name", None)
|
channel_name = getattr(request, "channel_name", None)
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
# HTTP request, not WebSocket - can't subscribe
|
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
@@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
_async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe(request, topic: str) -> None:
|
def unsubscribe(request, topic: str) -> None:
|
||||||
"""
|
"""Remove this WebSocket connection from a topic's group."""
|
||||||
Unsubscribe this WebSocket connection from a topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django request (must have channel_name attribute from WebSocket)
|
|
||||||
topic: Topic string to unsubscribe from
|
|
||||||
"""
|
|
||||||
channel_name = getattr(request, "channel_name", None)
|
channel_name = getattr(request, "channel_name", None)
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
return
|
return
|
||||||
@@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
_async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||||
|
|
||||||
|
|
||||||
def push(topic: str, data: dict | BaseModel) -> None:
|
def push(topic: str, data: dict | BaseModel) -> None:
|
||||||
"""
|
"""Send data to every connection subscribed to a topic."""
|
||||||
Push data to all connections subscribed to a topic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic: Topic string, e.g., "room:42"
|
|
||||||
data: Data to send (dict or Pydantic model)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
push("room:42", {
|
|
||||||
"type": "new_message",
|
|
||||||
"message": {"id": 1, "text": "Hello", "user": "alice@example.com"}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
import logging
|
logger.warning(
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"No channel layer configured, cannot push to topic '%s'", topic
|
"No channel layer configured, cannot push to topic '%s'", topic
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert Pydantic model to dict if needed
|
|
||||||
if isinstance(data, BaseModel):
|
if isinstance(data, BaseModel):
|
||||||
data = data.model_dump()
|
data = data.model_dump()
|
||||||
|
|
||||||
group_name = get_topic_group_name(topic)
|
group_name = get_topic_group_name(topic)
|
||||||
|
|
||||||
_async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
group_name,
|
group_name,
|
||||||
{
|
{
|
||||||
"type": "push.message", # Maps to push_message handler in consumer
|
# The event's "type" selects the consumer method of the same name,
|
||||||
|
# with dots translated to underscores.
|
||||||
|
"type": "push.message",
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
},
|
},
|
||||||
@@ -133,9 +93,12 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||||
"""Async version of push for use in async contexts."""
|
"""Send data to every connection subscribed to a topic, from the event loop."""
|
||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
|
logger.warning(
|
||||||
|
"No channel layer configured, cannot push to topic '%s'", topic
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(data, BaseModel):
|
if isinstance(data, BaseModel):
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
mizan.client - Server function implementation.
|
The server-function surface: the `client` decorator and `ServerFunction` base
|
||||||
|
come from `mizan_core`; execution and dispatch are Django-specific and live in
|
||||||
This subpackage contains everything needed to make server functions work:
|
`mizan.client.executor`.
|
||||||
- The @client decorator (lives in mizan_core.client.function)
|
|
||||||
- ServerFunction base class (mizan_core.client.function)
|
|
||||||
- Function execution logic (.executor — Django-specific dispatch)
|
|
||||||
- JWT authentication (.jwt — Django-specific session integration)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan.client import client, ServerFunction, compose
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Register the Django framework response base so view-path detection works
|
# Registering the Django response base has to happen before any
|
||||||
# in mizan_core.client.function. Has to happen before any @client-decorated
|
# @client-decorated code is evaluated, or view-path detection in
|
||||||
# code is evaluated.
|
# mizan_core.client.function cannot recognize a returned HttpResponse.
|
||||||
from django.http import HttpResponseBase as _HttpResponseBase
|
from django.http import HttpResponseBase as _HttpResponseBase
|
||||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||||
_set_response_base(_HttpResponseBase)
|
_set_response_base(_HttpResponseBase)
|
||||||
@@ -39,7 +32,7 @@ from mizan_core.client.function import (
|
|||||||
create_form_functions,
|
create_form_functions,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .executor import (
|
from mizan.client.executor import (
|
||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
function_call_view,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
mizan Function Executor
|
Dispatch for registered server functions over HTTP.
|
||||||
|
|
||||||
Handles execution of server functions.
|
Input is validated against the function's Pydantic Input before the body ever
|
||||||
This is the core of the "Server Functions" feature - callable from React
|
runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or
|
||||||
without REST boilerplate.
|
an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF;
|
||||||
|
anything else falls through to session auth with CSRF enforced.
|
||||||
Security model:
|
|
||||||
- All input validated against Pydantic schema BEFORE execution
|
|
||||||
- Authentication: JWT (stateless) or Session (stateful) - auto-detected
|
|
||||||
- JWT: Authorization header with Bearer token (no CSRF needed)
|
|
||||||
- Session: Cookie-based with CSRF token (via X-CSRFToken header)
|
|
||||||
- WebSocket RPC uses Origin header checking instead
|
|
||||||
- No implicit function exposure - must be explicitly registered
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,7 +14,7 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseBase, 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
|
||||||
@@ -29,15 +22,8 @@ from pydantic import BaseModel, ValidationError
|
|||||||
|
|
||||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||||
from mizan_core.registry import get_function, get_context_groups
|
from mizan_core.registry import get_function, get_context_groups
|
||||||
from mizan_core.upload import UploadedFile, bind_uploads
|
|
||||||
from mizan_core import invalidation as _core_inval
|
|
||||||
from mizan_core.authguard import enforce_auth as _core_enforce_auth
|
|
||||||
from mizan_core.errors import MizanError as _CoreMizanError
|
|
||||||
from mizan.setup.settings import get_settings
|
from mizan.setup.settings import get_settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,27 +89,55 @@ def _check_auth_requirement(
|
|||||||
auth_requirement: str | Callable | None,
|
auth_requirement: str | Callable | None,
|
||||||
) -> FunctionError | None:
|
) -> FunctionError | None:
|
||||||
"""
|
"""
|
||||||
Check if the request meets the auth requirement.
|
Test `request` against an auth requirement of 'required', 'staff',
|
||||||
|
'superuser', a callable, or None. Returns a FunctionError on failure.
|
||||||
|
|
||||||
Args:
|
The built-in checks read only flags already on request.user — a JWTUser or
|
||||||
request: The Django HttpRequest (with user set)
|
a session User — so none of them hit the database. A callable may.
|
||||||
auth_requirement: 'required', 'staff', 'superuser', callable, or None
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionError if auth check fails, None if it passes.
|
|
||||||
|
|
||||||
Note: This uses request.user which may be a JWTUser (stateless) or
|
|
||||||
Django User (from session). Either way, no additional DB query is made
|
|
||||||
for the built-in checks. Custom callables may query DB if they choose.
|
|
||||||
"""
|
"""
|
||||||
# Evaluation lives in the shared core (mizan_core.authguard); the callable
|
if auth_requirement is None:
|
||||||
# path receives the native Django request. Core raises; we render to the
|
|
||||||
# Django-shim FunctionError shape the executor expects.
|
|
||||||
try:
|
|
||||||
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
|
|
||||||
return None
|
return None
|
||||||
except _CoreMizanError as e:
|
|
||||||
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
|
user = request.user
|
||||||
|
|
||||||
|
if callable(auth_requirement):
|
||||||
|
try:
|
||||||
|
result = auth_requirement(request)
|
||||||
|
if result:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Access denied",
|
||||||
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message=str(e) or "Access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every string-based requirement implies authentication.
|
||||||
|
if not getattr(user, "is_authenticated", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
|
message="Authentication required",
|
||||||
|
)
|
||||||
|
|
||||||
|
if auth_requirement == "staff":
|
||||||
|
if not getattr(user, "is_staff", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Staff access required",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif auth_requirement == "superuser":
|
||||||
|
if not getattr(user, "is_superuser", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Superuser access required",
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_cache_log = logging.getLogger("mizan.cache")
|
_cache_log = logging.getLogger("mizan.cache")
|
||||||
@@ -133,7 +147,7 @@ def _purge_cache_for_invalidation(
|
|||||||
invalidate: list,
|
invalidate: list,
|
||||||
request: HttpRequest | None = None,
|
request: HttpRequest | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
|
"""Purge origin-side cache entries for invalidation targets, scoped by user when known."""
|
||||||
cache = get_cache()
|
cache = get_cache()
|
||||||
if cache is None:
|
if cache is None:
|
||||||
return
|
return
|
||||||
@@ -162,25 +176,97 @@ def _purge_cache_for_invalidation(
|
|||||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
_cache_log.warning("Cache purge failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
|
"""
|
||||||
|
Classify an affects target as a context or a function inside one.
|
||||||
|
|
||||||
|
("context", "user", None) — full context invalidation
|
||||||
|
("function", "user_profile", "user") — function within context
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
|
||||||
|
if target_name in groups:
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
if target_name in fn_names:
|
||||||
|
return ("function", target_name, ctx_name)
|
||||||
|
|
||||||
|
# An unregistered name is treated as a context so invalidation still
|
||||||
|
# propagates rather than being silently dropped.
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context_param_names(context_name: str) -> set[str]:
|
||||||
|
"""Union of the Input field names across every function in a context."""
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
param_names: set[str] = set()
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
return param_names
|
||||||
|
|
||||||
|
|
||||||
def _resolve_invalidation(
|
def _resolve_invalidation(
|
||||||
view_class: type | None,
|
view_class: type | None,
|
||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> list[str | dict[str, Any]] | None:
|
) -> list[str | dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
Resolve invalidation targets with three-tier auto-scoping.
|
Turn a mutation's `affects` metadata into invalidation targets, returning
|
||||||
|
None when there is nothing to invalidate.
|
||||||
|
|
||||||
Tier 1: Argument name matching — if the mutation's input args overlap
|
A target is scoped to specific params when the mutation's input argument
|
||||||
with the context's params by name, auto-scope.
|
names overlap the context's param names; otherwise the whole context is
|
||||||
Tier 2: Auth inference — Edge-side concern, not handled here.
|
invalidated. A function-level target is keyed by the function name.
|
||||||
Tier 3: Broad fallback — invalidate all instances.
|
|
||||||
|
|
||||||
Also handles function-level targeting: affects='user_profile' resolves
|
The returned list serializes into both the JSON body and the header.
|
||||||
to the function name (v1: runtime refetches the whole context anyway).
|
|
||||||
|
|
||||||
Returns a list suitable for both JSON body and header serialization.
|
|
||||||
Returns None if no invalidation needed.
|
|
||||||
"""
|
"""
|
||||||
return _core_inval.resolve_invalidation(view_class, input_data)
|
if view_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
target_name = target["name"]
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
target_name = target["name"]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(target_name)
|
||||||
|
|
||||||
|
resolved = _resolve_affects_target(target_name)
|
||||||
|
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||||
|
|
||||||
|
if input_data and ctx_for_params:
|
||||||
|
context_params = _get_context_param_names(ctx_for_params)
|
||||||
|
matched = {
|
||||||
|
k: v for k, v in input_data.items()
|
||||||
|
if k in context_params
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
result.append({"context": target_name, "params": matched})
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(target_name)
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_merges(
|
def _resolve_merges(
|
||||||
@@ -189,22 +275,100 @@ def _resolve_merges(
|
|||||||
result_data: Any,
|
result_data: Any,
|
||||||
) -> list[dict[str, Any]] | None:
|
) -> list[dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
Resolve merge targets from @client(merge=...).
|
Turn a mutation's `merge` metadata into `{context, slot, value, params?}`
|
||||||
|
entries. `slot` is the function-name inside the context bundle the value
|
||||||
Each entry is `{context, slot, value, params?}` — `slot` is the
|
lands in, resolved here by matching the mutation's declared Output against
|
||||||
function-name inside the context bundle the value lands in, resolved
|
each context-function's Output. Entries whose slot is ambiguous are
|
||||||
server-side by matching the mutation's return type against each
|
dropped, and params are scoped the same way `_resolve_invalidation` scopes
|
||||||
context-function's return type. Kernel does no shape inference.
|
them.
|
||||||
|
|
||||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
|
||||||
Entries whose slot can't be uniquely resolved are dropped.
|
|
||||||
"""
|
"""
|
||||||
return _core_inval.resolve_merges(view_class, input_data, result_data)
|
if view_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
|
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
targets = meta.get("merge") or []
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ctx_name in targets:
|
||||||
|
if ctx_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ctx_name)
|
||||||
|
|
||||||
|
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||||
|
if input_data:
|
||||||
|
context_params = _get_context_param_names(ctx_name)
|
||||||
|
matched = {
|
||||||
|
k: v for k, v in input_data.items()
|
||||||
|
if k in context_params
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
entry["params"] = matched
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||||
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core)."""
|
"""Find the one function in `context_name` whose Output matches the mutation's, if unique."""
|
||||||
return _core_inval.format_invalidate_header(invalidate)
|
if mutation_output is None:
|
||||||
|
return None
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
matches: list[str] = []
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
fn_output = getattr(fn_cls, "Output", None)
|
||||||
|
if fn_output is not None and type_matcher(fn_output, mutation_output):
|
||||||
|
matches.append(fn_name)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_invalidate_header(
|
||||||
|
invalidate: list[str | dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format invalidation targets as the X-Mizan-Invalidate header value:
|
||||||
|
comma-separated contexts, each optionally followed by semicolon-separated
|
||||||
|
`key=value` params. Keys and values are URL-encoded so a param can never
|
||||||
|
contain a delimiter.
|
||||||
|
|
||||||
|
["user"] → "user"
|
||||||
|
["user", "notifications"] → "user, notifications"
|
||||||
|
[{"context": "user", "params": {"user_id": 5}}]
|
||||||
|
→ "user;user_id=5"
|
||||||
|
[{"context": "search", "params": {"q": "hello world"}}]
|
||||||
|
→ "search;q=hello%20world"
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for entry in invalidate:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
parts.append(entry)
|
||||||
|
elif isinstance(entry, dict):
|
||||||
|
ctx = entry["context"]
|
||||||
|
params = entry.get("params", {})
|
||||||
|
if params:
|
||||||
|
param_str = ";".join(
|
||||||
|
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||||
|
for k, v in sorted(params.items())
|
||||||
|
)
|
||||||
|
parts.append(f"{ctx};{param_str}")
|
||||||
|
else:
|
||||||
|
parts.append(ctx)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def execute_function(
|
def execute_function(
|
||||||
@@ -213,22 +377,17 @@ def execute_function(
|
|||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||||
"""
|
"""
|
||||||
Execute a registered server function.
|
Look up, authorize, validate, and run a registered server function.
|
||||||
|
|
||||||
Args:
|
Returns the function's HttpResponse untouched when it returned one,
|
||||||
request: The Django HttpRequest
|
otherwise a FunctionResult or FunctionError.
|
||||||
fn_name: Name of the registered function
|
|
||||||
input_data: Input data to pass to the function
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionResult on success, FunctionError on failure
|
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
# Look up the function by name
|
|
||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
if view_class is None:
|
if view_class is None:
|
||||||
# In DEBUG mode, include the name for easier debugging
|
# Naming the missing function is a debugging aid, not something to
|
||||||
|
# hand an unauthenticated caller in production.
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
message = f"Function '{fn_name}' not found"
|
message = f"Function '{fn_name}' not found"
|
||||||
else:
|
else:
|
||||||
@@ -238,7 +397,6 @@ def execute_function(
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reject private functions from RPC dispatch
|
|
||||||
meta = getattr(view_class, "_meta", {})
|
meta = getattr(view_class, "_meta", {})
|
||||||
if meta.get("private"):
|
if meta.get("private"):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -246,36 +404,28 @@ def execute_function(
|
|||||||
message="Function is not client-callable",
|
message="Function is not client-callable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check auth requirement BEFORE executing
|
# Auth is checked before the function body ever runs.
|
||||||
auth_requirement = meta.get("auth")
|
auth_requirement = meta.get("auth")
|
||||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||||
if auth_error is not None:
|
if auth_error is not None:
|
||||||
return auth_error
|
return auth_error
|
||||||
|
|
||||||
# Instantiate the view with the request
|
|
||||||
view = view_class(request)
|
view = view_class(request)
|
||||||
|
|
||||||
# Check if this is a form function that handles input specially
|
|
||||||
meta = getattr(view_class, "_meta", {})
|
|
||||||
is_form_multipart = meta.get("multipart", False)
|
is_form_multipart = meta.get("multipart", False)
|
||||||
|
|
||||||
# For form functions with Input=None, skip Pydantic validation
|
|
||||||
# The form itself handles validation
|
|
||||||
input_cls = view.Input
|
input_cls = view.Input
|
||||||
if input_cls is None and is_form_multipart:
|
if input_cls is None and is_form_multipart:
|
||||||
# Form function - pass input_data directly (already parsed by view or will be)
|
# Form functions carry Input=None; the Django form owns validation.
|
||||||
validated_input = input_data
|
validated_input = input_data
|
||||||
elif input_cls is BaseModel:
|
elif input_cls is BaseModel:
|
||||||
has_input = False
|
has_input = False
|
||||||
validated_input = None
|
validated_input = None
|
||||||
else:
|
else:
|
||||||
# Check if it has any fields defined
|
|
||||||
has_input = bool(input_cls.model_fields) if input_cls else False
|
has_input = bool(input_cls.model_fields) if input_cls else False
|
||||||
|
|
||||||
# Validate input against Pydantic schema
|
|
||||||
try:
|
try:
|
||||||
if input_data:
|
if input_data:
|
||||||
# Ensure input_data is a dict (not array or other type)
|
|
||||||
if not isinstance(input_data, dict):
|
if not isinstance(input_data, dict):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.BAD_REQUEST,
|
code=ErrorCode.BAD_REQUEST,
|
||||||
@@ -284,11 +434,11 @@ def execute_function(
|
|||||||
)
|
)
|
||||||
validated_input = input_cls(**input_data)
|
validated_input = input_cls(**input_data)
|
||||||
elif has_input:
|
elif has_input:
|
||||||
# Check if function requires input fields
|
|
||||||
input_schema = input_cls.model_json_schema()
|
input_schema = input_cls.model_json_schema()
|
||||||
required_fields = input_schema.get("required", [])
|
required_fields = input_schema.get("required", [])
|
||||||
if required_fields:
|
if required_fields:
|
||||||
# Format as field errors for consistency
|
# Shaped like Pydantic's own field errors so the client
|
||||||
|
# has one error format to handle.
|
||||||
errors = {field: ["Field required"] for field in required_fields}
|
errors = {field: ["Field required"] for field in required_fields}
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.VALIDATION_ERROR,
|
code=ErrorCode.VALIDATION_ERROR,
|
||||||
@@ -297,10 +447,8 @@ def execute_function(
|
|||||||
)
|
)
|
||||||
validated_input = input_cls()
|
validated_input = input_cls()
|
||||||
else:
|
else:
|
||||||
# No input expected, create empty model
|
|
||||||
validated_input = None
|
validated_input = None
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
# Convert Pydantic errors to our format
|
|
||||||
errors = {}
|
errors = {}
|
||||||
for error in e.errors():
|
for error in e.errors():
|
||||||
field = ".".join(str(loc) for loc in error["loc"])
|
field = ".".join(str(loc) for loc in error["loc"])
|
||||||
@@ -314,7 +462,6 @@ def execute_function(
|
|||||||
details={"fields": errors},
|
details={"fields": errors},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute the function
|
|
||||||
try:
|
try:
|
||||||
output = view.call(validated_input)
|
output = view.call(validated_input)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
@@ -324,28 +471,24 @@ def execute_function(
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
# Functions can raise PermissionError for auth issues
|
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message=str(e) or "Permission denied",
|
message=str(e) or "Permission denied",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the full exception for debugging
|
|
||||||
logger.exception(f"Error executing function {fn_name}")
|
logger.exception(f"Error executing function {fn_name}")
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.INTERNAL_ERROR,
|
code=ErrorCode.INTERNAL_ERROR,
|
||||||
message="An internal error occurred",
|
message="An internal error occurred",
|
||||||
# Don't expose internal details in production
|
# Internals are only named when debug logging is already on.
|
||||||
details={"type": type(e).__name__}
|
details={"type": type(e).__name__}
|
||||||
if logger.isEnabledFor(logging.DEBUG)
|
if logger.isEnabledFor(logging.DEBUG)
|
||||||
else None,
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
|
||||||
from django.http import HttpResponseBase
|
from django.http import HttpResponseBase
|
||||||
|
|
||||||
if isinstance(output, HttpResponseBase):
|
if isinstance(output, HttpResponseBase):
|
||||||
# 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)
|
||||||
@@ -353,9 +496,8 @@ def execute_function(
|
|||||||
output["Cache-Control"] = "no-store"
|
output["Cache-Control"] = "no-store"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
# to_jsonable_python walks BaseModel / list / dict recursively, so nested
|
||||||
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
# shapes need no per-shape branch here.
|
||||||
# out wire-ready without a per-shape branch.
|
|
||||||
from pydantic_core import to_jsonable_python
|
from pydantic_core import to_jsonable_python
|
||||||
|
|
||||||
return FunctionResult(data=to_jsonable_python(output))
|
return FunctionResult(data=to_jsonable_python(output))
|
||||||
@@ -363,10 +505,9 @@ def execute_function(
|
|||||||
|
|
||||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempt to authenticate the request using MWT (Mizan Web Token).
|
Authenticate from the X-Mizan-Token header, setting request.user to an
|
||||||
|
MWTUser on success. False means no header, no configured secret, or a
|
||||||
Checks the X-Mizan-Token header. If present and valid, sets request.user
|
token that did not verify.
|
||||||
to an MWTUser. Returns True on success, False if no MWT header or invalid.
|
|
||||||
"""
|
"""
|
||||||
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
@@ -403,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool:
|
|||||||
|
|
||||||
def _try_jwt_auth(request: HttpRequest) -> bool:
|
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempt to authenticate the request using JWT.
|
Authenticate from an Authorization Bearer token, setting request.user to a
|
||||||
|
JWTUser built from the claims — no database query. False means no bearer
|
||||||
If Authorization header contains a valid Bearer token, authenticates
|
header or a token that did not verify; the caller must then reject rather
|
||||||
the request and sets request.user to a JWTUser. Returns True if JWT
|
than fall back to session auth.
|
||||||
auth succeeded.
|
|
||||||
|
|
||||||
IMPORTANT: This is stateless - no database query is made. The JWTUser
|
|
||||||
object is created from the token claims. If you need the full User
|
|
||||||
object, query it explicitly in your function.
|
|
||||||
|
|
||||||
Security: If JWT is provided but invalid, we return False and do NOT
|
|
||||||
fall back to session auth. The caller should reject the request.
|
|
||||||
"""
|
"""
|
||||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
if not auth_header.startswith("Bearer "):
|
if not auth_header.startswith("Bearer "):
|
||||||
@@ -432,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
|||||||
if payload is None:
|
if payload is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
request.user = JWTUser(payload)
|
request.user = JWTUser(payload)
|
||||||
request._mizan_jwt_authenticated = True
|
request._mizan_jwt_authenticated = True
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logging.getLogger("mizan.jwt").warning(
|
||||||
|
"JWT authentication failed unexpectedly", exc_info=True
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -448,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
|||||||
|
|
||||||
def _csrf_protect_unless_token(view_func):
|
def _csrf_protect_unless_token(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator that applies CSRF protection unless token auth is used.
|
Wrap a view so CSRF applies only on the session path. MWT is checked
|
||||||
|
first, then JWT; both are self-authenticating. A token that is present but
|
||||||
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
invalid rejects the request outright rather than falling back to session
|
||||||
Both are self-authenticating, so CSRF protection is not needed.
|
auth.
|
||||||
|
|
||||||
Security: If a token is provided but invalid, reject the request - do NOT
|
|
||||||
fall back to session auth.
|
|
||||||
"""
|
"""
|
||||||
csrf_protected_view = csrf_protect(view_func)
|
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):
|
||||||
# MWT takes priority
|
|
||||||
if _has_mwt_header(request):
|
if _has_mwt_header(request):
|
||||||
if _try_mwt_auth(request):
|
if _try_mwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -469,7 +600,6 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
message="Invalid or expired MWT",
|
message="Invalid or expired MWT",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# Legacy JWT fallback
|
|
||||||
if _has_jwt_header(request):
|
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)
|
||||||
@@ -478,7 +608,6 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
message="Invalid or expired JWT token",
|
message="Invalid or expired JWT token",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# No token — session auth with CSRF
|
|
||||||
return csrf_protected_view(request, *args, **kwargs)
|
return csrf_protected_view(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -487,53 +616,25 @@ def _csrf_protect_unless_token(view_func):
|
|||||||
@_csrf_protect_unless_token
|
@_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).
|
POST endpoint for server-function calls.
|
||||||
|
|
||||||
Authentication (auto-detected):
|
A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries
|
||||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
`fn` as a form field alongside the form's own fields, and its parsed data
|
||||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
and files are attached to the request for the form function to pick up.
|
||||||
|
|
||||||
Endpoint: POST /api/mizan/call/
|
Success answers `{"result": ...}`, plus `invalidate` / `merge` when the
|
||||||
|
function declared them; failure answers the FunctionError shape.
|
||||||
Request body (JSON):
|
|
||||||
{
|
|
||||||
"fn": "function_name", // Function name
|
|
||||||
"args": { ... } // Optional, depending on function
|
|
||||||
}
|
|
||||||
|
|
||||||
Request body (multipart/form-data for form submit functions):
|
|
||||||
fn: function_name
|
|
||||||
<field>: <value>
|
|
||||||
...
|
|
||||||
|
|
||||||
Response on success:
|
|
||||||
{
|
|
||||||
"error": false,
|
|
||||||
"data": { ... } // Function output
|
|
||||||
}
|
|
||||||
|
|
||||||
Response on error:
|
|
||||||
{
|
|
||||||
"error": true,
|
|
||||||
"code": "VALIDATION_ERROR",
|
|
||||||
"message": "Input validation failed",
|
|
||||||
"details": { ... }
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
# Only allow POST
|
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.BAD_REQUEST,
|
code=ErrorCode.BAD_REQUEST,
|
||||||
message="Only POST method allowed",
|
message="Only POST method allowed",
|
||||||
).to_response(status=405)
|
).to_response(status=405)
|
||||||
|
|
||||||
# Check content type to determine parsing method
|
|
||||||
content_type = request.content_type or ""
|
content_type = request.content_type or ""
|
||||||
is_multipart = content_type.startswith("multipart/form-data")
|
is_multipart = content_type.startswith("multipart/form-data")
|
||||||
|
|
||||||
if is_multipart:
|
if is_multipart:
|
||||||
# Multipart carries two shapes: a form submission (Django Form path) or
|
|
||||||
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
|
|
||||||
fn_name = request.POST.get("fn")
|
fn_name = request.POST.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -541,43 +642,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Missing 'fn' field",
|
message="Missing 'fn' field",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
fn_class = get_function(fn_name)
|
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||||
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
|
|
||||||
|
|
||||||
if is_form_fn:
|
request._mizan_form_data = input_data
|
||||||
# Form submit — POST fields + FILES handed to Django Form validation.
|
request._mizan_form_files = request.FILES
|
||||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
|
||||||
request._mizan_form_data = input_data
|
|
||||||
request._mizan_form_files = request.FILES
|
|
||||||
else:
|
|
||||||
# Upload RPC — the `args` JSON part carries the non-file fields; the
|
|
||||||
# file parts bind into the Input's Upload fields (constraints enforced).
|
|
||||||
raw_args = request.POST.get("args")
|
|
||||||
try:
|
|
||||||
input_data = json.loads(raw_args) if raw_args else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return FunctionError(
|
|
||||||
code=ErrorCode.BAD_REQUEST,
|
|
||||||
message="Invalid JSON in 'args' field",
|
|
||||||
).to_response()
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
files = {
|
|
||||||
field: [
|
|
||||||
UploadedFile(f.name, f.content_type, f.read())
|
|
||||||
for f in request.FILES.getlist(field)
|
|
||||||
]
|
|
||||||
for field in request.FILES
|
|
||||||
}
|
|
||||||
err = bind_uploads(input_cls, input_data, files)
|
|
||||||
if err is not None:
|
|
||||||
return FunctionError(
|
|
||||||
code=ErrorCode.BAD_REQUEST,
|
|
||||||
message=err,
|
|
||||||
).to_response()
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON body - standard RPC
|
|
||||||
try:
|
try:
|
||||||
if request.body:
|
if request.body:
|
||||||
body = json.loads(request.body)
|
body = json.loads(request.body)
|
||||||
@@ -592,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Invalid JSON in request body",
|
message="Invalid JSON in request body",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
# Extract function name and args
|
|
||||||
fn_name = body.get("fn")
|
fn_name = body.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -602,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
input_data = body.get("args")
|
input_data = body.get("args")
|
||||||
|
|
||||||
# Execute the function
|
|
||||||
result = execute_function(request, fn_name, input_data)
|
result = execute_function(request, fn_name, input_data)
|
||||||
|
|
||||||
# View path — function returned an HttpResponse directly
|
# The function returned an HttpResponse directly.
|
||||||
from django.http import HttpResponseBase
|
from django.http import HttpResponseBase
|
||||||
if isinstance(result, HttpResponseBase):
|
if isinstance(result, HttpResponseBase):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Return appropriate response
|
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
status = {
|
status = {
|
||||||
ErrorCode.NOT_FOUND: 404,
|
ErrorCode.NOT_FOUND: 404,
|
||||||
@@ -623,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
}.get(result.code, 400)
|
}.get(result.code, 400)
|
||||||
return result.to_response(status=status)
|
return result.to_response(status=status)
|
||||||
|
|
||||||
# RPC path — build response with server-driven invalidation
|
|
||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
response_data = {"result": result.data}
|
response_data = {"result": result.data}
|
||||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||||
@@ -650,18 +716,8 @@ def execute_context(
|
|||||||
params: dict[str, str],
|
params: dict[str, str],
|
||||||
) -> FunctionResult | FunctionError:
|
) -> FunctionResult | FunctionError:
|
||||||
"""
|
"""
|
||||||
Execute all functions in a named context with merged params.
|
Run every function in a named context, handing each only the params it
|
||||||
|
declares in its Input schema. The first failure aborts the whole bundle.
|
||||||
Each function receives only the params it declares in its Input schema.
|
|
||||||
If any function fails (auth, validation, execution), the entire request fails.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The Django HttpRequest
|
|
||||||
context_name: Name of the context (e.g., 'user', 'global')
|
|
||||||
params: Query parameters (strings — Pydantic coerces types)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FunctionResult with bundled data, or FunctionError
|
|
||||||
"""
|
"""
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
fn_names = groups.get(context_name)
|
fn_names = groups.get(context_name)
|
||||||
@@ -677,7 +733,6 @@ def execute_context(
|
|||||||
if view_class is None:
|
if view_class is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter params to only those in this function's Input schema
|
|
||||||
input_cls = getattr(view_class, "Input", None)
|
input_cls = getattr(view_class, "Input", None)
|
||||||
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||||
fn_params = {
|
fn_params = {
|
||||||
@@ -696,13 +751,9 @@ def execute_context(
|
|||||||
|
|
||||||
|
|
||||||
def _jwt_auth_only(view_func):
|
def _jwt_auth_only(view_func):
|
||||||
"""
|
"""Token auth for GET views: MWT first, then JWT. GET needs no CSRF."""
|
||||||
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):
|
||||||
# MWT takes priority
|
|
||||||
if _has_mwt_header(request):
|
if _has_mwt_header(request):
|
||||||
if _try_mwt_auth(request):
|
if _try_mwt_auth(request):
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
@@ -711,7 +762,6 @@ def _jwt_auth_only(view_func):
|
|||||||
message="Invalid or expired MWT",
|
message="Invalid or expired MWT",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# Legacy JWT fallback
|
|
||||||
if _has_jwt_header(request):
|
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)
|
||||||
@@ -720,7 +770,6 @@ def _jwt_auth_only(view_func):
|
|||||||
message="Invalid or expired JWT token",
|
message="Invalid or expired JWT token",
|
||||||
).to_response(status=401)
|
).to_response(status=401)
|
||||||
|
|
||||||
# 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
|
||||||
@@ -729,18 +778,12 @@ def _jwt_auth_only(view_func):
|
|||||||
@_jwt_auth_only
|
@_jwt_auth_only
|
||||||
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||||
"""
|
"""
|
||||||
Fetch all functions in a named context in a single bundled GET request.
|
GET endpoint answering every function in a named context as one bundle
|
||||||
|
keyed by function name, with query params fanned out to each.
|
||||||
|
|
||||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
The context's effective cache policy and revision are the strictest across
|
||||||
|
its functions: any function declaring cache=False disables caching for the
|
||||||
Response: raw bundled data, CDN-cacheable.
|
whole bundle, and the shortest declared TTL wins.
|
||||||
{
|
|
||||||
"user_profile": { ... },
|
|
||||||
"user_orders": [ ... ]
|
|
||||||
}
|
|
||||||
|
|
||||||
Headers:
|
|
||||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
|
||||||
"""
|
"""
|
||||||
if request.method != "GET":
|
if request.method != "GET":
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -750,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
|
|
||||||
params = request.GET.dict()
|
params = request.GET.dict()
|
||||||
|
|
||||||
# Resolve effective rev and cache policy across all functions in this context
|
|
||||||
_cache_log = logging.getLogger("mizan.cache")
|
_cache_log = logging.getLogger("mizan.cache")
|
||||||
groups = get_context_groups()
|
groups = get_context_groups()
|
||||||
fn_names = groups.get(context_name, [])
|
fn_names = groups.get(context_name, [])
|
||||||
@@ -772,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
else:
|
else:
|
||||||
effective_cache = min(effective_cache, fn_cache)
|
effective_cache = min(effective_cache, fn_cache)
|
||||||
|
|
||||||
# Origin-side cache lookup (skip if cache=False)
|
|
||||||
cache_backend = get_cache()
|
cache_backend = get_cache()
|
||||||
cache_settings = get_settings()
|
cache_settings = get_settings()
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -813,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
error_response["Cache-Control"] = "no-store"
|
error_response["Cache-Control"] = "no-store"
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
# Sorted keys keep the serialized body byte-identical for a given result,
|
||||||
|
# which is what makes it usable as a cache entry.
|
||||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||||
|
|
||||||
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
# Caching happens in the origin cache below and at the edge, both of which
|
||||||
# The browser and non-Mizan intermediaries must not cache.
|
# can be purged; a browser cache cannot, so it must not hold this.
|
||||||
response["Cache-Control"] = "no-store"
|
response["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
# Store in origin-side cache (skip if cache=False)
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
try:
|
try:
|
||||||
cache_put(
|
cache_put(
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
mizan.client.jwt - JWT authentication for server functions.
|
Token and settings names from `mizan.jwt`, re-exported under `mizan.client`
|
||||||
|
for the executor and the WebSocket consumer. The Ninja auth class is
|
||||||
Provides:
|
deliberately absent here — reach for `mizan.jwt.security` for that.
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
|
||||||
- JWT authentication utilities for validating tokens
|
|
||||||
|
|
||||||
Server Functions:
|
|
||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
|
||||||
|
|
||||||
Note: This module is purpose-built for mizan server functions.
|
|
||||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Token utilities (re-exports from django_jwt_session)
|
|
||||||
from mizan.jwt.tokens import (
|
from mizan.jwt.tokens import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
@@ -25,7 +15,6 @@ from mizan.jwt.tokens import (
|
|||||||
JWTUser,
|
JWTUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
|
||||||
from mizan.jwt.settings import get_settings, JWTSettings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -1,22 +1,146 @@
|
|||||||
"""
|
"""
|
||||||
Mizan Edge Manifest Generator (Django adapter surface).
|
Builds the Edge manifest: a static JSON document mapping each context to its
|
||||||
|
API endpoint, page routes, and parameter names.
|
||||||
The manifest derivation is AFI-common and lives in `mizan_core.manifest`;
|
|
||||||
Django exposes it through `python manage.py export_edge_manifest` and this
|
|
||||||
re-export. The manifest maps contexts to URL patterns and params, consumed by
|
|
||||||
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
|
|
||||||
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_registry
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"generate_edge_manifest",
|
"generate_edge_manifest",
|
||||||
"generate_edge_manifest_json",
|
"generate_edge_manifest_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build the manifest dict.
|
||||||
|
|
||||||
|
Each context entry carries the union of its functions' Input field names,
|
||||||
|
its API endpoint under `base_url`, any page routes declared via
|
||||||
|
`@client(route=...)`, and a render strategy derived from whether any
|
||||||
|
parameter is user-scoped. Each mutation entry carries the contexts it
|
||||||
|
affects and the parameter names shared with those contexts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
|
view_urls: Extra page routes per context name, merged with the ones
|
||||||
|
read off `@client(route=...)`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
groups = get_context_groups()
|
||||||
|
registry = get_registry()
|
||||||
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in sorted(groups.items()):
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
page_routes: list[str] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
for param_name in input_cls.model_fields:
|
||||||
|
param_names.add(param_name)
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
route = meta.get("route")
|
||||||
|
view_path = meta.get("view_path")
|
||||||
|
|
||||||
|
fn_entry: dict[str, Any] = {
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if view_path else "rpc",
|
||||||
|
}
|
||||||
|
if route:
|
||||||
|
fn_entry["route"] = route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(route)
|
||||||
|
if meta.get("rev"):
|
||||||
|
fn_entry["rev"] = meta["rev"]
|
||||||
|
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||||
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
sorted_params = sorted(param_names)
|
||||||
|
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted_params,
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
|
}
|
||||||
|
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
if not meta.get("affects"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||||
|
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||||
|
|
||||||
|
# Auto-scoped params — function params that match context params
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
auto_scoped: list[str] = []
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_param_names: set[str] = set()
|
||||||
|
ctx_fns = groups.get(ctx_name, [])
|
||||||
|
for ctx_fn_name in ctx_fns:
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls is None:
|
||||||
|
continue
|
||||||
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
|
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||||
|
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||||
|
for p in fn_params:
|
||||||
|
if p in ctx_param_names and p not in auto_scoped:
|
||||||
|
auto_scoped.append(p)
|
||||||
|
if auto_scoped:
|
||||||
|
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||||
|
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation["route"] = meta["route"]
|
||||||
|
mutation["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
indent: int = 2,
|
||||||
|
) -> str:
|
||||||
|
"""JSON-serialize the Edge manifest."""
|
||||||
|
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||||
|
|||||||
@@ -1,153 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
mizanFormMixin - Turn Django Forms into server functions.
|
mizanFormMixin exposes a Django Form as the server functions
|
||||||
|
`<name>.schema`, `<name>.validate`, and `<name>.submit`, registered from
|
||||||
This mixin transforms any Django Form into mizan server functions,
|
`__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute.
|
||||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
|
||||||
while exposing them through the unified server function API.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
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 server functions:
|
|
||||||
- contact.schema
|
|
||||||
- contact.validate
|
|
||||||
- contact.submit
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
import inspect
|
||||||
|
import logging
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel, create_model
|
from pydantic import BaseModel, create_model
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
logger = logging.getLogger("mizan.forms")
|
||||||
from .schemas import FormValidation
|
|
||||||
|
|
||||||
|
|
||||||
def _django_field_to_python_type(field: forms.Field) -> type:
|
|
||||||
"""
|
|
||||||
Map a Django form field to a Python type for Pydantic schema generation.
|
|
||||||
|
|
||||||
This provides TypeScript with proper field types instead of generic `any`.
|
|
||||||
"""
|
|
||||||
# Handle common Django field types
|
|
||||||
if isinstance(field, forms.BooleanField):
|
|
||||||
return bool
|
|
||||||
elif isinstance(field, forms.IntegerField):
|
|
||||||
return int
|
|
||||||
elif isinstance(field, forms.FloatField):
|
|
||||||
return float
|
|
||||||
elif isinstance(field, forms.DecimalField):
|
|
||||||
return str # Decimals serialize as strings for precision
|
|
||||||
elif isinstance(field, forms.DateTimeField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.DateField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.TimeField):
|
|
||||||
return str # ISO format string
|
|
||||||
elif isinstance(field, forms.JSONField):
|
|
||||||
return dict | list | str | int | float | bool | None
|
|
||||||
elif isinstance(field, forms.MultipleChoiceField):
|
|
||||||
return list[str]
|
|
||||||
elif isinstance(field, forms.FileField):
|
|
||||||
return str # File path/name as string
|
|
||||||
elif isinstance(field, forms.ImageField):
|
|
||||||
return str # File path/name as string
|
|
||||||
else:
|
|
||||||
# Default to string (covers CharField, EmailField, URLField, etc.)
|
|
||||||
return str
|
|
||||||
|
|
||||||
|
|
||||||
def _create_form_input_schema(
|
|
||||||
form_class: type[forms.BaseForm],
|
|
||||||
schema_name: str,
|
|
||||||
) -> type[BaseModel]:
|
|
||||||
"""
|
|
||||||
Create a Pydantic model from Django Form fields.
|
|
||||||
|
|
||||||
This generates a typed schema for the form's input data, giving TypeScript
|
|
||||||
full LSP support (autocomplete, type checking) for form fields.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
form_class: Django Form class to introspect
|
|
||||||
schema_name: Name for the generated Pydantic model (e.g., "ContactFormData")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A Pydantic BaseModel subclass with fields matching the form
|
|
||||||
"""
|
|
||||||
# Instantiate form without data to get field definitions
|
|
||||||
try:
|
|
||||||
form = form_class()
|
|
||||||
except TypeError:
|
|
||||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
|
||||||
fields_dict = getattr(form_class, "base_fields", {})
|
|
||||||
else:
|
|
||||||
fields_dict = form.fields
|
|
||||||
|
|
||||||
# Build Pydantic field definitions
|
|
||||||
pydantic_fields: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for field_name, field in fields_dict.items():
|
|
||||||
python_type = _django_field_to_python_type(field)
|
|
||||||
|
|
||||||
# Optional fields (not required or has initial value)
|
|
||||||
if not field.required:
|
|
||||||
python_type = python_type | None
|
|
||||||
default = None
|
|
||||||
elif field.initial is not None:
|
|
||||||
default = field.initial
|
|
||||||
else:
|
|
||||||
default = ... # Required field
|
|
||||||
|
|
||||||
pydantic_fields[field_name] = (python_type, default)
|
|
||||||
|
|
||||||
# Create the model with a unique name
|
|
||||||
model = create_model(schema_name, **pydantic_fields)
|
|
||||||
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
class mizanFormMeta(BaseModel):
|
class mizanFormMeta(BaseModel):
|
||||||
"""
|
"""
|
||||||
Configuration for a mizan form.
|
Per-form configuration. `name` is the API identifier the three registered
|
||||||
|
function names are built from; the rest are carried into the emitted schema.
|
||||||
This Pydantic model provides type-safe configuration with full LSP support,
|
|
||||||
and serializes to JSON for the frontend schema.
|
|
||||||
|
|
||||||
Required:
|
|
||||||
name: API identifier (e.g., "contact" → contact.schema, contact.validate, contact.submit)
|
|
||||||
|
|
||||||
Display options:
|
|
||||||
title: Display title (default: derived from class name)
|
|
||||||
subtitle: Display subtitle
|
|
||||||
submit_label: Submit button text (default: "Submit")
|
|
||||||
|
|
||||||
Frontend behavior:
|
|
||||||
live_validation: Enable live validation as user types (default: True)
|
|
||||||
live_form_errors: Show form-level errors during live validation (default: False)
|
|
||||||
refetch_schema_on_validate: Refetch schema on each validation - useful for
|
|
||||||
dynamic choice fields (default: False)
|
|
||||||
|
|
||||||
Features:
|
|
||||||
enable_formset: Generate formset endpoints (default: False)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Required
|
# Required
|
||||||
@@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel):
|
|||||||
|
|
||||||
class mizanFormMixin:
|
class mizanFormMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that exposes a Django Form as mizan server functions.
|
Mix into a Django Form alongside a `mizan = mizanFormMeta(...)` attribute to
|
||||||
|
register `<name>.schema`, `<name>.validate`, and `<name>.submit`.
|
||||||
|
|
||||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
`get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three
|
||||||
|
override points. Each is called unconditionally, so the definitions here are
|
||||||
class ContactForm(mizanFormMixin, forms.Form):
|
what a form that overrides none of them does.
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="contact",
|
|
||||||
title="Contact Us",
|
|
||||||
)
|
|
||||||
|
|
||||||
name = forms.CharField()
|
|
||||||
email = forms.EmailField()
|
|
||||||
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
return {"sent": True}
|
|
||||||
|
|
||||||
This auto-registers:
|
|
||||||
- contact.schema - Get form field definitions
|
|
||||||
- contact.validate - Validate form data
|
|
||||||
- contact.submit - Submit form
|
|
||||||
|
|
||||||
Overridable methods:
|
|
||||||
get_init_kwargs(cls, request) -> dict: Extra kwargs for form instantiation
|
|
||||||
on_submit_success(self, request) -> dict | None: Handle successful submission
|
|
||||||
on_submit_failure(self, request, errors) -> None: Handle failed submission
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Configuration - subclasses must define this
|
# Configuration - subclasses must define this
|
||||||
mizan: ClassVar[mizanFormMeta]
|
mizan: ClassVar[mizanFormMeta]
|
||||||
|
|
||||||
# Track registered forms to avoid duplicate registration
|
# Set on registration so a re-import does not register the class twice
|
||||||
_mizan_registered: ClassVar[bool] = False
|
_mizan_registered: ClassVar[bool] = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Override to provide extra kwargs for form instantiation.
|
Kwargs merged into every instantiation of this form. `request` is passed
|
||||||
|
through to a form whose `__init__` names it; Django's own `BaseForm`
|
||||||
Common use: pass request or user to forms that need them.
|
signature does not, and rejects any keyword it did not declare, so a
|
||||||
|
form that never asks for the request is constructed on data/files alone.
|
||||||
Example:
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request):
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
"""
|
"""
|
||||||
|
accepted = inspect.signature(cls.__init__).parameters
|
||||||
|
if "request" in accepted:
|
||||||
|
return {"request": request}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Called after successful form validation and submission.
|
Handle a validated submission. A returned dict is carried in the
|
||||||
|
response payload; a ModelForm's `save()` returns a model instance, which
|
||||||
Override to handle the form submission logic.
|
is not payload, so only a dict result is forwarded.
|
||||||
Return a dict to include data in the response.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
self.save()
|
|
||||||
return {"id": self.instance.pk}
|
|
||||||
"""
|
"""
|
||||||
# Default: call save() if available
|
|
||||||
if hasattr(self, "save"):
|
if hasattr(self, "save"):
|
||||||
result = self.save()
|
result = self.save()
|
||||||
# If save returns something serializable, include it
|
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
return result
|
return result
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
def on_submit_failure(self, request: HttpRequest, errors: Any) -> None:
|
||||||
"""
|
"""
|
||||||
Called after form validation fails.
|
Handle a rejected submission. The per-field errors already travel to the
|
||||||
|
client in the response body, so the rejection is recorded server-side
|
||||||
Override to add custom error handling, logging, etc.
|
rather than re-raised.
|
||||||
"""
|
"""
|
||||||
pass
|
logger.info(
|
||||||
|
"%s rejected a submission on %s: %s",
|
||||||
|
type(self).__name__,
|
||||||
|
getattr(request, "path", "<no path>"),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
def __init_subclass__(cls, **kwargs):
|
||||||
"""Auto-register when a concrete form class is defined."""
|
"""Auto-register when a concrete form class is defined."""
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
# Only register concrete forms with mizan config defined
|
|
||||||
if _is_concrete_mizan_form(cls):
|
if _is_concrete_mizan_form(cls):
|
||||||
_register_form_as_server_functions(cls)
|
_register_form_as_server_functions(cls)
|
||||||
|
|
||||||
|
|
||||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a class is a concrete mizan form ready for registration.
|
True when `cls` carries its own mizanFormMeta, is a Django form, and has
|
||||||
|
not already been registered.
|
||||||
A form is concrete if:
|
|
||||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
|
||||||
2. It inherits from Django's BaseForm
|
|
||||||
3. It hasn't been registered yet (for this class definition)
|
|
||||||
"""
|
"""
|
||||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
# Read cls.__dict__ so an inherited config does not re-register.
|
||||||
mizan_config = cls.__dict__.get("mizan")
|
mizan_config = cls.__dict__.get("mizan")
|
||||||
if not isinstance(mizan_config, mizanFormMeta):
|
if not isinstance(mizan_config, mizanFormMeta):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Must be a Django form
|
|
||||||
if not issubclass(cls, forms.BaseForm):
|
if not issubclass(cls, forms.BaseForm):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if already registered (handle re-imports gracefully)
|
|
||||||
if cls.__dict__.get("_mizan_registered", False):
|
if cls.__dict__.get("_mizan_registered", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -280,50 +123,36 @@ def _is_concrete_mizan_form(cls: type) -> bool:
|
|||||||
|
|
||||||
def _register_form_as_server_functions(form_class: type) -> None:
|
def _register_form_as_server_functions(form_class: type) -> None:
|
||||||
"""
|
"""
|
||||||
Register a Django Form class as mizan server functions.
|
Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for
|
||||||
|
`form_class`, plus the formset trio when `enable_formset` is set.
|
||||||
Creates and registers:
|
|
||||||
- {name}.schema - Returns form field definitions
|
|
||||||
- {name}.validate - Validates form data
|
|
||||||
- {name}.submit - Validates and submits form
|
|
||||||
|
|
||||||
Each function gets a unique typed schema for better TypeScript LSP support.
|
|
||||||
"""
|
"""
|
||||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
from mizan.forms.schemas import (
|
||||||
from .schema_utils import build_form_schema
|
FormSchema,
|
||||||
from .validation_utils import validate_form_instance
|
FormSubmitFail,
|
||||||
|
FormSubmitPass,
|
||||||
|
FormValidation,
|
||||||
|
)
|
||||||
|
from mizan.forms.schema_utils import build_form_schema
|
||||||
|
from mizan.forms.validation_utils import validate_form_instance
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan_core.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
|
||||||
|
|
||||||
# Mark as registered
|
|
||||||
form_class._mizan_registered = True
|
form_class._mizan_registered = True
|
||||||
|
|
||||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
# "contact" -> "Contact", "reset_password" -> "ResetPassword"
|
||||||
pascal_name = "".join(
|
pascal_name = "".join(
|
||||||
word.capitalize()
|
word.capitalize()
|
||||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
|
||||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
|
||||||
_form_data_schema_cache: dict[str, type[BaseModel]] = {}
|
|
||||||
|
|
||||||
def get_form_data_schema() -> type[BaseModel]:
|
|
||||||
"""Lazily create the form data schema (form fields aren't available at registration time)."""
|
|
||||||
if "schema" not in _form_data_schema_cache:
|
|
||||||
_form_data_schema_cache["schema"] = _create_form_input_schema(
|
|
||||||
form_class, f"{pascal_name}FormData"
|
|
||||||
)
|
|
||||||
return _form_data_schema_cache["schema"]
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Schema Function
|
# Schema Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Schema input wraps the form data for pre-populating dynamic fields
|
# `data` pre-populates dynamic fields before the schema is read off the form.
|
||||||
FormSchemaInput = create_model(
|
FormSchemaInput = create_model(
|
||||||
f"{pascal_name}SchemaInput",
|
f"{pascal_name}SchemaInput",
|
||||||
data=(dict[str, Any], {}),
|
data=(dict[str, Any], {}),
|
||||||
@@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
"form": True,
|
"form": True,
|
||||||
"form_name": form_name,
|
"form_name": form_name,
|
||||||
"form_role": "schema",
|
"form_role": "schema",
|
||||||
"form_class": form_class, # Store reference for schema generation
|
"form_class": form_class,
|
||||||
}
|
}
|
||||||
|
|
||||||
def call(self, input) -> FormSchema:
|
def call(self, input) -> FormSchema:
|
||||||
@@ -347,13 +176,12 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
data=input.data if input else {},
|
data=input.data if input else {},
|
||||||
**init_kwargs,
|
**init_kwargs,
|
||||||
)
|
)
|
||||||
# Override with mizanFormMeta values
|
# mizanFormMeta wins over anything derived from the form class.
|
||||||
if config.title is not None:
|
if config.title is not None:
|
||||||
schema.title = config.title
|
schema.title = config.title
|
||||||
if config.subtitle is not None:
|
if config.subtitle is not None:
|
||||||
schema.subtitle = config.subtitle
|
schema.subtitle = config.subtitle
|
||||||
schema.submit_label = config.submit_label
|
schema.submit_label = config.submit_label
|
||||||
# Behavior settings are nested in schema.meta
|
|
||||||
schema.meta.live_validation = config.live_validation
|
schema.meta.live_validation = config.live_validation
|
||||||
schema.meta.live_form_errors = config.live_form_errors
|
schema.meta.live_form_errors = config.live_form_errors
|
||||||
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
||||||
@@ -367,7 +195,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
# Validate Function
|
# Validate Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Use generic dict input - form fields aren't available during __init_subclass__
|
# Generic dict input — form fields are unavailable during __init_subclass__.
|
||||||
FormValidateInput = create_model(
|
FormValidateInput = create_model(
|
||||||
f"{pascal_name}ValidateInput",
|
f"{pascal_name}ValidateInput",
|
||||||
data=(dict[str, Any], ...),
|
data=(dict[str, Any], ...),
|
||||||
@@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
|
|
||||||
def call(self, input) -> FormValidation:
|
def call(self, input) -> FormValidation:
|
||||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||||
# Input data is already a dict
|
|
||||||
data = input.data
|
data = input.data
|
||||||
_, validation = validate_form_instance(
|
_, validation = validate_form_instance(
|
||||||
form_class,
|
form_class,
|
||||||
@@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
class SubmitFunction(ServerFunction):
|
class SubmitFunction(ServerFunction):
|
||||||
"""
|
# Input=None signals the executor to pass the raw dict through, since
|
||||||
Submit function handles both JSON and multipart/form-data.
|
# the Django form owns validation.
|
||||||
|
Input = None
|
||||||
The executor detects form functions and parses the request appropriately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Use dict for input - form fields unknown at registration time
|
|
||||||
Input = None # Signals executor to pass raw dict
|
|
||||||
|
|
||||||
_meta: ClassVar[dict] = {
|
_meta: ClassVar[dict] = {
|
||||||
"form": True,
|
"form": True,
|
||||||
"form_name": form_name,
|
"form_name": form_name,
|
||||||
"form_role": "submit",
|
"form_role": "submit",
|
||||||
"multipart": True, # Signal that this function accepts multipart
|
"multipart": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||||
"""Execute form submission."""
|
|
||||||
request = self.request
|
request = self.request
|
||||||
|
|
||||||
# Check if we have multipart data from executor
|
# Multipart bodies are parsed onto the request before dispatch.
|
||||||
if hasattr(request, "_mizan_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
data = request._mizan_form_data
|
data = request._mizan_form_data
|
||||||
files = request._mizan_form_files
|
files = request._mizan_form_files
|
||||||
elif input is not None:
|
elif input is not None:
|
||||||
# JSON input - already a dict
|
|
||||||
data = input if isinstance(input, dict) else input.model_dump()
|
data = input if isinstance(input, dict) else input.model_dump()
|
||||||
files = None
|
files = None
|
||||||
else:
|
else:
|
||||||
@@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
|
|
||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Create and validate form
|
|
||||||
form, validation = validate_form_instance(
|
form, validation = validate_form_instance(
|
||||||
form_class,
|
form_class,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Call the form's on_submit_success
|
|
||||||
result_data = form.on_submit_success(request)
|
result_data = form.on_submit_success(request)
|
||||||
return FormSubmitPass(success=True, data=result_data)
|
return FormSubmitPass(success=True, data=result_data)
|
||||||
|
|
||||||
# Call the form's on_submit_failure
|
|
||||||
form.on_submit_failure(request, validation)
|
form.on_submit_failure(request, validation)
|
||||||
return FormSubmitFail(success=False, errors=validation)
|
return FormSubmitFail(success=False, errors=validation)
|
||||||
|
|
||||||
@@ -472,36 +289,34 @@ def _register_formset_functions(
|
|||||||
form_class: type,
|
form_class: type,
|
||||||
form_name: str,
|
form_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register formset server functions for a form."""
|
"""Register the `{name}.formset.*` server functions for a form."""
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
|
||||||
from .schemas import (
|
from mizan.forms.schemas import (
|
||||||
FormsetSchema,
|
FormsetSchema,
|
||||||
FormsetSubmitFail,
|
FormsetSubmitFail,
|
||||||
FormsetSubmitPass,
|
FormsetSubmitPass,
|
||||||
FormsetValidation,
|
FormsetValidation,
|
||||||
)
|
)
|
||||||
from .schema_utils import build_form_schema
|
from mizan.forms.schema_utils import build_form_schema
|
||||||
from .validation_utils import build_formset_validation
|
from mizan.forms.validation_utils import build_formset_validation
|
||||||
from .formset_utils import forms_to_formset_post_data
|
from mizan.forms.formset_utils import forms_to_formset_post_data
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
formset_class = formset_factory(form_class)
|
formset_class = formset_factory(form_class)
|
||||||
|
|
||||||
# Generate PascalCase name for schemas
|
|
||||||
pascal_name = "".join(
|
pascal_name = "".join(
|
||||||
word.capitalize()
|
word.capitalize()
|
||||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
|
||||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Formset Schema Function
|
# Formset Schema Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Generic dict inputs throughout — form fields are unavailable during
|
||||||
|
# __init_subclass__, so no typed schema can be built here.
|
||||||
FormsetSchemaInput = create_model(
|
FormsetSchemaInput = create_model(
|
||||||
f"{pascal_name}FormsetSchemaInput",
|
f"{pascal_name}FormsetSchemaInput",
|
||||||
forms=(list[dict[str, Any]], []),
|
forms=(list[dict[str, Any]], []),
|
||||||
@@ -542,7 +357,6 @@ def _register_formset_functions(
|
|||||||
# Formset Validate Function
|
# Formset Validate Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Generic dict input - form fields aren't available during __init_subclass__
|
|
||||||
FormsetValidateInput = create_model(
|
FormsetValidateInput = create_model(
|
||||||
f"{pascal_name}FormsetValidateInput",
|
f"{pascal_name}FormsetValidateInput",
|
||||||
forms=(list[dict[str, Any]], ...),
|
forms=(list[dict[str, Any]], ...),
|
||||||
@@ -560,12 +374,12 @@ def _register_formset_functions(
|
|||||||
|
|
||||||
def call(self, input) -> FormsetValidation:
|
def call(self, input) -> FormsetValidation:
|
||||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||||
# Input.forms is already a list of dicts
|
|
||||||
forms_data = input.forms
|
forms_data = input.forms
|
||||||
|
|
||||||
formset_data = forms_to_formset_post_data(forms_data)
|
formset_data = forms_to_formset_post_data(forms_data)
|
||||||
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
||||||
|
|
||||||
|
# Every submitted row must validate; blank rows are not excused.
|
||||||
for form in formset:
|
for form in formset:
|
||||||
form.empty_permitted = False
|
form.empty_permitted = False
|
||||||
|
|
||||||
@@ -578,7 +392,6 @@ def _register_formset_functions(
|
|||||||
# Formset Submit Function
|
# Formset Submit Function
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
# Generic dict input - form fields aren't available during __init_subclass__
|
|
||||||
FormsetSubmitInput = create_model(
|
FormsetSubmitInput = create_model(
|
||||||
f"{pascal_name}FormsetSubmitInput",
|
f"{pascal_name}FormsetSubmitInput",
|
||||||
forms=(list[dict[str, Any]], ...),
|
forms=(list[dict[str, Any]], ...),
|
||||||
@@ -598,12 +411,10 @@ def _register_formset_functions(
|
|||||||
request = self.request
|
request = self.request
|
||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Handle multipart vs JSON
|
|
||||||
if hasattr(request, "_mizan_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
post_data = request._mizan_form_data
|
post_data = request._mizan_form_data
|
||||||
files = request._mizan_form_files
|
files = request._mizan_form_files
|
||||||
elif input and hasattr(input, "forms"):
|
elif input and hasattr(input, "forms"):
|
||||||
# Input.forms is already a list of dicts
|
|
||||||
forms_data = input.forms
|
forms_data = input.forms
|
||||||
post_data = forms_to_formset_post_data(forms_data)
|
post_data = forms_to_formset_post_data(forms_data)
|
||||||
files = None
|
files = None
|
||||||
@@ -620,10 +431,8 @@ def _register_formset_functions(
|
|||||||
return FormsetSubmitPass(success=True)
|
return FormsetSubmitPass(success=True)
|
||||||
|
|
||||||
validation = build_formset_validation(formset)
|
validation = build_formset_validation(formset)
|
||||||
# Call failure handler on each form
|
|
||||||
for form in formset.forms:
|
for form in formset.forms:
|
||||||
if hasattr(form, "on_submit_failure"):
|
form.on_submit_failure(request, validation)
|
||||||
form.on_submit_failure(request, validation)
|
|
||||||
|
|
||||||
return FormsetSubmitFail(success=False, errors=validation)
|
return FormsetSubmitFail(success=False, errors=validation)
|
||||||
|
|
||||||
@@ -641,10 +450,8 @@ def register_form(
|
|||||||
submit_handler: Any = None,
|
submit_handler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Register a Django Form class as Mizan server functions.
|
Register a plain Django Form as `{name}.schema`, `{name}.validate`, and —
|
||||||
|
when `submit_handler` is given — `{name}.submit`.
|
||||||
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.client.function import create_form_functions
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
@@ -660,10 +467,8 @@ def register_form(
|
|||||||
|
|
||||||
def get_forms() -> dict[str, list]:
|
def get_forms() -> dict[str, list]:
|
||||||
"""
|
"""
|
||||||
Group registered form-related functions by their form name.
|
Group registered form-related functions by their form name, e.g.
|
||||||
|
`{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`.
|
||||||
Returns a mapping like:
|
|
||||||
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
|
||||||
"""
|
"""
|
||||||
from mizan_core.registry import get_all_functions
|
from mizan_core.registry import get_all_functions
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Any, Optional
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import Field
|
from django.forms import Field
|
||||||
|
|
||||||
from .schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
from mizan.forms.schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||||
|
|
||||||
|
|
||||||
def create_form_instance(
|
def create_form_instance(
|
||||||
@@ -14,16 +14,14 @@ def create_form_instance(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> forms.BaseForm:
|
) -> forms.BaseForm:
|
||||||
"""
|
"""
|
||||||
Create a form instance, gracefully handling kwargs that the form doesn't accept.
|
Instantiate `form_class`, dropping kwargs its __init__ rejects.
|
||||||
|
|
||||||
Some Django forms (like allauth's) accept `request` in __init__, others don't.
|
Django form __init__ signatures vary — some accept `request`, others do
|
||||||
This function tries with all kwargs first, then progressively removes kwargs
|
not — so instantiation is retried with the offending kwarg removed until
|
||||||
that cause TypeErrors until instantiation succeeds.
|
it succeeds or the TypeError is not about an unexpected keyword.
|
||||||
"""
|
"""
|
||||||
# Common kwargs that forms may or may not accept
|
|
||||||
optional_kwargs = ['request', 'user', 'instance']
|
optional_kwargs = ['request', 'user', 'instance']
|
||||||
|
|
||||||
# Build init kwargs
|
|
||||||
init_kwargs = dict(kwargs)
|
init_kwargs = dict(kwargs)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
init_kwargs['data'] = data
|
init_kwargs['data'] = data
|
||||||
@@ -36,11 +34,9 @@ def create_form_instance(
|
|||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
|
|
||||||
# Check if it's an unexpected keyword argument error
|
|
||||||
if "unexpected keyword argument" not in error_msg:
|
if "unexpected keyword argument" not in error_msg:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Find which kwarg caused the problem and remove it
|
|
||||||
removed = False
|
removed = False
|
||||||
for kwarg in optional_kwargs:
|
for kwarg in optional_kwargs:
|
||||||
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
||||||
@@ -48,31 +44,28 @@ def create_form_instance(
|
|||||||
removed = True
|
removed = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# If we couldn't identify/remove the problematic kwarg, re-raise
|
|
||||||
if not removed:
|
if not removed:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
||||||
"""
|
"""
|
||||||
Extract choices from a field, handling ModelChoiceField properly.
|
Extract a field's choices as JSON-serializable pairs. ModelChoiceField
|
||||||
ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable.
|
yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`.
|
||||||
"""
|
"""
|
||||||
if not hasattr(field, "choices"):
|
if not hasattr(field, "choices"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
choices: list[FieldChoice] = []
|
choices: list[FieldChoice] = []
|
||||||
for raw_value, label in field.choices:
|
for raw_value, label in field.choices:
|
||||||
value = getattr(
|
value = getattr(raw_value, "value", raw_value)
|
||||||
raw_value, "value", raw_value
|
|
||||||
) # ModelChoiceIteratorValue -> .value
|
|
||||||
choices.append(FieldChoice(value=str(value), label=str(label)))
|
choices.append(FieldChoice(value=str(value), label=str(label)))
|
||||||
|
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
|
|
||||||
def _get_initial(value: Any) -> Any:
|
def _get_initial(value: Any) -> Any:
|
||||||
"""Convert initial value to JSON-serializable format."""
|
"""Convert an initial value to a JSON-serializable form."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if hasattr(value, "isoformat"):
|
if hasattr(value, "isoformat"):
|
||||||
@@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _class_name_to_title(name: str) -> str:
|
def _class_name_to_title(name: str) -> str:
|
||||||
"""
|
"""'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'."""
|
||||||
Convert a class name to a human-readable title.
|
|
||||||
e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'
|
|
||||||
"""
|
|
||||||
# Remove 'Form' suffix
|
|
||||||
name = re.sub(r"Form$", "", name)
|
name = re.sub(r"Form$", "", name)
|
||||||
# Insert spaces before capital letters
|
|
||||||
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _class_name_to_slug(name: str) -> str:
|
def _class_name_to_slug(name: str) -> str:
|
||||||
"""
|
"""'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'."""
|
||||||
Convert a class name to a slug.
|
|
||||||
e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'
|
|
||||||
"""
|
|
||||||
# Remove 'Form' suffix
|
|
||||||
name = re.sub(r"Form$", "", name)
|
name = re.sub(r"Form$", "", name)
|
||||||
# Insert underscores before capital letters and lowercase
|
|
||||||
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
||||||
return name.lower()
|
return name.lower()
|
||||||
|
|
||||||
@@ -114,48 +97,31 @@ def build_form_schema(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FormSchema:
|
) -> FormSchema:
|
||||||
"""
|
"""
|
||||||
Produce a FormSchema for the given Django form class and (optional) data.
|
Produce a FormSchema for a Django form class and optional bound data.
|
||||||
|
|
||||||
The form class can define metadata via an inner Meta class:
|
Attributes on the form's inner `Meta` class — `form_name`, `title`,
|
||||||
|
`subtitle`, `submit_label`, `refetch_schema_on_validate`,
|
||||||
class MyForm(forms.Form):
|
`live_validation`, `live_form_errors` — override the values otherwise
|
||||||
class Meta:
|
derived from the class name.
|
||||||
form_name = "my_form"
|
|
||||||
title = "My Form Title"
|
|
||||||
subtitle = "Optional description"
|
|
||||||
submit_label = "Submit"
|
|
||||||
|
|
||||||
# Frontend behavior (optional)
|
|
||||||
refetch_schema_on_validate = False # Set True for dynamic choice fields
|
|
||||||
live_validation = True # Set False to disable live validation
|
|
||||||
live_form_errors = False # Set True to show form errors live
|
|
||||||
|
|
||||||
If not provided, sensible defaults are derived from the class name.
|
|
||||||
"""
|
"""
|
||||||
form = create_form_instance(form_class, data=data, **kwargs)
|
form = create_form_instance(form_class, data=data, **kwargs)
|
||||||
|
|
||||||
# Extract metadata from form's Meta class
|
|
||||||
form_meta = getattr(form_class, "Meta", None)
|
form_meta = getattr(form_class, "Meta", None)
|
||||||
|
|
||||||
# Get form name (used as identifier)
|
|
||||||
name = getattr(form_meta, "form_name", None)
|
name = getattr(form_meta, "form_name", None)
|
||||||
if name is None:
|
if name is None:
|
||||||
name = _class_name_to_slug(form_class.__name__)
|
name = _class_name_to_slug(form_class.__name__)
|
||||||
|
|
||||||
# Get title (human-readable heading)
|
|
||||||
title = getattr(form_meta, "title", None)
|
title = getattr(form_meta, "title", None)
|
||||||
if title is None:
|
if title is None:
|
||||||
title = _class_name_to_title(form_class.__name__)
|
title = _class_name_to_title(form_class.__name__)
|
||||||
|
|
||||||
# Get optional subtitle
|
|
||||||
subtitle = getattr(form_meta, "subtitle", None)
|
subtitle = getattr(form_meta, "subtitle", None)
|
||||||
|
|
||||||
# Get submit button label
|
|
||||||
submit_label = getattr(form_meta, "submit_label", None)
|
submit_label = getattr(form_meta, "submit_label", None)
|
||||||
if submit_label is None:
|
if submit_label is None:
|
||||||
submit_label = "Submit"
|
submit_label = "Submit"
|
||||||
|
|
||||||
# Build frontend behavior metadata
|
|
||||||
frontend_meta = FormMeta(
|
frontend_meta = FormMeta(
|
||||||
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
||||||
live_validation=getattr(form_meta, "live_validation", True),
|
live_validation=getattr(form_meta, "live_validation", True),
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ from django import forms
|
|||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.utils.datastructures import MultiValueDict
|
from django.utils.datastructures import MultiValueDict
|
||||||
|
|
||||||
from .schemas import (
|
from mizan.forms.schemas import (
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldErrorList,
|
FieldErrorList,
|
||||||
FormValidation,
|
FormValidation,
|
||||||
FormsetValidation,
|
FormsetValidation,
|
||||||
)
|
)
|
||||||
from .schema_utils import create_form_instance
|
from mizan.forms.schema_utils import create_form_instance
|
||||||
|
|
||||||
|
|
||||||
def validate_form_instance(
|
def validate_form_instance(
|
||||||
@@ -19,12 +19,9 @@ def validate_form_instance(
|
|||||||
files: MultiValueDict[str, UploadedFile] | None = None,
|
files: MultiValueDict[str, UploadedFile] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> tuple[forms.BaseForm, FormValidation]:
|
) -> tuple[forms.BaseForm, FormValidation]:
|
||||||
"""
|
"""Build a form instance and return it alongside its structured field errors."""
|
||||||
Build a form instance and return (form, structured_validation_errors).
|
|
||||||
"""
|
|
||||||
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
||||||
|
|
||||||
# Run validation
|
|
||||||
form.is_valid()
|
form.is_valid()
|
||||||
|
|
||||||
validation = FormValidation(
|
validation = FormValidation(
|
||||||
@@ -46,9 +43,7 @@ def validate_form_instance(
|
|||||||
|
|
||||||
|
|
||||||
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
||||||
"""
|
"""Turn a Django formset's non-form and per-form errors into a FormsetValidation."""
|
||||||
Turn a Django formset into a FormsetValidation structure.
|
|
||||||
"""
|
|
||||||
return FormsetValidation(
|
return FormsetValidation(
|
||||||
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
||||||
per_form=[
|
per_form=[
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"""
|
|
||||||
mizan Allauth Integration
|
|
||||||
|
|
||||||
Backend support for django-allauth with mizan server functions.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
|
||||||
- Allauth form wrappers - expose allauth forms as server functions
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# In your app's apps.py
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
import mizan.allauth.forms # noqa - registers forms
|
|
||||||
import mizan.allauth.contexts # noqa - registers contexts
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"auth_status",
|
|
||||||
"user",
|
|
||||||
"AuthStatusOutput",
|
|
||||||
"UserOutput",
|
|
||||||
]
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""
|
|
||||||
Auth contexts for mizan Allauth integration.
|
|
||||||
|
|
||||||
These are the core auth primitives that the frontend allauth module depends on.
|
|
||||||
Separated into two concerns:
|
|
||||||
|
|
||||||
- auth_status: Authentication state and permission guards (fast, no DB hit with JWT)
|
|
||||||
- user: Full user profile data (may require DB query for JWT auth)
|
|
||||||
|
|
||||||
Both are registered as global contexts for SSR hydration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan.client import client
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Auth Status Context
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class AuthStatusOutput(BaseModel):
|
|
||||||
"""Authentication status and permission guards."""
|
|
||||||
|
|
||||||
is_authenticated: bool
|
|
||||||
user_id: int | None = None
|
|
||||||
is_staff: bool = False
|
|
||||||
is_superuser: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@client(context="global")
|
|
||||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
|
||||||
"""
|
|
||||||
Auth status context - provides authentication state and guards.
|
|
||||||
|
|
||||||
This works identically for both session and JWT auth. The data comes
|
|
||||||
from the request.user object (either full User or JWTUser with claims).
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
const auth = useAuthStatus()
|
|
||||||
if (auth.is_authenticated) { ... }
|
|
||||||
if (auth.is_staff) { ... }
|
|
||||||
"""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return AuthStatusOutput(is_authenticated=False)
|
|
||||||
|
|
||||||
return AuthStatusOutput(
|
|
||||||
is_authenticated=True,
|
|
||||||
user_id=user.id,
|
|
||||||
is_staff=user.is_staff,
|
|
||||||
is_superuser=user.is_superuser,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# User Profile Context
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class UserOutput(BaseModel):
|
|
||||||
"""Full user profile data."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
email: str
|
|
||||||
first_name: str = ""
|
|
||||||
last_name: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@client(context="global")
|
|
||||||
def user(request: HttpRequest) -> UserOutput | None:
|
|
||||||
"""
|
|
||||||
User profile context - provides full user data.
|
|
||||||
|
|
||||||
Unlike auth_status, this may require a DB query (for JWT auth where
|
|
||||||
the user object is a minimal JWTUser with only claims).
|
|
||||||
|
|
||||||
Returns None if not authenticated.
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
const user = useUser()
|
|
||||||
if (user) {
|
|
||||||
console.log(user.email)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
req_user = request.user
|
|
||||||
|
|
||||||
if not req_user.is_authenticated:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if we have full user data or just JWT claims
|
|
||||||
if hasattr(req_user, "email") and req_user.email:
|
|
||||||
# Full User object (session auth)
|
|
||||||
return UserOutput(
|
|
||||||
id=req_user.id,
|
|
||||||
email=req_user.email,
|
|
||||||
first_name=getattr(req_user, "first_name", "") or "",
|
|
||||||
last_name=getattr(req_user, "last_name", "") or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
# JWTUser - need to fetch from DB
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
try:
|
|
||||||
db_user = User.objects.get(pk=req_user.id)
|
|
||||||
return UserOutput(
|
|
||||||
id=db_user.id,
|
|
||||||
email=db_user.email,
|
|
||||||
first_name=db_user.first_name or "",
|
|
||||||
last_name=db_user.last_name or "",
|
|
||||||
)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return None
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
"""
|
|
||||||
Allauth forms as mizan server functions.
|
|
||||||
|
|
||||||
This module wraps allauth forms with mizanFormMixin, exposing them as
|
|
||||||
typed server functions for the React frontend.
|
|
||||||
|
|
||||||
Each form becomes three server functions:
|
|
||||||
- {name}.schema - Get form field definitions
|
|
||||||
- {name}.validate - Validate form data
|
|
||||||
- {name}.submit - Submit form
|
|
||||||
|
|
||||||
Import this module in your app's ready() to register the forms:
|
|
||||||
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
import mizan.allauth.forms # noqa
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
|
||||||
|
|
||||||
# Account forms
|
|
||||||
from allauth.account.forms import (
|
|
||||||
AddEmailForm,
|
|
||||||
ChangePasswordForm,
|
|
||||||
ConfirmLoginCodeForm,
|
|
||||||
LoginForm,
|
|
||||||
RequestLoginCodeForm,
|
|
||||||
ResetPasswordForm,
|
|
||||||
ResetPasswordKeyForm,
|
|
||||||
SetPasswordForm,
|
|
||||||
SignupForm,
|
|
||||||
UserTokenForm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Password reauthentication form - conditionally import
|
|
||||||
try:
|
|
||||||
from allauth.account.forms import ReauthenticateForm
|
|
||||||
|
|
||||||
HAS_REAUTH = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_REAUTH = False
|
|
||||||
|
|
||||||
# MFA forms - conditionally import
|
|
||||||
try:
|
|
||||||
from allauth.mfa.base.forms import AuthenticateForm as MFAAuthenticateForm
|
|
||||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
|
||||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
|
||||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
|
||||||
|
|
||||||
HAS_MFA = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_MFA = False
|
|
||||||
|
|
||||||
# WebAuthn forms (if available)
|
|
||||||
try:
|
|
||||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
|
||||||
|
|
||||||
HAS_WEBAUTHN = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_WEBAUTHN = False
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mizan.forms.schemas import FormValidation
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Account Forms
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class mizanLoginForm(LoginForm, mizanFormMixin):
|
|
||||||
"""Sign in with email and password."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="login",
|
|
||||||
title="Sign In",
|
|
||||||
subtitle="Welcome back. Enter your credentials to continue.",
|
|
||||||
submit_label="Sign In",
|
|
||||||
live_validation=False, # Don't validate credentials as user types
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.login(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanSignupForm(SignupForm, mizanFormMixin):
|
|
||||||
"""Create a new account."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="signup",
|
|
||||||
title="Create Account",
|
|
||||||
subtitle="Enter your details to get started.",
|
|
||||||
submit_label="Create Account",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
|
||||||
"""Add another email address to your account."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="add_email",
|
|
||||||
title="Add Email Address",
|
|
||||||
subtitle="Add another email address to your account.",
|
|
||||||
submit_label="Add Email",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
|
||||||
"""Change your account password."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="change_password",
|
|
||||||
title="Change Password",
|
|
||||||
subtitle="Update your password to keep your account secure.",
|
|
||||||
submit_label="Change Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
|
||||||
"""Set a password for accounts created via social login."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="set_password",
|
|
||||||
title="Set Password",
|
|
||||||
subtitle="Create a password for your account.",
|
|
||||||
submit_label="Set Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
|
||||||
"""Request a password reset email."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reset_password",
|
|
||||||
title="Reset Password",
|
|
||||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
|
||||||
submit_label="Send Reset Link",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
|
||||||
"""Set a new password using a reset key."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reset_password_from_key",
|
|
||||||
title="Set New Password",
|
|
||||||
subtitle="Enter your new password below.",
|
|
||||||
submit_label="Reset Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
|
||||||
"""Request a login code via email."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="request_login_code",
|
|
||||||
title="Sign In with Code",
|
|
||||||
subtitle="Enter your email address and we'll send you a login code.",
|
|
||||||
submit_label="Send Code",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
|
||||||
"""Confirm a login code."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="confirm_login_code",
|
|
||||||
title="Enter Code",
|
|
||||||
subtitle="Enter the code we sent to your email.",
|
|
||||||
submit_label="Verify Code",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
|
||||||
"""Verify an email with a token."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="user_token",
|
|
||||||
title="Verify Email",
|
|
||||||
subtitle="Enter the verification code from your email.",
|
|
||||||
submit_label="Verify",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Password reauthentication - conditionally define
|
|
||||||
if HAS_REAUTH:
|
|
||||||
|
|
||||||
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
|
||||||
"""Re-authenticate with password for sensitive actions."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reauthenticate",
|
|
||||||
title="Confirm Your Identity",
|
|
||||||
subtitle="Please enter your password to continue.",
|
|
||||||
submit_label="Confirm",
|
|
||||||
live_validation=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
from allauth.account.internal.flows import reauthentication
|
|
||||||
|
|
||||||
reauthentication.reauthenticate_by_password(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MFA Forms
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if HAS_MFA:
|
|
||||||
|
|
||||||
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
|
||||||
"""Authenticate with MFA during login."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="mfa_authenticate",
|
|
||||||
title="Two-Factor Authentication",
|
|
||||||
subtitle="Enter your authentication code to continue.",
|
|
||||||
submit_label="Verify",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
|
||||||
"""Re-authenticate with MFA for sensitive actions."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="mfa_reauthenticate",
|
|
||||||
title="Confirm Your Identity",
|
|
||||||
subtitle="Enter your authentication code to continue.",
|
|
||||||
submit_label="Confirm",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
|
||||||
"""Activate TOTP authenticator."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="activate_totp",
|
|
||||||
title="Set Up Authenticator",
|
|
||||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
|
||||||
submit_label="Activate",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
|
||||||
"""Deactivate TOTP authenticator."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="deactivate_totp",
|
|
||||||
title="Disable Authenticator",
|
|
||||||
subtitle="Enter your password to disable two-factor authentication.",
|
|
||||||
submit_label="Disable",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
|
||||||
"""Generate new recovery codes."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="generate_recovery_codes",
|
|
||||||
title="Recovery Codes",
|
|
||||||
subtitle="Generate new recovery codes for your account.",
|
|
||||||
submit_label="Generate Codes",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
if HAS_WEBAUTHN:
|
|
||||||
|
|
||||||
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
|
||||||
"""Authenticate with WebAuthn security key."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="webauthn_authenticate",
|
|
||||||
title="Security Key",
|
|
||||||
subtitle="Use your security key to authenticate.",
|
|
||||||
submit_label="Use Security Key",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
@@ -1,26 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
mizan.jwt - JWT authentication for server functions.
|
JWT issuance and validation for mizan server functions.
|
||||||
|
|
||||||
Provides:
|
`jwt_obtain` / `jwt_refresh` are server functions; importing
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
`mizan.jwt.functions` is what registers them. The Ninja auth class
|
||||||
- JWT authentication utilities for validating tokens
|
`JWTAuth` / `jwt_auth` resolves through `__getattr__` so that importing this
|
||||||
|
package does not pull django-ninja's settings access in at module load time.
|
||||||
Server Functions:
|
|
||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
|
||||||
|
|
||||||
Usage in apps.py or urls.py (to register the functions):
|
|
||||||
import mizan.jwt.functions # noqa: F401
|
|
||||||
|
|
||||||
Note: This module is purpose-built for mizan server functions.
|
|
||||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Server functions (import to register with @client decorator)
|
from mizan.jwt.functions import jwt_obtain, jwt_refresh
|
||||||
from .functions import jwt_obtain, jwt_refresh
|
|
||||||
|
|
||||||
# Token utilities
|
from mizan.jwt.tokens import (
|
||||||
from .tokens import (
|
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
@@ -31,17 +20,12 @@ from .tokens import (
|
|||||||
JWTUser,
|
JWTUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
from .settings import get_settings, JWTSettings
|
|
||||||
|
|
||||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
|
||||||
# django-ninja's settings access at module load time.
|
|
||||||
# Use: from mizan.jwt.security import jwt_auth
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
if name in ("JWTAuth", "jwt_auth"):
|
if name in ("JWTAuth", "jwt_auth"):
|
||||||
from .security import JWTAuth, jwt_auth
|
from mizan.jwt.security import JWTAuth, jwt_auth
|
||||||
|
|
||||||
globals()["JWTAuth"] = JWTAuth
|
globals()["JWTAuth"] = JWTAuth
|
||||||
globals()["jwt_auth"] = jwt_auth
|
globals()["jwt_auth"] = jwt_auth
|
||||||
|
|||||||
@@ -1,64 +1,33 @@
|
|||||||
"""
|
"""
|
||||||
Django Ninja Security Classes for JWT Authentication
|
Django Ninja security class for JWT bearer authentication, usable as
|
||||||
|
`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list.
|
||||||
Provides authentication classes that can be used with Django Ninja's
|
|
||||||
auth parameter to protect API endpoints.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja.security import HttpBearer
|
from ninja.security import HttpBearer
|
||||||
|
|
||||||
from .tokens import decode_token, JWTUser
|
from mizan.jwt.tokens import decode_token, JWTUser
|
||||||
|
|
||||||
|
|
||||||
class JWTAuth(HttpBearer):
|
class JWTAuth(HttpBearer):
|
||||||
"""
|
"""
|
||||||
JWT Bearer token authentication for Django Ninja.
|
Reads `Authorization: Bearer <access_token>` and sets `request.user` to a
|
||||||
|
JWTUser built from the token claims. No database query is made, so the
|
||||||
Usage:
|
resulting user carries only id, is_staff, and is_superuser.
|
||||||
from ninja_jwt_session import jwt_auth
|
|
||||||
|
|
||||||
@api.get("/protected/", auth=jwt_auth)
|
|
||||||
def protected_endpoint(request):
|
|
||||||
return {"user_id": request.user.id}
|
|
||||||
|
|
||||||
Or globally:
|
|
||||||
api = NinjaExtraAPI(auth=[django_auth, jwt_auth])
|
|
||||||
|
|
||||||
The token must be passed in the Authorization header:
|
|
||||||
Authorization: Bearer <access_token>
|
|
||||||
|
|
||||||
IMPORTANT: This is stateless - no database query is made.
|
|
||||||
request.user is a JWTUser object with id, is_staff, is_superuser.
|
|
||||||
If you need the full User object, query it explicitly:
|
|
||||||
user = User.objects.get(pk=request.user.id)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def authenticate(self, request: HttpRequest, token: str):
|
def authenticate(self, request: HttpRequest, token: str):
|
||||||
"""
|
"""Return a JWTUser for a valid access token, or None to fail auth."""
|
||||||
Validate the JWT and return a JWTUser if valid.
|
|
||||||
|
|
||||||
Returns None (authentication failed) if:
|
|
||||||
- Token is invalid or expired
|
|
||||||
- Token is not an access token
|
|
||||||
|
|
||||||
Note: No database query is made. The JWTUser is created from
|
|
||||||
token claims. This is truly stateless authentication.
|
|
||||||
"""
|
|
||||||
# Decode and validate the token
|
|
||||||
payload = decode_token(token, expected_type="access")
|
payload = decode_token(token, expected_type="access")
|
||||||
|
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
|
||||||
jwt_user = JWTUser(payload)
|
jwt_user = JWTUser(payload)
|
||||||
|
|
||||||
# Set request.user for compatibility with code expecting it
|
|
||||||
request.user = jwt_user
|
request.user = jwt_user
|
||||||
|
|
||||||
return jwt_user
|
return jwt_user
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance for convenience
|
|
||||||
jwt_auth = JWTAuth()
|
jwt_auth = JWTAuth()
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
"""
|
|
||||||
JWT Hybrid Settings
|
|
||||||
|
|
||||||
Configuration is read from Django settings with sensible defaults.
|
|
||||||
Supports both symmetric (HS256) and asymmetric (RS256) algorithms.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
@@ -13,8 +6,6 @@ from django.conf import settings as django_settings
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class JWTSettings:
|
class JWTSettings:
|
||||||
"""JWT configuration."""
|
|
||||||
|
|
||||||
# Signing keys
|
# Signing keys
|
||||||
private_key: str # Used for signing (required)
|
private_key: str # Used for signing (required)
|
||||||
public_key: str # Used for verification (same as private for HS256)
|
public_key: str # Used for verification (same as private for HS256)
|
||||||
@@ -33,26 +24,8 @@ class JWTSettings:
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> JWTSettings:
|
def get_settings() -> JWTSettings:
|
||||||
"""
|
|
||||||
Load JWT settings from Django settings.
|
|
||||||
|
|
||||||
Settings:
|
|
||||||
JWT_PRIVATE_KEY: Signing key (required)
|
|
||||||
JWT_PUBLIC_KEY: Verification key (defaults to private key for HS256)
|
|
||||||
JWT_ALGORITHM: Algorithm to use (default: HS256)
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRES_IN: Access token lifetime (default: 300)
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRES_IN: Refresh token lifetime (default: 604800)
|
|
||||||
JWT_VALIDATE_SESSION: Validate session on token use (default: True)
|
|
||||||
JWT_ROTATE_REFRESH_TOKEN: Rotate refresh tokens (default: True)
|
|
||||||
"""
|
|
||||||
private_key = getattr(django_settings, "JWT_PRIVATE_KEY", None)
|
private_key = getattr(django_settings, "JWT_PRIVATE_KEY", None)
|
||||||
|
|
||||||
if not private_key:
|
|
||||||
# Fall back to allauth setting if available (for compatibility)
|
|
||||||
headless_key = getattr(django_settings, "HEADLESS_JWT_PRIVATE_KEY", None)
|
|
||||||
if headless_key:
|
|
||||||
private_key = headless_key
|
|
||||||
|
|
||||||
if private_key is None:
|
if private_key is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"JWT_PRIVATE_KEY must be set in Django settings. "
|
"JWT_PRIVATE_KEY must be set in Django settings. "
|
||||||
@@ -60,7 +33,6 @@ def get_settings() -> JWTSettings:
|
|||||||
"For RS256, use a PEM-encoded RSA private key."
|
"For RS256, use a PEM-encoded RSA private key."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-detect algorithm based on key format if not explicitly set
|
|
||||||
algorithm = getattr(django_settings, "JWT_ALGORITHM", None)
|
algorithm = getattr(django_settings, "JWT_ALGORITHM", None)
|
||||||
|
|
||||||
if algorithm is None:
|
if algorithm is None:
|
||||||
@@ -100,14 +72,10 @@ def get_settings() -> JWTSettings:
|
|||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
algorithm=algorithm,
|
algorithm=algorithm,
|
||||||
access_token_expires_in=getattr(
|
access_token_expires_in=getattr(
|
||||||
django_settings,
|
django_settings, "JWT_ACCESS_TOKEN_EXPIRES_IN", 300
|
||||||
"JWT_ACCESS_TOKEN_EXPIRES_IN",
|
|
||||||
getattr(django_settings, "HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN", 300),
|
|
||||||
),
|
),
|
||||||
refresh_token_expires_in=getattr(
|
refresh_token_expires_in=getattr(
|
||||||
django_settings,
|
django_settings, "JWT_REFRESH_TOKEN_EXPIRES_IN", 604800
|
||||||
"JWT_REFRESH_TOKEN_EXPIRES_IN",
|
|
||||||
getattr(django_settings, "HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN", 604800),
|
|
||||||
),
|
),
|
||||||
validate_session=getattr(
|
validate_session=getattr(
|
||||||
django_settings, "JWT_VALIDATE_SESSION", True
|
django_settings, "JWT_VALIDATE_SESSION", True
|
||||||
|
|||||||
@@ -1,79 +1,218 @@
|
|||||||
"""
|
"""
|
||||||
JWT tokens — the Django adapter over the shared core (`mizan_core.auth.jwt`).
|
JWT creation and validation over PyJWT.
|
||||||
|
|
||||||
The token logic (mint/decode/refresh, `JWTUser`, `TokenPair`, `TokenPayload`)
|
Every token carries the Django session key in `sid`; `validate_session`
|
||||||
lives in the core; this module binds it to Django settings and keeps the
|
re-checks that the session still exists, which is what makes logout revoke
|
||||||
session-revocation check (`validate_session`), which is Django-session-specific.
|
outstanding tokens immediately.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from mizan_core.auth import jwt as _core_jwt
|
import jwt
|
||||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload
|
|
||||||
|
|
||||||
from .settings import get_settings
|
from mizan.jwt.settings import get_settings
|
||||||
|
|
||||||
__all__ = [
|
logger = logging.getLogger("mizan.jwt")
|
||||||
"TokenPair",
|
|
||||||
"TokenPayload",
|
|
||||||
"JWTUser",
|
|
||||||
"create_access_token",
|
|
||||||
"create_refresh_token",
|
|
||||||
"create_token_pair",
|
|
||||||
"decode_token",
|
|
||||||
"validate_session",
|
|
||||||
"refresh_tokens",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> JWTConfig:
|
class TokenPair(NamedTuple):
|
||||||
s = get_settings()
|
"""Access and refresh token pair."""
|
||||||
return JWTConfig(
|
access_token: str
|
||||||
private_key=s.private_key,
|
refresh_token: str
|
||||||
public_key=s.public_key,
|
expires_in: int
|
||||||
algorithm=s.algorithm,
|
|
||||||
access_token_expires_in=s.access_token_expires_in,
|
|
||||||
refresh_token_expires_in=s.refresh_token_expires_in,
|
class TokenPayload(NamedTuple):
|
||||||
|
"""Decoded token payload."""
|
||||||
|
user_id: int | str
|
||||||
|
session_key: str
|
||||||
|
token_type: str
|
||||||
|
is_staff: bool
|
||||||
|
is_superuser: bool
|
||||||
|
exp: int
|
||||||
|
iat: int
|
||||||
|
|
||||||
|
|
||||||
|
class JWTUser:
|
||||||
|
"""
|
||||||
|
Stand-in for `request.user` built entirely from JWT claims — no database
|
||||||
|
row is loaded, so only id, is_staff, and is_superuser are real. Anything
|
||||||
|
else about the user has to be queried explicitly by the caller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, payload: TokenPayload):
|
||||||
|
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
|
||||||
|
self.pk = self.id
|
||||||
|
self.is_staff = payload.is_staff
|
||||||
|
self.is_superuser = payload.is_superuser
|
||||||
|
self.is_authenticated = True
|
||||||
|
self.is_anonymous = False
|
||||||
|
self.is_active = True # A valid unexpired token stands in for the flag
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"JWTUser(id={self.id})"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
user_id: int | str,
|
||||||
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Create a short-lived access token."""
|
||||||
|
settings = get_settings()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"sid": session_key,
|
||||||
|
"staff": is_staff,
|
||||||
|
"super": is_superuser,
|
||||||
|
"type": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + settings.access_token_expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.private_key,
|
||||||
|
algorithm=settings.algorithm,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
def create_refresh_token(
|
||||||
return _core_jwt.create_access_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Create a longer-lived refresh token."""
|
||||||
|
settings = get_settings()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"sid": session_key,
|
||||||
|
"staff": is_staff,
|
||||||
|
"super": is_superuser,
|
||||||
|
"type": "refresh",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + settings.refresh_token_expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.private_key,
|
||||||
|
algorithm=settings.algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
def create_token_pair(
|
||||||
return _core_jwt.create_refresh_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
def create_token_pair(user_id, session_key, *, is_staff=False, is_superuser=False) -> TokenPair:
|
is_superuser: bool = False,
|
||||||
return _core_jwt.create_token_pair(user_id, session_key, _config(),
|
) -> TokenPair:
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
"""Create both access and refresh tokens."""
|
||||||
|
settings = get_settings()
|
||||||
|
return TokenPair(
|
||||||
|
access_token=create_access_token(
|
||||||
|
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||||
|
),
|
||||||
|
refresh_token=create_refresh_token(
|
||||||
|
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||||
|
),
|
||||||
|
expires_in=settings.access_token_expires_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||||
return _core_jwt.decode_token(token, _config(), expected_type=expected_type)
|
"""
|
||||||
|
Decode and validate a JWT, returning None when it is malformed, expired,
|
||||||
|
or not of `expected_type`.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.public_key,
|
||||||
|
algorithms=[settings.algorithm],
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
# Expired and forged tokens are routine on a public endpoint, so this
|
||||||
|
# stays at debug rather than flooding the log on every bad request.
|
||||||
|
logger.debug("JWT rejected: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if expected_type and payload.get("type") != expected_type:
|
||||||
|
logger.debug(
|
||||||
|
"JWT rejected: expected type %r, got %r",
|
||||||
|
expected_type,
|
||||||
|
payload.get("type"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TokenPayload(
|
||||||
|
user_id=payload["sub"],
|
||||||
|
session_key=payload["sid"],
|
||||||
|
token_type=payload["type"],
|
||||||
|
is_staff=payload.get("staff", False),
|
||||||
|
is_superuser=payload.get("super", False),
|
||||||
|
exp=payload["exp"],
|
||||||
|
iat=payload["iat"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_session(session_key: str) -> bool:
|
def validate_session(session_key: str) -> bool:
|
||||||
"""Immediate-logout revocation: is this Django session still alive?
|
"""
|
||||||
|
Report whether the Django session backing a token still exists. Returns
|
||||||
Honors `JWT_VALIDATE_SESSION` — when disabled, always True. This is the one
|
True unconditionally when session validation is switched off in settings.
|
||||||
Django-session-bound piece; the core's `refresh_tokens` takes it as an
|
|
||||||
injected `session_validator`.
|
|
||||||
"""
|
"""
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
if not get_settings().validate_session:
|
jwt_settings = get_settings()
|
||||||
|
|
||||||
|
if not jwt_settings.validate_session:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
engine = import_module(django_settings.SESSION_ENGINE)
|
engine = import_module(django_settings.SESSION_ENGINE)
|
||||||
session = engine.SessionStore(session_key=session_key)
|
SessionStore = engine.SessionStore
|
||||||
|
|
||||||
|
session = SessionStore(session_key=session_key)
|
||||||
|
|
||||||
|
# exists() reads the backend directly; load() would silently hand back an
|
||||||
|
# empty session for a missing key.
|
||||||
return session.exists(session_key)
|
return session.exists(session_key)
|
||||||
|
|
||||||
|
|
||||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||||
return _core_jwt.refresh_tokens(refresh_token, _config(), session_validator=validate_session)
|
"""
|
||||||
|
Exchange a refresh token for a fresh pair carrying the same claims.
|
||||||
|
Returns None when the token is invalid or its session is gone.
|
||||||
|
"""
|
||||||
|
payload = decode_token(refresh_token, expected_type="refresh")
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not validate_session(payload.session_key):
|
||||||
|
logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return create_token_pair(
|
||||||
|
payload.user_id,
|
||||||
|
payload.session_key,
|
||||||
|
is_staff=payload.is_staff,
|
||||||
|
is_superuser=payload.is_superuser,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Export channels schema as OpenAPI JSON for TypeScript generation.
|
Writes the channels schema to stdout as OpenAPI JSON.
|
||||||
|
|
||||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
|
||||||
The schema is consumed by openapi-typescript for type generation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py export_channels_schema
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -29,6 +23,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
schema = get_channels_openapi_schema()
|
schema = get_channels_openapi_schema()
|
||||||
|
|
||||||
|
# indent=0 is not compact in json.dumps; None is.
|
||||||
indent = options["indent"] if options["indent"] > 0 else None
|
indent = options["indent"] if options["indent"] > 0 else None
|
||||||
output = json.dumps(schema, indent=indent)
|
output = json.dumps(schema, indent=indent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
"""
|
"""Management command emitting the edge cache manifest as JSON."""
|
||||||
Export Edge Manifest
|
|
||||||
|
|
||||||
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
|
||||||
to configure CDN cache rules and invalidation routing.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py export_edge_manifest
|
|
||||||
python manage.py export_edge_manifest --output mizan-manifest.json
|
|
||||||
python manage.py export_edge_manifest --base-url /api/mizan
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Mizan IR (KDL) export — Django management command.
|
Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary
|
||||||
|
consumes. Nothing else in this command may write to stdout.
|
||||||
Usage:
|
|
||||||
python manage.py export_mizan_ir
|
|
||||||
|
|
||||||
Triggers Mizan client discovery to populate the registry, then writes
|
|
||||||
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
|
||||||
consumes this directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from mizan.setup.discovery import mizan_clients
|
||||||
from mizan_core.ir import build_ir
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +15,6 @@ class Command(BaseCommand):
|
|||||||
help = "Export every registered @client function as Mizan IR (KDL)."
|
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||||
|
|
||||||
def handle(self, *args, **options) -> None:
|
def handle(self, *args, **options) -> None:
|
||||||
# Load every project-side @client function so the registry is
|
# Discovery populates the registry build_ir() reads.
|
||||||
# populated before we emit. Conventionally apps/*/clients.py.
|
mizan_clients()
|
||||||
from mizan.setup.discovery import mizan_clients
|
|
||||||
|
|
||||||
mizan_clients("apps")
|
|
||||||
self.stdout.write(build_ir(), ending="")
|
self.stdout.write(build_ir(), ending="")
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
mizan.setup - Django integration helpers.
|
Curated Django-side surface: registration, lookup, discovery, and settings
|
||||||
|
helpers, re-exported from `mizan_core.registry`, `mizan.channels`,
|
||||||
The function/composition registry now lives in `mizan_core.registry`.
|
`mizan.forms`, and this package's own modules.
|
||||||
Channels register themselves through the channel-specific registry in
|
|
||||||
`mizan.channels`. Forms register through `mizan.forms`. This module
|
|
||||||
re-exports the helpers that Django mizan users typically reach for, so
|
|
||||||
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
|
||||||
working as a single curated surface.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from mizan_core.registry import (
|
from mizan_core.registry import (
|
||||||
@@ -35,12 +30,13 @@ from mizan.forms import (
|
|||||||
get_forms,
|
get_forms,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .discovery import (
|
from mizan.setup.discovery import (
|
||||||
|
discover_apps_roots,
|
||||||
mizan_clients,
|
mizan_clients,
|
||||||
mizan_module,
|
mizan_module,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .settings import (
|
from mizan.setup.settings import (
|
||||||
mizanSettings,
|
mizanSettings,
|
||||||
get_settings,
|
get_settings,
|
||||||
clear_settings_cache,
|
clear_settings_cache,
|
||||||
@@ -67,6 +63,7 @@ __all__ = [
|
|||||||
"validate_registry",
|
"validate_registry",
|
||||||
"clear_registry",
|
"clear_registry",
|
||||||
# Discovery
|
# Discovery
|
||||||
|
"discover_apps_roots",
|
||||||
"mizan_clients",
|
"mizan_clients",
|
||||||
"mizan_module",
|
"mizan_module",
|
||||||
# Settings
|
# Settings
|
||||||
|
|||||||
@@ -1,90 +1,93 @@
|
|||||||
"""
|
import logging
|
||||||
mizan Auto-Discovery
|
from pathlib import Path
|
||||||
|
|
||||||
Scans Django apps for server functions following the 'clients' layer convention:
|
|
||||||
- <app>/clients.py
|
|
||||||
- <app>/clients/**/*.py
|
|
||||||
|
|
||||||
Usage in urls.py:
|
|
||||||
from mizan.setup.discovery import mizan_clients
|
|
||||||
|
|
||||||
mizan_clients('apps') # Scans apps/*/clients.py
|
|
||||||
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
|
||||||
|
|
||||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
from django.apps import apps as django_apps
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
from mizan._vendor.app_visitor import (
|
||||||
|
DjangoAppVisitor,
|
||||||
|
DjangoAppVisitorHandler,
|
||||||
|
get_members,
|
||||||
|
)
|
||||||
|
|
||||||
from mizan_core.registry import register, get_function
|
from mizan_core.registry import register, get_function
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
|
||||||
"""Visitor handler that registers ServerFunction subclasses."""
|
|
||||||
|
|
||||||
|
class _RegisterServerFunctions(DjangoAppVisitorHandler):
|
||||||
def on_module(
|
def on_module(
|
||||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process discovered module members."""
|
|
||||||
for name, member in members:
|
for name, member in members:
|
||||||
# Register ServerFunction subclasses
|
|
||||||
if (
|
if (
|
||||||
isinstance(member, type)
|
isinstance(member, type)
|
||||||
and issubclass(member, ServerFunction)
|
and issubclass(member, ServerFunction)
|
||||||
and member is not ServerFunction
|
and member is not ServerFunction
|
||||||
and hasattr(member, "__name__")
|
and hasattr(member, "__name__")
|
||||||
):
|
):
|
||||||
# Use the function name as registration name
|
|
||||||
fn_name = getattr(member, "name", None) or member.__name__
|
fn_name = getattr(member, "name", None) or member.__name__
|
||||||
|
|
||||||
# Skip already registered (idempotent)
|
# Idempotent: the same class under the same name is a re-visit.
|
||||||
if get_function(fn_name) is member:
|
if get_function(fn_name) is member:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
register(member, fn_name)
|
register(member, fn_name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Already registered with different class - skip
|
logger.warning(
|
||||||
pass
|
"Server function name %r already registered with a "
|
||||||
|
"different class; skipping %s.%s",
|
||||||
|
fn_name,
|
||||||
|
member.__module__,
|
||||||
|
member.__qualname__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
def discover_apps_roots() -> list[str]:
|
||||||
"""
|
"""
|
||||||
Discover and register server functions from Django apps.
|
Dotted package prefixes the project's own apps sit under, relative to
|
||||||
|
BASE_DIR. `MIZAN_APPS_ROOT` in Django settings pins the answer.
|
||||||
|
|
||||||
Scans for the specified layer (default: 'clients') in each app:
|
An app counts as the project's own only when its directory is exactly
|
||||||
- <app>/<layer>.py
|
BASE_DIR joined with its dotted name — the same resolution DjangoAppVisitor
|
||||||
- <app>/<layer>/**/*.py
|
performs. That excludes installed packages even when the virtualenv holding
|
||||||
|
them sits inside BASE_DIR.
|
||||||
|
|
||||||
Args:
|
An app declared as "apps.blog" yields "apps"; a top-level "blog" yields "".
|
||||||
apps_root: Root package containing Django apps (e.g., 'apps')
|
|
||||||
layer: Module name pattern to scan (default: 'clients')
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# In urls.py
|
|
||||||
mizan_clients('apps') # Scans apps/*/clients.py
|
|
||||||
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
|
||||||
"""
|
"""
|
||||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
pinned = getattr(django_settings, "MIZAN_APPS_ROOT", None)
|
||||||
visitor.visit(_RegisterServerFunctions())
|
if pinned is not None:
|
||||||
|
return [pinned]
|
||||||
|
|
||||||
|
base_dir = Path(django_settings.BASE_DIR).resolve()
|
||||||
|
|
||||||
|
roots: list[str] = []
|
||||||
|
for app_config in django_apps.get_app_configs():
|
||||||
|
expected = base_dir.joinpath(*app_config.name.split("."))
|
||||||
|
if Path(app_config.path).resolve() != expected:
|
||||||
|
continue
|
||||||
|
root = app_config.name.rpartition(".")[0]
|
||||||
|
if root not in roots:
|
||||||
|
roots.append(root)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def mizan_clients(apps_root: str | None = None, layer: str = "clients") -> None:
|
||||||
|
"""
|
||||||
|
Scan <app>/<layer>.py and <app>/<layer>/**/*.py and register every
|
||||||
|
ServerFunction found. `apps_root` of None scans every discovered root.
|
||||||
|
"""
|
||||||
|
handler = _RegisterServerFunctions()
|
||||||
|
roots = [apps_root] if apps_root is not None else discover_apps_roots()
|
||||||
|
for root in roots:
|
||||||
|
DjangoAppVisitor(layer=layer, apps_root=root).visit(handler)
|
||||||
|
|
||||||
|
|
||||||
def mizan_module(module_path: str) -> None:
|
def mizan_module(module_path: str) -> None:
|
||||||
"""
|
"""Register the server functions defined in one module, e.g. 'mizan.jwt.functions'."""
|
||||||
Register server functions from a specific module.
|
|
||||||
|
|
||||||
Use this for library modules that don't follow the app convention.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
|
||||||
|
|
||||||
Example:
|
|
||||||
mizan_module('mizan.integrations.allauth')
|
|
||||||
mizan_module('mizan.jwt.functions')
|
|
||||||
"""
|
|
||||||
members = get_members(module_path)
|
members = get_members(module_path)
|
||||||
handler = _RegisterServerFunctions()
|
handler = _RegisterServerFunctions()
|
||||||
handler.on_module("", [], members)
|
handler.on_module("", [], members)
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
mizan.ssr — Server-side rendering via Bun subprocess.
|
Server-side rendering as a Django template backend: the template name is the
|
||||||
|
React component's file path and the context dict becomes its props.
|
||||||
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
|
from mizan.ssr.backend import MizanTemplates
|
||||||
|
|
||||||
__all__ = ["MizanTemplates"]
|
__all__ = ["MizanTemplates"]
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
Django template backend that resolves a template name to a .tsx/.jsx file
|
||||||
|
under DIRS and renders it through a Bun subprocess. `OPTIONS['worker']` names
|
||||||
TEMPLATES = [
|
the worker script; `OPTIONS['timeout']` bounds a single render.
|
||||||
{
|
|
||||||
'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
|
from __future__ import annotations
|
||||||
@@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist
|
|||||||
from django.template.backends.base import BaseEngine
|
from django.template.backends.base import BaseEngine
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from mizan_core.ssr import SSRBridge
|
from mizan.ssr.bridge import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
class MizanTemplate:
|
class MizanTemplate:
|
||||||
@@ -38,12 +28,12 @@ class MizanTemplate:
|
|||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
props = dict(context) if context else {}
|
props = dict(context) if context else {}
|
||||||
|
# Neither is JSON-serializable, and neither belongs in client hydration.
|
||||||
props.pop("request", None)
|
props.pop("request", None)
|
||||||
props.pop("csrf_token", None)
|
props.pop("csrf_token", None)
|
||||||
|
|
||||||
result = self._bridge.render(self.file_path, props)
|
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)
|
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||||
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
@@ -54,10 +44,12 @@ class MizanTemplate:
|
|||||||
|
|
||||||
class MizanTemplates(BaseEngine):
|
class MizanTemplates(BaseEngine):
|
||||||
"""
|
"""
|
||||||
Django template backend that renders React components via Bun.
|
Template backend whose template names are file paths resolved against
|
||||||
|
DIRS. The bridge subprocess is created on first template lookup.
|
||||||
|
|
||||||
Template names are file paths resolved against DIRS.
|
A template is a module the Bun worker imports by path, so a source string
|
||||||
Same model as Django's built-in template engines.
|
names nothing this engine can render — `from_string` is left to BaseEngine,
|
||||||
|
which rejects it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, params: dict[str, Any]) -> None:
|
def __init__(self, params: dict[str, Any]) -> None:
|
||||||
@@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine):
|
|||||||
self.get_bridge(),
|
self.get_bridge(),
|
||||||
)
|
)
|
||||||
raise TemplateDoesNotExist(template_name)
|
raise TemplateDoesNotExist(template_name)
|
||||||
|
|
||||||
def from_string(self, template_code: str) -> MizanTemplate:
|
|
||||||
raise TemplateDoesNotExist(
|
|
||||||
"MizanTemplates renders .tsx files, not template strings."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
SSR Bridge — manages a persistent Bun subprocess for React rendering.
|
Persistent Bun subprocess speaking newline-delimited JSON-RPC over
|
||||||
|
stdin/stdout.
|
||||||
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
|
|
||||||
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
|
|
||||||
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
|
|
||||||
render path — so the subprocess lifecycle and wire protocol are authored once.
|
|
||||||
|
|
||||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
|
||||||
|
|
||||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||||
Response: {"id": 1, "html": "<div>...</div>"}
|
Response: {"id": 1, "html": "<div>...</div>"}
|
||||||
|
|
||||||
The subprocess stays alive across requests. It is started on first use
|
Message id 0 is reserved for the worker's unsolicited ready signal.
|
||||||
and restarted automatically if it crashes.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,10 +29,9 @@ class RenderResult:
|
|||||||
|
|
||||||
class SSRBridge:
|
class SSRBridge:
|
||||||
"""
|
"""
|
||||||
Manages a persistent Bun subprocess for server-side rendering.
|
Owns the Bun subprocess. Thread-safe: concurrent render() callers are
|
||||||
|
matched to their response by message id, and stdin writes are serialized
|
||||||
Thread-safe. Multiple worker threads can call render() concurrently.
|
so requests never interleave mid-line.
|
||||||
Request-response matching via message IDs.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||||
@@ -47,18 +39,17 @@ class SSRBridge:
|
|||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._proc: subprocess.Popen | None = None
|
self._proc: subprocess.Popen | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
self._write_lock = threading.Lock()
|
||||||
self._counter = 0
|
self._counter = 0
|
||||||
self._pending: dict[int, threading.Event] = {}
|
self._pending: dict[int, threading.Event] = {}
|
||||||
self._results: dict[int, dict] = {}
|
self._results: dict[int, dict] = {}
|
||||||
self._reader_thread: threading.Thread | None = None
|
self._reader_thread: threading.Thread | None = None
|
||||||
self._ready = threading.Event()
|
self._ready = threading.Event()
|
||||||
|
|
||||||
# Ensure cleanup on process exit
|
|
||||||
atexit.register(self.shutdown)
|
atexit.register(self.shutdown)
|
||||||
|
|
||||||
def _ensure_running(self) -> None:
|
def _ensure_running(self) -> None:
|
||||||
"""Start the Bun subprocess if it's not running."""
|
"""Start the Bun subprocess if it is not already running."""
|
||||||
if self._proc is not None and self._proc.poll() is None:
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -78,7 +69,6 @@ class SSRBridge:
|
|||||||
)
|
)
|
||||||
self._reader_thread.start()
|
self._reader_thread.start()
|
||||||
|
|
||||||
# Wait for the "ready" signal from the worker
|
|
||||||
if not self._ready.wait(timeout=self._timeout):
|
if not self._ready.wait(timeout=self._timeout):
|
||||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
@@ -87,7 +77,7 @@ class SSRBridge:
|
|||||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||||
|
|
||||||
def _read_responses(self) -> None:
|
def _read_responses(self) -> None:
|
||||||
"""Background thread that reads JSON responses from stdout."""
|
"""Background thread that reads JSON responses from the worker's stdout."""
|
||||||
try:
|
try:
|
||||||
for line in self._proc.stdout:
|
for line in self._proc.stdout:
|
||||||
if isinstance(line, bytes):
|
if isinstance(line, bytes):
|
||||||
@@ -104,7 +94,6 @@ class SSRBridge:
|
|||||||
|
|
||||||
msg_id = msg.get("id")
|
msg_id = msg.get("id")
|
||||||
|
|
||||||
# Ready signal (id=0)
|
|
||||||
if msg_id == 0 and msg.get("ready"):
|
if msg_id == 0 and msg.get("ready"):
|
||||||
self._ready.set()
|
self._ready.set()
|
||||||
continue
|
continue
|
||||||
@@ -117,18 +106,10 @@ class SSRBridge:
|
|||||||
|
|
||||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||||
"""
|
"""
|
||||||
Render a React component to HTML.
|
Render the component at absolute path `file` with `props` to HTML.
|
||||||
|
|
||||||
Args:
|
Raises TimeoutError past the configured timeout and RuntimeError when
|
||||||
file: Absolute path to the .tsx/.jsx file to render.
|
the worker reports a render error or its pipe is broken.
|
||||||
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:
|
with self._lock:
|
||||||
self._ensure_running()
|
self._ensure_running()
|
||||||
@@ -144,7 +125,6 @@ class SSRBridge:
|
|||||||
"params": {"file": file, "props": props or {}},
|
"params": {"file": file, "props": props or {}},
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
|
||||||
with self._write_lock:
|
with self._write_lock:
|
||||||
try:
|
try:
|
||||||
self._proc.stdin.write(request.encode("utf-8"))
|
self._proc.stdin.write(request.encode("utf-8"))
|
||||||
@@ -168,19 +148,24 @@ class SSRBridge:
|
|||||||
return RenderResult(html=result["html"])
|
return RenderResult(html=result["html"])
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Stop the Bun subprocess."""
|
"""Stop the Bun subprocess, escalating to kill if terminate does not land."""
|
||||||
if self._proc is not None:
|
if self._proc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc.stdin.close()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Closing SSR worker stdin failed", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
logger.warning("SSR worker did not terminate; killing it", exc_info=True)
|
||||||
try:
|
try:
|
||||||
self._proc.stdin.close()
|
self._proc.kill()
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
logger.warning("Killing SSR worker failed", exc_info=True)
|
||||||
try:
|
|
||||||
self._proc.terminate()
|
self._proc = None
|
||||||
self._proc.wait(timeout=3)
|
logger.info("Bun SSR worker stopped")
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self._proc.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._proc = None
|
|
||||||
logger.info("Bun SSR worker stopped")
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Hello({ name }: { name?: string }) {
|
||||||
|
return <div data-mizan-component="Hello">Hello, {name}!</div>
|
||||||
|
}
|
||||||
@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
|
|||||||
|
|
||||||
def test_jwt_expired_with_session(self):
|
def test_jwt_expired_with_session(self):
|
||||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||||
# Create token with past expiration by mocking time (minting lives in the core now)
|
# Create token with past expiration by mocking time
|
||||||
with patch("mizan_core.auth.jwt.time.time", return_value=0):
|
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||||
tokens = create_token_pair(
|
tokens = create_token_pair(
|
||||||
self.user.pk,
|
self.user.pk,
|
||||||
self.session_key,
|
self.session_key,
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
Protocol Benchmark: HTTP vs WebSocket Server Functions
|
Latency and throughput measurements for server-function calls, comparing the
|
||||||
|
direct executor path against the full HTTP view path.
|
||||||
|
|
||||||
Compares performance of HTTP POST vs WebSocket RPC for server function calls.
|
These measure rather than assert on timing; each one still checks that the
|
||||||
Includes realistic scenarios with ORM queries.
|
function under measurement returned the right answer. Timings printed here are
|
||||||
|
only meaningful when the module is run in isolation.
|
||||||
Usage:
|
|
||||||
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
|
||||||
|
|
||||||
Note:
|
|
||||||
These are not unit tests - they measure performance. Results are printed
|
|
||||||
to stdout and should be run in isolation for accurate measurements.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import statistics
|
import statistics
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, AsyncMock
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.db import connection
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
from django.test import RequestFactory, TransactionTestCase
|
||||||
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
|
||||||
@@ -141,9 +133,10 @@ def setup_benchmark_functions():
|
|||||||
|
|
||||||
class ProtocolBenchmark(TransactionTestCase):
|
class ProtocolBenchmark(TransactionTestCase):
|
||||||
"""
|
"""
|
||||||
Benchmark comparing HTTP vs WebSocket (simulated) performance.
|
Per-call latency for the executor path versus the HTTP view path.
|
||||||
|
|
||||||
Uses TransactionTestCase to ensure database state is realistic.
|
TransactionTestCase rather than TestCase: the timings must include real
|
||||||
|
commits instead of running inside one rolled-back transaction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Number of iterations for each benchmark
|
# Number of iterations for each benchmark
|
||||||
@@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
# Create test users for ORM benchmarks
|
|
||||||
self._create_test_users()
|
self._create_test_users()
|
||||||
|
|
||||||
def _create_test_users(self):
|
def _create_test_users(self):
|
||||||
"""Create test users for benchmarks."""
|
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||||
# Create 100 test users
|
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(
|
users.append(
|
||||||
User(
|
User(
|
||||||
email=f"bench{i}@example.com",
|
email=f"bench{i}@example.com",
|
||||||
is_active=i % 10 != 0, # 90% active
|
is_active=i % 10 != 0,
|
||||||
is_staff=i < 5, # 5 staff
|
is_staff=i < 5,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||||
@@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
return request
|
return request
|
||||||
|
|
||||||
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
||||||
"""
|
"""Time direct executor calls and return timing statistics."""
|
||||||
Benchmark direct executor calls (simulates WebSocket RPC).
|
|
||||||
|
|
||||||
Returns timing statistics.
|
|
||||||
"""
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
times = []
|
times = []
|
||||||
|
|
||||||
@@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
return self._compute_stats(times, f"Executor ({label})")
|
return self._compute_stats(times, f"Executor ({label})")
|
||||||
|
|
||||||
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
||||||
"""
|
"""Time HTTP view calls and return timing statistics."""
|
||||||
Benchmark HTTP view calls.
|
|
||||||
|
|
||||||
Returns timing statistics.
|
|
||||||
"""
|
|
||||||
times = []
|
times = []
|
||||||
|
|
||||||
# Warmup
|
# Warmup
|
||||||
@@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
self.assertIn("bench", user["email"].lower())
|
self.assertIn("bench", user["email"].lower())
|
||||||
|
|
||||||
def test_summary(self):
|
def test_summary(self):
|
||||||
"""Print summary of all benchmarks."""
|
"""Print the legend for the preceding benchmark tables."""
|
||||||
print("\n\n" + "=" * 80)
|
print("\n\n" + "=" * 80)
|
||||||
print("BENCHMARK SUMMARY")
|
print("BENCHMARK SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
||||||
print(f"Warmup iterations: {self.WARMUP}")
|
print(f"Warmup iterations: {self.WARMUP}")
|
||||||
print("\nKey findings:")
|
print("\nColumns:")
|
||||||
print("- 'Executor' simulates WebSocket RPC (direct function call)")
|
print("- 'Executor' calls execute_function directly")
|
||||||
print("- 'HTTP' measures full request/response cycle")
|
print("- 'HTTP' calls function_call_view, so it includes JSON parsing,")
|
||||||
print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch")
|
print(" CSRF handling, and view dispatch")
|
||||||
print("- For I/O-bound operations, protocol overhead is negligible")
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
# Verify bench_simple still produces correct output after all benchmarks
|
# Verify bench_simple still produces correct output after all benchmarks
|
||||||
@@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ThroughputBenchmark(TransactionTestCase):
|
class ThroughputBenchmark(TransactionTestCase):
|
||||||
"""
|
"""Requests per second for the executor path versus the HTTP view path."""
|
||||||
Measure requests per second (throughput) for server functions.
|
|
||||||
|
|
||||||
Tests both sequential and concurrent scenarios.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DURATION_SECONDS = 2 # How long to run each throughput test
|
DURATION_SECONDS = 2 # How long to run each throughput test
|
||||||
|
|
||||||
@@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
self._create_test_users()
|
self._create_test_users()
|
||||||
|
|
||||||
def _create_test_users(self):
|
def _create_test_users(self):
|
||||||
"""Create test users for benchmarks."""
|
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(
|
users.append(
|
||||||
@@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
self.assertGreaterEqual(result.data["total_users"], 0)
|
self.assertGreaterEqual(result.data["total_users"], 0)
|
||||||
|
|
||||||
def test_throughput_summary(self):
|
def test_throughput_summary(self):
|
||||||
"""Print throughput summary."""
|
"""Print the measurement conditions for the preceding throughput tests."""
|
||||||
print("\n\n" + "=" * 80)
|
print("\n\n" + "=" * 80)
|
||||||
print("THROUGHPUT SUMMARY")
|
print("THROUGHPUT SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
||||||
print("\nNotes:")
|
print("\nConditions:")
|
||||||
print("- These are single-threaded sequential measurements")
|
print("- Single-threaded and sequential")
|
||||||
print("- Real throughput scales with worker processes (gunicorn -w N)")
|
print("- SQLite in-memory database")
|
||||||
print("- Database queries are the bottleneck, not protocol overhead")
|
|
||||||
print("- Async workers (uvicorn) can handle more concurrent connections")
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
# Verify bench_simple still produces correct output after all throughput tests
|
# Verify bench_simple still produces correct output after all throughput tests
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ Tests for mizan.channels module.
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.channels import (
|
from mizan.channels import (
|
||||||
ReactChannel,
|
Channel,
|
||||||
register,
|
register,
|
||||||
get_channel,
|
get_channel,
|
||||||
get_registered_channels,
|
get_registered_channels,
|
||||||
@@ -18,9 +17,6 @@ from mizan.channels import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Test Fixtures
|
# Test Fixtures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -42,52 +38,47 @@ class MockAnonymousUser:
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ReactChannel Base Class Tests
|
# Channel Base Class Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ReactChannelBaseTests(TestCase):
|
class ChannelBaseTests(TestCase):
|
||||||
"""Tests for ReactChannel base class."""
|
"""Tests for Channel base class."""
|
||||||
|
|
||||||
def test_react_channel_default_class_vars(self):
|
def test_channel_default_class_vars(self):
|
||||||
"""ReactChannel should have None defaults for nested classes."""
|
"""Channel should have None defaults for nested classes."""
|
||||||
self.assertIsNone(ReactChannel.Params)
|
self.assertIsNone(Channel.Params)
|
||||||
self.assertIsNone(ReactChannel.ReactMessage)
|
self.assertIsNone(Channel.ClientMessage)
|
||||||
self.assertIsNone(ReactChannel.DjangoMessage)
|
self.assertIsNone(Channel.ServerMessage)
|
||||||
|
|
||||||
def test_react_channel_requires_authorize_override(self):
|
def test_channel_requires_authorize_override(self):
|
||||||
"""ReactChannel subclass must override authorize()."""
|
"""A subclass without authorize() cannot be instantiated."""
|
||||||
|
|
||||||
class IncompleteChannel(ReactChannel):
|
class NoAuthorizeChannel(Channel):
|
||||||
pass
|
def group(self, params=None):
|
||||||
|
return "test"
|
||||||
|
|
||||||
channel = IncompleteChannel()
|
with self.assertRaises(TypeError) as ctx:
|
||||||
channel.user = MockUser()
|
NoAuthorizeChannel()
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError) as ctx:
|
self.assertIn("authorize", str(ctx.exception))
|
||||||
channel.authorize()
|
|
||||||
|
|
||||||
self.assertIn("must implement authorize()", str(ctx.exception))
|
def test_channel_requires_group_override(self):
|
||||||
|
"""A subclass without group() cannot be instantiated."""
|
||||||
|
|
||||||
def test_react_channel_requires_group_override(self):
|
class NoGroupChannel(Channel):
|
||||||
"""ReactChannel subclass must override group()."""
|
|
||||||
|
|
||||||
class IncompleteChannel(ReactChannel):
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
channel = IncompleteChannel()
|
with self.assertRaises(TypeError) as ctx:
|
||||||
channel.user = MockUser()
|
NoGroupChannel()
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError) as ctx:
|
self.assertIn("group", str(ctx.exception))
|
||||||
channel.group()
|
|
||||||
|
|
||||||
self.assertIn("must implement group()", str(ctx.exception))
|
def test_channel_receive_default(self):
|
||||||
|
"""Channel.receive() should return None by default."""
|
||||||
|
|
||||||
def test_react_channel_receive_default(self):
|
class BasicChannel(Channel):
|
||||||
"""ReactChannel.receive() should return None by default."""
|
|
||||||
|
|
||||||
class BasicChannel(ReactChannel):
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_react_channel_init_creates_empty_groups(self):
|
def test_channel_init_creates_empty_groups(self):
|
||||||
"""ReactChannel.__init__() should create empty _groups set."""
|
"""Channel.__init__() should create empty _groups set."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase):
|
|||||||
def test_channel_with_params(self):
|
def test_channel_with_params(self):
|
||||||
"""Channel should accept Params Pydantic model."""
|
"""Channel should accept Params Pydantic model."""
|
||||||
|
|
||||||
class ParamsChannel(ReactChannel):
|
class ParamsChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
limit: int = 10
|
limit: int = 10
|
||||||
@@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase):
|
|||||||
|
|
||||||
self.assertIsNotNone(ParamsChannel.Params)
|
self.assertIsNotNone(ParamsChannel.Params)
|
||||||
|
|
||||||
# Test params model
|
|
||||||
params = ParamsChannel.Params(room="general")
|
params = ParamsChannel.Params(room="general")
|
||||||
self.assertEqual(params.room, "general")
|
self.assertEqual(params.room, "general")
|
||||||
self.assertEqual(params.limit, 10)
|
self.assertEqual(params.limit, 10)
|
||||||
|
|
||||||
def test_channel_with_react_message(self):
|
def test_channel_with_client_message(self):
|
||||||
"""Channel should accept ReactMessage Pydantic model."""
|
"""Channel should accept ClientMessage Pydantic model."""
|
||||||
|
|
||||||
class MessageChannel(ReactChannel):
|
class MessageChannel(Channel):
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
timestamp: int
|
timestamp: int
|
||||||
|
|
||||||
@@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "messages"
|
return "messages"
|
||||||
|
|
||||||
self.assertIsNotNone(MessageChannel.ReactMessage)
|
self.assertIsNotNone(MessageChannel.ClientMessage)
|
||||||
|
|
||||||
# Test message model
|
msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345)
|
||||||
msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345)
|
|
||||||
self.assertEqual(msg.text, "Hello")
|
self.assertEqual(msg.text, "Hello")
|
||||||
self.assertEqual(msg.timestamp, 12345)
|
self.assertEqual(msg.timestamp, 12345)
|
||||||
|
|
||||||
def test_channel_with_django_message(self):
|
def test_channel_with_server_message(self):
|
||||||
"""Channel should accept DjangoMessage Pydantic model."""
|
"""Channel should accept ServerMessage Pydantic model."""
|
||||||
|
|
||||||
class BroadcastChannel(ReactChannel):
|
class BroadcastChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "broadcast"
|
return "broadcast"
|
||||||
|
|
||||||
self.assertIsNotNone(BroadcastChannel.DjangoMessage)
|
self.assertIsNotNone(BroadcastChannel.ServerMessage)
|
||||||
|
|
||||||
# Test message model
|
msg = BroadcastChannel.ServerMessage(
|
||||||
msg = BroadcastChannel.DjangoMessage(
|
|
||||||
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||||
)
|
)
|
||||||
self.assertEqual(msg.user, "john")
|
self.assertEqual(msg.user, "john")
|
||||||
@@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase):
|
|||||||
def test_channel_receive_with_typed_messages(self):
|
def test_channel_receive_with_typed_messages(self):
|
||||||
"""Channel.receive() should work with typed messages."""
|
"""Channel.receive() should work with typed messages."""
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase):
|
|||||||
return f"chat_{params.room}"
|
return f"chat_{params.room}"
|
||||||
|
|
||||||
def receive(self, params, msg):
|
def receive(self, params, msg):
|
||||||
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
return self.ServerMessage(user=self.user.email, text=msg.text)
|
||||||
|
|
||||||
channel = ChatChannel()
|
channel = ChatChannel()
|
||||||
channel.user = MockUser(email="test@example.com")
|
channel.user = MockUser(email="test@example.com")
|
||||||
|
|
||||||
params = ChatChannel.Params(room="general")
|
params = ChatChannel.Params(room="general")
|
||||||
incoming = ChatChannel.ReactMessage(text="Hello!")
|
incoming = ChatChannel.ClientMessage(text="Hello!")
|
||||||
|
|
||||||
result = channel.receive(params, incoming)
|
result = channel.receive(params, incoming)
|
||||||
|
|
||||||
self.assertIsInstance(result, ChatChannel.DjangoMessage)
|
self.assertIsInstance(result, ChatChannel.ServerMessage)
|
||||||
self.assertEqual(result.user, "test@example.com")
|
self.assertEqual(result.user, "test@example.com")
|
||||||
self.assertEqual(result.text, "Hello!")
|
self.assertEqual(result.text, "Hello!")
|
||||||
|
|
||||||
@@ -243,7 +231,7 @@ class RegistrationTests(TestCase):
|
|||||||
def test_register_adds_to_registry(self):
|
def test_register_adds_to_registry(self):
|
||||||
"""register() should add channel to registry."""
|
"""register() should add channel to registry."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -255,17 +243,31 @@ class RegistrationTests(TestCase):
|
|||||||
self.assertIn("test-channel", _registry)
|
self.assertIn("test-channel", _registry)
|
||||||
self.assertEqual(_registry["test-channel"], TestChannel)
|
self.assertEqual(_registry["test-channel"], TestChannel)
|
||||||
|
|
||||||
def test_register_duplicate_raises(self):
|
def test_register_sets_registered_name(self):
|
||||||
"""register() should raise on duplicate name."""
|
"""register() should stamp the wire name onto the class."""
|
||||||
|
|
||||||
class Channel1(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "test"
|
return "test"
|
||||||
|
|
||||||
class Channel2(ReactChannel):
|
register(TestChannel, "named-channel")
|
||||||
|
|
||||||
|
self.assertEqual(TestChannel._registered_name, "named-channel")
|
||||||
|
|
||||||
|
def test_register_duplicate_raises(self):
|
||||||
|
"""register() should raise on duplicate name."""
|
||||||
|
|
||||||
|
class Channel1(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "test"
|
||||||
|
|
||||||
|
class Channel2(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -279,21 +281,10 @@ class RegistrationTests(TestCase):
|
|||||||
|
|
||||||
self.assertIn("already registered", str(ctx.exception))
|
self.assertIn("already registered", str(ctx.exception))
|
||||||
|
|
||||||
def test_register_validates_authorize(self):
|
|
||||||
"""register() should validate that authorize method exists."""
|
|
||||||
|
|
||||||
class NoAuthorizeChannel(ReactChannel):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Should still pass because ReactChannel has authorize
|
|
||||||
# (just raises NotImplementedError when called)
|
|
||||||
register(NoAuthorizeChannel, "no-authorize-test")
|
|
||||||
self.assertIn("no-authorize-test", _registry)
|
|
||||||
|
|
||||||
def test_get_channel_returns_registered(self):
|
def test_get_channel_returns_registered(self):
|
||||||
"""get_channel() should return registered channel."""
|
"""get_channel() should return registered channel."""
|
||||||
|
|
||||||
class MyChannel(ReactChannel):
|
class MyChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -315,7 +306,7 @@ class RegistrationTests(TestCase):
|
|||||||
def test_get_registered_channels_returns_copy(self):
|
def test_get_registered_channels_returns_copy(self):
|
||||||
"""get_registered_channels() should return a copy of registry."""
|
"""get_registered_channels() should return a copy of registry."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -326,7 +317,6 @@ class RegistrationTests(TestCase):
|
|||||||
|
|
||||||
result = get_registered_channels()
|
result = get_registered_channels()
|
||||||
|
|
||||||
# Modifying result shouldn't affect original
|
|
||||||
result["modified"] = "test"
|
result["modified"] = "test"
|
||||||
|
|
||||||
self.assertIn("copy-test", _registry)
|
self.assertIn("copy-test", _registry)
|
||||||
@@ -360,7 +350,7 @@ class SchemaExportTests(TestCase):
|
|||||||
def test_get_channels_schema_with_basic_channel(self):
|
def test_get_channels_schema_with_basic_channel(self):
|
||||||
"""get_channels_schema() should include basic channel info."""
|
"""get_channels_schema() should include basic channel info."""
|
||||||
|
|
||||||
class BasicChannel(ReactChannel):
|
class BasicChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -376,13 +366,13 @@ class SchemaExportTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(channel_schema["name"], "basic")
|
self.assertEqual(channel_schema["name"], "basic")
|
||||||
self.assertIsNone(channel_schema["params"])
|
self.assertIsNone(channel_schema["params"])
|
||||||
self.assertIsNone(channel_schema["reactMessage"])
|
self.assertIsNone(channel_schema["clientMessage"])
|
||||||
self.assertIsNone(channel_schema["djangoMessage"])
|
self.assertIsNone(channel_schema["serverMessage"])
|
||||||
|
|
||||||
def test_get_channels_schema_with_params(self):
|
def test_get_channels_schema_with_params(self):
|
||||||
"""get_channels_schema() should include params schema."""
|
"""get_channels_schema() should include params schema."""
|
||||||
|
|
||||||
class ParamsChannel(ReactChannel):
|
class ParamsChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
limit: int = 50
|
limit: int = 50
|
||||||
@@ -407,14 +397,14 @@ class SchemaExportTests(TestCase):
|
|||||||
def test_get_channels_schema_with_messages(self):
|
def test_get_channels_schema_with_messages(self):
|
||||||
"""get_channels_schema() should include message schemas."""
|
"""get_channels_schema() should include message schemas."""
|
||||||
|
|
||||||
class FullChannel(ReactChannel):
|
class FullChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
channel_id: int
|
channel_id: int
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
@@ -431,24 +421,21 @@ class SchemaExportTests(TestCase):
|
|||||||
|
|
||||||
channel_schema = schema["channels"]["full-channel"]
|
channel_schema = schema["channels"]["full-channel"]
|
||||||
|
|
||||||
# Check params
|
|
||||||
self.assertIsNotNone(channel_schema["params"])
|
self.assertIsNotNone(channel_schema["params"])
|
||||||
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
||||||
|
|
||||||
# Check ReactMessage
|
self.assertIsNotNone(channel_schema["clientMessage"])
|
||||||
self.assertIsNotNone(channel_schema["reactMessage"])
|
self.assertIn("text", channel_schema["clientMessage"]["properties"])
|
||||||
self.assertIn("text", channel_schema["reactMessage"]["properties"])
|
|
||||||
|
|
||||||
# Check DjangoMessage
|
self.assertIsNotNone(channel_schema["serverMessage"])
|
||||||
self.assertIsNotNone(channel_schema["djangoMessage"])
|
self.assertIn("user", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("user", channel_schema["djangoMessage"]["properties"])
|
self.assertIn("text", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("text", channel_schema["djangoMessage"]["properties"])
|
self.assertIn("timestamp", channel_schema["serverMessage"]["properties"])
|
||||||
self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"])
|
|
||||||
|
|
||||||
def test_get_channels_schema_multiple_channels(self):
|
def test_get_channels_schema_multiple_channels(self):
|
||||||
"""get_channels_schema() should include all registered channels."""
|
"""get_channels_schema() should include all registered channels."""
|
||||||
|
|
||||||
class Channel1(ReactChannel):
|
class Channel1(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@@ -458,7 +445,7 @@ class SchemaExportTests(TestCase):
|
|||||||
def group(self, params):
|
def group(self, params):
|
||||||
return f"c1_{params.id}"
|
return f"c1_{params.id}"
|
||||||
|
|
||||||
class Channel2(ReactChannel):
|
class Channel2(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -473,13 +460,117 @@ class SchemaExportTests(TestCase):
|
|||||||
self.assertIn("channel-one", schema["channels"])
|
self.assertIn("channel-one", schema["channels"])
|
||||||
self.assertIn("channel-two", schema["channels"])
|
self.assertIn("channel-two", schema["channels"])
|
||||||
|
|
||||||
# Channel 1 has params
|
|
||||||
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
||||||
|
|
||||||
# Channel 2 has no params
|
|
||||||
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Registry Extension Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsExtensionTests(TestCase):
|
||||||
|
"""Tests for the channels extension plugged into mizan_core.registry."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._original_registry = dict(_registry)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_registry.clear()
|
||||||
|
_registry.update(self._original_registry)
|
||||||
|
|
||||||
|
def _extension(self):
|
||||||
|
from mizan_core.registry import _extensions
|
||||||
|
|
||||||
|
return _extensions["channels"]
|
||||||
|
|
||||||
|
def test_extension_all_returns_registry_copy(self):
|
||||||
|
"""all() should return name -> channel class, decoupled from the registry."""
|
||||||
|
|
||||||
|
class TestChannel(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "ext"
|
||||||
|
|
||||||
|
register(TestChannel, "ext-all")
|
||||||
|
|
||||||
|
result = self._extension().all()
|
||||||
|
|
||||||
|
self.assertEqual(result["ext-all"], TestChannel)
|
||||||
|
|
||||||
|
result["modified"] = TestChannel
|
||||||
|
self.assertNotIn("modified", _registry)
|
||||||
|
|
||||||
|
def test_extension_schema_names_both_directions(self):
|
||||||
|
"""schema() should carry client_message and server_message slots."""
|
||||||
|
|
||||||
|
class ChatChannel(Channel):
|
||||||
|
class Params(BaseModel):
|
||||||
|
room: str
|
||||||
|
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def authorize(self, params):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params):
|
||||||
|
return f"chat_{params.room}"
|
||||||
|
|
||||||
|
register(ChatChannel, "ext-chat")
|
||||||
|
|
||||||
|
entry = self._extension().schema()["ext-chat"]
|
||||||
|
|
||||||
|
self.assertEqual(entry["type"], "channel")
|
||||||
|
self.assertTrue(entry["bidirectional"])
|
||||||
|
self.assertIn("params", entry)
|
||||||
|
self.assertIn("client_message", entry)
|
||||||
|
self.assertIn("server_message", entry)
|
||||||
|
|
||||||
|
def test_extension_schema_omits_absent_client_message(self):
|
||||||
|
"""A server-push-only channel is not bidirectional."""
|
||||||
|
|
||||||
|
class NotificationsChannel(Channel):
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "notifications"
|
||||||
|
|
||||||
|
register(NotificationsChannel, "ext-notifications")
|
||||||
|
|
||||||
|
entry = self._extension().schema()["ext-notifications"]
|
||||||
|
|
||||||
|
self.assertNotIn("client_message", entry)
|
||||||
|
self.assertIn("server_message", entry)
|
||||||
|
self.assertFalse(entry["bidirectional"])
|
||||||
|
|
||||||
|
def test_extension_clear_empties_registry(self):
|
||||||
|
"""clear() should drop every registration."""
|
||||||
|
|
||||||
|
class TestChannel(Channel):
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "ext"
|
||||||
|
|
||||||
|
register(TestChannel, "ext-clear")
|
||||||
|
|
||||||
|
self._extension().clear()
|
||||||
|
|
||||||
|
self.assertEqual(_registry, {})
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Authorization Tests
|
# Authorization Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -491,7 +582,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_authenticated_user(self):
|
def test_authorize_with_authenticated_user(self):
|
||||||
"""authorize() should work with authenticated users."""
|
"""authorize() should work with authenticated users."""
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return self.user.is_authenticated
|
return self.user.is_authenticated
|
||||||
|
|
||||||
@@ -506,7 +597,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_anonymous_user(self):
|
def test_authorize_with_anonymous_user(self):
|
||||||
"""authorize() should work with anonymous users."""
|
"""authorize() should work with anonymous users."""
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return self.user.is_authenticated
|
return self.user.is_authenticated
|
||||||
|
|
||||||
@@ -521,7 +612,7 @@ class AuthorizationTests(TestCase):
|
|||||||
def test_authorize_with_params(self):
|
def test_authorize_with_params(self):
|
||||||
"""authorize() should have access to params."""
|
"""authorize() should have access to params."""
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
@@ -553,7 +644,7 @@ class GroupTests(TestCase):
|
|||||||
def test_group_returns_string(self):
|
def test_group_returns_string(self):
|
||||||
"""group() should return a string group name."""
|
"""group() should return a string group name."""
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -567,7 +658,7 @@ class GroupTests(TestCase):
|
|||||||
def test_group_with_params(self):
|
def test_group_with_params(self):
|
||||||
"""group() should use params for dynamic group names."""
|
"""group() should use params for dynamic group names."""
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room_id: int
|
room_id: int
|
||||||
|
|
||||||
@@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_join_group() should add group to _groups set."""
|
"""_join_group() should add group to _groups set."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_leave_group() should remove group from _groups set."""
|
"""_leave_group() should remove group from _groups set."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_leave_group() should ignore groups not in _groups."""
|
"""_leave_group() should ignore groups not in _groups."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase):
|
|||||||
await channel._leave_group("unknown-group")
|
await channel._leave_group("unknown-group")
|
||||||
return channel._groups
|
return channel._groups
|
||||||
|
|
||||||
groups = asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
# Should not have called group_discard
|
|
||||||
channel._channel_layer.group_discard.assert_not_called()
|
channel._channel_layer.group_discard.assert_not_called()
|
||||||
|
|
||||||
def test_leave_all_groups(self):
|
def test_leave_all_groups(self):
|
||||||
"""_leave_all_groups() should leave all joined groups."""
|
"""_leave_all_groups() should leave all joined groups."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase):
|
|||||||
"""_broadcast() should send message to channel layer."""
|
"""_broadcast() should send message to channel layer."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
channel = TestChannel()
|
channel = TestChannel()
|
||||||
channel._channel_layer = AsyncMock()
|
channel._channel_layer = AsyncMock()
|
||||||
|
|
||||||
message = TestChannel.DjangoMessage(text="Hello")
|
message = TestChannel.ServerMessage(text="Hello")
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await channel._broadcast("my-group", message)
|
await channel._broadcast("my-group", message)
|
||||||
@@ -747,8 +837,8 @@ class ServerPushTests(TestCase):
|
|||||||
"""push() should work for channels without params."""
|
"""push() should work for channels without params."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class NotificationChannel(ReactChannel):
|
class NotificationChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
@@ -762,7 +852,7 @@ class ServerPushTests(TestCase):
|
|||||||
mock_layer = AsyncMock()
|
mock_layer = AsyncMock()
|
||||||
mock_get_layer.return_value = mock_layer
|
mock_get_layer.return_value = mock_layer
|
||||||
|
|
||||||
message = NotificationChannel.DjangoMessage(
|
message = NotificationChannel.ServerMessage(
|
||||||
title="Alert", body="Something happened"
|
title="Alert", body="Something happened"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -781,11 +871,11 @@ class ServerPushTests(TestCase):
|
|||||||
"""push() should work for channels with params."""
|
"""push() should work for channels with params."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
@@ -798,7 +888,7 @@ class ServerPushTests(TestCase):
|
|||||||
mock_layer = AsyncMock()
|
mock_layer = AsyncMock()
|
||||||
mock_get_layer.return_value = mock_layer
|
mock_get_layer.return_value = mock_layer
|
||||||
|
|
||||||
message = RoomChannel.DjangoMessage(text="Hello room!")
|
message = RoomChannel.ServerMessage(text="Hello room!")
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await RoomChannel.push(room="general", message=message)
|
await RoomChannel.push(room="general", message=message)
|
||||||
@@ -814,10 +904,9 @@ class ServerPushTests(TestCase):
|
|||||||
def test_push_without_channel_layer_warns(self):
|
def test_push_without_channel_layer_warns(self):
|
||||||
"""push() should warn when no channel layer is configured."""
|
"""push() should warn when no channel layer is configured."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -829,7 +918,7 @@ class ServerPushTests(TestCase):
|
|||||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||||
mock_get_layer.return_value = None
|
mock_get_layer.return_value = None
|
||||||
|
|
||||||
message = TestChannel.DjangoMessage(text="test")
|
message = TestChannel.ServerMessage(text="test")
|
||||||
|
|
||||||
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||||
|
|
||||||
@@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase):
|
|||||||
|
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
# Should be valid JSON with OpenAPI structure
|
|
||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
self.assertIn("openapi", schema)
|
self.assertIn("openapi", schema)
|
||||||
@@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase):
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase):
|
|||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
# Check that channel is in x-mizan-channels metadata
|
|
||||||
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
||||||
self.assertIn("export-test", channel_names)
|
self.assertIn("export-test", channel_names)
|
||||||
|
|
||||||
|
def test_export_command_names_message_slots(self):
|
||||||
|
"""The x-mizan-channels table should name the client and server slots."""
|
||||||
|
from io import StringIO
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
class SlotChannel(Channel):
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def authorize(self, params=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params=None):
|
||||||
|
return "slots"
|
||||||
|
|
||||||
|
register(SlotChannel, "slot_channel")
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
|
schema = json.loads(out.getvalue())
|
||||||
|
entry = next(
|
||||||
|
c for c in schema["x-mizan-channels"] if c["name"] == "slot_channel"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(entry["pascalName"], "SlotChannel")
|
||||||
|
self.assertTrue(entry["hasClientMessage"])
|
||||||
|
self.assertTrue(entry["hasServerMessage"])
|
||||||
|
self.assertEqual(entry["clientMessageType"], "SlotChannelClientMessage")
|
||||||
|
self.assertEqual(entry["serverMessageType"], "SlotChannelServerMessage")
|
||||||
|
|
||||||
|
def test_export_command_type_names_match_the_ir(self):
|
||||||
|
"""A dotted-and-hyphenated wire name yields the same type names the IR
|
||||||
|
emits — the OpenAPI document and the IR describe one set of types."""
|
||||||
|
from io import StringIO
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from mizan_core.ir import build_ir, wire_to_pascal
|
||||||
|
|
||||||
|
class ActivityFeedChannel(Channel):
|
||||||
|
class Params(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
ack: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
event: str
|
||||||
|
|
||||||
|
def authorize(self, params):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params):
|
||||||
|
return f"activity_{params.user_id}"
|
||||||
|
|
||||||
|
register(ActivityFeedChannel, "activity.live-feed")
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
|
schema = json.loads(out.getvalue())
|
||||||
|
entry = next(
|
||||||
|
c for c in schema["x-mizan-channels"] if c["name"] == "activity.live-feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
pascal = wire_to_pascal("activity.live-feed")
|
||||||
|
self.assertEqual(pascal, "ActivityLiveFeed")
|
||||||
|
self.assertEqual(entry["pascalName"], pascal)
|
||||||
|
self.assertEqual(entry["paramsType"], f"{pascal}Params")
|
||||||
|
self.assertEqual(entry["clientMessageType"], f"{pascal}ClientMessage")
|
||||||
|
self.assertEqual(entry["serverMessageType"], f"{pascal}ServerMessage")
|
||||||
|
|
||||||
|
components = schema["components"]["schemas"]
|
||||||
|
ir = build_ir()
|
||||||
|
for slot in ("Params", "ClientMessage", "ServerMessage"):
|
||||||
|
self.assertIn(f"{pascal}{slot}", components)
|
||||||
|
self.assertIn(f'type "{pascal}{slot}"', ir)
|
||||||
|
|
||||||
def test_export_command_respects_indent(self):
|
def test_export_command_respects_indent(self):
|
||||||
"""export_channels_schema should respect --indent option."""
|
"""export_channels_schema should respect --indent option."""
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
# With indent
|
|
||||||
out_indent = StringIO()
|
out_indent = StringIO()
|
||||||
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
||||||
|
|
||||||
# Without indent (compact)
|
|
||||||
out_compact = StringIO()
|
out_compact = StringIO()
|
||||||
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
||||||
|
|
||||||
# Indented should be longer (has whitespace)
|
|
||||||
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
||||||
|
|
||||||
|
|
||||||
@@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase):
|
|||||||
"""Tests for WebSocket RPC functionality."""
|
"""Tests for WebSocket RPC functionality."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Clear mizan registry
|
|
||||||
from mizan_core.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
# Register test functions
|
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -1000,7 +1163,8 @@ class WebSocketRPCTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response["id"], "test-123")
|
self.assertEqual(response["id"], "test-123")
|
||||||
self.assertTrue(response["ok"])
|
self.assertTrue(response["ok"])
|
||||||
self.assertEqual(response["data"]["echo"], "Echo: Hello")
|
# data is the {result, invalidate, merge} envelope, as on the HTTP RPC path
|
||||||
|
self.assertEqual(response["data"]["result"]["echo"], "Echo: Hello")
|
||||||
|
|
||||||
def test_handle_rpc_with_multiple_args(self):
|
def test_handle_rpc_with_multiple_args(self):
|
||||||
"""_handle_rpc should handle functions with multiple arguments."""
|
"""_handle_rpc should handle functions with multiple arguments."""
|
||||||
@@ -1029,7 +1193,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
|
|
||||||
response = consumer.sent_messages[0]
|
response = consumer.sent_messages[0]
|
||||||
self.assertTrue(response["ok"])
|
self.assertTrue(response["ok"])
|
||||||
self.assertEqual(response["data"]["result"], 8)
|
self.assertEqual(response["data"]["result"]["result"], 8)
|
||||||
|
|
||||||
def test_handle_rpc_function_not_found(self):
|
def test_handle_rpc_function_not_found(self):
|
||||||
"""_handle_rpc should return error for unknown function."""
|
"""_handle_rpc should return error for unknown function."""
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from mizan_core.registry import (
|
|||||||
)
|
)
|
||||||
from mizan.forms import register_form
|
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 Channel
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -61,10 +61,9 @@ class ErrorOutput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def setup_function_style_tests():
|
def setup_function_style_tests():
|
||||||
"""Register function-style test functions.
|
"""
|
||||||
|
Register the function-style test functions. Applying @client does not put
|
||||||
Note: Since @client no longer auto-registers (registration happens via
|
a function in the registry, so each one is passed to register() here.
|
||||||
mizan_clients() discovery), we explicitly register each function here.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -514,8 +513,8 @@ class ContextTests(TestCase):
|
|||||||
fn = get_function("global_context")
|
fn = get_function("global_context")
|
||||||
self.assertEqual(fn._meta.get("context"), "global")
|
self.assertEqual(fn._meta.get("context"), "global")
|
||||||
|
|
||||||
def test_context_local(self):
|
def test_context_arbitrary_name_is_verbatim_and_silent(self):
|
||||||
"""Test @client(context='local') still works with deprecation warning."""
|
"""Any non-empty context string becomes the name verbatim, with no warning."""
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
class CtxOutput(BaseModel):
|
class CtxOutput(BaseModel):
|
||||||
@@ -528,8 +527,7 @@ class ContextTests(TestCase):
|
|||||||
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
||||||
return CtxOutput(data=f"user_{user_id}")
|
return CtxOutput(data=f"user_{user_id}")
|
||||||
|
|
||||||
self.assertEqual(len(w), 1)
|
self.assertEqual([str(entry.message) for entry in w], [])
|
||||||
self.assertIn("deprecated", str(w[0].message).lower())
|
|
||||||
|
|
||||||
register(local_context, "local_context")
|
register(local_context, "local_context")
|
||||||
|
|
||||||
@@ -1019,7 +1017,7 @@ 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")
|
||||||
|
|
||||||
# Mizan handles caching via its protocol; origin emits no-store
|
# Origin emits no-store
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_context_error_not_cached(self):
|
def test_context_error_not_cached(self):
|
||||||
@@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelTests(TestCase):
|
class ChannelTests(TestCase):
|
||||||
"""Tests for ReactChannel."""
|
"""Tests for Channel."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -1187,8 +1185,8 @@ class ChannelTests(TestCase):
|
|||||||
"""Test channel registration."""
|
"""Test channel registration."""
|
||||||
from mizan.channels import register as register_channel, get_channel
|
from mizan.channels import register as register_channel, get_channel
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -1201,14 +1199,14 @@ class ChannelTests(TestCase):
|
|||||||
"""Test channel schema export."""
|
"""Test channel schema export."""
|
||||||
from mizan.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: int
|
room: int
|
||||||
|
|
||||||
class ReactMessage(BaseModel):
|
class ClientMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -1225,16 +1223,16 @@ class ChannelTests(TestCase):
|
|||||||
chat_schema = schema["channels"]["chat"]
|
chat_schema = schema["channels"]["chat"]
|
||||||
self.assertEqual(chat_schema["type"], "channel")
|
self.assertEqual(chat_schema["type"], "channel")
|
||||||
self.assertIn("params", chat_schema)
|
self.assertIn("params", chat_schema)
|
||||||
self.assertIn("react_message", chat_schema)
|
self.assertIn("client_message", chat_schema)
|
||||||
self.assertIn("django_message", chat_schema)
|
self.assertIn("server_message", chat_schema)
|
||||||
self.assertTrue(chat_schema["bidirectional"])
|
self.assertTrue(chat_schema["bidirectional"])
|
||||||
|
|
||||||
def test_server_push_only_channel(self):
|
def test_server_push_only_channel(self):
|
||||||
"""Test channel without ReactMessage (server-push only)."""
|
"""Test channel without ClientMessage (server-push only)."""
|
||||||
from mizan.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class NotificationsChannel(ReactChannel):
|
class NotificationsChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -1244,7 +1242,7 @@ class ChannelTests(TestCase):
|
|||||||
schema = get_schema()
|
schema = get_schema()
|
||||||
notif_schema = schema["channels"]["notifications"]
|
notif_schema = schema["channels"]["notifications"]
|
||||||
|
|
||||||
self.assertNotIn("react_message", notif_schema)
|
self.assertNotIn("client_message", notif_schema)
|
||||||
self.assertFalse(notif_schema["bidirectional"])
|
self.assertFalse(notif_schema["bidirectional"])
|
||||||
|
|
||||||
|
|
||||||
@@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
||||||
|
|
||||||
This is a regression test for the bug where `UserOutput | None` was
|
Union types are not recognized by `isinstance(t, type)`, so
|
||||||
incorrectly treated as a primitive type (because Union types aren't
|
`UserOutput | None` can be mistaken for a primitive and wrapped in a
|
||||||
recognized by `isinstance(t, type)`), causing the output to be wrapped
|
'result' field. This pins that it is not.
|
||||||
in a 'result' field.
|
|
||||||
"""
|
"""
|
||||||
import types
|
import types
|
||||||
|
|
||||||
@@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase):
|
|||||||
self.assertFalse(result.data["success"])
|
self.assertFalse(result.data["success"])
|
||||||
self.assertIn("errors", result.data)
|
self.assertIn("errors", result.data)
|
||||||
|
|
||||||
|
def test_form_submit_failure_calls_hook(self):
|
||||||
|
"""A rejected submission calls on_submit_failure with the validation errors."""
|
||||||
|
from django import forms
|
||||||
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class HookForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="failure_hook_test")
|
||||||
|
required_field = forms.CharField()
|
||||||
|
|
||||||
|
def on_submit_failure(self, request, errors):
|
||||||
|
seen.append(errors)
|
||||||
|
|
||||||
|
request = self._make_request()
|
||||||
|
result = execute_function(request, "failure_hook_test.submit", {})
|
||||||
|
|
||||||
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
self.assertFalse(result.data["success"])
|
||||||
|
self.assertEqual(len(seen), 1)
|
||||||
|
self.assertEqual([entry.field for entry in seen[0].errors], ["required_field"])
|
||||||
|
|
||||||
def test_form_meta_serialization(self):
|
def test_form_meta_serialization(self):
|
||||||
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
||||||
from mizan.forms import mizanFormMeta
|
from mizan.forms import mizanFormMeta
|
||||||
@@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase):
|
|||||||
self.assertEqual(len(result.data["fields"]), 1)
|
self.assertEqual(len(result.data["fields"]), 1)
|
||||||
self.assertEqual(result.data["fields"][0]["type"], "text")
|
self.assertEqual(result.data["fields"][0]["type"], "text")
|
||||||
|
|
||||||
|
def test_default_init_kwargs_forwards_request_only_when_declared(self):
|
||||||
|
"""The base get_init_kwargs passes `request` to a form whose __init__ names it."""
|
||||||
|
from django import forms
|
||||||
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
|
class PlainInitForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="plain_init_test")
|
||||||
|
field = forms.CharField()
|
||||||
|
|
||||||
|
class RequestInitForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="request_init_test")
|
||||||
|
field = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, *args, request=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.seen_request = request
|
||||||
|
|
||||||
|
request = self._make_request()
|
||||||
|
|
||||||
|
self.assertEqual(PlainInitForm.get_init_kwargs(request), {})
|
||||||
|
self.assertEqual(RequestInitForm.get_init_kwargs(request), {"request": request})
|
||||||
|
|
||||||
|
# And the form actually constructs with the forwarded kwarg.
|
||||||
|
form = RequestInitForm(**RequestInitForm.get_init_kwargs(request))
|
||||||
|
self.assertIs(form.seen_request, request)
|
||||||
|
|
||||||
def test_formset_functions_not_registered_by_default(self):
|
def test_formset_functions_not_registered_by_default(self):
|
||||||
"""Test that formset functions are not registered by default."""
|
"""Test that formset functions are not registered by default."""
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -1847,7 +1892,7 @@ 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)
|
||||||
|
|
||||||
# Mizan handles caching; origin emits no-store
|
# Origin emits no-store
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_context_fetch_string_to_int_coercion(self):
|
def test_context_fetch_string_to_int_coercion(self):
|
||||||
@@ -2133,14 +2178,15 @@ class ReturnTypeBranchingTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Edge Compatibility Tests — Prove CDN caching works before Edge exists
|
# Edge Compatibility Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class EdgeCompatibilityTests(TestCase):
|
class EdgeCompatibilityTests(TestCase):
|
||||||
"""
|
"""
|
||||||
Tests that prove Edge caching is possible. Every failure mode that
|
Response properties a CDN layer reads: byte-identical bodies for identical
|
||||||
would break a CDN layer is tested here without building the CDN.
|
requests, sorted JSON keys, no-store on mutations and errors, and an
|
||||||
|
X-Mizan-Invalidate header that parses back to the JSON body's targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Deterministic JSON ──────────────────────────────────────────────────
|
# ── Deterministic JSON ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_deterministic_json_output(self):
|
def test_deterministic_json_output(self):
|
||||||
"""Same request produces byte-identical response body. Cache keys depend on this."""
|
"""Same request produces a byte-identical response body."""
|
||||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
|
|
||||||
@@ -2204,12 +2250,12 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
def test_context_get_no_store(self):
|
def test_context_get_no_store(self):
|
||||||
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
"""Context GET emits no-store."""
|
||||||
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")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
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 emits no-store."""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/mizan/call/",
|
"/api/mizan/call/",
|
||||||
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
||||||
@@ -2219,14 +2265,14 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_error_response_not_cacheable(self):
|
def test_error_response_not_cacheable(self):
|
||||||
"""Error responses have no-store. CDN must not cache errors."""
|
"""Error responses emit no-store."""
|
||||||
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_different_params_different_response(self):
|
def test_different_params_different_response(self):
|
||||||
"""Different query params produce different response bodies (different cache entries)."""
|
"""Different query params produce different response bodies."""
|
||||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
||||||
|
|
||||||
@@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
|
|
||||||
header = response["X-Mizan-Invalidate"]
|
header = response["X-Mizan-Invalidate"]
|
||||||
|
|
||||||
# Parse the header (this is what Edge would do)
|
# Parse the header back into structured entries
|
||||||
entries = []
|
entries = []
|
||||||
for part in header.split(", "):
|
for part in header.split(", "):
|
||||||
segments = part.split(";")
|
segments = part.split(";")
|
||||||
@@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
# ── Query param ordering doesn't affect content ─────────────────────────
|
# ── Query param ordering doesn't affect content ─────────────────────────
|
||||||
|
|
||||||
def test_param_order_irrelevant(self):
|
def test_param_order_irrelevant(self):
|
||||||
"""Different query param ordering produces same content (cache key normalization)."""
|
"""Different query param ordering produces the same content."""
|
||||||
@client(context=ReactContext("multi"))
|
@client(context=ReactContext("multi"))
|
||||||
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
||||||
return ValidOutput(valid=True)
|
return ValidOutput(valid=True)
|
||||||
@@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
]
|
]
|
||||||
header = _format_invalidate_header(original)
|
header = _format_invalidate_header(original)
|
||||||
|
|
||||||
# Parse (what Edge would do)
|
# Parse back
|
||||||
segments = header.split(";")
|
segments = header.split(";")
|
||||||
ctx = segments[0]
|
ctx = segments[0]
|
||||||
params = {}
|
params = {}
|
||||||
@@ -3339,7 +3385,8 @@ def _redis_available() -> bool:
|
|||||||
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
||||||
client.ping()
|
client.ping()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Tests for app-root discovery, which decides where mizan_clients() scans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from mizan.setup.discovery import discover_apps_roots
|
||||||
|
|
||||||
|
# The installed mizan-django package root: `tests` sits directly beneath it and
|
||||||
|
# the virtualenv holding django.contrib.* does too.
|
||||||
|
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverAppsRootsTests(TestCase):
|
||||||
|
@override_settings(MIZAN_APPS_ROOT="somewhere_else")
|
||||||
|
def test_pinned_root_is_used_verbatim(self):
|
||||||
|
self.assertEqual(discover_apps_roots(), ["somewhere_else"])
|
||||||
|
|
||||||
|
@override_settings(MIZAN_APPS_ROOT="")
|
||||||
|
def test_pinned_empty_root_means_apps_sit_at_base_dir(self):
|
||||||
|
self.assertEqual(discover_apps_roots(), [""])
|
||||||
|
|
||||||
|
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||||
|
def test_top_level_project_app_yields_the_empty_root(self):
|
||||||
|
self.assertIn("", discover_apps_roots())
|
||||||
|
|
||||||
|
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||||
|
def test_installed_packages_contribute_no_root(self):
|
||||||
|
# django.contrib.* resolve inside the virtualenv, which lives under
|
||||||
|
# BASE_DIR here — a containment check alone would wrongly admit them.
|
||||||
|
self.assertNotIn("django.contrib", discover_apps_roots())
|
||||||
|
|
||||||
|
@override_settings(BASE_DIR=PACKAGE_ROOT / "no_such_directory")
|
||||||
|
def test_no_matching_app_yields_no_roots(self):
|
||||||
|
self.assertEqual(discover_apps_roots(), [])
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Advanced Penetration Tests for mizan Server Functions
|
Attack-shaped tests over execute_function and the WebSocket consumer.
|
||||||
|
|
||||||
These tests simulate a professional security researcher attempting to break
|
Grouped by the surface each one drives: memory exhaustion, type confusion at
|
||||||
the protocol. Focus areas:
|
the serialization boundary, concurrent execution, Pydantic validation bypass,
|
||||||
|
WebSocket protocol framing, timing measurement, Unicode normalization, JSON
|
||||||
1. Race conditions and TOCTOU vulnerabilities
|
parsing limits, authorization boundaries, and registration collisions.
|
||||||
2. Memory exhaustion and resource depletion
|
|
||||||
3. Type confusion at serialization boundaries
|
|
||||||
4. Session/authentication state manipulation
|
|
||||||
5. Pydantic validation bypass attempts
|
|
||||||
6. WebSocket protocol-level attacks
|
|
||||||
7. Timing side-channel attacks
|
|
||||||
8. Concurrent state corruption
|
|
||||||
9. Deserialization attacks
|
|
||||||
10. Unicode normalization exploits
|
|
||||||
|
|
||||||
SAFE TO RUN: These tests don't execute actual exploits - they verify
|
|
||||||
that the defenses hold against attack patterns.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -485,9 +473,6 @@ class RaceConditionTests(TestCase):
|
|||||||
|
|
||||||
result = execute_function(request, "timed_auth_func", None)
|
result = execute_function(request, "timed_auth_func", None)
|
||||||
|
|
||||||
# The result reflects the state at time of check
|
|
||||||
# This test documents the behavior - the user's is_authenticated
|
|
||||||
# is read during execution, and if it changes, that's reflected
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
# On first access is_authenticated returns True, on second it returns False
|
# On first access is_authenticated returns True, on second it returns False
|
||||||
# The function reads is_authenticated once, so it sees the first value (True)
|
# The function reads is_authenticated once, so it sees the first value (True)
|
||||||
@@ -503,9 +488,8 @@ class PydanticBypassTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Attempt to bypass Pydantic validation.
|
Attempt to bypass Pydantic validation.
|
||||||
|
|
||||||
Note: The @client decorator creates dynamic Pydantic models from function
|
The @client decorator builds the Input model from the function's parameter
|
||||||
parameters. Custom validators must be on the parameter types themselves,
|
annotations, so a custom validator has to live on the parameter type itself.
|
||||||
not separate classes. This tests the actual validation behavior.
|
|
||||||
|
|
||||||
Attack vectors:
|
Attack vectors:
|
||||||
- Type coercion bypass
|
- Type coercion bypass
|
||||||
@@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
|
|
||||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||||
"""
|
"""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, Channel
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
@@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test attempting to subscribe to the same channel twice.
|
Test attempting to subscribe to the same channel twice.
|
||||||
"""
|
"""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, Channel
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
|
|
||||||
class TestChannel(ReactChannel):
|
class TestChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase):
|
|||||||
# Large differences could leak function existence
|
# Large differences could leak function existence
|
||||||
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
||||||
|
|
||||||
# Document the ratio but don't fail - this is informational
|
|
||||||
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
||||||
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
||||||
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
||||||
@@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# These look like "admin" but use different Unicode characters
|
# Built from chr(): each of these renders identically to its ASCII
|
||||||
|
# counterpart, so a literal would be unreadable in source.
|
||||||
lookalikes = [
|
lookalikes = [
|
||||||
"\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a'
|
chr(0x0430) + "dmin", # Cyrillic small a
|
||||||
"adm\u0131n", # Turkish dotless i (U+0131)
|
"adm" + chr(0x0131) + "n", # Turkish dotless i
|
||||||
"\u00e1dmin", # Latin a with acute
|
chr(0x00E1) + "dmin", # Latin a with acute
|
||||||
"\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters
|
"".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)),
|
||||||
"\u0251dmin", # Latin alpha
|
chr(0x0251) + "dmin", # Latin alpha
|
||||||
"\u0430\u0501m\u0456n", # Mix of Cyrillic characters
|
chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix
|
||||||
]
|
]
|
||||||
|
|
||||||
for lookalike in lookalikes:
|
for lookalike in lookalikes:
|
||||||
@@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# é can be represented as:
|
# Built from chr() so the two spellings stay distinguishable in source:
|
||||||
# 1. U+00E9 (precomposed)
|
# U+00E9 precomposed vs. "e" + U+0301 combining acute.
|
||||||
# 2. U+0065 U+0301 (decomposed: e + combining acute)
|
precomposed = "caf" + chr(0x00E9)
|
||||||
precomposed = "caf\u00e9" # café with precomposed é
|
decomposed = "cafe" + chr(0x0301)
|
||||||
decomposed = "cafe\u0301" # café with combining acute
|
|
||||||
|
|
||||||
# These look identical but are different byte sequences
|
# These look identical but are different byte sequences
|
||||||
self.assertNotEqual(precomposed, decomposed)
|
self.assertNotEqual(precomposed, decomposed)
|
||||||
@@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
|
# Built from chr(): every one of these renders as nothing at all.
|
||||||
zero_width_chars = [
|
zero_width_chars = [
|
||||||
"\u200b", # Zero-width space
|
chr(0x200B), # Zero-width space
|
||||||
"\u200c", # Zero-width non-joiner
|
chr(0x200C), # Zero-width non-joiner
|
||||||
"\u200d", # Zero-width joiner
|
chr(0x200D), # Zero-width joiner
|
||||||
"\u2060", # Word joiner
|
chr(0x2060), # Word joiner
|
||||||
"\ufeff", # Zero-width no-break space (BOM)
|
chr(0xFEFF), # Zero-width no-break space (BOM)
|
||||||
]
|
]
|
||||||
|
|
||||||
for zwc in zero_width_chars:
|
for zwc in zero_width_chars:
|
||||||
@@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = execute_function(request, "json_func", {"data": nested})
|
result = execute_function(request, "json_func", {"data": nested})
|
||||||
# Should either succeed or fail gracefully
|
|
||||||
self.assertIn(type(result), [FunctionResult, FunctionError])
|
self.assertIn(type(result), [FunctionResult, FunctionError])
|
||||||
except RecursionError:
|
except RecursionError as exc:
|
||||||
# This is acceptable - Python's recursion limit hit
|
print(f"\nCPython recursion limit reached at 500 levels: {exc}")
|
||||||
pass
|
|
||||||
|
|
||||||
def test_json_number_precision(self):
|
def test_json_number_precision(self):
|
||||||
"""
|
"""
|
||||||
@@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test that a different function cannot override an existing one.
|
Test that a different function cannot override an existing one.
|
||||||
|
|
||||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
Re-registering the same object under its own name is allowed; a
|
||||||
But a DIFFERENT function cannot take over an existing name.
|
different object claiming a taken name raises.
|
||||||
"""
|
"""
|
||||||
from mizan.client import ServerFunction
|
from mizan.client import ServerFunction
|
||||||
from mizan_core.registry import register
|
from mizan_core.registry import register
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Security-focused E2E tests for mizan server functions.
|
Adversarial-input tests: hostile payloads driven through execute_function,
|
||||||
|
function_call_view, and the WebSocket consumer.
|
||||||
These tests probe for potential vulnerabilities without running any
|
|
||||||
malicious code - they simply verify that defenses work correctly.
|
|
||||||
|
|
||||||
Security areas covered:
|
|
||||||
1. Input Validation - Large inputs, nested objects, type confusion
|
|
||||||
2. Authorization - Bypass attempts, permission checks
|
|
||||||
3. HTTP Endpoint - CSRF, method restrictions, JSON parsing
|
|
||||||
4. WebSocket RPC - Malformed messages, unauthorized calls
|
|
||||||
5. Information Disclosure - Error enumeration, internal detail leakage
|
|
||||||
6. Injection Prevention - Special characters, unicode edge cases
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import RequestFactory, TestCase, Client, override_settings
|
from django.test import RequestFactory, TestCase, Client, override_settings
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from mizan.client.executor import (
|
from mizan.client.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
@@ -29,9 +19,9 @@ from mizan.client.executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
function_call_view,
|
||||||
)
|
)
|
||||||
from mizan_core.registry import clear_registry, register, register_as, get_function
|
from mizan_core.registry import clear_registry, register
|
||||||
from mizan.client import ServerFunction, client
|
from mizan.client import client
|
||||||
from mizan.channels import ReactChannel
|
from mizan.channels import Channel
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -46,10 +36,6 @@ class SimpleOutput(BaseModel):
|
|||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class NestedInput(BaseModel):
|
|
||||||
level1: dict
|
|
||||||
|
|
||||||
|
|
||||||
class DeeplyNestedOutput(BaseModel):
|
class DeeplyNestedOutput(BaseModel):
|
||||||
depth: int
|
depth: int
|
||||||
|
|
||||||
@@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class InputValidationSecurityTests(TestCase):
|
class InputValidationSecurityTests(TestCase):
|
||||||
"""
|
"""Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields."""
|
||||||
Test input validation for security edge cases.
|
|
||||||
|
|
||||||
Verifies that Pydantic validation catches malicious or malformed input
|
|
||||||
BEFORE any function code executes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
"""Test that null bytes in strings are handled safely."""
|
"""Test that null bytes in strings are handled safely."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Null byte injection attempt
|
|
||||||
payload = "normal\x00injected"
|
payload = "normal\x00injected"
|
||||||
result = execute_function(request, "echo_any", {"message": payload})
|
result = execute_function(request, "echo_any", {"message": payload})
|
||||||
|
|
||||||
@@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
"""Test various unicode edge cases."""
|
"""Test various unicode edge cases."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
|
# Escapes, not literals: these codepoints are invisible in an editor, and
|
||||||
|
# a literal NUL cannot appear in Python source at all.
|
||||||
test_cases = [
|
test_cases = [
|
||||||
# Zero-width characters
|
# Zero-width characters
|
||||||
"normal\u200btext",
|
"normaltext",
|
||||||
# Right-to-left override (potential display issues)
|
# Right-to-left override (potential display issues)
|
||||||
"test\u202eevil",
|
"testevil",
|
||||||
# Emoji sequences
|
# Emoji sequences
|
||||||
"👨👩👧👦",
|
"\U0001f468\U0001f469\U0001f467\U0001f466",
|
||||||
# Combining characters
|
# Combining characters
|
||||||
"a\u0300\u0301\u0302",
|
"à́̂",
|
||||||
# Null character
|
# Null character
|
||||||
"test\u0000null",
|
"test\x00null",
|
||||||
# Replacement character
|
# Replacement character
|
||||||
"test\ufffdreplace",
|
"test<EFBFBD>replace",
|
||||||
]
|
]
|
||||||
|
|
||||||
for payload in test_cases:
|
for payload in test_cases:
|
||||||
@@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AuthorizationSecurityTests(TestCase):
|
class AuthorizationSecurityTests(TestCase):
|
||||||
"""
|
"""execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers."""
|
||||||
Test authorization bypass attempts.
|
|
||||||
|
|
||||||
Verifies that authentication/authorization checks can't be bypassed
|
|
||||||
through various attack vectors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
||||||
# Bad pattern: returns different errors for auth vs not found
|
|
||||||
# This is intentionally bad to test we detect it
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("User not logged in")
|
raise PermissionError("User not logged in")
|
||||||
return SimpleOutput(value="ok")
|
return SimpleOutput(value="ok")
|
||||||
@@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
|
||||||
def test_spoofed_is_authenticated_attribute(self):
|
def test_spoofed_is_authenticated_attribute(self):
|
||||||
"""Test that spoofing is_authenticated doesn't work."""
|
"""Test that a duck-typed user carrying is_authenticated is accepted."""
|
||||||
|
|
||||||
# Create object that claims to be authenticated but isn't a real user
|
|
||||||
class FakeUser:
|
class FakeUser:
|
||||||
is_authenticated = True
|
is_authenticated = True
|
||||||
id = 999
|
id = 999
|
||||||
@@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
request = self._make_request(user=FakeUser())
|
request = self._make_request(user=FakeUser())
|
||||||
result = execute_function(request, "requires_auth", None)
|
result = execute_function(request, "requires_auth", None)
|
||||||
|
|
||||||
# This actually works because we only check is_authenticated
|
# execute_function only reads is_authenticated, so this duck-type passes
|
||||||
# This test documents the behavior - real Django handles this
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
|
||||||
def test_user_id_manipulation_blocked(self):
|
def test_user_id_manipulation_blocked(self):
|
||||||
@@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
||||||
# Properly checking: can only access own data
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
if request.user.id != target_user_id:
|
if request.user.id != target_user_id:
|
||||||
@@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class HTTPEndpointSecurityTests(TestCase):
|
class HTTPEndpointSecurityTests(TestCase):
|
||||||
"""
|
"""Method restrictions, JSON body parsing, and function-name lookup on the HTTP view."""
|
||||||
Test HTTP endpoint security.
|
|
||||||
|
|
||||||
Verifies CSRF protection, method restrictions, and JSON parsing security.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
# Bypass CSRF for this test
|
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
response = function_call_view(request)
|
response = function_call_view(request)
|
||||||
@@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_function_identifier_traversal(self):
|
def test_function_identifier_traversal(self):
|
||||||
"""Test that path traversal-style function identifiers are handled."""
|
"""Test that path traversal-style function identifiers are handled."""
|
||||||
# Try various path traversal attempts as function identifiers
|
|
||||||
malicious_names = [
|
malicious_names = [
|
||||||
"../../../etc/passwd",
|
"../../../etc/passwd",
|
||||||
"..\\..\\windows\\system32",
|
"..\\..\\windows\\system32",
|
||||||
@@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WebSocketRPCSecurityTests(TestCase):
|
class WebSocketRPCSecurityTests(TestCase):
|
||||||
"""
|
"""Malformed and unresolvable RPC frames over the WebSocket consumer."""
|
||||||
Test WebSocket RPC security.
|
|
||||||
|
|
||||||
Verifies that malformed messages and unauthorized calls are handled safely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
consumer.channel_layer = MagicMock()
|
consumer.channel_layer = MagicMock()
|
||||||
consumer.channel_name = "test"
|
consumer.channel_name = "test"
|
||||||
|
|
||||||
# Track sent messages
|
|
||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call without id
|
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{"fn": "ws_echo", "args": {"message": "test"}}
|
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||||
)
|
)
|
||||||
@@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call without fn
|
|
||||||
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
||||||
|
|
||||||
# Should return error
|
|
||||||
self.assertEqual(len(sent_messages), 1)
|
self.assertEqual(len(sent_messages), 1)
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
||||||
@@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
sent_messages = []
|
sent_messages = []
|
||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Call with wrong input type
|
# Pydantic coerces an int to str, so an omitted required field is what
|
||||||
|
# actually produces a validation error here.
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{
|
{
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"fn": "ws_echo",
|
"fn": "ws_echo",
|
||||||
"args": {"message": 12345}, # Should be string
|
"args": {"message": 12345},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pydantic coerces int to string, so this actually succeeds
|
|
||||||
# Let's test with missing required field instead
|
|
||||||
sent_messages.clear()
|
sent_messages.clear()
|
||||||
async_to_sync(consumer._handle_rpc)(
|
async_to_sync(consumer._handle_rpc)(
|
||||||
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
{"id": "124", "fn": "ws_echo", "args": {}}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
@@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InformationDisclosureTests(TestCase):
|
class InformationDisclosureTests(TestCase):
|
||||||
"""
|
"""Contents of FunctionError responses with DEBUG=False."""
|
||||||
Test information disclosure vulnerabilities.
|
|
||||||
|
|
||||||
Verifies that error messages don't leak sensitive information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
||||||
# Simulate accessing sensitive config that might leak in error
|
|
||||||
secret_key = "super_secret_key_12345"
|
secret_key = "super_secret_key_12345"
|
||||||
raise RuntimeError(f"Database error with key: {secret_key}")
|
raise RuntimeError(f"Database error with key: {secret_key}")
|
||||||
|
|
||||||
@@ -703,7 +655,6 @@ class InformationDisclosureTests(TestCase):
|
|||||||
"""Test that error messages don't help enumerate functions in production."""
|
"""Test that error messages don't help enumerate functions in production."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Try various function names/UUIDs
|
|
||||||
test_names = [
|
test_names = [
|
||||||
"admin_panel",
|
"admin_panel",
|
||||||
"get_all_users",
|
"get_all_users",
|
||||||
@@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
result = execute_function(request, name, None)
|
result = execute_function(request, name, None)
|
||||||
self.assertIsInstance(result, FunctionError)
|
self.assertIsInstance(result, FunctionError)
|
||||||
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
||||||
# In production (DEBUG=False), error message is generic
|
# With DEBUG=False the message is identical for every name
|
||||||
# - doesn't reveal function name or UUID existence
|
|
||||||
self.assertEqual(result.message, "Function not found")
|
self.assertEqual(result.message, "Function not found")
|
||||||
|
|
||||||
def test_validation_errors_dont_leak_internals(self):
|
def test_validation_errors_dont_leak_internals(self):
|
||||||
@@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
result = execute_function(request, "validated_func", {"secret_field": 123})
|
result = execute_function(request, "validated_func", {"secret_field": 123})
|
||||||
|
|
||||||
# Pydantic coerces to string, so let's try with wrong structure
|
# Pydantic coerces to string, so an unknown field is what fails here.
|
||||||
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
||||||
|
|
||||||
self.assertIsInstance(result, FunctionError)
|
self.assertIsInstance(result, FunctionError)
|
||||||
@@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InjectionPreventionTests(TestCase):
|
class InjectionPreventionTests(TestCase):
|
||||||
"""
|
"""SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions."""
|
||||||
Test injection attack prevention.
|
|
||||||
|
|
||||||
Verifies that input validation prevents various injection attacks.
|
|
||||||
Note: These tests verify the framework's security, not actual injection
|
|
||||||
attempts - they just ensure malicious input is handled safely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
||||||
# This function just echoes - the test is about validation
|
|
||||||
return SimpleOutput(value=user_input)
|
return SimpleOutput(value=user_input)
|
||||||
|
|
||||||
register(echo_safe, "echo_safe")
|
register(echo_safe, "echo_safe")
|
||||||
@@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
for payload in sql_payloads:
|
for payload in sql_payloads:
|
||||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||||
# Should succeed - it's just a string, not executed as SQL
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
# The payload is returned as-is (no SQL execution)
|
|
||||||
self.assertEqual(result.data["value"], payload)
|
self.assertEqual(result.data["value"], payload)
|
||||||
|
|
||||||
def test_command_injection_in_string_field(self):
|
def test_command_injection_in_string_field(self):
|
||||||
@@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
for payload in cmd_payloads:
|
for payload in cmd_payloads:
|
||||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||||
# Should succeed - it's just a string
|
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["value"], payload)
|
self.assertEqual(result.data["value"], payload)
|
||||||
|
|
||||||
@@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase):
|
|||||||
"""Test that special JSON values are handled safely."""
|
"""Test that special JSON values are handled safely."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Various JSON edge cases
|
|
||||||
test_cases = [
|
test_cases = [
|
||||||
{"__proto__": {"polluted": True}},
|
{"__proto__": {"polluted": True}},
|
||||||
{"constructor": {"prototype": {}}},
|
{"constructor": {"prototype": {}}},
|
||||||
{"key": None},
|
{"key": None},
|
||||||
{"key": float("inf")}, # This will fail JSON serialization
|
{"key": float("inf")},
|
||||||
]
|
]
|
||||||
|
|
||||||
for data in test_cases:
|
for data in test_cases:
|
||||||
@@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelAuthorizationTests(TestCase):
|
class ChannelAuthorizationTests(TestCase):
|
||||||
"""
|
"""Subscription outcomes when authorize() returns False, raises, or gets bad params."""
|
||||||
Test WebSocket channel authorization.
|
|
||||||
|
|
||||||
Verifies that channel subscriptions properly check permissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
# Also clear the channels registry
|
|
||||||
from mizan.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
@@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def _register_test_channels(self):
|
def _register_test_channels(self):
|
||||||
"""Register test channels using the channels module's register."""
|
"""Register test channels using the channels module's register."""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
class PublicChannel(ReactChannel):
|
class PublicChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "public"
|
return "public"
|
||||||
|
|
||||||
class AuthChannel(ReactChannel):
|
class AuthChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "auth"
|
return "auth"
|
||||||
|
|
||||||
class RoomChannel(ReactChannel):
|
class RoomChannel(Channel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room_id: int
|
room_id: int
|
||||||
|
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params):
|
def authorize(self, params):
|
||||||
# Only allow access to room 1 and 2
|
|
||||||
return params.room_id in [1, 2]
|
return params.room_id in [1, 2]
|
||||||
|
|
||||||
def group(self, params):
|
def group(self, params):
|
||||||
@@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def test_authorize_exception_handling(self):
|
def test_authorize_exception_handling(self):
|
||||||
"""Test that exceptions in authorize() are handled safely."""
|
"""Test that exceptions in authorize() are handled safely."""
|
||||||
from mizan.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel
|
||||||
from mizan.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
class ErrorChannel(ReactChannel):
|
class ErrorChannel(Channel):
|
||||||
class DjangoMessage(BaseModel):
|
class ServerMessage(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
@@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
{"channel": "auth-channel", "params": {}}
|
{"channel": "auth-channel", "params": {}}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should be rejected
|
|
||||||
self.assertIn("error", sent_messages[0])
|
self.assertIn("error", sent_messages[0])
|
||||||
self.assertIn("Not authorized", sent_messages[0]["error"])
|
self.assertIn("Not authorized", sent_messages[0]["error"])
|
||||||
|
|
||||||
@@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should fail validation
|
|
||||||
self.assertIn("error", sent_messages[0])
|
self.assertIn("error", sent_messages[0])
|
||||||
|
|
||||||
def test_room_authorization_enforced(self):
|
def test_room_authorization_enforced(self):
|
||||||
@@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AbusePreventionTests(TestCase):
|
class AbusePreventionTests(TestCase):
|
||||||
"""
|
"""Repeated and batched execute_function calls."""
|
||||||
Test abuse prevention capabilities.
|
|
||||||
|
|
||||||
Note: The current implementation doesn't have built-in rate limiting,
|
|
||||||
so these tests document the expected behavior and identify areas
|
|
||||||
where rate limiting should be added.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
@@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase):
|
|||||||
"""Test that rapid function calls don't cause issues."""
|
"""Test that rapid function calls don't cause issues."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
# Make 100 rapid calls
|
|
||||||
results = []
|
results = []
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
result = execute_function(request, "simple_func", None)
|
result = execute_function(request, "simple_func", None)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
# All should succeed (no rate limiting currently) and return expected data
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["value"], "ok")
|
self.assertEqual(result.data["value"], "ok")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the Mizan SSR bridge and template backend.
|
Tests for the SSR bridge and the MizanTemplates Django template backend.
|
||||||
|
|
||||||
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
The bridge shells out to Bun, so every test here skips unless `bun` is on PATH
|
||||||
Tests skip gracefully if Bun is not available.
|
and the worker script is present.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -11,13 +11,12 @@ import threading
|
|||||||
|
|
||||||
from django.test import SimpleTestCase, RequestFactory
|
from django.test import SimpleTestCase, RequestFactory
|
||||||
|
|
||||||
# Path to the test worker
|
_REPO_ROOT = os.path.normpath(
|
||||||
_SSR_WORKER = os.path.join(
|
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
|
||||||
os.path.dirname(__file__),
|
|
||||||
"..", "..", "..", "..", "..", # up to repo root
|
|
||||||
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
|
||||||
)
|
)
|
||||||
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
_SSR_WORKER = os.path.join(_REPO_ROOT, "workers", "mizan-ssr", "src", "worker.tsx")
|
||||||
|
_COMPONENT_DIR = os.path.join(os.path.dirname(__file__), "ssr_components")
|
||||||
|
_HELLO = os.path.join(_COMPONENT_DIR, "Hello.tsx")
|
||||||
|
|
||||||
_BUN_AVAILABLE = shutil.which("bun") is not None
|
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||||
_SKIP_MSG = "Bun not available"
|
_SKIP_MSG = "Bun not available"
|
||||||
@@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase):
|
|||||||
if not _BUN_AVAILABLE:
|
if not _BUN_AVAILABLE:
|
||||||
self.skipTest(_SKIP_MSG)
|
self.skipTest(_SKIP_MSG)
|
||||||
if not os.path.exists(_SSR_WORKER):
|
if not os.path.exists(_SSR_WORKER):
|
||||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
from mizan.ssr.bridge import SSRBridge
|
from mizan.ssr.bridge import SSRBridge
|
||||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=10.0)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if hasattr(self, "bridge"):
|
if hasattr(self, "bridge"):
|
||||||
self.bridge.shutdown()
|
self.bridge.shutdown()
|
||||||
|
|
||||||
def test_ping(self):
|
def test_render_starts_worker_and_returns_html(self):
|
||||||
"""Worker starts and responds to ping."""
|
"""The first render boots the worker and returns rendered markup."""
|
||||||
self.assertTrue(self.bridge.ping())
|
result = self.bridge.render(_HELLO, {"name": "World"})
|
||||||
|
|
||||||
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("Hello,", result.html)
|
||||||
self.assertIn("World", result.html)
|
self.assertIn("World", result.html)
|
||||||
|
|
||||||
def test_render_with_props(self):
|
def test_render_passes_props_through(self):
|
||||||
"""Renders a component with multiple props."""
|
"""Props reach the component."""
|
||||||
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
result = self.bridge.render(_HELLO, {"name": "Alice"})
|
||||||
self.assertIn("Alice", result.html)
|
self.assertIn("Alice", result.html)
|
||||||
self.assertIn("42", result.html)
|
self.assertIn('data-mizan-component="Hello"', result.html)
|
||||||
|
|
||||||
def test_render_missing_component(self):
|
def test_render_missing_file_raises(self):
|
||||||
"""Rendering an unregistered component raises RuntimeError."""
|
"""Rendering a path with no module raises RuntimeError naming the failure."""
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx")
|
||||||
self.bridge.render("NonExistent", {})
|
with self.assertRaises(RuntimeError):
|
||||||
self.assertIn("not registered", str(ctx.exception))
|
self.bridge.render(missing, {})
|
||||||
|
|
||||||
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):
|
def test_crash_recovery(self):
|
||||||
"""Bridge restarts the worker if it dies."""
|
"""The bridge restarts the worker if it dies."""
|
||||||
# First render works
|
result = self.bridge.render(_HELLO, {"name": "Before"})
|
||||||
result = self.bridge.render("Hello", {"name": "Before"})
|
|
||||||
self.assertIn("Before", result.html)
|
self.assertIn("Before", result.html)
|
||||||
|
|
||||||
# Kill the subprocess
|
|
||||||
self.bridge._proc.kill()
|
self.bridge._proc.kill()
|
||||||
self.bridge._proc.wait()
|
self.bridge._proc.wait()
|
||||||
|
|
||||||
# Next render should restart and work
|
result = self.bridge.render(_HELLO, {"name": "After"})
|
||||||
result = self.bridge.render("Hello", {"name": "After"})
|
|
||||||
self.assertIn("After", result.html)
|
self.assertIn("After", result.html)
|
||||||
|
|
||||||
def test_concurrent_renders(self):
|
def test_concurrent_renders(self):
|
||||||
"""Multiple threads can render simultaneously."""
|
"""Concurrent callers each get their own response matched by message id."""
|
||||||
results = {}
|
results = {}
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
def render_in_thread(name: str, idx: int):
|
def render_in_thread(name: str, idx: int):
|
||||||
try:
|
try:
|
||||||
result = self.bridge.render("Hello", {"name": name})
|
results[idx] = self.bridge.render(_HELLO, {"name": name}).html
|
||||||
results[idx] = result.html
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors[idx] = e
|
errors[idx] = e
|
||||||
|
|
||||||
@@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase):
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
for t in threads:
|
for t in threads:
|
||||||
t.join(timeout=10)
|
t.join(timeout=20)
|
||||||
|
|
||||||
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
self.assertEqual(errors, {})
|
||||||
self.assertEqual(len(results), 5)
|
self.assertEqual(len(results), 5)
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
self.assertIn(f"User{i}", results[i])
|
self.assertIn(f"User{i}", results[i])
|
||||||
@@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
|||||||
if not _BUN_AVAILABLE:
|
if not _BUN_AVAILABLE:
|
||||||
self.skipTest(_SKIP_MSG)
|
self.skipTest(_SKIP_MSG)
|
||||||
if not os.path.exists(_SSR_WORKER):
|
if not os.path.exists(_SSR_WORKER):
|
||||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
from mizan.ssr.backend import MizanTemplates
|
from mizan.ssr.backend import MizanTemplates
|
||||||
self.engine = MizanTemplates({
|
self.engine = MizanTemplates({
|
||||||
"NAME": "mizan-test",
|
"NAME": "mizan-test",
|
||||||
"DIRS": [],
|
"DIRS": [_COMPONENT_DIR],
|
||||||
"APP_DIRS": False,
|
"APP_DIRS": False,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"worker_path": _SSR_WORKER,
|
"worker": _SSR_WORKER,
|
||||||
"timeout": 5,
|
"timeout": 10,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
@@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
|||||||
if hasattr(self, "engine") and self.engine._bridge is not None:
|
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||||
self.engine._bridge.shutdown()
|
self.engine._bridge.shutdown()
|
||||||
|
|
||||||
def test_get_template(self):
|
def test_get_template_resolves_name_to_file(self):
|
||||||
"""get_template returns a MizanTemplate."""
|
"""get_template resolves the name against DIRS to an absolute file path."""
|
||||||
from mizan.ssr.backend import MizanTemplate
|
from mizan.ssr.backend import MizanTemplate
|
||||||
template = self.engine.get_template("Hello")
|
template = self.engine.get_template("Hello.tsx")
|
||||||
self.assertIsInstance(template, MizanTemplate)
|
self.assertIsInstance(template, MizanTemplate)
|
||||||
self.assertEqual(template.component_name, "Hello")
|
self.assertEqual(template.file_path, os.path.abspath(_HELLO))
|
||||||
|
|
||||||
def test_template_render(self):
|
def test_missing_template_raises(self):
|
||||||
"""MizanTemplate.render() produces HTML."""
|
"""A name that resolves to no file under DIRS raises TemplateDoesNotExist."""
|
||||||
template = self.engine.get_template("Hello")
|
from django.template import TemplateDoesNotExist
|
||||||
|
with self.assertRaises(TemplateDoesNotExist):
|
||||||
|
self.engine.get_template("NoSuchComponent.tsx")
|
||||||
|
|
||||||
|
def test_template_render_emits_html_and_hydration_data(self):
|
||||||
|
"""render() wraps the markup and serializes the props for hydration."""
|
||||||
|
template = self.engine.get_template("Hello.tsx")
|
||||||
html = template.render({"name": "Django"})
|
html = template.render({"name": "Django"})
|
||||||
self.assertIn("Hello,", html)
|
self.assertIn("Hello,", html)
|
||||||
self.assertIn("Django", html)
|
self.assertIn("Django", html)
|
||||||
self.assertIn('data-mizan-component="Hello"', html)
|
self.assertIn('id="mizan-root"', html)
|
||||||
|
self.assertIn('window.__MIZAN_SSR_DATA__={"name": "Django"}', html)
|
||||||
|
|
||||||
def test_template_render_strips_django_internals(self):
|
def test_template_render_strips_django_internals(self):
|
||||||
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
"""request and csrf_token are dropped from props and from hydration data."""
|
||||||
template = self.engine.get_template("Hello")
|
template = self.engine.get_template("Hello.tsx")
|
||||||
request = self.factory.get("/")
|
request = self.factory.get("/")
|
||||||
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
html = template.render(
|
||||||
|
{"name": "Test", "request": request, "csrf_token": "abc"}, request
|
||||||
|
)
|
||||||
self.assertIn("Test", html)
|
self.assertIn("Test", html)
|
||||||
|
self.assertNotIn("csrf_token", html)
|
||||||
|
self.assertNotIn("abc", html)
|
||||||
|
|
||||||
def test_from_string_raises(self):
|
def test_from_string_is_unsupported(self):
|
||||||
"""from_string is not supported."""
|
"""This engine renders modules by path, so it has no source-string form."""
|
||||||
from django.template import TemplateDoesNotExist
|
with self.assertRaises(NotImplementedError):
|
||||||
with self.assertRaises(TemplateDoesNotExist):
|
|
||||||
self.engine.from_string("<div>Not supported</div>")
|
self.engine.from_string("<div>Not supported</div>")
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Upload dispatch — multipart RPC binds files into Upload fields and enforces
|
|
||||||
the declarative `File(...)` constraints."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.test import RequestFactory, TestCase
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan import Upload, File
|
|
||||||
from mizan.client import client
|
|
||||||
from mizan.client.executor import function_call_view
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarOut(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
size: int
|
|
||||||
name: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UploadDispatchTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
clear_registry()
|
|
||||||
self.factory = RequestFactory()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
def _register(self):
|
|
||||||
@client
|
|
||||||
def set_avatar(
|
|
||||||
request: HttpRequest,
|
|
||||||
user_id: int,
|
|
||||||
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
|
|
||||||
) -> AvatarOut:
|
|
||||||
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
|
|
||||||
|
|
||||||
register(set_avatar, "set_avatar")
|
|
||||||
|
|
||||||
def _post(self, args, files):
|
|
||||||
data = {"fn": "set_avatar", "args": json.dumps(args), **files}
|
|
||||||
request = self.factory.post("/api/mizan/call/", data) # multipart
|
|
||||||
request.user = AnonymousUser()
|
|
||||||
request._dont_enforce_csrf_checks = True
|
|
||||||
return function_call_view(request)
|
|
||||||
|
|
||||||
def test_upload_binds_and_executes(self):
|
|
||||||
self._register()
|
|
||||||
png = SimpleUploadedFile("a.png", b"\x89PNG" + b"x" * 100, content_type="image/png")
|
|
||||||
resp = self._post({"user_id": 5}, {"avatar": png})
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
data = json.loads(resp.content)
|
|
||||||
self.assertTrue(data["result"]["ok"])
|
|
||||||
self.assertEqual(data["result"]["name"], "a.png")
|
|
||||||
self.assertEqual(data["result"]["size"], 104)
|
|
||||||
|
|
||||||
def test_max_size_rejected(self):
|
|
||||||
self._register()
|
|
||||||
big = SimpleUploadedFile("b.png", b"x" * (2 * 1024 * 1024), content_type="image/png")
|
|
||||||
resp = self._post({"user_id": 5}, {"avatar": big})
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
self.assertIn("max size", resp.content.decode())
|
|
||||||
|
|
||||||
def test_content_type_rejected(self):
|
|
||||||
self._register()
|
|
||||||
gif = SimpleUploadedFile("c.gif", b"GIF89a", content_type="image/gif")
|
|
||||||
resp = self._post({"user_id": 5}, {"avatar": gif})
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
self.assertIn("content-type", resp.content.decode())
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
mizan URL Configuration
|
mizan's HTTP endpoints: session bootstrap, the server-function call endpoint,
|
||||||
|
and the bundled per-context fetch. Schema export is reachable only through the
|
||||||
HTTP endpoints:
|
`export_mizan_ir` management command, never over HTTP.
|
||||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
|
||||||
- POST /call/ - Server function calls (HTTP transport)
|
|
||||||
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
|
||||||
- Use the management command instead: python manage.py export_mizan_ir
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -16,7 +9,7 @@ from django.middleware.csrf import get_token
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
||||||
from .client.executor import function_call_view, context_fetch_view
|
from mizan.client.executor import function_call_view, context_fetch_view
|
||||||
|
|
||||||
app_name = "mizan"
|
app_name = "mizan"
|
||||||
|
|
||||||
@@ -24,13 +17,8 @@ app_name = "mizan"
|
|||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def session_init_view(request):
|
def session_init_view(request):
|
||||||
"""
|
"""
|
||||||
Initialize a Django session and return the CSRF token.
|
Start a Django session and return `{"csrfToken": ...}`. The decorator is
|
||||||
|
what puts the csrftoken cookie on the response.
|
||||||
Used by SSR to establish a session before making authenticated requests.
|
|
||||||
The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{ "csrfToken": "..." }
|
|
||||||
"""
|
"""
|
||||||
return JsonResponse({"csrfToken": get_token(request)})
|
return JsonResponse({"csrfToken": get_token(request)})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
AbstractBaseUser,
|
AbstractBaseUser,
|
||||||
BaseUserManager,
|
BaseUserManager,
|
||||||
@@ -7,8 +9,6 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class EmailUserManager(BaseUserManager):
|
class EmailUserManager(BaseUserManager):
|
||||||
"""Custom user manager using email as the unique identifier."""
|
|
||||||
|
|
||||||
def create_user(self, email, password=None, **extra_fields):
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
if not email:
|
if not email:
|
||||||
raise ValueError("Email is required")
|
raise ValueError("Email is required")
|
||||||
@@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager):
|
|||||||
|
|
||||||
|
|
||||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||||
"""Minimal user model with email as USERNAME_FIELD.
|
|
||||||
|
|
||||||
Matches the calling convention used in mizan's test suite:
|
|
||||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
|
||||||
"""
|
|
||||||
|
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
@@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
app_label = "tests"
|
app_label = "tests"
|
||||||
|
|
||||||
|
|
||||||
# ─── Shape test models ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin(models.Model):
|
class TimestampMixin(models.Model):
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
"""
|
|
||||||
Django settings for running mizan's test suite standalone.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
cd django/
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
pytest
|
|
||||||
"""
|
|
||||||
|
|
||||||
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
@@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls"
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# JWT settings for test_auth.py (can be overridden per-class with @override_settings)
|
|
||||||
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
|
|
||||||
# Session engine (for test_auth.py SessionStore usage)
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ function. Typed React client generated. Invalidation automatic.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context
|
mizan-fastapi's surface is RPC dispatch, context bundling, JSON-body
|
||||||
bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes,
|
invalidation, auth gating, and channels over a multiplexed WebSocket. Forms,
|
||||||
and SSR are out of scope for the FastAPI adapter — FastAPI projects use
|
Shapes, and SSR sit outside that surface — a FastAPI project reaches for its
|
||||||
native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's
|
own native equivalents (Pydantic, ORM-of-choice, FastAPI's SSR ecosystem).
|
||||||
own SSR ecosystem).
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -29,11 +28,13 @@ from mizan_fastapi import (
|
|||||||
mizan_exception_handler,
|
mizan_exception_handler,
|
||||||
mizan_validation_handler,
|
mizan_validation_handler,
|
||||||
router as mizan_router,
|
router as mizan_router,
|
||||||
|
ws_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(mizan_router, prefix="/api/mizan")
|
app.include_router(mizan_router, prefix="/api/mizan")
|
||||||
|
app.include_router(ws_router, prefix="/api/mizan")
|
||||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||||
```
|
```
|
||||||
@@ -82,9 +83,56 @@ a dedicated `clients.py` imported during startup.
|
|||||||
@client(rev=2) # cache revision (busts on bump)
|
@client(rev=2) # cache revision (busts on bump)
|
||||||
```
|
```
|
||||||
|
|
||||||
`websocket=True`, Forms, and Channels parameters are accepted by the
|
Forms parameters are accepted by the decorator (they're a `mizan-core`
|
||||||
decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi —
|
primitive) and carry no meaning to this adapter.
|
||||||
those features only have effect when paired with mizan-django.
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
A channel is a named fan-out over the WebSocket `ws_router` serves. Subclass
|
||||||
|
`Channel`, declare whichever payload models the channel carries, and register
|
||||||
|
it. The three model names are read from the client's side: `Params` keys the
|
||||||
|
fan-out, `ClientMessage` travels up, `ServerMessage` travels down.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mizan_fastapi import Channel, register_channel
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Chat(Channel):
|
||||||
|
|
||||||
|
class Params(BaseModel):
|
||||||
|
room: str
|
||||||
|
|
||||||
|
class ClientMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class ServerMessage(BaseModel):
|
||||||
|
user: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def authorize(self, params: Params) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def receive(self, params: Params, msg: ClientMessage) -> ServerMessage:
|
||||||
|
return self.ServerMessage(user="anon", text=msg.text)
|
||||||
|
|
||||||
|
|
||||||
|
register_channel(Chat, "chat")
|
||||||
|
```
|
||||||
|
|
||||||
|
Server code pushes to a group from anywhere:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await Chat.push(Chat.ServerMessage(user="system", text="hello"), room="general")
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered channels contribute to the exported IR, so codegen emits the
|
||||||
|
`<Pascal>Params` / `<Pascal>ClientMessage` / `<Pascal>ServerMessage` types and
|
||||||
|
the matching frontend hook.
|
||||||
|
|
||||||
|
Group membership lives in the process that holds the socket, so a push reaches
|
||||||
|
only the subscribers attached to that process. Fan-out that spans processes is
|
||||||
|
a shared broker in front of `broadcast`.
|
||||||
|
|
||||||
## Auth integration
|
## Auth integration
|
||||||
|
|
||||||
@@ -169,8 +217,6 @@ python -m mizan_fastapi.ir <module>
|
|||||||
|
|
||||||
Imports the named module (which must register every `@client` function as
|
Imports the named module (which must register every `@client` function as
|
||||||
import-time side effects), then prints the Mizan KDL IR to stdout.
|
import-time side effects), then prints the Mizan KDL IR to stdout.
|
||||||
Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes
|
|
||||||
either backend the same subprocess way.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -183,4 +229,4 @@ emit equivalent schemas for the same registered functions. See
|
|||||||
|
|
||||||
A live e2e harness exercises this adapter end-to-end at
|
A live e2e harness exercises this adapter end-to-end at
|
||||||
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
|
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
|
||||||
→ FastAPI server, 14/14 Playwright tests).
|
→ FastAPI server, driven by Playwright).
|
||||||
|
|||||||
@@ -8,13 +8,8 @@ dependencies = [
|
|||||||
"mizan-core",
|
"mizan-core",
|
||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"python-multipart>=0.0.9",
|
|
||||||
"sqlalchemy>=2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
|||||||
@@ -1,41 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||||
|
|
||||||
HTTP RPC dispatch and context bundling on top of mizan-core's function
|
Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket),
|
||||||
registry, sharing the auth / invalidation / cache / upload core with the
|
the error hierarchy with its exception handlers, and the channel base class with
|
||||||
Django adapter.
|
its registry.
|
||||||
|
|
||||||
The full AFI-common surface is wired here over FastAPI-native primitives,
|
|
||||||
each riding the shared core:
|
|
||||||
|
|
||||||
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
|
|
||||||
functions through the same `mizan_core.dispatch` as `POST /call/`.
|
|
||||||
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
|
|
||||||
`mizan_core.ssr.SSRBridge` Bun subprocess.
|
|
||||||
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
|
|
||||||
console entry) emit the manifest derived in `mizan_core.manifest`, including
|
|
||||||
each context's `render_strategy`.
|
|
||||||
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
|
|
||||||
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
|
|
||||||
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
|
|
||||||
role functions over Pydantic.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
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 (
|
from mizan_fastapi.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
MizanError,
|
MizanError,
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -48,54 +19,26 @@ from .executor import (
|
|||||||
compute_invalidation,
|
compute_invalidation,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
# Register the FastAPI/Starlette response base so view-path detection works in
|
from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
# mizan_core.client.function (a @client function returning a Response is a
|
from mizan_fastapi.websocket import ws_router
|
||||||
# view-path function — header-only invalidation, "view" in the edge manifest).
|
from mizan_fastapi.channels import (
|
||||||
# Must run before any @client-decorated code is evaluated.
|
Channel,
|
||||||
from starlette.responses import Response as _Response
|
broadcast,
|
||||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
get_channel,
|
||||||
_set_response_base(_Response)
|
register as register_channel,
|
||||||
|
)
|
||||||
from . import shapes, forms
|
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
|
||||||
from .auth import MizanAuthMiddleware, mizan_auth
|
|
||||||
from .config import MizanConfig, from_env
|
|
||||||
from .manifest import edge_manifest, generate_edge_manifest, render_strategies
|
|
||||||
from .ssr import SSRRenderer
|
|
||||||
from mizan_core.upload import File, Upload, UploadedFile
|
|
||||||
|
|
||||||
# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit)
|
|
||||||
# are submodule bindings; expose their public primitives at the package root.
|
|
||||||
Shape = shapes.Shape
|
|
||||||
Diff = shapes.Diff
|
|
||||||
NestedDiff = shapes.NestedDiff
|
|
||||||
mizanForm = forms.mizanForm
|
|
||||||
FormConfig = forms.FormConfig
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
"mizan_auth",
|
|
||||||
"MizanAuthMiddleware",
|
|
||||||
"MizanConfig",
|
|
||||||
"from_env",
|
|
||||||
"router",
|
"router",
|
||||||
|
"ws_router",
|
||||||
|
"Channel",
|
||||||
|
"register_channel",
|
||||||
|
"get_channel",
|
||||||
|
"broadcast",
|
||||||
"mizan_exception_handler",
|
"mizan_exception_handler",
|
||||||
"mizan_validation_handler",
|
"mizan_validation_handler",
|
||||||
"execute_function",
|
"execute_function",
|
||||||
"compute_invalidation",
|
"compute_invalidation",
|
||||||
"edge_manifest",
|
|
||||||
"generate_edge_manifest",
|
|
||||||
"render_strategies",
|
|
||||||
"SSRRenderer",
|
|
||||||
"shapes",
|
|
||||||
"forms",
|
|
||||||
"Shape",
|
|
||||||
"Diff",
|
|
||||||
"NestedDiff",
|
|
||||||
"mizanForm",
|
|
||||||
"FormConfig",
|
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
"MizanError",
|
"MizanError",
|
||||||
"NotFound",
|
"NotFound",
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""
|
|
||||||
Built-in identity for FastAPI — Django-equivalent automatic `request.state.user`.
|
|
||||||
|
|
||||||
Opt in via `Depends(mizan_auth())` on a route/router, or mount `MizanAuthMiddleware`
|
|
||||||
app-wide. Both decode a bearer-JWT (`Authorization: Bearer`) or MWT (`X-Mizan-Token`)
|
|
||||||
via the shared core and set `request.state.user`. A present-but-invalid token is
|
|
||||||
rejected (401) rather than silently downgraded — the `INVALID` sentinel contract.
|
|
||||||
|
|
||||||
If you'd rather resolve identity yourself, set `request.state.user` upstream and skip
|
|
||||||
these; dispatch reads it directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
from mizan_core.auth import INVALID, authenticate
|
|
||||||
from mizan_core.errors import Unauthorized
|
|
||||||
|
|
||||||
from .config import get_config
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve(request: Request) -> None:
|
|
||||||
ident = authenticate(request.headers, get_config(request).auth)
|
|
||||||
if ident is INVALID:
|
|
||||||
raise Unauthorized("Invalid or expired token")
|
|
||||||
if ident is not None:
|
|
||||||
request.state.user = ident
|
|
||||||
|
|
||||||
|
|
||||||
def mizan_auth() -> Callable:
|
|
||||||
"""FastAPI dependency that populates `request.state.user` from a token."""
|
|
||||||
async def _dep(request: Request) -> None:
|
|
||||||
_resolve(request)
|
|
||||||
return _dep
|
|
||||||
|
|
||||||
|
|
||||||
class MizanAuthMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""App-wide variant of `mizan_auth` — resolves identity on every request."""
|
|
||||||
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
try:
|
|
||||||
_resolve(request)
|
|
||||||
except Unauthorized:
|
|
||||||
from .router import _no_store
|
|
||||||
from mizan_core.errors import ErrorCode
|
|
||||||
return _no_store(
|
|
||||||
{"error": {"code": ErrorCode.UNAUTHORIZED.value, "message": "Invalid or expired token"}},
|
|
||||||
status_code=401,
|
|
||||||
)
|
|
||||||
return await call_next(request)
|
|
||||||
162
backends/mizan-fastapi/src/mizan_fastapi/channels.py
Normal file
162
backends/mizan-fastapi/src/mizan_fastapi/channels.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Channels for FastAPI — multiplexed pub/sub over the one WebSocket connection.
|
||||||
|
|
||||||
|
A channel names a group of subscribers and decides who may join it. `group(params)`
|
||||||
|
is the fan-out key, so two subscribers with the same params share a group and a push
|
||||||
|
addressed to those params reaches both.
|
||||||
|
|
||||||
|
Membership is held in this process. A push therefore reaches only the subscribers
|
||||||
|
whose socket is attached to the process that sent it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.registry import RegistryExtension, register_extension
|
||||||
|
|
||||||
|
# group name -> the live sockets subscribed to it
|
||||||
|
_groups: dict[str, set[Any]] = defaultdict(set)
|
||||||
|
_registry: dict[str, type["Channel"]] = {}
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class Channel:
|
||||||
|
"""A named fan-out. Subclass, override what the channel decides, and register it.
|
||||||
|
|
||||||
|
The three nested models are the channel's payload types, named from the client's
|
||||||
|
side: `Params` keys the fan-out, `ClientMessage` travels up, `ServerMessage`
|
||||||
|
travels down. A subclass may also define `on_connect(params)` / `on_disconnect()`;
|
||||||
|
the socket handler calls them when they exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: ClassVar[str] = ""
|
||||||
|
Params: ClassVar[type[BaseModel] | None] = None
|
||||||
|
ClientMessage: ClassVar[type[BaseModel] | None] = None
|
||||||
|
ServerMessage: ClassVar[type[BaseModel] | None] = None
|
||||||
|
|
||||||
|
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||||
|
"""Whether this subscriber may join. Default: anyone may."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def group(self, params: BaseModel | None = None) -> str:
|
||||||
|
"""The fan-out key. Subscribers sharing it share every message sent to it."""
|
||||||
|
if params is None:
|
||||||
|
return self.name
|
||||||
|
parts = sorted(f"{k}={v}" for k, v in params.model_dump().items())
|
||||||
|
return f"{self.name}:{':'.join(parts)}" if parts else self.name
|
||||||
|
|
||||||
|
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||||
|
"""What a client-sent message becomes for the group. None drops it."""
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def push(cls, message: BaseModel | dict, **params: Any) -> None:
|
||||||
|
"""Send to every subscriber whose params key this group, from anywhere in the app."""
|
||||||
|
channel = cls()
|
||||||
|
key = channel.group(_Params(params) if params else None)
|
||||||
|
await broadcast(key, cls.__name__, message, params)
|
||||||
|
|
||||||
|
|
||||||
|
class _Params:
|
||||||
|
"""Params given as keywords rather than a model, so `group` can read them uniformly."""
|
||||||
|
|
||||||
|
def __init__(self, values: dict[str, Any]) -> None:
|
||||||
|
self._values = values
|
||||||
|
|
||||||
|
def model_dump(self) -> dict[str, Any]:
|
||||||
|
return self._values
|
||||||
|
|
||||||
|
|
||||||
|
def register(channel_class: type[Channel], name: str) -> None:
|
||||||
|
channel_class.name = name
|
||||||
|
_registry[name] = channel_class
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel(name: str) -> type[Channel] | None:
|
||||||
|
return _registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def registered() -> dict[str, type[Channel]]:
|
||||||
|
return dict(_registry)
|
||||||
|
|
||||||
|
|
||||||
|
async def join(group: str, socket: Any) -> None:
|
||||||
|
async with _lock:
|
||||||
|
_groups[group].add(socket)
|
||||||
|
|
||||||
|
|
||||||
|
async def leave(group: str, socket: Any) -> None:
|
||||||
|
async with _lock:
|
||||||
|
_groups[group].discard(socket)
|
||||||
|
if not _groups[group]:
|
||||||
|
del _groups[group]
|
||||||
|
|
||||||
|
|
||||||
|
async def leave_all(socket: Any) -> None:
|
||||||
|
async with _lock:
|
||||||
|
for group in [g for g, sockets in _groups.items() if socket in sockets]:
|
||||||
|
_groups[group].discard(socket)
|
||||||
|
if not _groups[group]:
|
||||||
|
del _groups[group]
|
||||||
|
|
||||||
|
|
||||||
|
async def members(group: str) -> set[Any]:
|
||||||
|
async with _lock:
|
||||||
|
return set(_groups.get(group, ()))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(
|
||||||
|
group: str, type_name: str, message: BaseModel | dict, params: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Deliver to the group. A socket that fails to take it has departed, and is dropped."""
|
||||||
|
payload = {
|
||||||
|
"channel": group.split(":", 1)[0],
|
||||||
|
"params": params or {},
|
||||||
|
"type": type_name,
|
||||||
|
"data": jsonable_encoder(message),
|
||||||
|
}
|
||||||
|
for socket in await members(group):
|
||||||
|
try:
|
||||||
|
await socket.send_json(payload)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
|
||||||
|
await leave(group, socket)
|
||||||
|
|
||||||
|
|
||||||
|
class _ChannelsExtension(RegistryExtension):
|
||||||
|
"""The `channels` slot of the core registry — one entry per registered channel,
|
||||||
|
each carrying the JSON schema of whichever payload models the channel declares.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def all(self) -> dict[str, type[Channel]]:
|
||||||
|
return dict(_registry)
|
||||||
|
|
||||||
|
def schema(self) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for name, channel_class in _registry.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"type": "channel",
|
||||||
|
"bidirectional": False,
|
||||||
|
}
|
||||||
|
if channel_class.Params is not None:
|
||||||
|
entry["params"] = channel_class.Params.model_json_schema()
|
||||||
|
if channel_class.ClientMessage is not None:
|
||||||
|
entry["client_message"] = channel_class.ClientMessage.model_json_schema()
|
||||||
|
entry["bidirectional"] = True
|
||||||
|
if channel_class.ServerMessage is not None:
|
||||||
|
entry["server_message"] = channel_class.ServerMessage.model_json_schema()
|
||||||
|
out[name] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
|
register_extension("channels", _ChannelsExtension())
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""
|
|
||||||
FastAPI configuration — the "no settings.py" seam.
|
|
||||||
|
|
||||||
Builds the shared core's `AuthConfig` (JWT + MWT) and a `CacheOrchestrator`
|
|
||||||
from environment variables, overridable per-app via `app.state.mizan_config`.
|
|
||||||
|
|
||||||
Env:
|
|
||||||
MIZAN_CACHE_SECRET HMAC cache signing key (enables origin cache)
|
|
||||||
MIZAN_CACHE_REDIS_URL Redis URL (else in-memory cache)
|
|
||||||
MIZAN_MWT_SECRET MWT signing key
|
|
||||||
MIZAN_MWT_AUDIENCE MWT audience (default "mizan")
|
|
||||||
JWT_PRIVATE_KEY JWT signing key (enables bearer-JWT auth)
|
|
||||||
JWT_PUBLIC_KEY JWT verify key (default: private key, HS256)
|
|
||||||
JWT_ALGORITHM default "HS256"
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRES_IN / JWT_REFRESH_TOKEN_EXPIRES_IN
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from mizan_core.auth import AuthConfig, JWTConfig
|
|
||||||
from mizan_core.cache.backend import CacheBackend, MemoryCache
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MizanConfig:
|
|
||||||
auth: AuthConfig
|
|
||||||
cache: CacheOrchestrator
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_backend(secret: str | None, redis_url: str | None) -> CacheBackend | None:
|
|
||||||
if not secret:
|
|
||||||
return None
|
|
||||||
if redis_url:
|
|
||||||
from mizan_core.cache.backend import RedisCache
|
|
||||||
return RedisCache(redis_url)
|
|
||||||
return MemoryCache()
|
|
||||||
|
|
||||||
|
|
||||||
def _jwt_config() -> JWTConfig | None:
|
|
||||||
key = os.getenv("JWT_PRIVATE_KEY")
|
|
||||||
if not key:
|
|
||||||
return None
|
|
||||||
return JWTConfig(
|
|
||||||
private_key=key,
|
|
||||||
public_key=os.getenv("JWT_PUBLIC_KEY", key),
|
|
||||||
algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
|
|
||||||
access_token_expires_in=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES_IN", "300")),
|
|
||||||
refresh_token_expires_in=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES_IN", "604800")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def from_env() -> MizanConfig:
|
|
||||||
secret = os.getenv("MIZAN_CACHE_SECRET")
|
|
||||||
backend = _cache_backend(secret, os.getenv("MIZAN_CACHE_REDIS_URL"))
|
|
||||||
auth = AuthConfig(
|
|
||||||
jwt=_jwt_config(),
|
|
||||||
mwt_secret=os.getenv("MIZAN_MWT_SECRET"),
|
|
||||||
mwt_audience=os.getenv("MIZAN_MWT_AUDIENCE", "mizan"),
|
|
||||||
)
|
|
||||||
return MizanConfig(auth=auth, cache=CacheOrchestrator(backend, secret))
|
|
||||||
|
|
||||||
|
|
||||||
def get_config(request) -> MizanConfig:
|
|
||||||
"""Per-app config: `app.state.mizan_config` if set, else built from env (cached)."""
|
|
||||||
app = getattr(request, "app", None)
|
|
||||||
state = getattr(app, "state", None)
|
|
||||||
override = getattr(state, "mizan_config", None) if state is not None else None
|
|
||||||
if override is not None:
|
|
||||||
return override
|
|
||||||
global _DEFAULT
|
|
||||||
if _DEFAULT is None:
|
|
||||||
_DEFAULT = from_env()
|
|
||||||
return _DEFAULT
|
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT: MizanConfig | None = None
|
|
||||||
@@ -1,69 +1,263 @@
|
|||||||
"""
|
"""
|
||||||
Dispatch — a thin shim over the shared core (`mizan_core.dispatch`).
|
RPC dispatch — looks up registered functions, validates input against the
|
||||||
|
function's Pydantic Input model, executes, and returns the serialized result.
|
||||||
|
|
||||||
The protocol machinery (auth, validation, execution, invalidation, merge, cache)
|
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||||
lives in `mizan_core`; this module re-exports the canonical error taxonomy and
|
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||||
keeps backward-compatible helpers. The router drives `dispatch_call` /
|
let them propagate to your own handler.
|
||||||
`dispatch_context` directly to get invalidation + origin cache.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
from fastapi.encoders import jsonable_encoder
|
||||||
from mizan_core.errors import (
|
from pydantic import BaseModel, ValidationError
|
||||||
BadRequest,
|
|
||||||
ErrorCode,
|
|
||||||
Forbidden,
|
|
||||||
InternalError,
|
|
||||||
MizanError,
|
|
||||||
NotFound,
|
|
||||||
NotImplementedYet,
|
|
||||||
Unauthorized,
|
|
||||||
ValidationFailed,
|
|
||||||
)
|
|
||||||
from mizan_core.invalidation import resolve_invalidation, resolve_merges
|
|
||||||
|
|
||||||
__all__ = [
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
"ErrorCode",
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
"MizanError",
|
|
||||||
"NotFound",
|
|
||||||
"BadRequest",
|
|
||||||
"ValidationFailed",
|
|
||||||
"Unauthorized",
|
|
||||||
"Forbidden",
|
|
||||||
"NotImplementedYet",
|
|
||||||
"InternalError",
|
|
||||||
"compute_invalidation",
|
|
||||||
"compute_merges",
|
|
||||||
"execute_function",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_NO_CACHE = CacheOrchestrator(None, None)
|
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(str, Enum):
|
||||||
|
NOT_FOUND = "NOT_FOUND"
|
||||||
|
BAD_REQUEST = "BAD_REQUEST"
|
||||||
|
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||||
|
UNAUTHORIZED = "UNAUTHORIZED"
|
||||||
|
FORBIDDEN = "FORBIDDEN"
|
||||||
|
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
||||||
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS = {
|
||||||
|
ErrorCode.NOT_FOUND: 404,
|
||||||
|
ErrorCode.BAD_REQUEST: 400,
|
||||||
|
ErrorCode.VALIDATION_ERROR: 422,
|
||||||
|
ErrorCode.UNAUTHORIZED: 401,
|
||||||
|
ErrorCode.FORBIDDEN: 403,
|
||||||
|
ErrorCode.NOT_IMPLEMENTED: 501,
|
||||||
|
ErrorCode.INTERNAL_ERROR: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MizanError(Exception):
|
||||||
|
"""Base for protocol-level dispatch errors."""
|
||||||
|
|
||||||
|
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_code(self) -> int:
|
||||||
|
return _STATUS[self.code]
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
|
||||||
|
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
|
||||||
|
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
||||||
|
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
|
||||||
|
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
|
||||||
|
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
||||||
|
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _user(request: Any) -> Any:
|
||||||
|
return getattr(getattr(request, "state", None), "user", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_authenticated(user: Any) -> bool:
|
||||||
|
return bool(user) and getattr(user, "is_authenticated", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_auth(request: Any, requirement: Any) -> None:
|
||||||
|
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
|
||||||
|
if requirement is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = _user(request)
|
||||||
|
|
||||||
|
match requirement:
|
||||||
|
case True | "required":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
case "staff":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
if not getattr(user, "is_staff", False):
|
||||||
|
raise Forbidden("Staff access required")
|
||||||
|
case "superuser":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
if not getattr(user, "is_superuser", False):
|
||||||
|
raise Forbidden("Superuser access required")
|
||||||
|
case f if callable(f):
|
||||||
|
if not f(request):
|
||||||
|
raise Forbidden("Permission denied")
|
||||||
|
case other:
|
||||||
|
raise InternalError(f"Unknown auth requirement: {other!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Input validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
||||||
|
"""Validate input_data against the function's Input model. Returns the instance or None."""
|
||||||
|
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
fields = input_cls.model_fields
|
||||||
|
required = [name for name, f in fields.items() if f.is_required()]
|
||||||
|
|
||||||
|
if not input_data:
|
||||||
|
if required:
|
||||||
|
raise ValidationFailed(
|
||||||
|
"Input validation failed",
|
||||||
|
details={"fields": {name: ["Field required"] for name in required}},
|
||||||
|
)
|
||||||
|
return input_cls()
|
||||||
|
|
||||||
|
if not isinstance(input_data, dict):
|
||||||
|
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return input_cls(**input_data)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ValidationFailed(
|
||||||
|
"Input validation failed",
|
||||||
|
details={"errors": e.errors()},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_function(fn_name: str) -> Any:
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
if view_class is None:
|
||||||
|
raise NotFound("Function not found")
|
||||||
|
if getattr(view_class, "_meta", {}).get("private"):
|
||||||
|
raise Forbidden("Function is not client-callable")
|
||||||
|
return view_class
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(result: Any) -> Any:
|
||||||
|
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
|
||||||
|
# (and nested shapes) come out wire-ready without a per-shape branch here.
|
||||||
|
return jsonable_encoder(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_function(
|
||||||
|
request: Any,
|
||||||
|
fn_name: str,
|
||||||
|
input_data: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
|
||||||
|
|
||||||
|
Awaits `view.acall` — async handlers run on the loop, sync handlers run
|
||||||
|
in the default threadpool, both via the same entrypoint.
|
||||||
|
"""
|
||||||
|
view_class = _resolve_function(fn_name)
|
||||||
|
_enforce_auth(request, view_class._meta.get("auth"))
|
||||||
|
|
||||||
|
view = view_class(request)
|
||||||
|
validated = _validate_input(view.Input, input_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await view.acall(validated)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||||
|
except MizanError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise InternalError(str(e)) from e
|
||||||
|
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
||||||
"""`@client(affects=...)` → invalidation list (empty when none). Shared core."""
|
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||||
return resolve_invalidation(view_class, input_data) or []
|
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
||||||
|
return [_invalidation_target(target, input_data or {}) for target in affects]
|
||||||
|
|
||||||
|
|
||||||
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
||||||
"""`@client(merge=...)` → merge list (empty when none). Shared core."""
|
"""Build the `merge` list from @client(merge=...) metadata.
|
||||||
return resolve_merges(view_class, input_data, result) or []
|
|
||||||
|
|
||||||
|
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||||
async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any:
|
function inside the context bundle the value lands in. The slot is
|
||||||
"""Dispatch a function and return its serialized result (auth enforced via core).
|
resolved server-side via `types_match_for_merge` so the kernel does
|
||||||
|
no shape inference — the server has the schema, type-checked routing
|
||||||
Backward-compat entry point; the router uses `dispatch_call` directly to also
|
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||||
capture invalidation/merge and run the origin cache.
|
with a warning; the consumer falls back to refetch via `affects`.
|
||||||
"""
|
"""
|
||||||
identity = getattr(getattr(request, "state", None), "user", None)
|
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||||
res = await dispatch_call(
|
if not targets:
|
||||||
DispatchRequest(identity=identity, args=input_data, native_request=request),
|
return []
|
||||||
fn_name,
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
_NO_CACHE,
|
out: list[dict[str, Any]] = []
|
||||||
)
|
for ctx_name in targets:
|
||||||
return res.data
|
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
||||||
|
scoped = _scoped_params(ctx_name, input_data or {})
|
||||||
|
if scoped:
|
||||||
|
entry["params"] = scoped
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||||
|
"""Find the unique function-name slot whose return type matches the mutation's output.
|
||||||
|
|
||||||
|
Returns None on no match or ambiguous match (multiple candidates).
|
||||||
|
"""
|
||||||
|
if mutation_output is None:
|
||||||
|
return None
|
||||||
|
matches: list[str] = []
|
||||||
|
for fn_name in get_context_groups().get(context_name, []):
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
fn_output = getattr(fn_cls, "Output", None)
|
||||||
|
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||||
|
matches.append(fn_name)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Match input args against the context's declared Input field names."""
|
||||||
|
fn_names = get_context_groups().get(context_name, [])
|
||||||
|
declared: set[str] = set()
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
declared.update(input_cls.model_fields.keys())
|
||||||
|
return {k: v for k, v in input_data.items() if k in declared}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||||
|
match target.get("type"):
|
||||||
|
case "context":
|
||||||
|
name = target["name"]
|
||||||
|
scoped = _scoped_params(name, input_data)
|
||||||
|
return {"context": name, "params": scoped} if scoped else name
|
||||||
|
case "function":
|
||||||
|
return {"function": target["name"]}
|
||||||
|
case _:
|
||||||
|
return target
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
"""
|
|
||||||
Forms — the Pydantic binding (schema / validate / submit roles).
|
|
||||||
|
|
||||||
A Mizan form is exposed as three server functions — `{name}.schema`,
|
|
||||||
`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of
|
|
||||||
`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and
|
|
||||||
identical to the Django adapter's (`mizan.forms`); only the *binding* differs:
|
|
||||||
Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`.
|
|
||||||
|
|
||||||
from mizan_fastapi.forms import mizanForm, FormConfig
|
|
||||||
|
|
||||||
class ContactForm(mizanForm):
|
|
||||||
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
|
|
||||||
|
|
||||||
name: str
|
|
||||||
email: EmailStr
|
|
||||||
message: str
|
|
||||||
|
|
||||||
def on_submit_success(self, request) -> dict:
|
|
||||||
send_email(self.model_dump())
|
|
||||||
return {"sent": True}
|
|
||||||
|
|
||||||
Subclassing registers the three role functions automatically (parity with the
|
|
||||||
Django `mizanFormMixin.__init_subclass__` auto-registration):
|
|
||||||
|
|
||||||
contact.schema → field definitions (FormSchema)
|
|
||||||
contact.validate → structured field errors (FormValidation)
|
|
||||||
contact.submit → validate, then on_submit_success / on_submit_failure
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, ClassVar, get_args, get_origin
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ValidationError, create_model
|
|
||||||
|
|
||||||
from mizan_core.client.function import ServerFunction
|
|
||||||
from mizan_core.registry import get_all_functions, register
|
|
||||||
|
|
||||||
from .schemas import (
|
|
||||||
FieldError,
|
|
||||||
FieldErrorList,
|
|
||||||
FieldSchema,
|
|
||||||
FormMeta,
|
|
||||||
FormSchema,
|
|
||||||
FormSubmitFail,
|
|
||||||
FormSubmitPass,
|
|
||||||
FormValidation,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"FormConfig",
|
|
||||||
"mizanForm",
|
|
||||||
"get_forms",
|
|
||||||
"FormSchema",
|
|
||||||
"FormValidation",
|
|
||||||
"FormSubmitPass",
|
|
||||||
"FormSubmitFail",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the
|
|
||||||
# Django binding's `_django_field_to_python_type` intent: hand the client a real
|
|
||||||
# field type instead of a generic string.
|
|
||||||
_TYPE_WIDGET = {
|
|
||||||
bool: ("checkbox", "CheckboxInput"),
|
|
||||||
int: ("number", "NumberInput"),
|
|
||||||
float: ("number", "NumberInput"),
|
|
||||||
str: ("text", "TextInput"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FormConfig(BaseModel):
|
|
||||||
"""Form metadata + frontend behavior (parity with `mizanFormMeta`)."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
title: str | None = None
|
|
||||||
subtitle: str | None = None
|
|
||||||
submit_label: str = "Submit"
|
|
||||||
live_validation: bool = True
|
|
||||||
live_form_errors: bool = False
|
|
||||||
refetch_schema_on_validate: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _unwrap_optional(annotation: Any) -> Any:
|
|
||||||
"""`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged."""
|
|
||||||
if get_origin(annotation) in (None,):
|
|
||||||
return annotation
|
|
||||||
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
||||||
if len(args) == 1 and type(None) in get_args(annotation):
|
|
||||||
return args[0]
|
|
||||||
return annotation
|
|
||||||
|
|
||||||
|
|
||||||
def _field_type_widget(annotation: Any) -> tuple[str, str]:
|
|
||||||
base = _unwrap_optional(annotation)
|
|
||||||
return _TYPE_WIDGET.get(base, ("text", "TextInput"))
|
|
||||||
|
|
||||||
|
|
||||||
def _humanize(name: str) -> str:
|
|
||||||
return name.replace("_", " ").title()
|
|
||||||
|
|
||||||
|
|
||||||
def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema:
|
|
||||||
"""Derive a `FormSchema` from a Pydantic form's fields + config."""
|
|
||||||
cfg = form_cls.mizan
|
|
||||||
fields: list[FieldSchema] = []
|
|
||||||
for field_name, info in form_cls.model_fields.items():
|
|
||||||
type_str, widget = _field_type_widget(info.annotation)
|
|
||||||
required = info.is_required()
|
|
||||||
initial = None if required else info.get_default(call_default_factory=False)
|
|
||||||
if initial is None and info.default is not None and info.default is not ...:
|
|
||||||
initial = info.default
|
|
||||||
meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
||||||
fields.append(
|
|
||||||
FieldSchema(
|
|
||||||
name=field_name,
|
|
||||||
label=str(info.title or _humanize(field_name)),
|
|
||||||
type=type_str,
|
|
||||||
widget=widget,
|
|
||||||
required=required,
|
|
||||||
disabled=bool(meta.get("disabled", False)),
|
|
||||||
help_text=str(info.description or ""),
|
|
||||||
initial=initial if initial is not ... else None,
|
|
||||||
max_length=getattr(info, "max_length", None),
|
|
||||||
min_length=getattr(info, "min_length", None),
|
|
||||||
choices=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return FormSchema(
|
|
||||||
name=cfg.name,
|
|
||||||
title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")),
|
|
||||||
subtitle=cfg.subtitle,
|
|
||||||
submit_label=cfg.submit_label,
|
|
||||||
fields=fields,
|
|
||||||
meta=FormMeta(
|
|
||||||
refetch_schema_on_validate=cfg.refetch_schema_on_validate,
|
|
||||||
live_validation=cfg.live_validation,
|
|
||||||
live_form_errors=cfg.live_form_errors,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validation_from_error(exc: ValidationError) -> FormValidation:
|
|
||||||
"""Group a Pydantic `ValidationError` into the `FormValidation` wire shape."""
|
|
||||||
by_field: dict[str, list[FieldError]] = {}
|
|
||||||
for err in exc.errors():
|
|
||||||
loc = err.get("loc", ())
|
|
||||||
field = str(loc[0]) if loc else "__all__"
|
|
||||||
by_field.setdefault(field, []).append(
|
|
||||||
FieldError(message=err.get("msg", "Invalid value"), code=err.get("type"))
|
|
||||||
)
|
|
||||||
return FormValidation(
|
|
||||||
errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]:
|
|
||||||
"""Validate `data`; return `(instance|None, validation)` — instance None on failure."""
|
|
||||||
try:
|
|
||||||
instance = form_cls(**(data or {}))
|
|
||||||
return instance, FormValidation(errors=[])
|
|
||||||
except ValidationError as exc:
|
|
||||||
return None, _validation_from_error(exc)
|
|
||||||
|
|
||||||
|
|
||||||
class mizanForm(BaseModel):
|
|
||||||
"""Base for a Pydantic-backed Mizan form.
|
|
||||||
|
|
||||||
Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing
|
|
||||||
auto-registers the schema/validate/submit role functions. Override
|
|
||||||
`on_submit_success` / `on_submit_failure` for submit-time behavior.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mizan: ClassVar[FormConfig]
|
|
||||||
|
|
||||||
def on_submit_success(self, request: Any) -> dict | None:
|
|
||||||
"""Handle a validated submission. Override; returns optional result data."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def on_submit_failure(self, request: Any, errors: FormValidation) -> None:
|
|
||||||
"""Handle a failed submission (logging, etc.). Override."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
cfg = cls.__dict__.get("mizan")
|
|
||||||
if isinstance(cfg, FormConfig):
|
|
||||||
_register_form(cls)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_form(form_cls: type[mizanForm]) -> None:
|
|
||||||
"""Register `{name}.schema/.validate/.submit` for a Pydantic form class."""
|
|
||||||
cfg = form_cls.mizan
|
|
||||||
name = cfg.name
|
|
||||||
pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_"))
|
|
||||||
|
|
||||||
schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {}))
|
|
||||||
validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...))
|
|
||||||
submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...))
|
|
||||||
|
|
||||||
class SchemaFunction(ServerFunction):
|
|
||||||
Input = schema_input
|
|
||||||
Output = FormSchema
|
|
||||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"}
|
|
||||||
|
|
||||||
def call(self, input) -> FormSchema:
|
|
||||||
return build_form_schema(form_cls)
|
|
||||||
|
|
||||||
class ValidateFunction(ServerFunction):
|
|
||||||
Input = validate_input
|
|
||||||
Output = FormValidation
|
|
||||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"}
|
|
||||||
|
|
||||||
def call(self, input) -> FormValidation:
|
|
||||||
_, validation = _validate(form_cls, input.data)
|
|
||||||
return validation
|
|
||||||
|
|
||||||
class SubmitFunction(ServerFunction):
|
|
||||||
Input = submit_input
|
|
||||||
Output = FormSubmitPass
|
|
||||||
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"}
|
|
||||||
|
|
||||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
|
||||||
instance, validation = _validate(form_cls, input.data)
|
|
||||||
if instance is not None:
|
|
||||||
return FormSubmitPass(success=True, data=instance.on_submit_success(self.request))
|
|
||||||
instance_for_failure = form_cls.model_construct(**(input.data or {}))
|
|
||||||
instance_for_failure.on_submit_failure(self.request, validation)
|
|
||||||
return FormSubmitFail(success=False, errors=validation)
|
|
||||||
|
|
||||||
for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")):
|
|
||||||
fn.__name__ = f"{name}_{role}"
|
|
||||||
fn.__qualname__ = fn.__name__
|
|
||||||
register(fn, f"{name}.{role}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_forms() -> dict[str, list]:
|
|
||||||
"""Group registered form role functions by form name (parity helper)."""
|
|
||||||
forms: dict[str, list] = {}
|
|
||||||
for _, cls in get_all_functions().items():
|
|
||||||
meta = getattr(cls, "_meta", {})
|
|
||||||
if meta.get("form"):
|
|
||||||
forms.setdefault(meta.get("form_name"), []).append(cls)
|
|
||||||
return forms
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""
|
|
||||||
Form role output schemas — the wire shapes the schema/validate/submit roles emit.
|
|
||||||
|
|
||||||
These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta,
|
|
||||||
FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated
|
|
||||||
client is identical regardless of which backend authored the form. The only
|
|
||||||
difference is the source: Django builds these from `forms.Field` introspection;
|
|
||||||
this builds them from Pydantic `FieldInfo`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class FormMeta(BaseModel):
|
|
||||||
"""Frontend behavior flags (parity with the Django adapter)."""
|
|
||||||
|
|
||||||
refetch_schema_on_validate: bool = False
|
|
||||||
live_validation: bool = True
|
|
||||||
live_form_errors: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class FieldChoice(BaseModel):
|
|
||||||
value: str
|
|
||||||
label: str
|
|
||||||
|
|
||||||
|
|
||||||
class FieldError(BaseModel):
|
|
||||||
message: str
|
|
||||||
code: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FieldErrorList(BaseModel):
|
|
||||||
field: str
|
|
||||||
errors: list[FieldError]
|
|
||||||
|
|
||||||
|
|
||||||
class FieldSchema(BaseModel):
|
|
||||||
name: str
|
|
||||||
label: str
|
|
||||||
type: str
|
|
||||||
widget: str
|
|
||||||
required: bool
|
|
||||||
disabled: bool
|
|
||||||
help_text: str
|
|
||||||
initial: Any = None
|
|
||||||
max_length: Optional[int] = None
|
|
||||||
min_length: Optional[int] = None
|
|
||||||
choices: Optional[list[FieldChoice]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FormSchema(BaseModel):
|
|
||||||
"""Schema returned by the `.schema` role: form metadata + field definitions."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
title: str
|
|
||||||
subtitle: Optional[str] = None
|
|
||||||
submit_label: str
|
|
||||||
fields: list[FieldSchema]
|
|
||||||
meta: FormMeta = FormMeta()
|
|
||||||
|
|
||||||
|
|
||||||
class FormValidation(BaseModel):
|
|
||||||
errors: list[FieldErrorList]
|
|
||||||
|
|
||||||
|
|
||||||
class FormSubmitPass(BaseModel):
|
|
||||||
success: bool
|
|
||||||
data: Optional[dict] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FormSubmitFail(BaseModel):
|
|
||||||
success: bool
|
|
||||||
errors: FormValidation
|
|
||||||
@@ -6,8 +6,7 @@ Usage:
|
|||||||
|
|
||||||
Imports the named module (whose import side effects must register every
|
Imports the named module (whose import side effects must register every
|
||||||
@client function with `mizan_core.registry`), then writes the canonical
|
@client function with `mizan_core.registry`), then writes the canonical
|
||||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
Mizan IR as KDL to stdout.
|
||||||
directly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
Edge manifest — FastAPI adapter surface.
|
|
||||||
|
|
||||||
The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`);
|
|
||||||
this module exposes it over FastAPI's surface as a callable and a console entry
|
|
||||||
(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest`
|
|
||||||
management command.
|
|
||||||
|
|
||||||
The `render_strategy` field each context carries — `"psr"` when the context has
|
|
||||||
no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge
|
|
||||||
reads to decide between one shared pre-rendered artifact and a per-user cached
|
|
||||||
one. It is derived in the core from the same registry metadata, so FastAPI and
|
|
||||||
Django emit byte-identical manifests for an identical registry.
|
|
||||||
|
|
||||||
CLI:
|
|
||||||
mizan-fastapi-edge-manifest myproject.app
|
|
||||||
mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json
|
|
||||||
|
|
||||||
The positional argument is an import target (``module`` or ``module:attr``); it
|
|
||||||
is imported for its registration side effects (importing the module runs the
|
|
||||||
`@client` decorators and `register(...)` calls that populate the registry)
|
|
||||||
before the manifest is derived.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"]
|
|
||||||
|
|
||||||
|
|
||||||
def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]:
|
|
||||||
"""The Edge manifest for the current registry.
|
|
||||||
|
|
||||||
Call after the app's `@client` functions are imported/registered. The
|
|
||||||
returned dict carries each context's ``render_strategy`` (PSR vs.
|
|
||||||
dynamic_cached) and the mutation→context invalidation routing.
|
|
||||||
"""
|
|
||||||
return generate_edge_manifest(base_url=base_url)
|
|
||||||
|
|
||||||
|
|
||||||
def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]:
|
|
||||||
"""Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``.
|
|
||||||
|
|
||||||
PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a
|
|
||||||
context with no user-scoped param renders one shared artifact (``psr``) that
|
|
||||||
is re-rendered on mutation; a user-scoped context renders per-user
|
|
||||||
(``dynamic_cached``). This surfaces that decision directly so a PSR driver can
|
|
||||||
enumerate which contexts to pre-render without re-deriving it.
|
|
||||||
"""
|
|
||||||
contexts = edge_manifest(base_url)["contexts"]
|
|
||||||
return {name: entry["render_strategy"] for name, entry in contexts.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _import_target(target: str) -> None:
|
|
||||||
"""Import a ``module`` or ``module:attr`` target for its registration effects."""
|
|
||||||
module_name = target.split(":", 1)[0]
|
|
||||||
importlib.import_module(module_name)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Console entry: import the app target, emit the Edge manifest as JSON."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="mizan-fastapi-edge-manifest",
|
|
||||||
description="Export the Mizan Edge manifest for a FastAPI app.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"app",
|
|
||||||
help="Import target whose @client functions to register "
|
|
||||||
"(e.g. 'myproject.app' or 'myproject.app:app').",
|
|
||||||
)
|
|
||||||
parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.")
|
|
||||||
parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.")
|
|
||||||
parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
sys.path.insert(0, "")
|
|
||||||
_import_target(args.app)
|
|
||||||
|
|
||||||
indent = args.indent if args.indent > 0 else None
|
|
||||||
text = generate_edge_manifest_json(base_url=args.base_url, indent=indent)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
Path(args.output).write_text(text, encoding="utf-8")
|
|
||||||
else:
|
|
||||||
sys.stdout.write(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,35 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
FastAPI router exposing Mizan's HTTP endpoints:
|
FastAPI router exposing Mizan's HTTP endpoints:
|
||||||
|
|
||||||
|
GET /session/ — session-init probe
|
||||||
POST /call/ — RPC dispatch
|
POST /call/ — RPC dispatch
|
||||||
GET /ctx/{context_name}/ — bundled context fetch
|
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 __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field
|
||||||
from starlette.datastructures import UploadFile
|
|
||||||
|
|
||||||
from mizan_core.auth import INVALID, authenticate
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
|
||||||
from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized
|
|
||||||
from mizan_core.registry import get_function
|
|
||||||
from mizan_core.upload import UploadedFile, bind_uploads
|
|
||||||
|
|
||||||
from .config import MizanConfig, get_config
|
from mizan_fastapi.executor import (
|
||||||
|
ErrorCode,
|
||||||
|
MizanError,
|
||||||
|
NotFound,
|
||||||
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -44,13 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
|||||||
|
|
||||||
@router.get("/session/")
|
@router.get("/session/")
|
||||||
async def session_init() -> JSONResponse:
|
async def session_init() -> JSONResponse:
|
||||||
"""Session-init endpoint. AFI-common; wired here at parity with mizan-django.
|
"""Session-init probe. The CSRF slot is null — nothing on this backend issues a token."""
|
||||||
|
|
||||||
The endpoint itself is the AFI-common surface. The CSRF *token* is a Django
|
|
||||||
session mechanism with no FastAPI equivalent, so this returns a null token —
|
|
||||||
the difference is in the token's backing mechanism, not in whether the
|
|
||||||
endpoint is owed. The wire-parity harness uses it as its readiness probe.
|
|
||||||
"""
|
|
||||||
return _no_store({"csrfToken": None})
|
return _no_store({"csrfToken": None})
|
||||||
|
|
||||||
|
|
||||||
@@ -59,197 +48,29 @@ class CallBody(BaseModel):
|
|||||||
args: dict[str, Any] = Field(default_factory=dict)
|
args: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
|
|
||||||
"""Read a call request, JSON or multipart. Returns `(fn, args)`.
|
|
||||||
|
|
||||||
Multipart carries the non-file fields in a JSON `args` part and each file as
|
|
||||||
its own part; the file parts bind into the Input's Upload fields with the
|
|
||||||
declarative `File(...)` constraints enforced.
|
|
||||||
"""
|
|
||||||
content_type = request.headers.get("content-type", "")
|
|
||||||
if content_type.startswith("multipart/form-data"):
|
|
||||||
form = await request.form()
|
|
||||||
fn = form.get("fn")
|
|
||||||
if not isinstance(fn, str) or not fn:
|
|
||||||
raise BadRequest("Missing 'fn' field")
|
|
||||||
raw_args = form.get("args")
|
|
||||||
try:
|
|
||||||
args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise BadRequest("Invalid JSON in 'args' field")
|
|
||||||
|
|
||||||
fn_class = get_function(fn)
|
|
||||||
input_cls = getattr(fn_class, "Input", None) if fn_class else None
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
files: dict[str, list[UploadedFile]] = {}
|
|
||||||
for key in set(form.keys()):
|
|
||||||
wrapped = [
|
|
||||||
UploadedFile(p.filename, p.content_type, await p.read())
|
|
||||||
for p in form.getlist(key)
|
|
||||||
if isinstance(p, UploadFile)
|
|
||||||
]
|
|
||||||
if wrapped:
|
|
||||||
files[key] = wrapped
|
|
||||||
err = bind_uploads(input_cls, args, files)
|
|
||||||
if err is not None:
|
|
||||||
raise BadRequest(err)
|
|
||||||
return fn, args
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = CallBody(**(await request.json()))
|
|
||||||
except (ValueError, ValidationError):
|
|
||||||
raise BadRequest("Invalid request body")
|
|
||||||
return body.fn, body.args
|
|
||||||
|
|
||||||
|
|
||||||
def _identity(request: Request, cfg: MizanConfig):
|
|
||||||
"""Identity for dispatch: a host-set `request.state.user`, else a token decode.
|
|
||||||
|
|
||||||
A present-but-invalid token rejects (401); no token → None (anonymous).
|
|
||||||
"""
|
|
||||||
existing = getattr(getattr(request, "state", None), "user", None)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
ident = authenticate(request.headers, cfg.auth)
|
|
||||||
if ident is INVALID:
|
|
||||||
raise Unauthorized("Invalid or expired token")
|
|
||||||
return ident
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/call/")
|
@router.post("/call/")
|
||||||
async def function_call(request: Request) -> JSONResponse:
|
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||||
"""RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with
|
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||||
the `X-Mizan-Invalidate` header alongside the body."""
|
fn_class = get_function(body.fn)
|
||||||
cfg = get_config(request)
|
result = await execute_function(request, body.fn, body.args)
|
||||||
fn, args = await _parse_call(request)
|
invalidate = compute_invalidation(fn_class, body.args)
|
||||||
res = await dispatch_call(
|
merges = compute_merges(fn_class, body.args, result)
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request),
|
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||||
fn, cfg.cache,
|
if merges:
|
||||||
)
|
payload["merge"] = merges
|
||||||
payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []}
|
return _no_store(payload)
|
||||||
if res.merge:
|
|
||||||
payload["merge"] = res.merge
|
|
||||||
headers = {"Cache-Control": "no-store"}
|
|
||||||
if res.invalidate_header:
|
|
||||||
headers["X-Mizan-Invalidate"] = res.invalidate_header
|
|
||||||
return JSONResponse(payload, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ctx/{context_name}/")
|
@router.get("/ctx/{context_name}/")
|
||||||
async def context_fetch(context_name: str, request: Request) -> Response:
|
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||||
"""Bundled context fetch — origin-cached. `{function_name: result, ...}`."""
|
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||||
cfg = get_config(request)
|
fn_names = get_context_groups().get(context_name)
|
||||||
res = await dispatch_context(
|
if not fn_names:
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params),
|
raise NotFound(f"Context '{context_name}' not found")
|
||||||
native_request=request),
|
|
||||||
context_name, cfg.cache,
|
|
||||||
)
|
|
||||||
headers = {"Cache-Control": "no-store"}
|
|
||||||
if res.cache_status:
|
|
||||||
headers["X-Mizan-Cache"] = res.cache_status
|
|
||||||
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
|
||||||
|
|
||||||
|
params = dict(request.query_params)
|
||||||
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
|
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||||
|
return _no_store(bundled)
|
||||||
|
|
||||||
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
|
|
||||||
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
|
|
||||||
token decode from the handshake headers. A present-but-invalid token rejects.
|
|
||||||
|
|
||||||
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
|
|
||||||
identically over either transport.
|
|
||||||
"""
|
|
||||||
existing = getattr(getattr(websocket, "state", None), "user", None)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
ident = authenticate(websocket.headers, cfg.auth)
|
|
||||||
if ident is INVALID:
|
|
||||||
raise Unauthorized("Invalid or expired token")
|
|
||||||
return ident
|
|
||||||
|
|
||||||
|
|
||||||
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
|
|
||||||
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
|
|
||||||
if exc.details:
|
|
||||||
err["details"] = exc.details
|
|
||||||
return {"id": request_id, "ok": False, "error": err}
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/")
|
|
||||||
async def websocket_rpc(websocket: WebSocket) -> None:
|
|
||||||
"""WebSocket RPC transport for `@client(websocket=True)` functions.
|
|
||||||
|
|
||||||
Frame protocol (parity with mizan-django's Channels consumer):
|
|
||||||
|
|
||||||
→ {"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
|
|
||||||
← {"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
|
|
||||||
← {"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
|
|
||||||
|
|
||||||
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
|
|
||||||
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
|
|
||||||
and origin-cache purge are identical across transports. Only functions that
|
|
||||||
declared `websocket=True` are callable here; an HTTP-only function returns a
|
|
||||||
`FORBIDDEN` frame rather than executing.
|
|
||||||
"""
|
|
||||||
cfg = get_config(websocket)
|
|
||||||
await websocket.accept()
|
|
||||||
try:
|
|
||||||
identity = _ws_identity(websocket, cfg)
|
|
||||||
except Unauthorized as exc:
|
|
||||||
await websocket.send_json(_error_frame(None, exc))
|
|
||||||
await websocket.close(code=1008)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
content = await websocket.receive_json()
|
|
||||||
await _handle_ws_rpc(websocket, content, identity, cfg)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
|
|
||||||
"""Dispatch one WS RPC frame through the shared dispatch core."""
|
|
||||||
if content.get("action") != "rpc":
|
|
||||||
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
|
|
||||||
return
|
|
||||||
|
|
||||||
request_id = content.get("id")
|
|
||||||
fn_name = content.get("fn")
|
|
||||||
args = content.get("args", {})
|
|
||||||
|
|
||||||
if not fn_name:
|
|
||||||
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
|
|
||||||
return
|
|
||||||
|
|
||||||
fn_class = get_function(fn_name)
|
|
||||||
if fn_class is None:
|
|
||||||
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
|
|
||||||
return
|
|
||||||
if not getattr(fn_class, "_meta", {}).get("websocket"):
|
|
||||||
await websocket.send_json(
|
|
||||||
_error_frame(
|
|
||||||
request_id,
|
|
||||||
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = await dispatch_call(
|
|
||||||
DispatchRequest(identity=identity, args=args, native_request=websocket),
|
|
||||||
fn_name, cfg.cache,
|
|
||||||
)
|
|
||||||
except MizanError as exc:
|
|
||||||
await websocket.send_json(_error_frame(request_id, exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
|
|
||||||
"invalidate": res.invalidate or []}
|
|
||||||
if res.merge:
|
|
||||||
frame["merge"] = res.merge
|
|
||||||
await websocket.send_json(frame)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Typed query projection (Shapes) — the SQLAlchemy binding.
|
|
||||||
|
|
||||||
A Shape is a Pydantic model that declares *which* fields and relationships of an
|
|
||||||
ORM model to project. The declaration surface is identical to the Django
|
|
||||||
adapter's `mizan.shapes` (`django-readers` binding):
|
|
||||||
|
|
||||||
class AuthorShape(Shape[Author]):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
books: list[BookShape] = [] # nested relationship
|
|
||||||
|
|
||||||
AuthorShape.query(session, lambda s: s.where(Author.name == "Ann"))
|
|
||||||
|
|
||||||
Only the ORM binding differs: where the Django Shape lowers its spec to
|
|
||||||
`django-readers` pairs (queryset prepare + instance project), this lowers it to a
|
|
||||||
SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested
|
|
||||||
relationship (the projection-load that keeps the query count flat), then projects
|
|
||||||
each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a
|
|
||||||
constructed shape against current DB rows, mirroring the Django semantics.
|
|
||||||
|
|
||||||
The one surface difference SQLAlchemy forces is an explicit `session` argument to
|
|
||||||
`query` / `diff` / `diff_many` — Django models carry an implicit `objects`
|
|
||||||
manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the
|
|
||||||
Shape declaration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import types
|
|
||||||
from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.inspection import inspect as sa_inspect
|
|
||||||
from sqlalchemy.orm import Session, selectinload
|
|
||||||
|
|
||||||
_M = TypeVar("_M")
|
|
||||||
_S = TypeVar("_S", bound="Shape")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_shape_class(hint) -> type[Shape] | None:
|
|
||||||
"""The nested Shape a field annotation projects, if any.
|
|
||||||
|
|
||||||
Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional —
|
|
||||||
the same forms the Django binding's `_extract_shape_class` accepts.
|
|
||||||
"""
|
|
||||||
origin = getattr(hint, "__origin__", None)
|
|
||||||
args = getattr(hint, "__args__", ())
|
|
||||||
|
|
||||||
if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape):
|
|
||||||
return args[0]
|
|
||||||
|
|
||||||
if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape:
|
|
||||||
return hint
|
|
||||||
|
|
||||||
if origin is Union or isinstance(hint, types.UnionType):
|
|
||||||
for arg in args:
|
|
||||||
if arg is type(None):
|
|
||||||
continue
|
|
||||||
if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape:
|
|
||||||
return arg
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model(cls) -> Any | None:
|
|
||||||
"""The mapped model a Shape subclass is parameterized on (`Shape[Model]`)."""
|
|
||||||
for base in cls.__bases__:
|
|
||||||
meta = getattr(base, "__pydantic_generic_metadata__", None) or {}
|
|
||||||
if meta.get("origin") is Shape and (args := meta.get("args")):
|
|
||||||
return args[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Shape(BaseModel, Generic[_M]):
|
|
||||||
"""Typed projection over a SQLAlchemy mapped model.
|
|
||||||
|
|
||||||
Subclass as `Shape[Model]`; annotate the fields/relationships to project.
|
|
||||||
Scalar annotations become columns to read; annotations referencing another
|
|
||||||
Shape become relationships to eager-load and project recursively.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_model: ClassVar[Any]
|
|
||||||
_nested: ClassVar[dict[str, type[Shape]]]
|
|
||||||
_field_names: ClassVar[list[str]]
|
|
||||||
_pk_field: ClassVar[str]
|
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
|
|
||||||
if not (model := _resolve_model(cls)):
|
|
||||||
return
|
|
||||||
|
|
||||||
mapper = sa_inspect(model)
|
|
||||||
cls._model = model
|
|
||||||
cls._nested = {}
|
|
||||||
pk_cols = mapper.primary_key
|
|
||||||
cls._pk_field = pk_cols[0].key if pk_cols else "id"
|
|
||||||
|
|
||||||
hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__
|
|
||||||
field_names: list[str] = []
|
|
||||||
for name, hint in hints.items():
|
|
||||||
if name.startswith("_"):
|
|
||||||
continue
|
|
||||||
if shape_cls := _extract_shape_class(hint):
|
|
||||||
cls._nested[name] = shape_cls
|
|
||||||
else:
|
|
||||||
field_names.append(name)
|
|
||||||
cls._field_names = field_names
|
|
||||||
|
|
||||||
# ─── Loading + projection ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _loader_options(cls) -> list[Any]:
|
|
||||||
"""`selectinload(...)` chains for every nested relationship (recursive).
|
|
||||||
|
|
||||||
This is the SQLAlchemy analogue of django-readers' prefetch wiring: each
|
|
||||||
nested Shape contributes a `selectinload` on its relationship attribute,
|
|
||||||
with the child Shape's own loader options nested beneath it, so the whole
|
|
||||||
projection loads in O(depth) queries rather than N+1.
|
|
||||||
"""
|
|
||||||
options: list[Any] = []
|
|
||||||
for name, shape_cls in cls._nested.items():
|
|
||||||
attr = getattr(cls._model, name)
|
|
||||||
child = shape_cls._loader_options()
|
|
||||||
loader = selectinload(attr)
|
|
||||||
options.append(loader.options(*child) if child else loader)
|
|
||||||
return options
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _project(cls: type[_S], instance: Any) -> _S:
|
|
||||||
"""Project a loaded ORM instance into this Shape (recursively for nested)."""
|
|
||||||
data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names}
|
|
||||||
for name, shape_cls in cls._nested.items():
|
|
||||||
related = getattr(instance, name)
|
|
||||||
if related is None:
|
|
||||||
data[name] = None
|
|
||||||
elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)):
|
|
||||||
data[name] = [shape_cls._project(child) for child in related]
|
|
||||||
else:
|
|
||||||
data[name] = shape_cls._project(related)
|
|
||||||
return cls.model_validate(data)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]:
|
|
||||||
"""Project the model into a list of shapes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An open SQLAlchemy `Session`.
|
|
||||||
*stmt_fns: Callables `(select) -> select` applied in order to the base
|
|
||||||
`select(Model)` — filters/ordering/limits (the SQLAlchemy analogue
|
|
||||||
of the Django binding's queryset functions).
|
|
||||||
**relation_stmt: Per-relationship callables `(select) -> select` whose
|
|
||||||
criteria scope a nested relationship's load (e.g.
|
|
||||||
``books=lambda s: s.where(Book.is_published.is_(True))``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of projected shape instances.
|
|
||||||
"""
|
|
||||||
stmt = select(cls._model)
|
|
||||||
|
|
||||||
loaders = cls._loader_options_scoped(relation_stmt)
|
|
||||||
if loaders:
|
|
||||||
stmt = stmt.options(*loaders)
|
|
||||||
|
|
||||||
for fn in stmt_fns:
|
|
||||||
stmt = fn(stmt)
|
|
||||||
|
|
||||||
rows = session.execute(stmt).unique().scalars().all()
|
|
||||||
return [cls._project(obj) for obj in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]:
|
|
||||||
"""`_loader_options`, but with caller-supplied criteria applied per relation."""
|
|
||||||
if not relation_stmt:
|
|
||||||
return cls._loader_options()
|
|
||||||
options: list[Any] = []
|
|
||||||
for name, shape_cls in cls._nested.items():
|
|
||||||
attr = getattr(cls._model, name)
|
|
||||||
loader = selectinload(attr)
|
|
||||||
child = shape_cls._loader_options()
|
|
||||||
if child:
|
|
||||||
loader = loader.options(*child)
|
|
||||||
scope = relation_stmt.get(name)
|
|
||||||
if scope is not None:
|
|
||||||
# `selectinload(...).and_(...)` filters the related rows loaded.
|
|
||||||
criteria = scope(select(shape_cls._model)).whereclause
|
|
||||||
if criteria is not None:
|
|
||||||
loader = selectinload(attr.and_(criteria))
|
|
||||||
if child:
|
|
||||||
loader = loader.options(*child)
|
|
||||||
options.append(loader)
|
|
||||||
return options
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _get_pk(cls, instance) -> Any | None:
|
|
||||||
return getattr(instance, cls._pk_field, None)
|
|
||||||
|
|
||||||
# ─── Diff ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]:
|
|
||||||
"""Diff a batch of shapes against current DB state in one fetch.
|
|
||||||
|
|
||||||
New items (no PK) diff against `None`; existing items batch-fetch by PK.
|
|
||||||
Raises if an item declares a PK that no row matches.
|
|
||||||
"""
|
|
||||||
pk_field = cls._pk_field
|
|
||||||
pk_map: dict[Any, _S] = {}
|
|
||||||
new_items: list[_S] = []
|
|
||||||
for item in items:
|
|
||||||
pk = cls._get_pk(item)
|
|
||||||
(pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item))
|
|
||||||
|
|
||||||
current_map: dict[Any, _S] = {}
|
|
||||||
if pk_map:
|
|
||||||
pk_col = getattr(cls._model, pk_field)
|
|
||||||
current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys()))))
|
|
||||||
current_map = {cls._get_pk(c): c for c in current}
|
|
||||||
|
|
||||||
results: list[tuple[_S, Diff]] = []
|
|
||||||
for item in new_items:
|
|
||||||
results.append((item, cls._diff_one(item, None)))
|
|
||||||
for pk, item in pk_map.items():
|
|
||||||
current = current_map.get(pk)
|
|
||||||
if current is None:
|
|
||||||
raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist")
|
|
||||||
results.append((item, cls._diff_one(item, current)))
|
|
||||||
return results
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff":
|
|
||||||
pk_field = cls._pk_field
|
|
||||||
changed = (
|
|
||||||
{k: getattr(incoming, k) for k in cls._field_names
|
|
||||||
if k != pk_field and getattr(incoming, k) != getattr(current, k)}
|
|
||||||
if current
|
|
||||||
else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field}
|
|
||||||
)
|
|
||||||
|
|
||||||
nested: dict[str, NestedDiff] = {}
|
|
||||||
for name, shape_cls in cls._nested.items():
|
|
||||||
incoming_items = getattr(incoming, name, None) or []
|
|
||||||
current_items = (getattr(current, name, None) or []) if current else []
|
|
||||||
if not isinstance(incoming_items, list):
|
|
||||||
incoming_items = [incoming_items]
|
|
||||||
if not isinstance(current_items, list):
|
|
||||||
current_items = [current_items]
|
|
||||||
|
|
||||||
current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None}
|
|
||||||
incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None}
|
|
||||||
|
|
||||||
nested[name] = NestedDiff(
|
|
||||||
created=[c for c in incoming_items if shape_cls._get_pk(c) is None],
|
|
||||||
updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]],
|
|
||||||
deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk],
|
|
||||||
)
|
|
||||||
|
|
||||||
return Diff(is_new=current is None, changed=changed, _nested=nested)
|
|
||||||
|
|
||||||
def diff(self, session: Session) -> "Diff":
|
|
||||||
"""Diff this shape against its current DB row (or `None` if new)."""
|
|
||||||
cls = type(self)
|
|
||||||
pk = cls._get_pk(self)
|
|
||||||
if pk is not None:
|
|
||||||
pk_col = getattr(cls._model, cls._pk_field)
|
|
||||||
results = cls.query(session, lambda s: s.where(pk_col == pk))
|
|
||||||
if not results:
|
|
||||||
raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist")
|
|
||||||
current = results[0]
|
|
||||||
else:
|
|
||||||
current = None
|
|
||||||
return cls._diff_one(self, current)
|
|
||||||
|
|
||||||
|
|
||||||
class NestedDiff:
|
|
||||||
__slots__ = ("created", "updated", "deleted")
|
|
||||||
|
|
||||||
def __init__(self, created=(), updated=(), deleted=()):
|
|
||||||
self.created = list(created)
|
|
||||||
self.updated = list(updated)
|
|
||||||
self.deleted = list(deleted)
|
|
||||||
|
|
||||||
|
|
||||||
class Diff:
|
|
||||||
__slots__ = ("is_new", "changed", "_nested")
|
|
||||||
|
|
||||||
def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]):
|
|
||||||
self.is_new = is_new
|
|
||||||
self.changed = changed
|
|
||||||
self._nested = _nested
|
|
||||||
|
|
||||||
def nested(self, name: str) -> NestedDiff:
|
|
||||||
"""Strict access to a nested diff. Raises `KeyError` for an unknown name."""
|
|
||||||
if name not in self._nested:
|
|
||||||
valid = ", ".join(sorted(self._nested)) or "(none)"
|
|
||||||
raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
|
||||||
return self._nested[name]
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> NestedDiff:
|
|
||||||
if name.startswith("_"):
|
|
||||||
raise AttributeError(name)
|
|
||||||
if name not in self._nested:
|
|
||||||
valid = ", ".join(sorted(self._nested)) or "(none)"
|
|
||||||
raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
|
|
||||||
return self._nested[name]
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""
|
|
||||||
SSR render path — FastAPI adapter surface over the shared Bun bridge.
|
|
||||||
|
|
||||||
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
|
|
||||||
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
|
|
||||||
backend, so instead of Django's `MizanTemplates` veneer this exposes an
|
|
||||||
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
|
|
||||||
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
|
|
||||||
markup plus the hydration payload the client reads on mount.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from mizan_fastapi.ssr import SSRRenderer
|
|
||||||
|
|
||||||
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
|
|
||||||
|
|
||||||
@app.get("/profile/{user_id}")
|
|
||||||
async def profile(user_id: int):
|
|
||||||
return ssr.render("components/Profile.tsx", {"user_id": user_id})
|
|
||||||
|
|
||||||
`render` resolves the template name to an absolute file path against `dirs`
|
|
||||||
(parity with Django's `DIRS`), then renders the component's default export. The
|
|
||||||
hydration wrapping matches the Django backend byte-for-byte so the same client
|
|
||||||
bundle hydrates either server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from mizan_core.ssr import SSRBridge
|
|
||||||
|
|
||||||
|
|
||||||
class SSRRenderer:
|
|
||||||
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
|
|
||||||
|
|
||||||
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
|
|
||||||
serializes worker I/O); a single renderer can be shared across the app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
|
|
||||||
self._dirs = list(dirs or [])
|
|
||||||
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
|
|
||||||
|
|
||||||
def _resolve(self, template_name: str) -> str:
|
|
||||||
"""Resolve a template name to an absolute file path against `dirs`.
|
|
||||||
|
|
||||||
An already-absolute, existing path is used directly; otherwise each `dirs`
|
|
||||||
entry is tried in order (parity with Django's `DIRS` resolution).
|
|
||||||
"""
|
|
||||||
if os.path.isabs(template_name) and os.path.isfile(template_name):
|
|
||||||
return template_name
|
|
||||||
for dir_path in self._dirs:
|
|
||||||
candidate = os.path.join(dir_path, template_name)
|
|
||||||
if os.path.isfile(candidate):
|
|
||||||
return os.path.abspath(candidate)
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
|
|
||||||
"""Render the component to an HTML string (markup + hydration script)."""
|
|
||||||
props = dict(props or {})
|
|
||||||
result = self._bridge.render(self._resolve(template_name), props)
|
|
||||||
hydration_json = json.dumps(props, sort_keys=True, default=str)
|
|
||||||
return (
|
|
||||||
f'<div id="mizan-root">{result.html}</div>'
|
|
||||||
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
|
|
||||||
"""Render the component and return a FastAPI `HTMLResponse`."""
|
|
||||||
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
"""Stop the underlying Bun subprocess."""
|
|
||||||
self._bridge.shutdown()
|
|
||||||
218
backends/mizan-fastapi/src/mizan_fastapi/websocket.py
Normal file
218
backends/mizan-fastapi/src/mizan_fastapi/websocket.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
The WebSocket endpoint — channel subscriptions and RPC over one connection.
|
||||||
|
|
||||||
|
Client sends:
|
||||||
|
{"action": "subscribe", "channel": "chat", "params": {...}}
|
||||||
|
{"action": "unsubscribe", "channel": "chat", "params": {...}}
|
||||||
|
{"action": "message", "channel": "chat", "params": {...}, "data": {...}}
|
||||||
|
{"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||||
|
{"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||||
|
|
||||||
|
Server sends:
|
||||||
|
{"channel": "chat", "params": {...}, "type": "...", "data": {...}}
|
||||||
|
{"id": "request-id", "ok": true, "data": {...}}
|
||||||
|
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||||
|
{"error": "..."}
|
||||||
|
|
||||||
|
An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the
|
||||||
|
HTTP route builds, and both are produced by `execute_function`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
|
from mizan_fastapi import channels
|
||||||
|
from mizan_fastapi.executor import (
|
||||||
|
MizanError,
|
||||||
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ws_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class _SocketRequest:
|
||||||
|
"""What a server function receives when the call arrived over the socket.
|
||||||
|
|
||||||
|
There is no Starlette `Request` on a socket, so this carries the surface a function
|
||||||
|
actually reads — `state`, headers, and a method, since a socket RPC sends data and
|
||||||
|
expects an answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
method = "POST"
|
||||||
|
|
||||||
|
def __init__(self, socket: WebSocket) -> None:
|
||||||
|
self.state = socket.state
|
||||||
|
self.scope = socket.scope
|
||||||
|
self.headers = socket.headers
|
||||||
|
self.query_params = socket.query_params
|
||||||
|
self.socket = socket
|
||||||
|
|
||||||
|
|
||||||
|
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any:
|
||||||
|
"""Params as the channel's declared model, or a bare holder when it declares none."""
|
||||||
|
model = channel_cls.Params
|
||||||
|
if model is not None and raw:
|
||||||
|
return model(**raw)
|
||||||
|
return channels._Params(raw) if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(body: dict[str, Any]) -> tuple[Any, Any, str] | None:
|
||||||
|
"""The channel class, its params, and the name — or None when the name is unknown."""
|
||||||
|
name = body.get("channel") or ""
|
||||||
|
channel_cls = channels.get_channel(name)
|
||||||
|
if channel_cls is None:
|
||||||
|
return None
|
||||||
|
return channel_cls, _params_model(channel_cls, body.get("params")), name
|
||||||
|
|
||||||
|
|
||||||
|
async def _subscribe(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||||
|
found = _resolve(body)
|
||||||
|
if found is None:
|
||||||
|
await socket.send_json({"error": f"unknown channel {body.get('channel')!r}"})
|
||||||
|
return
|
||||||
|
channel_cls, params, name = found
|
||||||
|
|
||||||
|
channel = channel_cls()
|
||||||
|
if not channel.authorize(params):
|
||||||
|
await socket.send_json({"error": f"not authorized for channel {name!r}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
await channels.join(channel.group(params), socket)
|
||||||
|
hook = getattr(channel, "on_connect", None)
|
||||||
|
if hook is not None:
|
||||||
|
await hook(params)
|
||||||
|
|
||||||
|
|
||||||
|
async def _unsubscribe(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||||
|
found = _resolve(body)
|
||||||
|
if found is None:
|
||||||
|
return
|
||||||
|
channel_cls, params, _ = found
|
||||||
|
channel = channel_cls()
|
||||||
|
await channels.leave(channel.group(params), socket)
|
||||||
|
hook = getattr(channel, "on_disconnect", None)
|
||||||
|
if hook is not None:
|
||||||
|
await hook()
|
||||||
|
|
||||||
|
|
||||||
|
async def _message(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||||
|
found = _resolve(body)
|
||||||
|
if found is None:
|
||||||
|
await socket.send_json({"error": f"unknown channel {body.get('channel')!r}"})
|
||||||
|
return
|
||||||
|
channel_cls, params, name = found
|
||||||
|
|
||||||
|
channel = channel_cls()
|
||||||
|
if not channel.authorize(params):
|
||||||
|
await socket.send_json({"error": f"not authorized for channel {name!r}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
outgoing = channel.receive(params, body.get("data") or {})
|
||||||
|
if outgoing is None:
|
||||||
|
return # the channel dropped it
|
||||||
|
await channels.broadcast(
|
||||||
|
channel.group(params), type(outgoing).__name__, outgoing, body.get("params")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rpc(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||||
|
request_id = body.get("id")
|
||||||
|
fn_name = body.get("fn")
|
||||||
|
if not fn_name:
|
||||||
|
await socket.send_json(
|
||||||
|
{
|
||||||
|
"id": request_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": {"code": "BAD_REQUEST", "message": "rpc requires 'fn'"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
args = body.get("args") or {}
|
||||||
|
try:
|
||||||
|
fn_class = get_function(fn_name)
|
||||||
|
result = await execute_function(_SocketRequest(socket), fn_name, args)
|
||||||
|
except MizanError as e:
|
||||||
|
payload: dict[str, Any] = {"code": e.code.value, "message": e.message}
|
||||||
|
if e.details:
|
||||||
|
payload["details"] = e.details
|
||||||
|
await socket.send_json({"id": request_id, "ok": False, "error": payload})
|
||||||
|
return
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"result": result,
|
||||||
|
"invalidate": compute_invalidation(fn_class, args),
|
||||||
|
}
|
||||||
|
merges = compute_merges(fn_class, args, result)
|
||||||
|
if merges:
|
||||||
|
data["merge"] = merges
|
||||||
|
await socket.send_json({"id": request_id, "ok": True, "data": data})
|
||||||
|
|
||||||
|
|
||||||
|
async def _ctx(socket: WebSocket, body: dict[str, Any]) -> None:
|
||||||
|
"""A context bundle over the socket, so a client needs no second connection to read."""
|
||||||
|
request_id = body.get("id")
|
||||||
|
name = body.get("context") or ""
|
||||||
|
fn_names = get_context_groups().get(name)
|
||||||
|
if not fn_names:
|
||||||
|
await socket.send_json(
|
||||||
|
{
|
||||||
|
"id": request_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": {"code": "NOT_FOUND", "message": f"Context '{name}' not found"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
params = body.get("params") or {}
|
||||||
|
request = _SocketRequest(socket)
|
||||||
|
try:
|
||||||
|
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||||
|
except MizanError as e:
|
||||||
|
payload: dict[str, Any] = {"code": e.code.value, "message": e.message}
|
||||||
|
if e.details:
|
||||||
|
payload["details"] = e.details
|
||||||
|
await socket.send_json({"id": request_id, "ok": False, "error": payload})
|
||||||
|
return
|
||||||
|
|
||||||
|
await socket.send_json({"id": request_id, "ok": True, "data": bundled})
|
||||||
|
|
||||||
|
|
||||||
|
_ACTIONS = {
|
||||||
|
"subscribe": _subscribe,
|
||||||
|
"unsubscribe": _unsubscribe,
|
||||||
|
"message": _message,
|
||||||
|
"rpc": _rpc,
|
||||||
|
"ctx": _ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ws_router.websocket("/ws/")
|
||||||
|
async def mizan_socket(socket: WebSocket) -> None:
|
||||||
|
"""One connection, every action.
|
||||||
|
|
||||||
|
A close is how a socket ends, so the disconnect is logged rather than raised;
|
||||||
|
`finally` clears the membership either way.
|
||||||
|
"""
|
||||||
|
await socket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
body = await socket.receive_json()
|
||||||
|
handler = _ACTIONS.get(body.get("action"))
|
||||||
|
if handler is None:
|
||||||
|
await socket.send_json({"error": f"unknown action {body.get('action')!r}"})
|
||||||
|
continue
|
||||||
|
await handler(socket, body)
|
||||||
|
except WebSocketDisconnect as e:
|
||||||
|
logger.debug("mizan socket closed: code=%s reason=%s", e.code, e.reason)
|
||||||
|
finally:
|
||||||
|
await channels.leave_all(socket)
|
||||||
@@ -88,7 +88,7 @@ def app():
|
|||||||
|
|
||||||
@client
|
@client
|
||||||
async def async_echo(request, text: str) -> EchoOutput:
|
async def async_echo(request, text: str) -> EchoOutput:
|
||||||
# await something on the loop to prove we're really running async
|
# Yielding to the loop fails outright if the handler is not awaited.
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
return EchoOutput(message=f"async: {text}")
|
return EchoOutput(message=f"async: {text}")
|
||||||
|
|
||||||
@@ -183,18 +183,19 @@ class ContextFetchTests:
|
|||||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
# ─── Auth gating ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class AuthTests:
|
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):
|
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class InvalidationTests:
|
class InvalidationTests:
|
||||||
def test_mutation_emits_invalidate_list(self, http):
|
def test_mutation_emits_invalidate_list(self, http):
|
||||||
r = http.post(
|
r = http.post(
|
||||||
@@ -211,8 +212,6 @@ class InvalidationTests:
|
|||||||
|
|
||||||
|
|
||||||
class StructuredOutputTests:
|
class StructuredOutputTests:
|
||||||
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
|
||||||
|
|
||||||
def test_list_of_basemodel_returns_bare_array(self, http):
|
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -232,21 +231,20 @@ class StructuredOutputTests:
|
|||||||
assert r_missing.json()["result"] is None
|
assert r_missing.json()["result"] is None
|
||||||
|
|
||||||
|
|
||||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
# ─── Async handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class AsyncHandlerTests:
|
class AsyncHandlerTests:
|
||||||
"""`async def` handlers dispatch on the loop via view.acall."""
|
|
||||||
|
|
||||||
def test_async_handler_returns_awaited_result(self, http):
|
def test_async_handler_returns_awaited_result(self, http):
|
||||||
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["result"] == {"message": "async: hello"}
|
assert r.json()["result"] == {"message": "async: hello"}
|
||||||
|
|
||||||
|
|
||||||
class MergeTests:
|
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||||
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
|
||||||
|
|
||||||
|
|
||||||
|
class MergeTests:
|
||||||
def test_merge_target_emits_merge_entry(self, http):
|
def test_merge_target_emits_merge_entry(self, http):
|
||||||
r = http.post(
|
r = http.post(
|
||||||
"/api/mizan/call/",
|
"/api/mizan/call/",
|
||||||
@@ -254,9 +252,8 @@ class MergeTests:
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
# items_list returns list[ItemOutput], so the slot resolves to items_list.
|
||||||
assert body["merge"] == [
|
assert body["merge"] == [
|
||||||
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||||
]
|
]
|
||||||
# invalidate stays empty when only merge is declared
|
|
||||||
assert body["invalidate"] == []
|
assert body["invalidate"] == []
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
"""
|
|
||||||
Edge-manifest + PSR behavior — the genuine capability behind the
|
|
||||||
`edge_manifest` and `psr` probes.
|
|
||||||
|
|
||||||
Proves the FastAPI adapter emits the manifest the spec defines (contexts,
|
|
||||||
mutations, params, user_scoped, render_strategy, page_routes) by deriving it from
|
|
||||||
a real registry, and that `render_strategy` falls out of the user-scoped-param
|
|
||||||
rule: a context whose params overlap {user_id, user, owner_id, account_id} is
|
|
||||||
`dynamic_cached`, otherwise `psr`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.responses import Response
|
|
||||||
|
|
||||||
import mizan_fastapi # registers the Starlette Response base for view-path detection
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
|
|
||||||
from mizan_fastapi import edge_manifest, generate_edge_manifest
|
|
||||||
from mizan_fastapi.manifest import render_strategies
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_registry():
|
|
||||||
clear_registry()
|
|
||||||
yield
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
|
|
||||||
def _register(fn, name):
|
|
||||||
register(fn, name)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_scoped_context_is_dynamic_cached():
|
|
||||||
@client(context="user")
|
|
||||||
def user_profile(request, user_id: int) -> dict:
|
|
||||||
return {"id": user_id}
|
|
||||||
|
|
||||||
_register(user_profile, "user_profile")
|
|
||||||
|
|
||||||
manifest = edge_manifest()
|
|
||||||
ctx = manifest["contexts"]["user"]
|
|
||||||
assert ctx["user_scoped"] is True
|
|
||||||
assert ctx["render_strategy"] == "dynamic_cached"
|
|
||||||
assert ctx["params"] == ["user_id"]
|
|
||||||
assert ctx["endpoints"] == ["/api/mizan/ctx/user/"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_user_scoped_context_is_psr():
|
|
||||||
@client(context="catalog")
|
|
||||||
def catalog_items(request, category: str) -> list[dict]:
|
|
||||||
return [{"category": category}]
|
|
||||||
|
|
||||||
_register(catalog_items, "catalog_items")
|
|
||||||
|
|
||||||
ctx = edge_manifest()["contexts"]["catalog"]
|
|
||||||
assert ctx["user_scoped"] is False
|
|
||||||
assert ctx["render_strategy"] == "psr"
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_strategies_maps_each_context():
|
|
||||||
@client(context="user")
|
|
||||||
def me(request, user_id: int) -> dict:
|
|
||||||
return {"id": user_id}
|
|
||||||
|
|
||||||
@client(context="catalog")
|
|
||||||
def items(request) -> list[dict]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
_register(me, "me")
|
|
||||||
_register(items, "items")
|
|
||||||
|
|
||||||
strategies = render_strategies()
|
|
||||||
assert strategies == {"user": "dynamic_cached", "catalog": "psr"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_records_affects_and_auto_scope():
|
|
||||||
@client(context="user")
|
|
||||||
def user_profile(request, user_id: int) -> dict:
|
|
||||||
return {"id": user_id}
|
|
||||||
|
|
||||||
@client(affects="user")
|
|
||||||
def rename(request, user_id: int, name: str) -> dict:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
_register(user_profile, "user_profile")
|
|
||||||
_register(rename, "rename")
|
|
||||||
|
|
||||||
mutation = edge_manifest()["mutations"]["rename"]
|
|
||||||
assert mutation["affects"] == ["user"]
|
|
||||||
# user_id matches the context's param → auto-scoped
|
|
||||||
assert mutation["auto_scoped_params"] == ["user_id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_private_and_route_mutation_carried():
|
|
||||||
@client(affects="subscription", private=True, route="/webhooks/stripe/", methods=["POST"])
|
|
||||||
def stripe_webhook(request) -> Response:
|
|
||||||
return Response(status_code=200)
|
|
||||||
|
|
||||||
@client(context="subscription")
|
|
||||||
def subscription(request, user_id: int) -> dict:
|
|
||||||
return {"id": user_id}
|
|
||||||
|
|
||||||
_register(stripe_webhook, "stripe_webhook")
|
|
||||||
_register(subscription, "subscription")
|
|
||||||
|
|
||||||
mutation = edge_manifest()["mutations"]["stripe_webhook"]
|
|
||||||
assert mutation["private"] is True
|
|
||||||
assert mutation["route"] == "/webhooks/stripe/"
|
|
||||||
assert mutation["methods"] == ["POST"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_view_path_function_records_route_and_page_routes():
|
|
||||||
@client(context="profile", route="/profile/<user_id>/")
|
|
||||||
def profile_page(request, user_id: int) -> Response:
|
|
||||||
return Response(status_code=200)
|
|
||||||
|
|
||||||
_register(profile_page, "profile_page")
|
|
||||||
|
|
||||||
ctx = edge_manifest()["contexts"]["profile"]
|
|
||||||
assert ctx["page_routes"] == ["/profile/<user_id>/"]
|
|
||||||
fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page")
|
|
||||||
assert fn_entry["path"] == "view"
|
|
||||||
assert fn_entry["route"] == "/profile/<user_id>/"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fastapi_manifest_matches_core_derivation():
|
|
||||||
"""The adapter callable is a thin pass-through to the shared core derivation."""
|
|
||||||
|
|
||||||
@client(context="user")
|
|
||||||
def user_profile(request, user_id: int) -> dict:
|
|
||||||
return {"id": user_id}
|
|
||||||
|
|
||||||
_register(user_profile, "user_profile")
|
|
||||||
|
|
||||||
assert edge_manifest() == generate_edge_manifest(base_url="/api/mizan")
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_entry_emits_manifest_json(tmp_path):
|
|
||||||
"""`mizan-fastapi-edge-manifest <module>` imports the module then prints JSON."""
|
|
||||||
app_module = tmp_path / "manifest_app.py"
|
|
||||||
app_module.write_text(
|
|
||||||
"from mizan_core.client.function import client\n"
|
|
||||||
"from mizan_core.registry import register\n"
|
|
||||||
"@client(context='user')\n"
|
|
||||||
"def user_profile(request, user_id: int) -> dict:\n"
|
|
||||||
" return {'id': user_id}\n"
|
|
||||||
"register(user_profile, 'user_profile')\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "mizan_fastapi.manifest", "manifest_app", "--indent", "0"],
|
|
||||||
cwd=tmp_path,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
manifest = json.loads(result.stdout)
|
|
||||||
assert manifest["contexts"]["user"]["render_strategy"] == "dynamic_cached"
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""
|
|
||||||
Forms behavior — the genuine capability behind the `forms` probe.
|
|
||||||
|
|
||||||
Proves the Pydantic binding exposes the same schema / validate / submit role
|
|
||||||
contract as the Django adapter: subclassing `mizanForm` auto-registers
|
|
||||||
`{name}.schema`, `{name}.validate`, `{name}.submit` with the matching
|
|
||||||
`_meta["form_role"]`, the schema role emits typed field definitions, validate
|
|
||||||
returns structured field errors, and submit validates then runs
|
|
||||||
`on_submit_success` / `on_submit_failure`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mizan_core.registry import clear_registry, get_function
|
|
||||||
|
|
||||||
from mizan_fastapi.forms import (
|
|
||||||
FormConfig,
|
|
||||||
FormSubmitFail,
|
|
||||||
FormSubmitPass,
|
|
||||||
FormValidation,
|
|
||||||
build_form_schema,
|
|
||||||
get_forms,
|
|
||||||
mizanForm,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean():
|
|
||||||
clear_registry()
|
|
||||||
yield
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_contact_form():
|
|
||||||
class ContactForm(mizanForm):
|
|
||||||
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
|
|
||||||
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
def on_submit_success(self, request) -> dict:
|
|
||||||
return {"sent": True, "to": self.email}
|
|
||||||
|
|
||||||
return ContactForm
|
|
||||||
|
|
||||||
|
|
||||||
def test_subclassing_registers_three_role_functions():
|
|
||||||
_make_contact_form()
|
|
||||||
for role in ("schema", "validate", "submit"):
|
|
||||||
fn = get_function(f"contact.{role}")
|
|
||||||
assert fn is not None, f"contact.{role} not registered"
|
|
||||||
assert fn._meta["form"] is True
|
|
||||||
assert fn._meta["form_name"] == "contact"
|
|
||||||
assert fn._meta["form_role"] == role
|
|
||||||
|
|
||||||
|
|
||||||
def test_schema_role_emits_field_definitions():
|
|
||||||
form_cls = _make_contact_form()
|
|
||||||
SchemaFn = get_function("contact.schema")
|
|
||||||
schema = SchemaFn(request=None).call(None)
|
|
||||||
assert schema.name == "contact"
|
|
||||||
assert schema.title == "Contact Us"
|
|
||||||
assert schema.submit_label == "Send"
|
|
||||||
field_names = {f.name for f in schema.fields}
|
|
||||||
assert field_names == {"name", "email", "message"}
|
|
||||||
# `message` has a default → not required; `name`/`email` required
|
|
||||||
by_name = {f.name: f for f in schema.fields}
|
|
||||||
assert by_name["name"].required is True
|
|
||||||
assert by_name["message"].required is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_form_schema_maps_types():
|
|
||||||
class TypedForm(mizanForm):
|
|
||||||
mizan = FormConfig(name="typed")
|
|
||||||
count: int
|
|
||||||
ratio: float
|
|
||||||
active: bool
|
|
||||||
label: str
|
|
||||||
|
|
||||||
schema = build_form_schema(TypedForm)
|
|
||||||
by_name = {f.name: f for f in schema.fields}
|
|
||||||
assert by_name["count"].type == "number"
|
|
||||||
assert by_name["ratio"].type == "number"
|
|
||||||
assert by_name["active"].type == "checkbox"
|
|
||||||
assert by_name["label"].type == "text"
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_role_passes_clean_data():
|
|
||||||
_make_contact_form()
|
|
||||||
ValidateFn = get_function("contact.validate")
|
|
||||||
ValidateInput = ValidateFn.Input
|
|
||||||
out = ValidateFn(request=None).call(ValidateInput(data={"name": "Ryth", "email": "r@x.com"}))
|
|
||||||
assert isinstance(out, FormValidation)
|
|
||||||
assert out.errors == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_role_reports_field_errors():
|
|
||||||
_make_contact_form()
|
|
||||||
ValidateFn = get_function("contact.validate")
|
|
||||||
ValidateInput = ValidateFn.Input
|
|
||||||
out = ValidateFn(request=None).call(ValidateInput(data={"email": "r@x.com"})) # missing 'name'
|
|
||||||
error_fields = {e.field for e in out.errors}
|
|
||||||
assert "name" in error_fields
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_role_runs_on_submit_success():
|
|
||||||
_make_contact_form()
|
|
||||||
SubmitFn = get_function("contact.submit")
|
|
||||||
SubmitInput = SubmitFn.Input
|
|
||||||
result = SubmitFn(request=None).call(
|
|
||||||
SubmitInput(data={"name": "Ryth", "email": "ryth@example.com", "message": "hi"})
|
|
||||||
)
|
|
||||||
assert isinstance(result, FormSubmitPass)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.data == {"sent": True, "to": "ryth@example.com"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_role_returns_fail_on_invalid():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
class GuardedForm(mizanForm):
|
|
||||||
mizan = FormConfig(name="guarded")
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def on_submit_failure(self, request, errors) -> None:
|
|
||||||
captured["errors"] = errors
|
|
||||||
|
|
||||||
SubmitFn = get_function("guarded.submit")
|
|
||||||
SubmitInput = SubmitFn.Input
|
|
||||||
result = SubmitFn(request=None).call(SubmitInput(data={})) # missing required 'name'
|
|
||||||
assert isinstance(result, FormSubmitFail)
|
|
||||||
assert result.success is False
|
|
||||||
assert any(e.field == "name" for e in result.errors.errors)
|
|
||||||
# on_submit_failure hook fired with the validation
|
|
||||||
assert "errors" in captured
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_forms_groups_by_form_name():
|
|
||||||
_make_contact_form()
|
|
||||||
forms = get_forms()
|
|
||||||
assert set(forms.keys()) == {"contact"}
|
|
||||||
assert len(forms["contact"]) == 3
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""FastAPI parity with Django: X-Mizan-Invalidate header, origin cache, token auth."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import Depends, FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan_core.auth import AuthConfig, JWTConfig, create_access_token
|
|
||||||
from mizan_core.cache.backend import MemoryCache
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
from mizan_fastapi import (
|
|
||||||
MizanAuthMiddleware,
|
|
||||||
MizanConfig,
|
|
||||||
MizanError,
|
|
||||||
mizan_auth,
|
|
||||||
mizan_exception_handler,
|
|
||||||
router as mizan_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Out(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
|
|
||||||
|
|
||||||
SECRET = "x" * 32
|
|
||||||
JWT = JWTConfig(private_key=SECRET, public_key=SECRET)
|
|
||||||
|
|
||||||
|
|
||||||
def _app(*, with_cache=False, with_auth_dep=False) -> FastAPI:
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
UserCtx = "user"
|
|
||||||
|
|
||||||
@client(context=UserCtx)
|
|
||||||
def user_profile(request, user_id: int) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
@client(affects=UserCtx)
|
|
||||||
def update_profile(request, user_id: int) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
@client(auth=True)
|
|
||||||
def whoami(request) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
register(user_profile, "user_profile")
|
|
||||||
register(update_profile, "update_profile")
|
|
||||||
register(whoami, "whoami")
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
cache = CacheOrchestrator(MemoryCache(), SECRET) if with_cache else CacheOrchestrator(None, None)
|
|
||||||
app.state.mizan_config = MizanConfig(auth=AuthConfig(jwt=JWT), cache=cache)
|
|
||||||
deps = [Depends(mizan_auth())] if with_auth_dep else []
|
|
||||||
app.include_router(mizan_router, prefix="/api/mizan", dependencies=deps)
|
|
||||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_emits_invalidate_header():
|
|
||||||
c = TestClient(_app())
|
|
||||||
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 5}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["invalidate"] == [{"context": "user", "params": {"user_id": 5}}]
|
|
||||||
assert r.headers["X-Mizan-Invalidate"] == "user;user_id=5"
|
|
||||||
|
|
||||||
|
|
||||||
def test_origin_cache_hit_miss():
|
|
||||||
c = TestClient(_app(with_cache=True))
|
|
||||||
r1 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
|
|
||||||
assert r1.status_code == 200 and r1.headers["X-Mizan-Cache"] == "MISS"
|
|
||||||
r2 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
|
|
||||||
assert r2.headers["X-Mizan-Cache"] == "HIT"
|
|
||||||
assert r1.content == r2.content
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_required_rejects_anonymous():
|
|
||||||
c = TestClient(_app())
|
|
||||||
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_required_passes_with_bearer_jwt():
|
|
||||||
c = TestClient(_app(with_auth_dep=True))
|
|
||||||
tok = create_access_token("7", "sess", JWT, is_staff=True)
|
|
||||||
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}},
|
|
||||||
headers={"Authorization": f"Bearer {tok}"})
|
|
||||||
assert r.status_code == 200 and r.json()["result"] == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_bearer_token_rejected():
|
|
||||||
c = TestClient(_app())
|
|
||||||
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 1}},
|
|
||||||
headers={"Authorization": "Bearer not-a-real-token"})
|
|
||||||
assert r.status_code == 401
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
"""
|
|
||||||
Shapes behavior — the genuine capability behind the `shapes` probe.
|
|
||||||
|
|
||||||
Proves the SQLAlchemy binding has the same Shape declaration surface and
|
|
||||||
projection/diff semantics as the Django `django-readers` binding:
|
|
||||||
|
|
||||||
- `Shape[Model]` resolves the mapped model + PK from the generic arg;
|
|
||||||
- scalar annotations project columns, Shape-typed annotations project relations;
|
|
||||||
- `.query(session, *stmt_fns, **relation_stmt)` flat / nested / scoped;
|
|
||||||
- nested loads stay flat (selectinload, not N+1);
|
|
||||||
- `.diff()` / `.diff_many()` detect field changes + nested created/updated/deleted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import ForeignKey, create_engine, event
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
|
|
||||||
|
|
||||||
from mizan_fastapi.shapes import Diff, NestedDiff, Shape
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Mapped models ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Publisher(Base):
|
|
||||||
__tablename__ = "publisher"
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
name: Mapped[str]
|
|
||||||
country: Mapped[str]
|
|
||||||
authors: Mapped[list["Author"]] = relationship(back_populates="publisher")
|
|
||||||
|
|
||||||
|
|
||||||
class Author(Base):
|
|
||||||
__tablename__ = "author"
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
name: Mapped[str]
|
|
||||||
bio: Mapped[str] = mapped_column(default="")
|
|
||||||
publisher_id: Mapped[int] = mapped_column(ForeignKey("publisher.id"))
|
|
||||||
publisher: Mapped[Publisher] = relationship(back_populates="authors")
|
|
||||||
books: Mapped[list["Book"]] = relationship(back_populates="author")
|
|
||||||
|
|
||||||
|
|
||||||
class Book(Base):
|
|
||||||
__tablename__ = "book"
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
title: Mapped[str]
|
|
||||||
is_published: Mapped[bool] = mapped_column(default=True)
|
|
||||||
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
|
||||||
author: Mapped[Author] = relationship(back_populates="books")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Shapes (declaration surface identical to the Django adapter) ──────────────
|
|
||||||
|
|
||||||
|
|
||||||
class FlatAuthorShape(Shape[Author]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class FlatBookShape(Shape[Book]):
|
|
||||||
id: int | None = None
|
|
||||||
title: str
|
|
||||||
is_published: bool
|
|
||||||
|
|
||||||
|
|
||||||
class BookCardShape(Shape[Book]):
|
|
||||||
id: int | None = None
|
|
||||||
title: str
|
|
||||||
is_published: bool
|
|
||||||
author: FlatAuthorShape # single nested FK
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorCardShape(Shape[Author]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
bio: str
|
|
||||||
books: list[FlatBookShape] = [] # list nested reverse-FK
|
|
||||||
|
|
||||||
|
|
||||||
class PublisherDetailShape(Shape[Publisher]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
authors: list[AuthorCardShape] = [] # 3-level nesting
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Fixtures ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session():
|
|
||||||
engine = create_engine("sqlite://")
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
with Session(engine) as s:
|
|
||||||
pub = Publisher(name="Orbit", country="UK")
|
|
||||||
ann = Author(name="Ann Leckie", bio="Imperial Radch", publisher=pub)
|
|
||||||
devi = Author(name="Devi Pillai", bio="", publisher=pub)
|
|
||||||
ann.books = [
|
|
||||||
Book(title="Ancillary Justice", is_published=True),
|
|
||||||
Book(title="Provenance", is_published=False),
|
|
||||||
]
|
|
||||||
s.add_all([pub, ann, devi])
|
|
||||||
s.commit()
|
|
||||||
yield s
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Declaration ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_resolves_model_and_pk():
|
|
||||||
assert FlatAuthorShape._model is Author
|
|
||||||
assert FlatAuthorShape._pk_field == "id"
|
|
||||||
|
|
||||||
|
|
||||||
def test_flat_shape_has_no_nested():
|
|
||||||
assert FlatAuthorShape._nested == {}
|
|
||||||
assert FlatAuthorShape._field_names == ["id", "name"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_nested_detected():
|
|
||||||
assert BookCardShape._nested == {"author": FlatAuthorShape}
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_nested_detected():
|
|
||||||
assert AuthorCardShape._nested == {"books": FlatBookShape}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Query ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_flat_query_projects_fields(session):
|
|
||||||
authors = FlatAuthorShape.query(session)
|
|
||||||
assert len(authors) == 2
|
|
||||||
assert {a.name for a in authors} == {"Ann Leckie", "Devi Pillai"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_with_stmt_fn_filters(session):
|
|
||||||
authors = FlatAuthorShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
|
|
||||||
assert [a.name for a in authors] == ["Ann Leckie"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_nested_fk_projected(session):
|
|
||||||
books = BookCardShape.query(session, lambda s: s.where(Book.title == "Ancillary Justice"))
|
|
||||||
assert len(books) == 1
|
|
||||||
assert books[0].author.name == "Ann Leckie"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_nested_reverse_fk_projected(session):
|
|
||||||
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
|
|
||||||
assert len(authors) == 1
|
|
||||||
assert {b.title for b in authors[0].books} == {"Ancillary Justice", "Provenance"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_nested_list(session):
|
|
||||||
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Devi Pillai"))
|
|
||||||
assert authors[0].books == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_three_level_nesting(session):
|
|
||||||
pubs = PublisherDetailShape.query(session)
|
|
||||||
assert len(pubs) == 1
|
|
||||||
leckie = next(a for a in pubs[0].authors if a.name == "Ann Leckie")
|
|
||||||
assert len(leckie.books) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_relation_stmt_scopes_nested_load(session):
|
|
||||||
authors = AuthorCardShape.query(
|
|
||||||
session,
|
|
||||||
lambda s: s.where(Author.name == "Ann Leckie"),
|
|
||||||
books=lambda s: s.where(Book.is_published.is_(True)),
|
|
||||||
)
|
|
||||||
assert [b.title for b in authors[0].books] == ["Ancillary Justice"]
|
|
||||||
assert all(b.is_published for b in authors[0].books)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_query_stays_flat(session):
|
|
||||||
"""selectinload keeps the projection at O(depth) queries, not N+1."""
|
|
||||||
counter = {"n": 0}
|
|
||||||
|
|
||||||
@event.listens_for(session.bind, "after_cursor_execute")
|
|
||||||
def _count(*args):
|
|
||||||
counter["n"] += 1
|
|
||||||
|
|
||||||
AuthorCardShape.query(session)
|
|
||||||
# one query for authors + one selectin for books
|
|
||||||
assert counter["n"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Diff ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_no_changes(session):
|
|
||||||
book = session.query(Book).filter_by(title="Ancillary Justice").one()
|
|
||||||
shape = FlatBookShape(id=book.id, title="Ancillary Justice", is_published=True)
|
|
||||||
d = shape.diff(session)
|
|
||||||
assert d.is_new is False
|
|
||||||
assert d.changed == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_detects_field_change(session):
|
|
||||||
book = session.query(Book).filter_by(title="Ancillary Justice").one()
|
|
||||||
shape = FlatBookShape(id=book.id, title="Ancillary Justice (rev)", is_published=True)
|
|
||||||
d = shape.diff(session)
|
|
||||||
assert d.changed["title"] == "Ancillary Justice (rev)"
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_new_item(session):
|
|
||||||
shape = FlatBookShape(id=None, title="Elantris", is_published=True)
|
|
||||||
d = shape.diff(session)
|
|
||||||
assert d.is_new is True
|
|
||||||
assert "title" in d.changed
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_nonexistent_pk_raises(session):
|
|
||||||
shape = FlatBookShape(id=999999, title="Ghost", is_published=False)
|
|
||||||
with pytest.raises(LookupError):
|
|
||||||
shape.diff(session)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_diff_created_updated_deleted(session):
|
|
||||||
author = session.query(Author).filter_by(name="Ann Leckie").one()
|
|
||||||
books = sorted(author.books, key=lambda b: b.title)
|
|
||||||
# keep one (updated), drop one (deleted), add one (created)
|
|
||||||
shape = AuthorCardShape(
|
|
||||||
id=author.id,
|
|
||||||
name="Ann Leckie",
|
|
||||||
bio="Imperial Radch",
|
|
||||||
books=[
|
|
||||||
FlatBookShape(id=books[0].id, title="Ancillary Justice REWRITTEN", is_published=True),
|
|
||||||
FlatBookShape(id=None, title="Ancillary Sword", is_published=True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
d = shape.diff(session)
|
|
||||||
assert len(d.books.updated) == 1
|
|
||||||
assert len(d.books.created) == 1
|
|
||||||
assert len(d.books.deleted) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_strict_nested_access_raises_on_typo(session):
|
|
||||||
author = session.query(Author).filter_by(name="Ann Leckie").one()
|
|
||||||
shape = FlatAuthorShape(id=author.id, name="Ann Leckie")
|
|
||||||
d = shape.diff(session)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
_ = d.bookz
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
d.nested("bookz")
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_many_batches(session):
|
|
||||||
books = session.query(Book).all()
|
|
||||||
items = [FlatBookShape(id=b.id, title=b.title + "!", is_published=b.is_published) for b in books]
|
|
||||||
results = FlatBookShape.diff_many(session, items)
|
|
||||||
assert len(results) == len(books)
|
|
||||||
assert all("title" in d.changed for _, d in results)
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_many_mixed_new_and_existing(session):
|
|
||||||
book = session.query(Book).first()
|
|
||||||
items = [
|
|
||||||
FlatBookShape(id=book.id, title=book.title, is_published=book.is_published),
|
|
||||||
FlatBookShape(id=None, title="Brand New", is_published=False),
|
|
||||||
]
|
|
||||||
results = FlatBookShape.diff_many(session, items)
|
|
||||||
assert sum(1 for _, d in results if d.is_new) == 1
|
|
||||||
assert sum(1 for _, d in results if not d.is_new) == 1
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"""
|
|
||||||
SSR behavior — the genuine capability behind the `ssr_bridge` probe.
|
|
||||||
|
|
||||||
The SSR subprocess lifecycle + JSON-RPC protocol live in the shared
|
|
||||||
`mizan_core.ssr.SSRBridge`; the FastAPI `SSRRenderer` resolves a component path
|
|
||||||
against `dirs`, drives the bridge, and wraps the result with the hydration script
|
|
||||||
the client reads on mount.
|
|
||||||
|
|
||||||
Bun is not assumed present in CI, so the bridge is driven against a stand-in
|
|
||||||
worker that speaks the SAME newline-delimited JSON-RPC protocol (ready signal +
|
|
||||||
`render` → `{id, html}`). That exercises the real bridge code path (spawn,
|
|
||||||
message-ID correlation, threaded reader) — only the renderer binary is swapped.
|
|
||||||
The path-resolution and hydration-wrapping are tested directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mizan_core.ssr import SSRBridge
|
|
||||||
from mizan_fastapi.ssr import SSRRenderer
|
|
||||||
|
|
||||||
|
|
||||||
# A Python stand-in for the Bun worker: emits the ready signal, then for each
|
|
||||||
# render request echoes a deterministic HTML fragment built from the props.
|
|
||||||
_FAKE_WORKER = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
import json, sys
|
|
||||||
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\\n"); sys.stdout.flush()
|
|
||||||
for line in sys.stdin:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
msg = json.loads(line)
|
|
||||||
if msg.get("method") == "render":
|
|
||||||
props = msg["params"]["props"]
|
|
||||||
html = "<p>" + props.get("name", "") + "</p>"
|
|
||||||
sys.stdout.write(json.dumps({"id": msg["id"], "html": html}) + "\\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_worker(tmp_path):
|
|
||||||
worker = tmp_path / "fake_worker.py"
|
|
||||||
worker.write_text(_FAKE_WORKER, encoding="utf-8")
|
|
||||||
return str(worker)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def python_bridge(fake_worker, monkeypatch):
|
|
||||||
"""An `SSRBridge` whose subprocess is python (not bun), driving the fake worker."""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
real_popen = subprocess.Popen
|
|
||||||
|
|
||||||
def fake_popen(cmd, *args, **kwargs):
|
|
||||||
# Swap the `bun run <worker>` invocation for `python <worker>`.
|
|
||||||
if cmd[:2] == ["bun", "run"]:
|
|
||||||
cmd = [sys.executable, cmd[2]]
|
|
||||||
return real_popen(cmd, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
||||||
bridge = SSRBridge(worker_path=fake_worker, timeout=5.0)
|
|
||||||
yield bridge
|
|
||||||
bridge.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
def test_bridge_round_trips_render(python_bridge):
|
|
||||||
result = python_bridge.render("/abs/Hello.tsx", {"name": "World"})
|
|
||||||
assert result.html == "<p>World</p>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_bridge_correlates_concurrent_renders(python_bridge):
|
|
||||||
# Two renders on the persistent subprocess return their own results.
|
|
||||||
a = python_bridge.render("/abs/A.tsx", {"name": "A"})
|
|
||||||
b = python_bridge.render("/abs/B.tsx", {"name": "B"})
|
|
||||||
assert (a.html, b.html) == ("<p>A</p>", "<p>B</p>")
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderer_resolves_against_dirs_and_wraps_hydration(fake_worker, monkeypatch, tmp_path):
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
real_popen = subprocess.Popen
|
|
||||||
monkeypatch.setattr(
|
|
||||||
subprocess, "Popen",
|
|
||||||
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
|
||||||
)
|
|
||||||
|
|
||||||
components = tmp_path / "frontend"
|
|
||||||
components.mkdir()
|
|
||||||
(components / "Hello.tsx").write_text("export default () => null", encoding="utf-8")
|
|
||||||
|
|
||||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
|
||||||
try:
|
|
||||||
html = renderer.render_to_string("Hello.tsx", {"name": "Mizan"})
|
|
||||||
finally:
|
|
||||||
renderer.shutdown()
|
|
||||||
|
|
||||||
assert '<div id="mizan-root"><p>Mizan</p></div>' in html
|
|
||||||
assert 'window.__MIZAN_SSR_DATA__={"name": "Mizan"}' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderer_returns_html_response(fake_worker, monkeypatch, tmp_path):
|
|
||||||
import subprocess
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
real_popen = subprocess.Popen
|
|
||||||
monkeypatch.setattr(
|
|
||||||
subprocess, "Popen",
|
|
||||||
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
|
|
||||||
)
|
|
||||||
|
|
||||||
components = tmp_path / "frontend"
|
|
||||||
components.mkdir()
|
|
||||||
(components / "Card.tsx").write_text("export default () => null", encoding="utf-8")
|
|
||||||
|
|
||||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
|
|
||||||
try:
|
|
||||||
response = renderer.render("Card.tsx", {"name": "x"})
|
|
||||||
finally:
|
|
||||||
renderer.shutdown()
|
|
||||||
|
|
||||||
assert isinstance(response, HTMLResponse)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderer_raises_on_missing_component(fake_worker, tmp_path):
|
|
||||||
renderer = SSRRenderer(worker=fake_worker, dirs=[str(tmp_path)])
|
|
||||||
try:
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
renderer.render_to_string("Nope.tsx", {})
|
|
||||||
finally:
|
|
||||||
renderer.shutdown()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Upload dispatch over FastAPI multipart — files bind into Upload fields and
|
|
||||||
the declarative `File(...)` constraints are enforced."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
from mizan_fastapi import File, MizanError, Upload, mizan_exception_handler, router as mizan_router
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarOut(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
size: int
|
|
||||||
name: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app():
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
@client
|
|
||||||
def set_avatar(
|
|
||||||
request,
|
|
||||||
user_id: int,
|
|
||||||
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
|
|
||||||
) -> AvatarOut:
|
|
||||||
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
|
|
||||||
|
|
||||||
register(set_avatar, "set_avatar")
|
|
||||||
|
|
||||||
fastapi_app = FastAPI()
|
|
||||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
|
||||||
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
|
||||||
return fastapi_app
|
|
||||||
|
|
||||||
|
|
||||||
def _post(test_client: TestClient, args: dict, file_tuple: tuple):
|
|
||||||
return test_client.post(
|
|
||||||
"/api/mizan/call/",
|
|
||||||
data={"fn": "set_avatar", "args": json.dumps(args)},
|
|
||||||
files={"avatar": file_tuple},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_binds_and_executes(app):
|
|
||||||
resp = _post(TestClient(app), {"user_id": 5}, ("a.png", b"\x89PNG" + b"x" * 100, "image/png"))
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
result = resp.json()["result"]
|
|
||||||
assert result["ok"] is True
|
|
||||||
assert result["name"] == "a.png"
|
|
||||||
assert result["size"] == 104
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_size_rejected(app):
|
|
||||||
resp = _post(TestClient(app), {"user_id": 5}, ("b.png", b"x" * (2 * 1024 * 1024), "image/png"))
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert "max size" in resp.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_content_type_rejected(app):
|
|
||||||
resp = _post(TestClient(app), {"user_id": 5}, ("c.gif", b"GIF89a", "image/gif"))
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert "content-type" in resp.text
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""
|
|
||||||
WebSocket RPC behavior — the genuine capability behind the `websocket` probe.
|
|
||||||
|
|
||||||
Proves the `/ws/` route dispatches `@client(websocket=True)` functions through
|
|
||||||
the SAME `mizan_core.dispatch` core as `POST /call/`: input validation, the
|
|
||||||
`{result, invalidate, merge}` envelope, `auth=` enforcement, and the
|
|
||||||
websocket=True gate that rejects HTTP-only functions. The frame protocol matches
|
|
||||||
mizan-django's Channels consumer (`action:"rpc"` → `{id, ok, data|error}`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
from mizan_fastapi import (
|
|
||||||
MizanError,
|
|
||||||
mizan_exception_handler,
|
|
||||||
mizan_validation_handler,
|
|
||||||
router as mizan_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EchoOut(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app():
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
@client(websocket=True)
|
|
||||||
def ws_echo(request, text: str) -> EchoOut:
|
|
||||||
return EchoOut(message=f"ws: {text}")
|
|
||||||
|
|
||||||
@client(websocket=True)
|
|
||||||
def ws_add(request, a: int, b: int) -> dict:
|
|
||||||
return {"total": a + b}
|
|
||||||
|
|
||||||
@client(websocket=True, affects="user")
|
|
||||||
def ws_update(request, user_id: int) -> dict:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@client(websocket=True, auth=True)
|
|
||||||
def ws_secret(request) -> dict:
|
|
||||||
return {"secret": True}
|
|
||||||
|
|
||||||
@client # HTTP-only — must be rejected over WS
|
|
||||||
def http_only(request) -> dict:
|
|
||||||
return {"http": True}
|
|
||||||
|
|
||||||
for fn, name in (
|
|
||||||
(ws_echo, "ws_echo"), (ws_add, "ws_add"), (ws_update, "ws_update"),
|
|
||||||
(ws_secret, "ws_secret"), (http_only, "http_only"),
|
|
||||||
):
|
|
||||||
register(fn, name)
|
|
||||||
|
|
||||||
fastapi_app = FastAPI()
|
|
||||||
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
|
||||||
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
|
||||||
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
|
||||||
yield fastapi_app
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def http(app):
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ws_rpc_dispatches_and_returns_data(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "1", "fn": "ws_echo", "args": {"text": "hi"}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame == {"id": "1", "ok": True, "data": {"message": "ws: hi"}, "invalidate": []}
|
|
||||||
|
|
||||||
|
|
||||||
def test_ws_rpc_validates_input_through_core(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "2", "fn": "ws_add", "args": {"a": "nope", "b": 3}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is False
|
|
||||||
assert frame["error"]["code"] == "VALIDATION_ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ws_rpc_carries_invalidation(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "3", "fn": "ws_update", "args": {"user_id": 5}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is True
|
|
||||||
assert "user" in frame["invalidate"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_only_function_is_forbidden_over_ws(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "4", "fn": "http_only", "args": {}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is False
|
|
||||||
assert frame["error"]["code"] == "FORBIDDEN"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_function_over_ws_is_not_found(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "5", "fn": "ghost", "args": {}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is False
|
|
||||||
assert frame["error"]["code"] == "NOT_FOUND"
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_required_function_rejects_anonymous_over_ws(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "6", "fn": "ws_secret", "args": {}})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is False
|
|
||||||
assert frame["error"]["code"] == "UNAUTHORIZED"
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_fn_field_is_bad_request(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "7"})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert frame["ok"] is False
|
|
||||||
assert frame["error"]["code"] == "BAD_REQUEST"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_action_errors(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "bogus"})
|
|
||||||
frame = ws.receive_json()
|
|
||||||
assert "error" in frame
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_calls_on_one_connection(http):
|
|
||||||
with http.websocket_connect("/api/mizan/ws/") as ws:
|
|
||||||
ws.send_json({"action": "rpc", "id": "a", "fn": "ws_echo", "args": {"text": "1"}})
|
|
||||||
first = ws.receive_json()
|
|
||||||
ws.send_json({"action": "rpc", "id": "b", "fn": "ws_echo", "args": {"text": "2"}})
|
|
||||||
second = ws.receive_json()
|
|
||||||
assert first["data"]["message"] == "ws: 1"
|
|
||||||
assert second["data"]["message"] == "ws: 2"
|
|
||||||
329
backends/mizan-rust-axum/Cargo.lock
generated
329
backends/mizan-rust-axum/Cargo.lock
generated
@@ -27,7 +27,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"base64",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -39,7 +38,6 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"multer",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -47,10 +45,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sha1",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -78,90 +74,18 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block-buffer"
|
|
||||||
version = "0.10.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crypto-common"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
"typenum",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "data-encoding"
|
|
||||||
version = "2.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "digest"
|
|
||||||
version = "0.10.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|
||||||
dependencies = [
|
|
||||||
"block-buffer",
|
|
||||||
"crypto-common",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encoding_rs"
|
|
||||||
version = "0.8.35"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -186,23 +110,6 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-macro"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-sink"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -216,49 +123,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-macro",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "generic-array"
|
|
||||||
version = "0.14.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|
||||||
dependencies = [
|
|
||||||
"typenum",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -389,12 +264,28 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -411,15 +302,10 @@ name = "mizan-axum"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
|
||||||
"futures-util",
|
|
||||||
"http-body-util",
|
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
"multer",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
@@ -429,13 +315,11 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
|
||||||
"hmac",
|
|
||||||
"linkme",
|
"linkme",
|
||||||
|
"minijinja",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -448,23 +332,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "multer"
|
|
||||||
version = "3.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"encoding_rs",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"memchr",
|
|
||||||
"mime",
|
|
||||||
"spin",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -483,15 +350,6 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ppv-lite86"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -510,36 +368,6 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rand_chacha",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -618,28 +446,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha1"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha2"
|
|
||||||
version = "0.10.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -662,18 +468,6 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -691,33 +485,12 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -737,18 +510,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"tokio",
|
|
||||||
"tungstenite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -813,48 +574,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"rand",
|
|
||||||
"sha1",
|
|
||||||
"thiserror",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typenum"
|
|
||||||
version = "1.20.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf-8"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "version_check"
|
|
||||||
version = "0.9.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -876,26 +601,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.8.50"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.8.50"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -7,17 +7,9 @@ license = "Elastic-2.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mizan-core = { path = "../../cores/mizan-rust" }
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
axum = { version = "0.7", features = ["ws", "multipart"] }
|
axum = "0.7"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
futures-util = "0.3"
|
|
||||||
multer = "3"
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
|
||||||
tokio-tungstenite = "0.24"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
//! Render a `MizanError` as an axum `Response`: the JSON envelope
|
||||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
|
||||||
//! with a Cache-Control: no-store header.
|
//! `Cache-Control: no-store` header.
|
||||||
|
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each variant's status spelled as an axum constant. Naming the constant
|
||||||
|
/// rather than round-tripping a `u16` leaves no numeric value axum could
|
||||||
|
/// reject, so the mapping is total.
|
||||||
|
fn status_of(err: &MizanError) -> StatusCode {
|
||||||
|
match err {
|
||||||
|
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
|
||||||
|
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = StatusCode::from_u16(self.0.http_status())
|
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
|
||||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
let mut resp = (status, Json(self.0.to_json())).into_response();
|
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
resp
|
resp
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
//! Forms endpoints — schema / validate / submit over the registered form
|
|
||||||
//! functions. The Forms capability is AFI-common; the binding is
|
|
||||||
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
|
|
||||||
//! form_role=…)]` function here). A form is the set of registered functions
|
|
||||||
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
|
|
||||||
//! own route that dispatches the function whose `(form_name, form_role)`
|
|
||||||
//! matches.
|
|
||||||
//!
|
|
||||||
//! POST /form/:form_name/schema/
|
|
||||||
//! POST /form/:form_name/validate/
|
|
||||||
//! POST /form/:form_name/submit/
|
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use axum::Json;
|
|
||||||
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
|
||||||
use crate::state::MizanState;
|
|
||||||
|
|
||||||
/// Find the registered form function with this `(form_name, form_role)`.
|
|
||||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
|
||||||
FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch the form function for `(form_name, role)`. Shared by the three
|
|
||||||
/// role routes below.
|
|
||||||
async fn dispatch_role(
|
|
||||||
state: &MizanState,
|
|
||||||
form_name: &str,
|
|
||||||
role: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotFound(format!(
|
|
||||||
"no form {form_name:?} with role {role:?}"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let args_value = match args {
|
|
||||||
Value::Object(_) | Value::Null => args,
|
|
||||||
other => Value::Object({
|
|
||||||
let mut m = Map::new();
|
|
||||||
m.insert("data".into(), other);
|
|
||||||
m
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
|
||||||
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
|
|
||||||
|
|
||||||
let mut resp = (StatusCode::OK, Json(result)).into_response();
|
|
||||||
resp.headers_mut()
|
|
||||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
||||||
Ok(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
|
|
||||||
pub async fn form_schema(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Path(form_name): Path<String>,
|
|
||||||
Json(args): Json<Value>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
dispatch_role(&state, &form_name, "schema", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /form/:form_name/validate/ — validate submitted data without committing.
|
|
||||||
pub async fn form_validate(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Path(form_name): Path<String>,
|
|
||||||
Json(args): Json<Value>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
dispatch_role(&state, &form_name, "validate", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /form/:form_name/submit/ — validate and commit the form.
|
|
||||||
pub async fn form_submit(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Path(form_name): Path<String>,
|
|
||||||
Json(args): Json<Value>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
dispatch_role(&state, &form_name, "submit", args).await
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,36 @@
|
|||||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
|
//! HTTP handlers for the Mizan endpoints.
|
||||||
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
|
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
|
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||||
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
|
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
|
||||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Number, Value};
|
||||||
|
use std::any::Any;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::ApiError;
|
||||||
use crate::state::MizanState;
|
|
||||||
|
|
||||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
/// Type-erased application state threaded into every `dispatch()` call via
|
||||||
|
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
||||||
|
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||||
|
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
|
/// Body for POST /call/.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CallBody {
|
pub struct CallBody {
|
||||||
pub fn_: Option<String>,
|
/// `fn` is a Rust keyword, hence the serde rename.
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
pub function_name: Option<String>,
|
pub function_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Map<String, Value>,
|
pub args: Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CallBody {
|
|
||||||
fn resolved_name(&self) -> Option<&str> {
|
|
||||||
self.function_name.as_deref().or(self.fn_.as_deref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CallResponse {
|
pub struct CallResponse {
|
||||||
pub result: Value,
|
pub result: Value,
|
||||||
@@ -42,454 +39,129 @@ pub struct CallResponse {
|
|||||||
pub merge: Option<Vec<Value>>,
|
pub merge: Option<Vec<Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn no_store(json: Value) -> Response {
|
fn no_store<T: Serialize>(body: T) -> Response {
|
||||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
let mut resp = (StatusCode::OK, Json(body)).into_response();
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
|
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
|
||||||
/// through the shared `authenticate`. A present-but-invalid token rejects with
|
/// handler selects the registrations that string names and matches over the
|
||||||
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
|
/// two shapes that selection has; `[]` is the selection a string nothing
|
||||||
pub(crate) fn identity_from_headers(
|
/// registered under makes, and it is answered with the NOT_FOUND envelope.
|
||||||
headers: &HeaderMap,
|
|
||||||
state: &MizanState,
|
|
||||||
) -> Result<Option<Identity>, ApiError> {
|
|
||||||
let mwt = headers
|
|
||||||
.get("X-Mizan-Token")
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
let bearer = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
|
|
||||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
|
||||||
AuthOutcome::Anonymous => Ok(None),
|
|
||||||
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
|
|
||||||
"Invalid or expired token".into(),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enforce a function's `@client(auth=...)` against the resolved identity.
|
|
||||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
|
|
||||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
|
||||||
enforce_auth(identity, &req).map_err(ApiError)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject a client call into a `private` function (no RPC endpoint).
|
|
||||||
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(ApiError(MizanError::Forbidden(
|
|
||||||
"Function is not client-callable".into(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uid_str(identity: Option<&Identity>) -> Option<String> {
|
|
||||||
identity.map(|i| i.user_id.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
|
|
||||||
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
|
|
||||||
/// invalidated contexts.
|
|
||||||
pub async fn function_call(
|
pub async fn function_call(
|
||||||
State(state): State<Arc<MizanState>>,
|
State(app_state): State<AppStateAny>,
|
||||||
headers: HeaderMap,
|
Json(body): Json<CallBody>,
|
||||||
body: axum::body::Body,
|
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let identity = identity_from_headers(&headers, &state)?;
|
let registered = function_named(&body.function_name);
|
||||||
let content_type = headers
|
let fn_spec = match registered.as_slice() {
|
||||||
.get(header::CONTENT_TYPE)
|
[] => {
|
||||||
.and_then(|v| v.to_str().ok())
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
.unwrap_or("")
|
"function {:?} not registered",
|
||||||
.to_string();
|
body.function_name
|
||||||
|
))))
|
||||||
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
|
}
|
||||||
parse_multipart(&content_type, body).await?
|
[fn_spec, ..] => *fn_spec,
|
||||||
} else {
|
|
||||||
parse_json_call(body).await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
ApiError(MizanError::NotFound(format!(
|
let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
|
||||||
"function {fn_name:?} not registered"
|
Ok(result) => result,
|
||||||
)))
|
Err(e) => return Err(ApiError(e)),
|
||||||
})?;
|
};
|
||||||
reject_if_private(fn_spec)?;
|
|
||||||
guard(fn_spec, identity.as_ref())?;
|
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||||
let result = fn_spec
|
.iter()
|
||||||
.dispatch(req, Value::Object(args.clone()))
|
.map(InvalidationTarget::to_json)
|
||||||
.await
|
.collect();
|
||||||
.map_err(ApiError)?;
|
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||||
|
|
||||||
let targets = compute_invalidation(fn_spec, &args);
|
|
||||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
|
||||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Purge the origin cache for everything this mutation invalidated.
|
Ok(no_store(CallResponse {
|
||||||
if !targets.is_empty() {
|
|
||||||
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = CallResponse {
|
|
||||||
result,
|
result,
|
||||||
invalidate,
|
invalidate,
|
||||||
merge: merge_payload,
|
merge: merge_payload,
|
||||||
};
|
}))
|
||||||
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
|
|
||||||
if !targets.is_empty() {
|
|
||||||
let header_val = format_invalidate_header(&targets);
|
|
||||||
if let Ok(hv) = HeaderValue::from_str(&header_val) {
|
|
||||||
resp.headers_mut().insert("X-Mizan-Invalidate", hv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
|
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
|
||||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
/// path segment, so `[]` is the selection a segment no registered function
|
||||||
.await
|
/// declares membership in makes, answered with the NOT_FOUND envelope.
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
|
|
||||||
let call: CallBody = serde_json::from_slice(&bytes)
|
|
||||||
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
|
|
||||||
let fn_name = call
|
|
||||||
.resolved_name()
|
|
||||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
|
||||||
.to_string();
|
|
||||||
Ok((fn_name, call.args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
|
|
||||||
/// Each file part binds into the matching Upload-typed input field as a
|
|
||||||
/// base64-carrying value the `mizan_core::Upload` field deserializes.
|
|
||||||
async fn parse_multipart(
|
|
||||||
content_type: &str,
|
|
||||||
body: axum::body::Body,
|
|
||||||
) -> Result<(String, Map<String, Value>), ApiError> {
|
|
||||||
let boundary = multer::parse_boundary(content_type)
|
|
||||||
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
|
|
||||||
let stream = body.into_data_stream();
|
|
||||||
let mut mp = multer::Multipart::new(stream, boundary);
|
|
||||||
|
|
||||||
let mut fn_name: Option<String> = None;
|
|
||||||
let mut args: Map<String, Value> = Map::new();
|
|
||||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
|
||||||
|
|
||||||
while let Some(field) = mp
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
|
|
||||||
{
|
|
||||||
let name = field.name().unwrap_or("").to_string();
|
|
||||||
let filename = field.file_name().map(|s| s.to_string());
|
|
||||||
let part_content_type = field.content_type().map(|s| s.to_string());
|
|
||||||
|
|
||||||
if filename.is_some() {
|
|
||||||
// A file part → the JSON shape `mizan_core::Upload` deserializes
|
|
||||||
// (filename, content_type, base64 bytes).
|
|
||||||
let data = field
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
|
|
||||||
files.entry(name).or_default().push(uploaded_file_json(
|
|
||||||
filename,
|
|
||||||
part_content_type,
|
|
||||||
&data,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
let text = field
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
|
|
||||||
if name == "fn" {
|
|
||||||
fn_name = Some(text);
|
|
||||||
} else if name == "args" {
|
|
||||||
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
|
|
||||||
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
|
|
||||||
})?;
|
|
||||||
if let Value::Object(m) = parsed {
|
|
||||||
args = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind file parts into args by field name (single vs list).
|
|
||||||
for (field_name, parts) in files {
|
|
||||||
if parts.len() == 1 {
|
|
||||||
args.insert(field_name, parts.into_iter().next().unwrap());
|
|
||||||
} else {
|
|
||||||
args.insert(field_name, Value::Array(parts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fn_name =
|
|
||||||
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
|
|
||||||
Ok((fn_name, args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a received file part as the JSON shape an `Upload` field expects.
|
|
||||||
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
|
||||||
use base64::Engine;
|
|
||||||
serde_json::json!({
|
|
||||||
"filename": filename,
|
|
||||||
"content_type": content_type,
|
|
||||||
"data_b64": STANDARD.encode(data),
|
|
||||||
"size": data.len(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
|
|
||||||
pub async fn context_fetch(
|
pub async fn context_fetch(
|
||||||
State(state): State<Arc<MizanState>>,
|
State(app_state): State<AppStateAny>,
|
||||||
headers: HeaderMap,
|
|
||||||
Path(context_name): Path<String>,
|
Path(context_name): Path<String>,
|
||||||
Query(params): Query<BTreeMap<String, String>>,
|
Query(params): Query<BTreeMap<String, String>>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
if lookup_context(&context_name).is_none() {
|
let members = context_members(&context_name);
|
||||||
return Err(ApiError(MizanError::NotFound(format!(
|
let selected = match members.as_slice() {
|
||||||
"context {context_name:?} not registered"
|
[] => {
|
||||||
))));
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
}
|
"context {context_name:?} names no registered functions"
|
||||||
|
))))
|
||||||
let identity = identity_from_headers(&headers, &state)?;
|
}
|
||||||
|
selected => selected,
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|f| f.context() == Some(&context_name))
|
|
||||||
.collect();
|
|
||||||
if members.is_empty() {
|
|
||||||
return Err(ApiError(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} has no registered members"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Origin cache: the canonical-JSON bundle body is keyed by (context,
|
|
||||||
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
|
|
||||||
let cache_params: BTreeMap<String, Value> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
|
||||||
.collect();
|
|
||||||
let uid = uid_str(identity.as_ref());
|
|
||||||
|
|
||||||
if let Some(cached) = state
|
|
||||||
.cache
|
|
||||||
.get(&context_name, &cache_params, uid.as_deref(), 0)
|
|
||||||
{
|
|
||||||
return Ok(cached_response(cached, "HIT"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce auth per member (the bundle is only as open as its strictest fn).
|
|
||||||
let mut bundled = Map::new();
|
|
||||||
for fn_spec in &members {
|
|
||||||
guard(*fn_spec, identity.as_ref())?;
|
|
||||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
|
||||||
let result = fn_spec
|
|
||||||
.dispatch(req, Value::Object(args))
|
|
||||||
.await
|
|
||||||
.map_err(ApiError)?;
|
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = canonical_bytes(&Value::Object(bundled));
|
|
||||||
let status = if state.cache.enabled() {
|
|
||||||
state
|
|
||||||
.cache
|
|
||||||
.put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0);
|
|
||||||
"MISS"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
};
|
||||||
Ok(cached_response(body, status))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
|
let mut bundled = Map::new();
|
||||||
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
|
for fn_spec in selected {
|
||||||
fn canonical_bytes(v: &Value) -> Vec<u8> {
|
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||||
fn sort(v: &Value) -> Value {
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
match v {
|
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||||
Value::Object(m) => {
|
Ok(result) => {
|
||||||
let mut keys: Vec<&String> = m.keys().collect();
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
keys.sort();
|
|
||||||
let mut out = Map::new();
|
|
||||||
for k in keys {
|
|
||||||
out.insert(k.clone(), sort(&m[k]));
|
|
||||||
}
|
|
||||||
Value::Object(out)
|
|
||||||
}
|
}
|
||||||
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
|
Err(e) => return Err(ApiError(e)),
|
||||||
other => other.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Python's default separators add a space after ':' and ','. Match that so
|
|
||||||
// a Rust-written cache body and a Python-written one are byte-equal.
|
Ok(no_store(Value::Object(bundled)))
|
||||||
let sorted = sort(v);
|
|
||||||
python_json(&sorted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
|
/// A query string carries every value as text, so each declared input param
|
||||||
/// (`", "` and `": "`).
|
/// reads its raw text as the primitive it declares. Text spelling something
|
||||||
fn python_json(v: &Value) -> Vec<u8> {
|
/// else stays the text it already is: `dispatch` validates every arg against
|
||||||
let compact = serde_json::to_string(v).unwrap();
|
/// the declared shape and is the one step that words the VALIDATION_FAILED
|
||||||
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
|
/// answer, so re-wording it here would give one request two spellings of the
|
||||||
// This is a structural transform on the already-sorted value, so the
|
/// same complaint.
|
||||||
// bytes match `json.dumps` for the JSON value space Mizan returns.
|
|
||||||
let spaced = respace(&compact);
|
|
||||||
spaced.into_bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert the spaces Python's default `json.dumps` uses after structural
|
|
||||||
/// `,`/`:` — but only outside string literals.
|
|
||||||
fn respace(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len() + s.len() / 8);
|
|
||||||
let mut in_str = false;
|
|
||||||
let mut escaped = false;
|
|
||||||
for c in s.chars() {
|
|
||||||
if in_str {
|
|
||||||
out.push(c);
|
|
||||||
if escaped {
|
|
||||||
escaped = false;
|
|
||||||
} else if c == '\\' {
|
|
||||||
escaped = true;
|
|
||||||
} else if c == '"' {
|
|
||||||
in_str = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match c {
|
|
||||||
'"' => {
|
|
||||||
in_str = true;
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
',' => out.push_str(", "),
|
|
||||||
':' => out.push_str(": "),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
|
|
||||||
let mut resp = (StatusCode::OK, body).into_response();
|
|
||||||
let h = resp.headers_mut();
|
|
||||||
h.insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("application/json"),
|
|
||||||
);
|
|
||||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
||||||
if !cache_status.is_empty() {
|
|
||||||
if let Ok(v) = HeaderValue::from_str(cache_status) {
|
|
||||||
h.insert("X-Mizan-Cache", v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coerce string-valued query params into typed JSON via the function's
|
|
||||||
/// declared input_params.
|
|
||||||
fn coerce_query_args(
|
fn coerce_query_args(
|
||||||
fn_spec: &dyn FunctionSpec,
|
fn_spec: &dyn FunctionSpec,
|
||||||
params: &BTreeMap<String, String>,
|
params: &BTreeMap<String, String>,
|
||||||
) -> Map<String, Value> {
|
) -> Map<String, Value> {
|
||||||
let mut out = Map::new();
|
let mut out = Map::new();
|
||||||
for ip in fn_spec.input_params() {
|
for ip in fn_spec.input_params() {
|
||||||
if let Some(raw) = params.get(ip.name) {
|
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
|
||||||
let parsed = match ip.primitive {
|
let as_text = Value::from(raw.clone());
|
||||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
let coerced = match ip.primitive {
|
||||||
mizan_core::Primitive::Number => raw
|
Primitive::String => as_text,
|
||||||
.parse::<f64>()
|
Primitive::Boolean => match raw.as_str() {
|
||||||
.ok()
|
"true" => Value::Bool(true),
|
||||||
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
|
"false" => Value::Bool(false),
|
||||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
_spells_neither => as_text,
|
||||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
},
|
||||||
|
Primitive::Integer => match raw.parse::<i64>() {
|
||||||
|
Ok(integer) => Value::from(integer),
|
||||||
|
Err(_spells_no_integer) => as_text,
|
||||||
|
},
|
||||||
|
Primitive::Number => match raw.parse::<f64>() {
|
||||||
|
Ok(float) => match Number::from_f64(float) {
|
||||||
|
Some(number) => Value::Number(number),
|
||||||
|
None => as_text,
|
||||||
|
},
|
||||||
|
Err(_spells_no_number) => as_text,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone())));
|
out.insert(ip.name.into(), coerced);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
|
/// GET /session/ — emits `{"csrfToken": null}`.
|
||||||
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
|
|
||||||
/// mechanism; the endpoint here returns a null token and serves as the
|
|
||||||
/// readiness probe the wire-parity harness uses.
|
|
||||||
pub async fn session_init() -> Response {
|
pub async fn session_init() -> Response {
|
||||||
no_store(serde_json::json!({ "csrfToken": null }))
|
no_store(serde_json::json!({ "csrfToken": null }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
|
|
||||||
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
|
|
||||||
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
|
|
||||||
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
|
|
||||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
|
||||||
no_store(manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
|
|
||||||
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
|
|
||||||
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
|
|
||||||
/// re-renders. This is the adapter telling Edge *how* to cache each context —
|
|
||||||
/// the PSR half of the manifest, addressable per-context.
|
|
||||||
pub async fn psr_descriptor(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Path(context_name): Path<String>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
|
|
||||||
let ctx = manifest
|
|
||||||
.get("contexts")
|
|
||||||
.and_then(|c| c.get(&context_name))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} not in manifest"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
let render_strategy = ctx
|
|
||||||
.get("render_strategy")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
let page_routes = ctx
|
|
||||||
.get("page_routes")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
|
||||||
Ok(no_store(serde_json::json!({
|
|
||||||
"context": context_name,
|
|
||||||
"render_strategy": render_strategy,
|
|
||||||
"page_routes": page_routes,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
|
|
||||||
/// output, derived from the registered type graph by `mizan_core::shapes`.
|
|
||||||
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
|
|
||||||
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotFound(format!(
|
|
||||||
"no shape projection for {fn_name:?}"
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
Ok(no_store(projection_to_json(&proj)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
|
||||||
let mut fields = Vec::new();
|
|
||||||
for f in &proj.fields {
|
|
||||||
match f {
|
|
||||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
|
||||||
shapes::ShapeField::Nested(n, sub) => {
|
|
||||||
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::json!({ "type": proj.type_name, "fields": fields })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,80 +1,57 @@
|
|||||||
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
|
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||||
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
|
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! use axum::Router;
|
//! use axum::Router;
|
||||||
//! use mizan_axum::{router, MizanState};
|
//! use mizan_axum::router;
|
||||||
//!
|
//!
|
||||||
//! #[tokio::main]
|
//! #[tokio::main]
|
||||||
//! async fn main() {
|
//! async fn main() {
|
||||||
//! let state = MizanState::builder()
|
//! let app = Router::new().nest("/api/mizan", router());
|
||||||
//! .app_state(MyState { /* ... */ })
|
|
||||||
//! .build();
|
|
||||||
//! let app = Router::new().nest("/api/mizan", router(state));
|
|
||||||
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||||
//! axum::serve(listener, app).await.unwrap();
|
//! axum::serve(listener, app).await.unwrap();
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
//! Exposed endpoints:
|
||||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
//! * `GET /session/` — session-init probe
|
||||||
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
|
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||||
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
|
//! * `GET /ctx/:name/` — bundled context fetch
|
||||||
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
|
|
||||||
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
|
|
||||||
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
|
|
||||||
//! * `GET /shape/:fn/` — typed query projection (Shapes)
|
|
||||||
//! * `POST /ssr/` — server-side render via the Bun worker
|
|
||||||
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
|
|
||||||
|
|
||||||
mod errors;
|
mod errors;
|
||||||
mod forms;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod ssr;
|
|
||||||
mod state;
|
|
||||||
mod ws;
|
|
||||||
|
|
||||||
pub use errors::ApiError;
|
pub use errors::ApiError;
|
||||||
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
|
pub use handlers::{
|
||||||
pub use ssr::{ssr_render, SsrRequest};
|
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
||||||
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
|
};
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Build the Mizan router with a fully-configured [`MizanState`] (app state +
|
/// Build the Mizan router with user-supplied app state. The state is
|
||||||
/// auth + cache + optional SSR worker). Mount under a prefix:
|
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
||||||
/// `Router::new().nest("/api/mizan", router(state))`.
|
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
|
||||||
pub fn router(state: Arc<MizanState>) -> Router {
|
/// type.
|
||||||
|
///
|
||||||
|
/// Mount under a prefix:
|
||||||
|
/// `Router::new().nest("/api/mizan", router(my_state))`.
|
||||||
|
pub fn router<S>(state: S) -> Router
|
||||||
|
where
|
||||||
|
S: Any + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let state: AppStateAny = Arc::new(state);
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/session/", get(handlers::session_init))
|
.route("/session/", get(handlers::session_init))
|
||||||
.route("/call/", post(handlers::function_call))
|
.route("/call/", post(handlers::function_call))
|
||||||
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
||||||
.route("/ws/", get(ws::ws_handler))
|
|
||||||
.route("/manifest/", get(handlers::edge_manifest))
|
|
||||||
.route("/psr/:context_name/", get(handlers::psr_descriptor))
|
|
||||||
.route("/shape/:fn_name/", get(handlers::shape_projection))
|
|
||||||
.route("/ssr/", post(ssr::ssr_render))
|
|
||||||
.route("/form/:form_name/schema/", post(forms::form_schema))
|
|
||||||
.route("/form/:form_name/validate/", post(forms::form_validate))
|
|
||||||
.route("/form/:form_name/submit/", post(forms::form_submit))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Router variant for the common case of just an app state, no auth/cache.
|
/// Router variant for callers that have no app state to thread — the
|
||||||
pub fn router_with_state<S>(app_state: S) -> Router
|
/// dispatch path receives a unit-typed handle.
|
||||||
where
|
|
||||||
S: Any + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
router(MizanState::builder().app_state(app_state).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Router variant for callers that have no app state to thread — the dispatch
|
|
||||||
/// path receives a unit-typed handle. Used by the AFI fixture and stateless
|
|
||||||
/// test apps.
|
|
||||||
pub fn router_stateless() -> Router {
|
pub fn router_stateless() -> Router {
|
||||||
router(MizanState::builder().build())
|
router(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
|
|
||||||
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
|
|
||||||
//! `SSRBridge`). The bridge spawns on first render and stays alive.
|
|
||||||
//!
|
|
||||||
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::Json;
|
|
||||||
use mizan_core::MizanError;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
|
||||||
use crate::state::MizanState;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SsrRequest {
|
|
||||||
pub file: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub props: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /ssr/ — render a component file via the Bun SSR worker.
|
|
||||||
pub async fn ssr_render(
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
Json(req): Json<SsrRequest>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let bridge = state.ssr().ok_or_else(|| {
|
|
||||||
ApiError(MizanError::NotImplementedYet(
|
|
||||||
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let props = if req.props.is_null() {
|
|
||||||
json!({})
|
|
||||||
} else {
|
|
||||||
req.props
|
|
||||||
};
|
|
||||||
let html = bridge
|
|
||||||
.render(&req.file, props)
|
|
||||||
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
|
|
||||||
|
|
||||||
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
|
|
||||||
resp.headers_mut().insert(
|
|
||||||
axum::http::header::CACHE_CONTROL,
|
|
||||||
axum::http::HeaderValue::from_static("no-store"),
|
|
||||||
);
|
|
||||||
Ok(resp)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
//! Router state — the Mizan config (auth + origin cache) threaded alongside
|
|
||||||
//! the user's type-erased app state.
|
|
||||||
//!
|
|
||||||
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
|
|
||||||
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
|
|
||||||
//! their concrete type — unchanged from the pre-AFI router). `auth` and
|
|
||||||
//! `cache` are the AFI-common config the handlers read for enforcement and
|
|
||||||
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
|
|
||||||
|
|
||||||
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
|
|
||||||
use std::any::Any;
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
|
|
||||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
|
||||||
|
|
||||||
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
|
|
||||||
pub struct MizanState {
|
|
||||||
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
|
|
||||||
pub app_state: AppStateAny,
|
|
||||||
/// JWT/MWT auth config (token → identity resolution + enforcement).
|
|
||||||
pub auth: AuthConfig,
|
|
||||||
/// Origin-side HMAC cache orchestrator (disabled by default).
|
|
||||||
pub cache: CacheOrchestrator,
|
|
||||||
/// Mizan API mount point, used by the edge-manifest endpoint.
|
|
||||||
pub base_url: String,
|
|
||||||
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
|
|
||||||
pub(crate) ssr_worker: Option<String>,
|
|
||||||
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MizanState {
|
|
||||||
pub fn builder() -> MizanStateBuilder {
|
|
||||||
MizanStateBuilder::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SSR bridge, spawned on first use. `None` if no worker was set.
|
|
||||||
pub fn ssr(&self) -> Option<&SsrBridge> {
|
|
||||||
let worker = self.ssr_worker.as_ref()?;
|
|
||||||
Some(
|
|
||||||
self.ssr_bridge
|
|
||||||
.get_or_init(|| SsrBridge::bun(worker.clone())),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
|
|
||||||
/// disabled, `/api/mizan` base URL, no SSR worker.
|
|
||||||
pub struct MizanStateBuilder {
|
|
||||||
app_state: AppStateAny,
|
|
||||||
auth: AuthConfig,
|
|
||||||
cache: CacheOrchestrator,
|
|
||||||
base_url: String,
|
|
||||||
ssr_worker: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MizanStateBuilder {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
app_state: Arc::new(()),
|
|
||||||
auth: AuthConfig::new(),
|
|
||||||
cache: CacheOrchestrator::disabled(),
|
|
||||||
base_url: "/api/mizan".to_string(),
|
|
||||||
ssr_worker: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MizanStateBuilder {
|
|
||||||
/// Set the consumer's app state (threaded into dispatch).
|
|
||||||
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
|
|
||||||
self.app_state = Arc::new(state);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn auth(mut self, auth: AuthConfig) -> Self {
|
|
||||||
self.auth = auth;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
|
|
||||||
self.cache = cache;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
|
||||||
self.base_url = base_url.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure the Bun SSR worker path; the bridge spawns on first render.
|
|
||||||
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
|
|
||||||
self.ssr_worker = Some(worker_path.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build(self) -> Arc<MizanState> {
|
|
||||||
Arc::new(MizanState {
|
|
||||||
app_state: self.app_state,
|
|
||||||
auth: self.auth,
|
|
||||||
cache: self.cache,
|
|
||||||
base_url: self.base_url,
|
|
||||||
ssr_worker: self.ssr_worker,
|
|
||||||
ssr_bridge: OnceLock::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
|
|
||||||
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
|
|
||||||
//! that dispatches call/fetch frames through the same `mizan-core` registry
|
|
||||||
//! the HTTP path uses. A call frame naming a non-websocket function is
|
|
||||||
//! rejected, so the transport boundary the IR declares is enforced.
|
|
||||||
//!
|
|
||||||
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
|
|
||||||
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
|
|
||||||
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
|
|
||||||
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
|
|
||||||
//! ← {"id": 2, "data": {fnName: result, ...}}
|
|
||||||
//! ← {"id": N, "error": {"code": ..., "message": ...}}
|
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::response::Response;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use mizan_core::{
|
|
||||||
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
|
|
||||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::state::MizanState;
|
|
||||||
|
|
||||||
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
|
|
||||||
pub async fn ws_handler(
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
State(state): State<Arc<MizanState>>,
|
|
||||||
) -> Response {
|
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
|
|
||||||
while let Some(Ok(msg)) = socket.next().await {
|
|
||||||
let text = match msg {
|
|
||||||
Message::Text(t) => t,
|
|
||||||
Message::Close(_) => break,
|
|
||||||
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
|
|
||||||
};
|
|
||||||
let reply = handle_frame(&state, &text).await;
|
|
||||||
if socket
|
|
||||||
.send(Message::Text(reply.to_string()))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_frame(state: &MizanState, text: &str) -> Value {
|
|
||||||
let frame: Value = match serde_json::from_str(text) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
|
|
||||||
};
|
|
||||||
let id = frame.get("id").cloned().unwrap_or(Value::Null);
|
|
||||||
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
|
|
||||||
|
|
||||||
match op {
|
|
||||||
"call" => match dispatch_ws_call(state, &frame).await {
|
|
||||||
Ok(v) => with_id(id, v),
|
|
||||||
Err(e) => err_frame(id, &e),
|
|
||||||
},
|
|
||||||
"fetch" => match dispatch_ws_fetch(state, &frame).await {
|
|
||||||
Ok(v) => with_id(id, json!({ "data": v })),
|
|
||||||
Err(e) => err_frame(id, &e),
|
|
||||||
},
|
|
||||||
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
|
||||||
let fn_name = frame
|
|
||||||
.get("fn")
|
|
||||||
.and_then(|f| f.as_str())
|
|
||||||
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
|
|
||||||
let args = frame
|
|
||||||
.get("args")
|
|
||||||
.and_then(|a| a.as_object())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let fn_spec =
|
|
||||||
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
|
||||||
}
|
|
||||||
// The WS transport only carries functions that opted into it.
|
|
||||||
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
|
|
||||||
return Err(MizanError::BadRequest(format!(
|
|
||||||
"function {fn_name:?} is not exposed over the WebSocket transport"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
enforce_anon_guard(fn_spec)?;
|
|
||||||
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
|
||||||
|
|
||||||
let targets = compute_invalidation(fn_spec, &args);
|
|
||||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
|
||||||
|
|
||||||
let mut out = Map::new();
|
|
||||||
out.insert("result".into(), result);
|
|
||||||
out.insert("invalidate".into(), Value::Array(invalidate));
|
|
||||||
if !merges.is_empty() {
|
|
||||||
out.insert(
|
|
||||||
"merge".into(),
|
|
||||||
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Value::Object(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
|
|
||||||
let ctx = frame
|
|
||||||
.get("context")
|
|
||||||
.and_then(|c| c.as_str())
|
|
||||||
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
|
|
||||||
if lookup_context(ctx).is_none() {
|
|
||||||
return Err(MizanError::NotFound(format!("context {ctx:?}")));
|
|
||||||
}
|
|
||||||
let params = frame
|
|
||||||
.get("params")
|
|
||||||
.and_then(|p| p.as_object())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|f| f.context() == Some(ctx))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut bundle = Map::new();
|
|
||||||
for fn_spec in &members {
|
|
||||||
enforce_anon_guard(*fn_spec)?;
|
|
||||||
let mut args = Map::new();
|
|
||||||
for ip in fn_spec.input_params() {
|
|
||||||
if let Some(v) = params.get(ip.name) {
|
|
||||||
args.insert(ip.name.into(), v.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let req = RequestHandle::from_dyn(state.app_state.as_ref());
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
|
||||||
bundle.insert(fn_spec.name().to_string(), result);
|
|
||||||
}
|
|
||||||
Ok(Value::Object(bundle))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enforce a function's auth guard for the WS transport. The WS upgrade
|
|
||||||
/// carries no per-frame identity in this baseline, so a guarded function is
|
|
||||||
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
|
|
||||||
/// applied with an anonymous identity.
|
|
||||||
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
|
|
||||||
let req = AuthRequirement::from_str_opt(fn_spec.auth());
|
|
||||||
mizan_core::enforce_auth(None, &req)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_id(id: Value, mut body: Value) -> Value {
|
|
||||||
if let Some(obj) = body.as_object_mut() {
|
|
||||||
obj.insert("id".into(), id);
|
|
||||||
}
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
fn err_frame(id: Value, e: &MizanError) -> Value {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"error": { "code": e.code(), "message": e.message() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
//! Runtime behavior tests for the axum adapter — the conformance ceiling that
|
|
||||||
//! the source-presence probes set the floor for. Each AFI-common HTTP cell is
|
|
||||||
//! driven end to end through the real router (`tower::ServiceExt::oneshot`,
|
|
||||||
//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs
|
|
||||||
//! against a real bound port.
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Request, StatusCode};
|
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use mizan_core as mizan;
|
|
||||||
use mizan_core::prelude::*;
|
|
||||||
use mizan_core::{
|
|
||||||
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
|
||||||
};
|
|
||||||
use mizan_axum::{router, MizanState};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
// ─── Fixture: the functions these tests dispatch ────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct Profile {
|
|
||||||
pub user_id: i64,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct Ok {
|
|
||||||
pub ok: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct Secret {
|
|
||||||
pub flag: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct UploadEcho {
|
|
||||||
pub filename: String,
|
|
||||||
pub size: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct SchemaOut {
|
|
||||||
pub fields: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::context("bprofile")]
|
|
||||||
pub struct BProfileCtx;
|
|
||||||
|
|
||||||
#[mizan::client(context = BProfileCtx)]
|
|
||||||
pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
|
|
||||||
Profile {
|
|
||||||
user_id,
|
|
||||||
name: format!("user-{user_id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(affects = BProfileCtx)]
|
|
||||||
pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok {
|
|
||||||
let _ = (user_id, name);
|
|
||||||
Ok { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(auth = "staff")]
|
|
||||||
pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret {
|
|
||||||
Secret {
|
|
||||||
flag: "top-secret".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(websocket)]
|
|
||||||
pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok {
|
|
||||||
let _ = n;
|
|
||||||
Ok { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client]
|
|
||||||
pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho {
|
|
||||||
let _ = user_id;
|
|
||||||
UploadEcho {
|
|
||||||
filename: avatar.filename.clone().unwrap_or_default(),
|
|
||||||
size: avatar.size() as i64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(form_name = "contact", form_role = "submit")]
|
|
||||||
pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok {
|
|
||||||
let _ = name;
|
|
||||||
Ok { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(form_name = "contact", form_role = "schema")]
|
|
||||||
pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut {
|
|
||||||
SchemaOut {
|
|
||||||
fields: vec!["name".into()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn stateless_app() -> axum::Router {
|
|
||||||
router(MizanState::builder().build())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn body_json(resp: axum::response::Response) -> Value {
|
|
||||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
serde_json::from_slice(&bytes).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response {
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/call/")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(json!({"fn": fn_name, "args": args}).to_string()))
|
|
||||||
.unwrap();
|
|
||||||
app.clone().oneshot(req).await.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── invalidate_header + invalidate_body + rpc_call ──────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn call_emits_invalidate_body_and_header() {
|
|
||||||
let app = stateless_app();
|
|
||||||
let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await;
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
// The header is co-equal with the body channel: scoped to user_id=7.
|
|
||||||
let header = resp
|
|
||||||
.headers()
|
|
||||||
.get("X-Mizan-Invalidate")
|
|
||||||
.expect("X-Mizan-Invalidate present")
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
assert_eq!(header, "bprofile;user_id=7");
|
|
||||||
assert_eq!(
|
|
||||||
resp.headers().get("cache-control").unwrap(),
|
|
||||||
"no-store"
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
assert_eq!(body["result"], json!({"ok": true}));
|
|
||||||
// Body invalidate entry is the scoped object form.
|
|
||||||
assert_eq!(
|
|
||||||
body["invalidate"],
|
|
||||||
json!([{"context": "bprofile", "params": {"user_id": 7}}])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn auth_guard_rejects_anonymous_and_admits_staff() {
|
|
||||||
// No auth config + a staff-guarded fn → anonymous is rejected 401.
|
|
||||||
let app = stateless_app();
|
|
||||||
let resp = post_call(&app, "b_secret", json!({})).await;
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
// With a JWT config + a staff token, the same call is admitted. Mint at
|
|
||||||
// the real clock so the token is unexpired when the handler verifies it.
|
|
||||||
let cfg = JwtConfig::new("beh-secret");
|
|
||||||
let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix());
|
|
||||||
let auth = AuthConfig {
|
|
||||||
jwt: Some(cfg),
|
|
||||||
mwt_secret: None,
|
|
||||||
mwt_audience: "mizan".into(),
|
|
||||||
};
|
|
||||||
let app = router(MizanState::builder().auth(auth).build());
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/call/")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.header("authorization", format!("Bearer {token}"))
|
|
||||||
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
assert_eq!(body["result"], json!({"flag": "top-secret"}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn auth_guard_forbids_non_staff_token() {
|
|
||||||
// A valid but non-staff token → 403 on a staff-guarded fn.
|
|
||||||
let cfg = JwtConfig::new("beh-secret");
|
|
||||||
let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix());
|
|
||||||
let auth = AuthConfig {
|
|
||||||
jwt: Some(cfg),
|
|
||||||
mwt_secret: None,
|
|
||||||
mwt_audience: "mizan".into(),
|
|
||||||
};
|
|
||||||
let app = router(MizanState::builder().auth(auth).build());
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/call/")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.header("authorization", format!("Bearer {token}"))
|
|
||||||
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn invalid_token_is_rejected_not_downgraded() {
|
|
||||||
// A present-but-bad bearer rejects (401) even on an unguarded context —
|
|
||||||
// the INVALID-sentinel contract.
|
|
||||||
let auth = AuthConfig {
|
|
||||||
jwt: Some(JwtConfig::new("beh-secret")),
|
|
||||||
mwt_secret: None,
|
|
||||||
mwt_audience: "mizan".into(),
|
|
||||||
};
|
|
||||||
let app = router(MizanState::builder().auth(auth).build());
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("GET")
|
|
||||||
.uri("/ctx/bprofile/?user_id=1")
|
|
||||||
.header("authorization", "Bearer not-a-real-token")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── origin_cache ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn context_fetch_uses_origin_cache() {
|
|
||||||
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
|
||||||
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into()));
|
|
||||||
let app = router(MizanState::builder().cache(cache).build());
|
|
||||||
|
|
||||||
// First fetch: MISS, populates the cache.
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/ctx/bprofile/?user_id=3")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
|
||||||
let first = body_json(resp).await;
|
|
||||||
assert_eq!(first["b_user_profile"]["user_id"], json!(3));
|
|
||||||
|
|
||||||
// Second fetch: HIT, served from cache.
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/ctx/bprofile/?user_id=3")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT");
|
|
||||||
let second = body_json(resp).await;
|
|
||||||
assert_eq!(first, second);
|
|
||||||
|
|
||||||
// A mutation scoped to user_id=3 purges that key → next fetch MISSes.
|
|
||||||
let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await;
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/ctx/bprofile/?user_id=3")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── upload ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn multipart_upload_binds_into_input() {
|
|
||||||
let app = stateless_app();
|
|
||||||
let boundary = "----mizanbeh";
|
|
||||||
let file_bytes = b"PNGDATA-0123456789";
|
|
||||||
let body = format!(
|
|
||||||
"--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\
|
|
||||||
--{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\
|
|
||||||
--{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
|
|
||||||
Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n",
|
|
||||||
b = boundary,
|
|
||||||
data = String::from_utf8_lossy(file_bytes),
|
|
||||||
);
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/call/")
|
|
||||||
.header(
|
|
||||||
"content-type",
|
|
||||||
format!("multipart/form-data; boundary={boundary}"),
|
|
||||||
)
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
assert_eq!(body["result"]["filename"], json!("a.png"));
|
|
||||||
assert_eq!(body["result"]["size"], json!(file_bytes.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── edge_manifest + psr ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn manifest_and_psr_descriptor() {
|
|
||||||
let app = stateless_app();
|
|
||||||
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/manifest/")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await;
|
|
||||||
// bprofile is user-scoped (user_id) → dynamic_cached.
|
|
||||||
assert_eq!(
|
|
||||||
manifest["contexts"]["bprofile"]["render_strategy"],
|
|
||||||
json!("dynamic_cached")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
manifest["mutations"]["b_update_profile"]["affects"],
|
|
||||||
json!(["bprofile"])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Per-context PSR descriptor.
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/psr/bprofile/")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let psr = body_json(app.oneshot(req).await.unwrap()).await;
|
|
||||||
assert_eq!(psr["render_strategy"], json!("dynamic_cached"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── shapes ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shape_projection_endpoint() {
|
|
||||||
let app = stateless_app();
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/shape/b_user_profile/")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
// Output type name is camelCased by the macro (`b_user_profile` →
|
|
||||||
// `bUserProfile`), suffixed `Output`.
|
|
||||||
assert_eq!(body["type"], json!("bUserProfileOutput"));
|
|
||||||
let fields = body["fields"].as_array().unwrap();
|
|
||||||
assert!(fields.contains(&json!("user_id")));
|
|
||||||
assert!(fields.contains(&json!("name")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── forms ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn forms_schema_and_submit_routes() {
|
|
||||||
let app = stateless_app();
|
|
||||||
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/form/contact/schema/")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from("{}"))
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
assert_eq!(body["fields"], json!(["name"]));
|
|
||||||
|
|
||||||
let req = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/form/contact/submit/")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(json!({"name": "Ada"}).to_string()))
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
assert_eq!(body_json(resp).await, json!({"ok": true}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── websocket ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn websocket_transport_dispatches_and_rejects_non_ws_fn() {
|
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
|
|
||||||
// Bind a real socket — the WS upgrade needs an actual connection.
|
|
||||||
let app = stateless_app();
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = format!("ws://{addr}/ws/");
|
|
||||||
let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
|
|
||||||
|
|
||||||
// A websocket-declared fn dispatches.
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
socket
|
|
||||||
.send(Message::Text(
|
|
||||||
json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let reply = socket.next().await.unwrap().unwrap();
|
|
||||||
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
|
||||||
assert_eq!(v["id"], json!(1));
|
|
||||||
assert_eq!(v["result"], json!({"ok": true}));
|
|
||||||
|
|
||||||
// A non-websocket fn over WS is rejected (transport boundary enforced).
|
|
||||||
socket
|
|
||||||
.send(Message::Text(
|
|
||||||
json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let reply = socket.next().await.unwrap().unwrap();
|
|
||||||
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
|
|
||||||
assert_eq!(v["id"], json!(2));
|
|
||||||
assert!(v["error"]["message"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.contains("WebSocket transport"));
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
}
|
|
||||||
118
backends/mizan-tauri/Cargo.lock
generated
118
backends/mizan-tauri/Cargo.lock
generated
@@ -558,7 +558,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"subtle",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1229,15 +1228,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -1555,7 +1545,7 @@ dependencies = [
|
|||||||
"cesu8",
|
"cesu8",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -1564,15 +1554,37 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys-macros"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1670,9 +1682,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.14"
|
version = "0.1.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1735,6 +1747,12 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1750,6 +1768,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -1776,13 +1804,11 @@ name = "mizan-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
|
||||||
"hmac",
|
|
||||||
"linkme",
|
"linkme",
|
||||||
|
"minijinja",
|
||||||
"mizan-macros",
|
"mizan-macros",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1799,12 +1825,10 @@ dependencies = [
|
|||||||
name = "mizan-tauri"
|
name = "mizan-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
|
||||||
"mizan-core",
|
"mizan-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,7 +1859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -1849,7 +1873,7 @@ version = "0.6.0+11769913"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jni-sys",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2460,9 +2484,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2901,12 +2925,6 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3359,21 +3377,9 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-macros"
|
|
||||||
version = "2.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3796,9 +3802,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3809,9 +3815,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.72"
|
version = "0.4.71"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -3819,9 +3825,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -3829,9 +3835,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3842,9 +3848,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -3898,9 +3904,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -10,8 +10,3 @@ mizan-core = { path = "../../cores/mizan-rust" }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tauri = { version = "2", features = ["test"] }
|
|
||||||
tokio = { version = "1", features = ["rt", "macros"] }
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ distributed slice's entries) and prints `mizan_core::build_ir()`:
|
|||||||
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
|
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
|
||||||
// keeps them in the final binary.
|
// keeps them in the final binary.
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn _force_link() {
|
fn _force_link() {
|
||||||
use my_app_lib::commands;
|
use my_app_lib::commands;
|
||||||
let _ = commands::greet;
|
let _ = commands::greet;
|
||||||
@@ -216,11 +215,11 @@ const greeting = await callGreet({ name: "world" });
|
|||||||
console.log(greeting.message);
|
console.log(greeting.message);
|
||||||
```
|
```
|
||||||
|
|
||||||
For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the
|
For the framework hooks the `react` target generates (`useGreet()` etc.,
|
||||||
imperative `callGreet` with `isPending`/`error` state), wrap your tree
|
wrapping the imperative `callGreet` with `isPending`/`error` state), wrap
|
||||||
with `<MizanContext>` at the root — same as the HTTP-transport setup. The
|
your tree with `<MizanContext>` at the root — same as the HTTP-transport
|
||||||
generated provider is transport-agnostic; it reads from `config.transport`
|
setup. The generated provider is transport-agnostic; it reads from
|
||||||
the kernel is using.
|
`config.transport` the kernel is using.
|
||||||
|
|
||||||
### tsconfig / vite preserve symlinks
|
### tsconfig / vite preserve symlinks
|
||||||
|
|
||||||
@@ -274,12 +273,6 @@ Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport`
|
|||||||
re-wraps them into the same `MizanError` shape the HTTP transport
|
re-wraps them into the same `MizanError` shape the HTTP transport
|
||||||
produces, so consumer code is identical regardless of transport.
|
produces, so consumer code is identical regardless of transport.
|
||||||
|
|
||||||
## Reference application
|
|
||||||
|
|
||||||
`claude-manage` is the production reference — Tauri + React + Pydantic
|
|
||||||
schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and
|
|
||||||
`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
|
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
|
||||||
|
|||||||
@@ -1,146 +1,79 @@
|
|||||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||||
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
|
|
||||||
//!
|
//!
|
||||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
//! Ships as a Tauri plugin:
|
||||||
//!
|
//!
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! tauri::Builder::default()
|
//! tauri::Builder::default()
|
||||||
//! .plugin(mizan_tauri::init())
|
//! .plugin(mizan_tauri::init())
|
||||||
//! .run(tauri::generate_context!())
|
//! .run(tauri::generate_context!())
|
||||||
//! .expect("error while running tauri application");
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The plugin exposes commands reachable from the JS-side
|
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||||
//! `@mizan/tauri-transport`:
|
//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
|
||||||
|
//! FUNCTIONS / CONTEXTS registries. There is no per-function
|
||||||
|
//! `tauri::command`; the registry IS the dispatch table.
|
||||||
//!
|
//!
|
||||||
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
|
//! Wire envelope:
|
||||||
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
|
|
||||||
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
|
|
||||||
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
|
|
||||||
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
|
|
||||||
//! Tauri `Channel<T>` carries the push stream instead.
|
|
||||||
//!
|
|
||||||
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
|
|
||||||
//!
|
//!
|
||||||
//! ```json
|
//! ```json
|
||||||
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
|
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||||
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
|
//! { "op": "fetch", "context": "session", "params": {} }
|
||||||
//! { "op": "shape", "fn": "user_profile" }
|
|
||||||
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Response shapes mirror the HTTP adapter:
|
//! Response shapes:
|
||||||
//!
|
//!
|
||||||
//! * `call` → `{ result, invalidate, merge? }`
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
//! * `shape` → `{ type, fields }`
|
|
||||||
//! * `form` → the form function's result
|
|
||||||
//!
|
|
||||||
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
|
|
||||||
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
|
|
||||||
//! `authenticate` and enforced against each function's `auth=` requirement.
|
|
||||||
//! There is no header channel over IPC, so the token rides the envelope.
|
|
||||||
//!
|
//!
|
||||||
//! Errors come back as the `Err` variant of the command's `Result`, which
|
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||||
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
|
//! Tauri serializes into the JS-side `Promise.reject`.
|
||||||
//! into a `MizanError`.
|
|
||||||
|
|
||||||
mod ssr;
|
|
||||||
|
|
||||||
pub use ssr::{ssr_render, MizanSsr};
|
|
||||||
|
|
||||||
use mizan_core::{
|
use mizan_core::{
|
||||||
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
|
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||||
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
|
InvalidationTarget, MergeEntry, MizanError, RequestHandle,
|
||||||
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use tauri::ipc::Channel;
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Manager, Runtime,
|
Runtime,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
|
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||||
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
|
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||||
/// dispatch commands read it from managed state.
|
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||||
pub struct MizanTauriConfig {
|
|
||||||
pub auth: AuthConfig,
|
|
||||||
pub cache: CacheOrchestrator,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MizanTauriConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
auth: AuthConfig::new(),
|
|
||||||
cache: CacheOrchestrator::disabled(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
|
|
||||||
/// Registers a default (auth-off, cache-disabled) config if the consumer
|
|
||||||
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
|
|
||||||
/// and `plugin:mizan|mizan_subscribe`.
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::<R>::new("mizan")
|
Builder::<R>::new("mizan")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||||
mizan_invoke,
|
|
||||||
mizan_subscribe,
|
|
||||||
ssr::ssr_render
|
|
||||||
])
|
|
||||||
.setup(|app, _api| {
|
|
||||||
if app.try_state::<MizanTauriConfig>().is_none() {
|
|
||||||
app.manage(MizanTauriConfig::default());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Wire envelope ===
|
// === Wire envelope ===
|
||||||
|
|
||||||
/// One Mizan request. Tauri's serde deserializer pulls this out of the
|
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||||
/// `envelope` field of the invoke payload.
|
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||||
|
/// field of the invoke payload.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "op")]
|
#[serde(tag = "op")]
|
||||||
pub enum Envelope {
|
pub enum Envelope {
|
||||||
#[serde(rename = "call")]
|
#[serde(rename = "call")]
|
||||||
Call {
|
Call {
|
||||||
|
/// Wire-level function name — registered name on the Rust side.
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
function_name: String,
|
function_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
args: Map<String, Value>,
|
args: Map<String, Value>,
|
||||||
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
|
|
||||||
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
|
|
||||||
#[serde(default)]
|
|
||||||
token: Option<String>,
|
|
||||||
},
|
},
|
||||||
#[serde(rename = "fetch")]
|
#[serde(rename = "fetch")]
|
||||||
Fetch {
|
Fetch {
|
||||||
context: String,
|
context: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
params: Map<String, Value>,
|
params: Map<String, Value>,
|
||||||
#[serde(default)]
|
|
||||||
token: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "shape")]
|
|
||||||
Shape {
|
|
||||||
#[serde(rename = "fn")]
|
|
||||||
function_name: String,
|
|
||||||
},
|
|
||||||
#[serde(rename = "form")]
|
|
||||||
Form {
|
|
||||||
form: String,
|
|
||||||
role: String,
|
|
||||||
#[serde(default)]
|
|
||||||
args: Value,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
/// Error payload returned to the frontend. The JS-side transport reads
|
||||||
/// `{"code", "message", "details?"}` shape.
|
/// `code` / `message` / `details` and constructs a `MizanError`.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ErrorPayload {
|
pub struct ErrorPayload {
|
||||||
pub code: &'static str,
|
pub code: &'static str,
|
||||||
@@ -164,384 +97,102 @@ impl From<MizanError> for ErrorPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Auth ===
|
// === Dispatch ===
|
||||||
|
|
||||||
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
|
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||||
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
|
/// handler — the consumer never wires it directly.
|
||||||
/// `INVALID`-sentinel contract); absent → anonymous.
|
///
|
||||||
fn identity_from_token(
|
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||||
token: Option<&str>,
|
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
config: &MizanTauriConfig,
|
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||||
) -> Result<Option<Identity>, MizanError> {
|
/// emission. Stateless functions ignore the handle.
|
||||||
let (mwt, bearer) = match token {
|
///
|
||||||
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
|
/// Each arm selects the registrations its envelope names and matches over
|
||||||
Some(t) => (Some(t), None),
|
/// the two shapes that selection has. Both shapes are ordinary: the JS side
|
||||||
None => (None, None),
|
/// picks the string, so `[]` is the selection a string nothing registered
|
||||||
};
|
/// under makes, and it is answered with the NOT_FOUND envelope.
|
||||||
match authenticate(mwt, bearer, &config.auth, now_unix()) {
|
|
||||||
AuthOutcome::Authenticated(id) => Ok(Some(id)),
|
|
||||||
AuthOutcome::Anonymous => Ok(None),
|
|
||||||
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
|
|
||||||
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Dispatch commands ===
|
|
||||||
|
|
||||||
/// The single Mizan request/response command. Tauri auto-injects `app`; the
|
|
||||||
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
|
|
||||||
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn mizan_invoke<R: Runtime>(
|
async fn mizan_invoke<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
envelope: Envelope,
|
envelope: Envelope,
|
||||||
) -> Result<Value, ErrorPayload> {
|
) -> Result<Value, ErrorPayload> {
|
||||||
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
|
|
||||||
/// response (or a `MizanError`). This is the programmatic entry point the
|
|
||||||
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
|
|
||||||
/// tests) can drive the Mizan protocol without the IPC serialization layer.
|
|
||||||
pub async fn dispatch<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
envelope: Envelope,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
// Read the managed config (lifetime-bound to `app`, which outlives this
|
|
||||||
// dispatch); fall back to a default if none was registered. The `State`
|
|
||||||
// guard is held across the awaits below.
|
|
||||||
let managed = app.try_state::<MizanTauriConfig>();
|
|
||||||
let default;
|
|
||||||
let cfg: &MizanTauriConfig = match managed.as_ref() {
|
|
||||||
Some(state) => state.inner(),
|
|
||||||
None => {
|
|
||||||
default = MizanTauriConfig::default();
|
|
||||||
&default
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match envelope {
|
match envelope {
|
||||||
Envelope::Call {
|
Envelope::Call {
|
||||||
function_name,
|
function_name,
|
||||||
args,
|
args,
|
||||||
token,
|
} => {
|
||||||
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
|
let registered = function_named(&function_name);
|
||||||
Envelope::Fetch {
|
let fn_spec = match registered.as_slice() {
|
||||||
context,
|
[] => {
|
||||||
params,
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
token,
|
"function {function_name:?} not registered"
|
||||||
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
|
))))
|
||||||
Envelope::Shape { function_name } => handle_shape(&function_name),
|
}
|
||||||
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
|
[fn_spec, ..] => *fn_spec,
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_call<R: Runtime>(
|
let req = RequestHandle::new(&app);
|
||||||
app: &tauri::AppHandle<R>,
|
match fn_spec.dispatch(req, Value::Object(args.clone())).await {
|
||||||
cfg: &MizanTauriConfig,
|
Ok(result) => Ok(call_payload(fn_spec, &args, result)),
|
||||||
fn_name: &str,
|
Err(e) => Err(ErrorPayload::from(e)),
|
||||||
mut args: Map<String, Value>,
|
|
||||||
token: Option<&str>,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
let identity = identity_from_token(token, cfg)?;
|
|
||||||
|
|
||||||
let fn_spec = lookup_function(fn_name)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
|
||||||
}
|
|
||||||
guard(fn_spec, identity.as_ref())?;
|
|
||||||
|
|
||||||
// Bind any file parts the envelope carries into the call args (see
|
|
||||||
// `bind_uploads`).
|
|
||||||
bind_uploads(fn_spec, &mut args)?;
|
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
|
||||||
|
|
||||||
let targets = compute_invalidation(fn_spec, &args);
|
|
||||||
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
|
|
||||||
let merges = compute_merges(fn_spec, &args, &result);
|
|
||||||
|
|
||||||
// Purge the origin cache for everything this mutation invalidated.
|
|
||||||
if !targets.is_empty() {
|
|
||||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
|
||||||
cfg.cache.purge(&targets, uid.as_deref());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut payload = json!({ "result": result, "invalidate": invalidate });
|
|
||||||
if !merges.is_empty() {
|
|
||||||
payload.as_object_mut().unwrap().insert(
|
|
||||||
"merge".into(),
|
|
||||||
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_fetch<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
cfg: &MizanTauriConfig,
|
|
||||||
context_name: &str,
|
|
||||||
params: Map<String, Value>,
|
|
||||||
token: Option<&str>,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
let identity = identity_from_token(token, cfg)?;
|
|
||||||
|
|
||||||
if lookup_context(context_name).is_none() {
|
|
||||||
return Err(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} not registered"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|f| f.context() == Some(context_name))
|
|
||||||
.collect();
|
|
||||||
if members.is_empty() {
|
|
||||||
return Err(MizanError::NotFound(format!(
|
|
||||||
"context {context_name:?} has no registered members"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Origin cache: a desktop shell still benefits from memoizing a context
|
|
||||||
// bundle by (context, params, user). Key the params as JSON values.
|
|
||||||
let cache_params: std::collections::BTreeMap<String, Value> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
let uid = identity.as_ref().map(|i| i.user_id.clone());
|
|
||||||
|
|
||||||
if let Some(cached) = cfg
|
|
||||||
.cache
|
|
||||||
.get(context_name, &cache_params, uid.as_deref(), 0)
|
|
||||||
{
|
|
||||||
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
|
|
||||||
return Ok(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bundled = Map::new();
|
|
||||||
for fn_spec in &members {
|
|
||||||
guard(*fn_spec, identity.as_ref())?;
|
|
||||||
let args = filter_args(*fn_spec, ¶ms);
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
|
|
||||||
bundled.insert(fn_spec.name().to_string(), result);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = Value::Object(bundled);
|
|
||||||
if cfg.cache.enabled() {
|
|
||||||
let bytes = serde_json::to_vec(&body).unwrap();
|
|
||||||
cfg.cache
|
|
||||||
.put(context_name, &cache_params, bytes, uid.as_deref(), 0);
|
|
||||||
}
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `shape` op — the typed query projection for a function's output, derived by
|
|
||||||
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
|
|
||||||
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
|
|
||||||
let proj = shapes::project_function_output(fn_name)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
|
|
||||||
Ok(projection_to_json(&proj))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
|
|
||||||
let mut fields = Vec::new();
|
|
||||||
for f in &proj.fields {
|
|
||||||
match f {
|
|
||||||
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
|
|
||||||
shapes::ShapeField::Nested(n, sub) => {
|
|
||||||
fields.push(json!({ n.clone(): projection_to_json(sub) }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Envelope::Fetch { context, params } => {
|
||||||
json!({ "type": proj.type_name, "fields": fields })
|
let members = context_members(&context);
|
||||||
}
|
let selected = match members.as_slice() {
|
||||||
|
[] => {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"context {context:?} names no registered functions"
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
selected => selected,
|
||||||
|
};
|
||||||
|
|
||||||
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
|
let mut bundled = Map::new();
|
||||||
/// Forms binding). `form_validate` / `form_submit` map to the registered
|
for fn_spec in selected {
|
||||||
/// function whose `(form_name, form_role)` matches.
|
let args = filter_args(*fn_spec, ¶ms);
|
||||||
async fn handle_form<R: Runtime>(
|
let req = RequestHandle::new(&app);
|
||||||
app: &tauri::AppHandle<R>,
|
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||||
form_name: &str,
|
Ok(result) => {
|
||||||
role: &str,
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
args: Value,
|
}
|
||||||
) -> Result<Value, MizanError> {
|
Err(e) => return Err(ErrorPayload::from(e)),
|
||||||
match role {
|
}
|
||||||
"schema" => form_schema(app, form_name).await,
|
}
|
||||||
"validate" => form_validate(app, form_name, args).await,
|
|
||||||
"submit" => form_submit(app, form_name, args).await,
|
Ok(Value::Object(bundled))
|
||||||
other => Err(MizanError::BadRequest(format!(
|
}
|
||||||
"unknown form role {other:?} (expected schema|validate|submit)"
|
|
||||||
))),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
|
/// The `call` response body — the handler's result alongside the
|
||||||
FUNCTIONS
|
/// invalidation targets and merge entries the registry derives from the
|
||||||
.iter()
|
/// arguments and that result.
|
||||||
.copied()
|
fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
|
||||||
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch_form_role<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
role: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
let fn_spec = lookup_form_fn(form_name, role)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
|
|
||||||
let args_value = match args {
|
|
||||||
Value::Object(_) | Value::Null => args,
|
|
||||||
other => json!({ "data": other }),
|
|
||||||
};
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
fn_spec.dispatch(req, args_value).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_schema<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "schema", Value::Null).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_validate<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "validate", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn form_submit<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
form_name: &str,
|
|
||||||
args: Value,
|
|
||||||
) -> Result<Value, MizanError> {
|
|
||||||
dispatch_form_role(app, form_name, "submit", args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === WebSocket-equivalent: IPC subscription channel ===
|
|
||||||
|
|
||||||
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
pub struct SubscriptionFrame {
|
|
||||||
pub result: Value,
|
|
||||||
pub invalidate: Vec<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
|
|
||||||
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
|
|
||||||
/// the push stream instead — the IPC transport's co-equal of the HTTP
|
|
||||||
/// WebSocket. The initial dispatch result is emitted immediately on the
|
|
||||||
/// channel; subsequent server-side pushes use the same `on_event` channel.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn mizan_subscribe<R: Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
function_name: String,
|
|
||||||
args: Map<String, Value>,
|
|
||||||
on_event: Channel<SubscriptionFrame>,
|
|
||||||
) -> Result<(), ErrorPayload> {
|
|
||||||
subscribe(&app, &function_name, args, on_event)
|
|
||||||
.await
|
|
||||||
.map_err(ErrorPayload::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
|
|
||||||
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
|
|
||||||
/// wraps — exposed for embedders and behavior tests.
|
|
||||||
pub async fn subscribe<R: Runtime>(
|
|
||||||
app: &tauri::AppHandle<R>,
|
|
||||||
function_name: &str,
|
|
||||||
args: Map<String, Value>,
|
|
||||||
on_event: Channel<SubscriptionFrame>,
|
|
||||||
) -> Result<(), MizanError> {
|
|
||||||
let fn_spec = lookup_function(function_name)
|
|
||||||
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
|
|
||||||
if fn_spec.private() {
|
|
||||||
return Err(MizanError::Forbidden("Function is not client-callable".into()));
|
|
||||||
}
|
|
||||||
// Only `#[mizan(websocket)]` functions are exposed over the subscription
|
|
||||||
// channel — the same transport boundary the HTTP WebSocket enforces.
|
|
||||||
if !matches!(
|
|
||||||
fn_spec.transport(),
|
|
||||||
mizan_core::Transport::Websocket | mizan_core::Transport::Both
|
|
||||||
) {
|
|
||||||
return Err(MizanError::BadRequest(format!(
|
|
||||||
"function {function_name:?} is not exposed over the subscription transport"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let req = RequestHandle::new(app);
|
|
||||||
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
|
|
||||||
let invalidate = compute_invalidation(fn_spec, &args)
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(InvalidationTarget::to_json)
|
.map(InvalidationTarget::to_json)
|
||||||
.collect();
|
.collect();
|
||||||
|
let merges = compute_merges(fn_spec, args, &result);
|
||||||
|
|
||||||
on_event
|
let mut payload = Map::new();
|
||||||
.send(SubscriptionFrame { result, invalidate })
|
payload.insert("result".into(), result);
|
||||||
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
|
payload.insert("invalidate".into(), Value::Array(invalidate));
|
||||||
Ok(())
|
if !merges.is_empty() {
|
||||||
|
let entries: Vec<Value> = merges.iter().map(MergeEntry::to_json).collect();
|
||||||
|
payload.insert("merge".into(), Value::Array(entries));
|
||||||
|
}
|
||||||
|
Value::Object(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Helpers ===
|
/// The envelope's params narrowed to the keys this function declares as
|
||||||
|
/// input. The Tauri arg channel already carries typed JSON, so no
|
||||||
/// Filter the envelope's params down to keys this function declares as input.
|
/// string-to-primitive coercion is needed here.
|
||||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||||
let mut out = Map::new();
|
let declared = fn_spec.input_params();
|
||||||
for ip in fn_spec.input_params() {
|
params
|
||||||
if let Some(v) = params.get(ip.name) {
|
.iter()
|
||||||
out.insert(ip.name.into(), v.clone());
|
.filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str()))
|
||||||
}
|
.map(|(name, value)| (name.clone(), value.clone()))
|
||||||
}
|
.collect()
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind file parts carried in the IPC envelope into the call args.
|
|
||||||
///
|
|
||||||
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
|
|
||||||
/// JSON object `{filename, content_type, data_b64}` (the JS transport
|
|
||||||
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
|
|
||||||
/// deserializes, so for a single file the arg is already in place. This binder
|
|
||||||
/// performs the one transform IPC needs: a top-level `_files` map
|
|
||||||
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
|
|
||||||
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
|
|
||||||
/// also validates that anything presenting as a file carries `data_b64`,
|
|
||||||
/// surfacing a clear error before the typed `Upload` deserialize runs.
|
|
||||||
fn bind_uploads(
|
|
||||||
fn_spec: &dyn FunctionSpec,
|
|
||||||
args: &mut Map<String, Value>,
|
|
||||||
) -> Result<(), MizanError> {
|
|
||||||
if let Some(Value::Object(files)) = args.remove("_files") {
|
|
||||||
for (field, parts) in files {
|
|
||||||
args.insert(field, parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The set of param names this function declares — only validate args that
|
|
||||||
// could land in a typed field.
|
|
||||||
let declared: std::collections::HashSet<&str> =
|
|
||||||
fn_spec.input_params().iter().map(|p| p.name).collect();
|
|
||||||
for (name, value) in args.iter() {
|
|
||||||
if !declared.contains(name.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Value::Object(obj) = value {
|
|
||||||
let looks_like_file =
|
|
||||||
obj.contains_key("filename") || obj.contains_key("content_type");
|
|
||||||
if looks_like_file && !obj.contains_key("data_b64") {
|
|
||||||
return Err(MizanError::BadRequest(format!(
|
|
||||||
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
//! SSR over the IPC transport — drive the Bun renderer through the shared
|
|
||||||
//! `mizan_core::SsrBridge` (the same newline-delimited JSON-RPC protocol the
|
|
||||||
//! Django/FastAPI/axum adapters use). A desktop shell renders React the same
|
|
||||||
//! way the server does: spawn the Bun worker once, drive `renderToString`
|
|
||||||
//! through it, keep it alive.
|
|
||||||
//!
|
|
||||||
//! Exposed as a Tauri command + a managed `MizanSsr` holding the bridge:
|
|
||||||
//!
|
|
||||||
//! invoke('plugin:mizan|ssr_render', { file: '/abs/X.tsx', props: {...} })
|
|
||||||
//! → { html: "<div>...</div>" }
|
|
||||||
|
|
||||||
use mizan_core::SsrBridge;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tauri::{Manager, Runtime};
|
|
||||||
|
|
||||||
use crate::ErrorPayload;
|
|
||||||
|
|
||||||
/// Managed SSR state — holds the persistent Bun bridge. Register it with
|
|
||||||
/// `app.manage(MizanSsr::new("path/to/worker.tsx"))` to enable `ssr_render`.
|
|
||||||
pub struct MizanSsr {
|
|
||||||
bridge: Arc<SsrBridge>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MizanSsr {
|
|
||||||
/// Build an SSR state that launches `bun run <worker_path>` on first render.
|
|
||||||
pub fn new(worker_path: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
bridge: Arc::new(SsrBridge::bun(worker_path)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shared `mizan_core` SSR bridge backing this state — the persistent
|
|
||||||
/// Bun subprocess that runs `renderToString` over JSON-RPC. Exposed so a
|
|
||||||
/// consumer can render directly (e.g. PSR re-render on mutation) without
|
|
||||||
/// going through the `ssr_render` IPC command.
|
|
||||||
pub fn ssr_bridge(&self) -> &SsrBridge {
|
|
||||||
&self.bridge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct SsrResult {
|
|
||||||
pub html: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ssr_render` — render a component file to HTML via the Bun SSR worker.
|
|
||||||
/// Requires a managed `MizanSsr` (else returns a NOT_IMPLEMENTED error).
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn ssr_render<R: Runtime>(
|
|
||||||
app: tauri::AppHandle<R>,
|
|
||||||
file: String,
|
|
||||||
props: Option<Value>,
|
|
||||||
) -> Result<SsrResult, ErrorPayload> {
|
|
||||||
let state = app.try_state::<MizanSsr>().ok_or_else(|| {
|
|
||||||
ErrorPayload::from(mizan_core::MizanError::NotImplementedYet(
|
|
||||||
"no SSR worker configured (app.manage(MizanSsr::new(...)))".into(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let bridge = state.bridge.clone();
|
|
||||||
let props = props.unwrap_or_else(|| serde_json::json!({}));
|
|
||||||
let html = bridge
|
|
||||||
.render(&file, props)
|
|
||||||
.map_err(|e| ErrorPayload::from(mizan_core::MizanError::InternalError(e.to_string())))?;
|
|
||||||
Ok(SsrResult { html })
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling
|
|
||||||
//! over the source-presence probes. Each IPC-applicable cell is driven through
|
|
||||||
//! the real dispatch path against a mock Tauri `AppHandle`
|
|
||||||
//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel
|
|
||||||
//! frames. The IPC serialization boundary is exercised by Tauri's own
|
|
||||||
//! `get_ipc_response` machinery in integration; here we drive `dispatch` /
|
|
||||||
//! `subscribe` (the programmatic entry points the commands wrap) so the
|
|
||||||
//! protocol logic — auth, cache, upload binding, shapes, forms, subscription —
|
|
||||||
//! is asserted directly.
|
|
||||||
|
|
||||||
use mizan_core as mizan;
|
|
||||||
use mizan_core::prelude::*;
|
|
||||||
use mizan_core::{
|
|
||||||
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
|
|
||||||
};
|
|
||||||
use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tauri::ipc::Channel;
|
|
||||||
use tauri::test::mock_app;
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
|
|
||||||
// ─── Fixture functions (auto-registered via linkme at link time) ────────────
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct TProfile {
|
|
||||||
pub user_id: i64,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct TOk {
|
|
||||||
pub ok: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct TSecret {
|
|
||||||
pub flag: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct TUploadEcho {
|
|
||||||
pub filename: String,
|
|
||||||
pub size: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::context("tprofile")]
|
|
||||||
pub struct TProfileCtx;
|
|
||||||
|
|
||||||
#[mizan::client(context = TProfileCtx)]
|
|
||||||
pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile {
|
|
||||||
TProfile {
|
|
||||||
user_id,
|
|
||||||
name: format!("user-{user_id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(affects = TProfileCtx)]
|
|
||||||
pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk {
|
|
||||||
let _ = (user_id, name);
|
|
||||||
TOk { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(auth = "staff")]
|
|
||||||
pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret {
|
|
||||||
TSecret {
|
|
||||||
flag: "ipc-secret".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(websocket)]
|
|
||||||
pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk {
|
|
||||||
let _ = room;
|
|
||||||
TOk { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client]
|
|
||||||
pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho {
|
|
||||||
let _ = user_id;
|
|
||||||
TUploadEcho {
|
|
||||||
filename: avatar.filename.clone().unwrap_or_default(),
|
|
||||||
size: avatar.size() as i64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[mizan::client(form_name = "tcontact", form_role = "submit")]
|
|
||||||
pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk {
|
|
||||||
let _ = name;
|
|
||||||
TOk { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Harness ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build a mock app with the given Mizan config managed.
|
|
||||||
fn app_with(config: MizanTauriConfig) -> AppHandle<tauri::test::MockRuntime> {
|
|
||||||
let app = mock_app();
|
|
||||||
let handle = app.handle().clone();
|
|
||||||
handle.manage(config);
|
|
||||||
// Leak the app so its `AppHandle` stays valid for the test body; the
|
|
||||||
// process tears down at test end.
|
|
||||||
std::mem::forget(app);
|
|
||||||
handle
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rt() -> tokio::runtime::Runtime {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── rpc_call + invalidate_body ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn call_returns_result_and_invalidate() {
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
rt().block_on(async {
|
|
||||||
let env = Envelope::Call {
|
|
||||||
function_name: "t_update_profile".into(),
|
|
||||||
args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]),
|
|
||||||
token: None,
|
|
||||||
};
|
|
||||||
let resp = dispatch(&handle, env).await.unwrap();
|
|
||||||
assert_eq!(resp["result"], json!({"ok": true}));
|
|
||||||
// IPC carries invalidation in the envelope (no header channel).
|
|
||||||
assert_eq!(
|
|
||||||
resp["invalidate"],
|
|
||||||
json!([{"context": "tprofile", "params": {"user_id": 7}}])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── auth_enforcement ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auth_guard_over_ipc() {
|
|
||||||
rt().block_on(async {
|
|
||||||
// No auth config → anonymous → staff-guarded fn rejected.
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
let err = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Call {
|
|
||||||
function_name: "t_secret".into(),
|
|
||||||
args: Map::new(),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
|
||||||
|
|
||||||
// Staff JWT on the envelope token → admitted.
|
|
||||||
let cfg = JwtConfig::new("ipc-secret");
|
|
||||||
let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix());
|
|
||||||
let config = MizanTauriConfig {
|
|
||||||
auth: AuthConfig {
|
|
||||||
jwt: Some(cfg),
|
|
||||||
mwt_secret: None,
|
|
||||||
mwt_audience: "mizan".into(),
|
|
||||||
},
|
|
||||||
cache: CacheOrchestrator::disabled(),
|
|
||||||
};
|
|
||||||
let handle = app_with(config);
|
|
||||||
let resp = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Call {
|
|
||||||
function_name: "t_secret".into(),
|
|
||||||
args: Map::new(),
|
|
||||||
token: Some(format!("Bearer {token}")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp["result"]["flag"], json!("ipc-secret"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_token_rejected_over_ipc() {
|
|
||||||
rt().block_on(async {
|
|
||||||
let config = MizanTauriConfig {
|
|
||||||
auth: AuthConfig {
|
|
||||||
jwt: Some(JwtConfig::new("ipc-secret")),
|
|
||||||
mwt_secret: None,
|
|
||||||
mwt_audience: "mizan".into(),
|
|
||||||
},
|
|
||||||
cache: CacheOrchestrator::disabled(),
|
|
||||||
};
|
|
||||||
let handle = app_with(config);
|
|
||||||
let err = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Fetch {
|
|
||||||
context: "tprofile".into(),
|
|
||||||
params: obj(&[("user_id", json!(1))]),
|
|
||||||
token: Some("Bearer garbage".into()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── origin_cache ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_uses_origin_cache() {
|
|
||||||
rt().block_on(async {
|
|
||||||
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
|
|
||||||
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into()));
|
|
||||||
let config = MizanTauriConfig {
|
|
||||||
auth: AuthConfig::new(),
|
|
||||||
cache,
|
|
||||||
};
|
|
||||||
let handle = app_with(config);
|
|
||||||
|
|
||||||
let fetch = || Envelope::Fetch {
|
|
||||||
context: "tprofile".into(),
|
|
||||||
params: obj(&[("user_id", json!(3))]),
|
|
||||||
token: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let first = dispatch(&handle, fetch()).await.unwrap();
|
|
||||||
assert_eq!(first["t_user_profile"]["user_id"], json!(3));
|
|
||||||
|
|
||||||
// The cache now holds the bundle — confirm a key exists under the
|
|
||||||
// context prefix (proves the put happened).
|
|
||||||
let key = mizan::derive_cache_key(
|
|
||||||
"ipc-cache-secret",
|
|
||||||
"tprofile",
|
|
||||||
&std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]),
|
|
||||||
None,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(backend.get(&key).is_some(), "fetch populated the origin cache");
|
|
||||||
|
|
||||||
// Second fetch returns the same bundle (served from cache).
|
|
||||||
let second = dispatch(&handle, fetch()).await.unwrap();
|
|
||||||
assert_eq!(first, second);
|
|
||||||
|
|
||||||
// A scoped mutation purges the key.
|
|
||||||
let _ = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Call {
|
|
||||||
function_name: "t_update_profile".into(),
|
|
||||||
args: obj(&[("user_id", json!(3)), ("name", json!("New"))]),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(backend.get(&key).is_none(), "mutation purged the cache key");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── upload ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upload_binds_from_envelope() {
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
|
||||||
use base64::Engine;
|
|
||||||
rt().block_on(async {
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
let data = b"IPC-FILE-BYTES";
|
|
||||||
let file = json!({
|
|
||||||
"filename": "a.png",
|
|
||||||
"content_type": "image/png",
|
|
||||||
"data_b64": STANDARD.encode(data),
|
|
||||||
});
|
|
||||||
let resp = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Call {
|
|
||||||
function_name: "t_set_avatar".into(),
|
|
||||||
args: obj(&[("user_id", json!(9)), ("avatar", file)]),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp["result"]["filename"], json!("a.png"));
|
|
||||||
assert_eq!(resp["result"]["size"], json!(data.len()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── shapes ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shape_op_projects_output() {
|
|
||||||
rt().block_on(async {
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
let resp = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Shape {
|
|
||||||
function_name: "t_user_profile".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp["type"], json!("tUserProfileOutput"));
|
|
||||||
let fields = resp["fields"].as_array().unwrap();
|
|
||||||
assert!(fields.contains(&json!("user_id")));
|
|
||||||
assert!(fields.contains(&json!("name")));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── forms ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn form_submit_op() {
|
|
||||||
rt().block_on(async {
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
let resp = dispatch(
|
|
||||||
&handle,
|
|
||||||
Envelope::Form {
|
|
||||||
form: "tcontact".into(),
|
|
||||||
role: "submit".into(),
|
|
||||||
args: json!({"name": "Ada"}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp, json!({"ok": true}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── websocket-equivalent: subscription channel ──────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn subscription_pushes_frame_and_rejects_non_ws_fn() {
|
|
||||||
rt().block_on(async {
|
|
||||||
let handle = app_with(MizanTauriConfig::default());
|
|
||||||
|
|
||||||
// A websocket-declared fn pushes a frame on the channel.
|
|
||||||
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let sink = captured.clone();
|
|
||||||
let channel: Channel<SubscriptionFrame> = Channel::new(move |body| {
|
|
||||||
// The channel serializes the SubscriptionFrame to JSON; read it
|
|
||||||
// back as a generic Value.
|
|
||||||
let v: Value = body.deserialize().unwrap_or(Value::Null);
|
|
||||||
sink.lock().unwrap().push(v);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let frames = captured.lock().unwrap();
|
|
||||||
assert_eq!(frames.len(), 1, "subscription pushed exactly one frame");
|
|
||||||
assert_eq!(frames[0]["result"], json!({"ok": true}));
|
|
||||||
|
|
||||||
// A non-websocket fn over the subscription transport is rejected.
|
|
||||||
let reject_channel: Channel<SubscriptionFrame> = Channel::new(|_| Ok(()));
|
|
||||||
let err = subscribe(
|
|
||||||
&handle,
|
|
||||||
"t_user_profile",
|
|
||||||
obj(&[("user_id", json!(1))]),
|
|
||||||
reject_channel,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.message().contains("subscription transport"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
|
||||||
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
|
||||||
}
|
|
||||||
@@ -5,31 +5,15 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@mizan/ts",
|
"name": "@mizan/ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"react": "^19",
|
|
||||||
"react-dom": "^19",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19",
|
"bun-types": "latest"
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"bun-types": "latest",
|
|
||||||
"react": "^19",
|
|
||||||
"react-dom": "^19"
|
|
||||||
},
|
},
|
||||||
"license": "Elastic-2.0"
|
"license": "Elastic-2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
7
backends/mizan-ts/src/cache/backend.ts
vendored
7
backends/mizan-ts/src/cache/backend.ts
vendored
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Cache backends — MemoryCache for testing.
|
* A cache backend is a flat string-to-string store.
|
||||||
*
|
*
|
||||||
* Simple key-value store. No reverse indexes.
|
* There is no reverse index from a context to its keys, so `deleteByPrefix` is
|
||||||
|
* what a broad purge relies on and every backend owes it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CacheBackend {
|
export interface CacheBackend {
|
||||||
get(key: string): string | null
|
get(key: string): string | null
|
||||||
set(key: string, value: string): void
|
set(key: string, value: string): void
|
||||||
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
|
|||||||
|
|
||||||
deleteByPrefix(prefix: string): number {
|
deleteByPrefix(prefix: string): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
// Snapshot the keys — deleting while iterating the live view is UB.
|
||||||
for (const key of [...this._store.keys()]) {
|
for (const key of [...this._store.keys()]) {
|
||||||
if (key.startsWith(prefix)) {
|
if (key.startsWith(prefix)) {
|
||||||
this._store.delete(key)
|
this._store.delete(key)
|
||||||
|
|||||||
17
backends/mizan-ts/src/cache/index.ts
vendored
17
backends/mizan-ts/src/cache/index.ts
vendored
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* 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 { MemoryCache } from './backend'
|
||||||
export type { CacheBackend } from './backend'
|
export type { CacheBackend } from './backend'
|
||||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||||
@@ -52,6 +44,13 @@ export function cachePut(
|
|||||||
backend.set(key, value)
|
backend.set(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached entries for a context. Returns the number removed.
|
||||||
|
*
|
||||||
|
* With params and a secret the exact key is recomputed and dropped; without
|
||||||
|
* them every key carrying the context prefix is scanned and dropped. There is
|
||||||
|
* no reverse index from a context to its keys, so those are the only two forms.
|
||||||
|
*/
|
||||||
export function cachePurge(
|
export function cachePurge(
|
||||||
backend: CacheBackend,
|
backend: CacheBackend,
|
||||||
context: string,
|
context: string,
|
||||||
@@ -61,11 +60,9 @@ export function cachePurge(
|
|||||||
rev: number = 0,
|
rev: number = 0,
|
||||||
): number {
|
): number {
|
||||||
if (params && secret) {
|
if (params && secret) {
|
||||||
// Scoped purge — recompute key and delete directly
|
|
||||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||||
return backend.delete(key) ? 1 : 0
|
return backend.delete(key) ? 1 : 0
|
||||||
} else {
|
} else {
|
||||||
// Broad purge — prefix scan
|
|
||||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||||
return backend.deleteByPrefix(prefix)
|
return backend.deleteByPrefix(prefix)
|
||||||
}
|
}
|
||||||
|
|||||||
14
backends/mizan-ts/src/cache/keys.ts
vendored
14
backends/mizan-ts/src/cache/keys.ts
vendored
@@ -1,10 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
* Cache key derivation — HMAC-SHA256 over a JSON-canonical form.
|
||||||
*
|
*
|
||||||
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
|
||||||
* Cross-language conformance verified by pin tests.
|
* broad purge run as a prefix scan over the backend's keyspace.
|
||||||
*
|
|
||||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac } from 'crypto'
|
||||||
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
*
|
||||||
|
* Hand-rolled rather than JSON.stringify because the bytes must match
|
||||||
|
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
|
||||||
|
* a key derived here is looked up by the Python side under the same secret,
|
||||||
|
* so any serialization drift silently splits the keyspace in two.
|
||||||
*/
|
*/
|
||||||
function stableStringify(obj: any): string {
|
function stableStringify(obj: any): string {
|
||||||
if (obj === null || obj === undefined) return 'null'
|
if (obj === null || obj === undefined) return 'null'
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
/**
|
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||||
* Mizan @client decorator and function wrapper.
|
|
||||||
*
|
|
||||||
* Two registration styles:
|
|
||||||
*
|
|
||||||
* 1. Function wrapper (standalone functions):
|
|
||||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
|
||||||
*
|
|
||||||
* 2. Class decorator (methods):
|
|
||||||
* class Handlers {
|
|
||||||
* @client({ context: UserCtx })
|
|
||||||
* async userProfile(userId: number) { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
|
|
||||||
import { register } from './registry'
|
import { register } from './registry'
|
||||||
|
|
||||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||||
@@ -21,25 +6,6 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
|
|
||||||
if (!merge) return undefined
|
|
||||||
const items = Array.isArray(merge) ? merge : [merge]
|
|
||||||
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize the public auth option into the stored requirement.
|
|
||||||
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
|
|
||||||
* 'staff'/'superuser' pass through, anything else throws at decoration time.
|
|
||||||
*/
|
|
||||||
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
|
|
||||||
if (auth === undefined) return undefined
|
|
||||||
if (auth === true) return 'required'
|
|
||||||
if (typeof auth === 'function') return auth
|
|
||||||
if (auth === 'staff' || auth === 'superuser') return auth
|
|
||||||
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAffects(
|
function normalizeAffects(
|
||||||
affects: ClientOptions['affects'],
|
affects: ClientOptions['affects'],
|
||||||
): RegistryEntry['affects'] | undefined {
|
): RegistryEntry['affects'] | undefined {
|
||||||
@@ -54,8 +20,14 @@ function normalizeAffects(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover parameter names by parsing the function's own source text.
|
||||||
|
*
|
||||||
|
* Names are the wire contract — the dispatcher matches request params to
|
||||||
|
* positional arguments by name — and JS erases them at runtime, so the
|
||||||
|
* source string is the only place they survive.
|
||||||
|
*/
|
||||||
function extractParams(fn: Function): ParamDef[] {
|
function extractParams(fn: Function): ParamDef[] {
|
||||||
// Extract parameter names from function.toString()
|
|
||||||
const source = fn.toString()
|
const source = fn.toString()
|
||||||
const match = source.match(/\(([^)]*)\)/)
|
const match = source.match(/\(([^)]*)\)/)
|
||||||
if (!match || !match[1].trim()) return []
|
if (!match || !match[1].trim()) return []
|
||||||
@@ -65,75 +37,85 @@ function extractParams(fn: Function): ParamDef[] {
|
|||||||
.map(p => p.trim())
|
.map(p => p.trim())
|
||||||
.filter(p => p && !p.startsWith('...'))
|
.filter(p => p && !p.startsWith('...'))
|
||||||
.map(p => {
|
.map(p => {
|
||||||
// Handle destructured defaults: name = default, name: type
|
// Strips a default value or a type annotation off the name
|
||||||
const name = p.split(/[=:]/)[0].trim()
|
const name = p.split(/[=:]/)[0].trim()
|
||||||
return { name, type: 'any', required: !p.includes('=') }
|
return { name, type: 'any', required: !p.includes('=') }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry {
|
/** Wrap and register a standalone function. */
|
||||||
const context = resolveContext(options.context)
|
|
||||||
const affects = normalizeAffects(options.affects)
|
|
||||||
|
|
||||||
if (context && affects) {
|
|
||||||
throw new Error('context and affects are mutually exclusive')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
fn: fn as any,
|
|
||||||
context,
|
|
||||||
affects,
|
|
||||||
merge: normalizeMerge(options.merge),
|
|
||||||
params: extractParams(fn),
|
|
||||||
private: options.private ?? false,
|
|
||||||
viewPath: false,
|
|
||||||
route: options.route,
|
|
||||||
methods: options.methods,
|
|
||||||
auth: normalizeAuth(options.auth),
|
|
||||||
websocket: options.websocket,
|
|
||||||
rev: options.rev,
|
|
||||||
cache: options.cache,
|
|
||||||
ir: options.ir,
|
|
||||||
form: options.form,
|
|
||||||
formName: options.formName,
|
|
||||||
formRole: options.formRole,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function wrapper — registers a standalone function.
|
|
||||||
*
|
|
||||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
|
||||||
*/
|
|
||||||
export function client<T extends (...args: any[]) => Promise<any>>(
|
export function client<T extends (...args: any[]) => Promise<any>>(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
fn: T,
|
fn: T,
|
||||||
): T
|
): T
|
||||||
|
|
||||||
/**
|
/** Register a class method. */
|
||||||
* Class method decorator.
|
|
||||||
*
|
|
||||||
* class Handlers {
|
|
||||||
* @client({ context: UserCtx })
|
|
||||||
* async userProfile(userId: number) { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export function client(options: ClientOptions): MethodDecorator
|
export function client(options: ClientOptions): MethodDecorator
|
||||||
|
|
||||||
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
||||||
// Function wrapper form: client(options, fn)
|
// Function wrapper form: client(options, fn)
|
||||||
if (fn && typeof fn === 'function') {
|
if (fn && typeof fn === 'function') {
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
|
const context = resolveContext(options.context)
|
||||||
|
const affects = normalizeAffects(options.affects)
|
||||||
|
|
||||||
|
if (context && affects) {
|
||||||
|
throw new Error('context and affects are mutually exclusive')
|
||||||
|
}
|
||||||
|
|
||||||
const name = fn.name || 'anonymous'
|
const name = fn.name || 'anonymous'
|
||||||
register(buildEntry(options, name, fn))
|
const params = extractParams(fn)
|
||||||
|
|
||||||
|
const entry: RegistryEntry = {
|
||||||
|
name,
|
||||||
|
fn: fn as RegistryEntry['fn'],
|
||||||
|
context,
|
||||||
|
affects,
|
||||||
|
params,
|
||||||
|
private: options.private ?? false,
|
||||||
|
// A wrapped function's return is only known once called, so the
|
||||||
|
// view-vs-RPC split is decided by the dispatcher, not here.
|
||||||
|
viewPath: false,
|
||||||
|
route: options.route,
|
||||||
|
methods: options.methods,
|
||||||
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
register(entry)
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decorator form: @client(options)
|
// Decorator form: @client(options)
|
||||||
const options = optionsOrFn as ClientOptions
|
const options = optionsOrFn as ClientOptions
|
||||||
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
register(buildEntry(options, propertyKey, descriptor.value))
|
const originalMethod = descriptor.value
|
||||||
|
const context = resolveContext(options.context)
|
||||||
|
const affects = normalizeAffects(options.affects)
|
||||||
|
|
||||||
|
if (context && affects) {
|
||||||
|
throw new Error('context and affects are mutually exclusive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = extractParams(originalMethod)
|
||||||
|
|
||||||
|
const entry: RegistryEntry = {
|
||||||
|
name: propertyKey,
|
||||||
|
fn: originalMethod,
|
||||||
|
context,
|
||||||
|
affects,
|
||||||
|
params,
|
||||||
|
private: options.private ?? false,
|
||||||
|
viewPath: false,
|
||||||
|
route: options.route,
|
||||||
|
methods: options.methods,
|
||||||
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
register(entry)
|
||||||
return descriptor
|
return descriptor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Request dispatch — context GET and mutation POST handlers.
|
* Context GET and mutation POST handlers.
|
||||||
*
|
*
|
||||||
* Framework-agnostic. Returns plain objects. The router adapter
|
* Handlers return plain MizanResponse objects; turning one into a
|
||||||
* (Express, Hono, etc.) converts to framework-specific responses.
|
* framework's own response type is the router adapter's job.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
import { ANONYMOUS, type Identity } from './identity'
|
|
||||||
import type { AuthRequirement } from './types'
|
|
||||||
import { UploadedFile, bindUploads } from './upload'
|
|
||||||
|
|
||||||
let _cacheSecret: string | null = null
|
let _cacheSecret: string | null = null
|
||||||
|
|
||||||
@@ -25,54 +22,6 @@ export interface MizanResponse {
|
|||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthDenial {
|
|
||||||
status: 401 | 403
|
|
||||||
code: 'UNAUTHORIZED' | 'FORBIDDEN'
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether `identity` satisfies the stored `auth` requirement.
|
|
||||||
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
|
|
||||||
* on failure, or null when access is allowed.
|
|
||||||
*/
|
|
||||||
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
|
|
||||||
if (auth === undefined) return null
|
|
||||||
|
|
||||||
// Callable runs first — before the authentication gate.
|
|
||||||
if (typeof auth === 'function') {
|
|
||||||
try {
|
|
||||||
return auth(identity)
|
|
||||||
? null
|
|
||||||
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!identity.isAuthenticated) {
|
|
||||||
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth === 'staff' && !identity.isStaff) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth === 'superuser' && !identity.isSuperuser) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function authDenialResponse(denial: AuthDenial): MizanResponse {
|
|
||||||
return {
|
|
||||||
status: denial.status,
|
|
||||||
body: { error: true, code: denial.code, message: denial.message },
|
|
||||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle GET /api/mizan/ctx/:contextName/
|
* Handle GET /api/mizan/ctx/:contextName/
|
||||||
*
|
*
|
||||||
@@ -81,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse {
|
|||||||
export async function handleContextFetch(
|
export async function handleContextFetch(
|
||||||
contextName: string,
|
contextName: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const groups = getContextGroups()
|
const groups = getContextGroups()
|
||||||
const fnNames = groups[contextName]
|
const fnNames = groups[contextName]
|
||||||
@@ -94,15 +42,6 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
|
|
||||||
// leak to an unauthorized caller. Any denial short-circuits, uncached.
|
|
||||||
for (const fnName of fnNames) {
|
|
||||||
const entry = getFunction(fnName)
|
|
||||||
if (!entry) continue
|
|
||||||
const denial = checkAuth(entry.auth, identity)
|
|
||||||
if (denial) return authDenialResponse(denial)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||||
let effectiveRev = 0
|
let effectiveRev = 0
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
@@ -123,7 +62,10 @@ export async function handleContextFetch(
|
|||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* cache miss on error */ }
|
} catch (e: any) {
|
||||||
|
// A failed lookup degrades to a miss, so recompute below rather than fail the request.
|
||||||
|
console.error(`mizan: cache lookup failed for context '${contextName}'`, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: Record<string, any> = {}
|
const results: Record<string, any> = {}
|
||||||
@@ -147,6 +89,7 @@ export async function handleContextFetch(
|
|||||||
|
|
||||||
results[fnName] = result
|
results[fnName] = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error(`mizan: context function '${fnName}' raised`, e)
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
@@ -172,7 +115,10 @@ export async function handleContextFetch(
|
|||||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||||
try {
|
try {
|
||||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||||
} catch { /* cache store failure is non-fatal */ }
|
} catch (e: any) {
|
||||||
|
// The results are already computed, so a store failure costs a future hit, not this response.
|
||||||
|
console.error(`mizan: cache store failed for context '${contextName}'`, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -187,15 +133,13 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle POST /api/mizan/call/ — JSON body form.
|
* Handle POST /api/mizan/call/
|
||||||
*
|
*
|
||||||
* Dispatches to a named function. Returns result + invalidation. The multipart
|
* Dispatches to a named function. Returns result + invalidation.
|
||||||
* form (`handleMultipartCall`) binds file parts first, then routes here.
|
|
||||||
*/
|
*/
|
||||||
export async function handleMutationCall(
|
export async function handleMutationCall(
|
||||||
fnName: string,
|
fnName: string,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const entry = getFunction(fnName)
|
const entry = getFunction(fnName)
|
||||||
|
|
||||||
@@ -216,10 +160,6 @@ export async function handleMutationCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth enforcement — after private rejection, before execution.
|
|
||||||
const denial = checkAuth(entry.auth, identity)
|
|
||||||
if (denial) return authDenialResponse(denial)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const argValues = entry.params.map(p => args[p.name])
|
const argValues = entry.params.map(p => args[p.name])
|
||||||
const result = await entry.fn(...argValues)
|
const result = await entry.fn(...argValues)
|
||||||
@@ -253,20 +193,25 @@ export async function handleMutationCall(
|
|||||||
// Purge origin-side cache
|
// Purge origin-side cache
|
||||||
const cb = getCache()
|
const cb = getCache()
|
||||||
if (cb) {
|
if (cb) {
|
||||||
try {
|
for (const target of invalidate) {
|
||||||
for (const entry of invalidate) {
|
try {
|
||||||
if (typeof entry === 'string') {
|
if (typeof target === 'string') {
|
||||||
cachePurge(cb, entry)
|
cachePurge(cb, target)
|
||||||
} else {
|
} else {
|
||||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
cachePurge(cb, target.context, target.params, _cacheSecret)
|
||||||
}
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// The client still gets X-Mizan-Invalidate, so a stale origin entry
|
||||||
|
// is recoverable; one bad target must not skip the remaining ones.
|
||||||
|
console.error(`mizan: cache purge failed for`, target, e)
|
||||||
}
|
}
|
||||||
} catch { /* purge failure is non-fatal */ }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 200, body: responseData, headers }
|
return { status: 200, body: responseData, headers }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error(`mizan: mutation '${fnName}' raised`, e)
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
@@ -274,63 +219,3 @@ export async function handleMutationCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function badRequest(message: string): MizanResponse {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: { error: true, code: 'BAD_REQUEST', message },
|
|
||||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle POST /api/mizan/call/ — multipart/form-data form.
|
|
||||||
*
|
|
||||||
* Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields
|
|
||||||
* arrive in a JSON `args` part, and each file part binds into the function's
|
|
||||||
* Upload-typed inputs (by field name) with declared `File(...)` constraints
|
|
||||||
* enforced. After binding, execution is identical to the JSON path.
|
|
||||||
*
|
|
||||||
* A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other
|
|
||||||
* parts that share an Upload field name are accepted too.
|
|
||||||
*/
|
|
||||||
export async function handleMultipartCall(
|
|
||||||
form: FormData,
|
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
|
||||||
const fnRaw = form.get('fn')
|
|
||||||
if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field")
|
|
||||||
const fnName = fnRaw
|
|
||||||
|
|
||||||
const argsRaw = form.get('args')
|
|
||||||
let args: Record<string, any>
|
|
||||||
try {
|
|
||||||
args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {}
|
|
||||||
} catch {
|
|
||||||
return badRequest("Invalid JSON in 'args' field")
|
|
||||||
}
|
|
||||||
if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object")
|
|
||||||
|
|
||||||
const entry = getFunction(fnName)
|
|
||||||
if (entry) {
|
|
||||||
// Collect file parts by field name into UploadedFile buckets.
|
|
||||||
const files = new Map<string, UploadedFile[]>()
|
|
||||||
for (const key of new Set(form.keys())) {
|
|
||||||
if (key === 'fn' || key === 'args') continue
|
|
||||||
const bucket: UploadedFile[] = []
|
|
||||||
for (const part of form.getAll(key)) {
|
|
||||||
if (part instanceof Blob) {
|
|
||||||
const data = new Uint8Array(await part.arrayBuffer())
|
|
||||||
const filename = part instanceof File ? part.name : null
|
|
||||||
bucket.push(new UploadedFile(filename, part.type || null, data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bucket.length > 0) files.set(key, bucket)
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = bindUploads(entry, args, files)
|
|
||||||
if (err !== null) return badRequest(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleMutationCall(fnName, args, identity)
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user