Compare commits
84 Commits
70c817c2be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587be8c4ab | |||
| ae684a36cb | |||
| adcc027894 | |||
| 6c5f6f1fba | |||
| 58d2cb2848 | |||
| b41f469bbd | |||
| 66b2db81fb | |||
| 67ad91b673 | |||
| 4effcc7597 | |||
| 776e0cf27a | |||
| ffdf9aa24d | |||
| 578e124d67 | |||
| a5ef93b879 | |||
| 22dcf0e3c1 | |||
| 54f060c273 | |||
| a1d1d6928f | |||
| 45bde51166 | |||
| 9900f8a36f | |||
| 7fb0c4a400 | |||
| 43bcf3f26f | |||
| c15c6f3e14 | |||
| cc887fb1f6 | |||
| f0f7a93ed2 | |||
| 255e10cb21 | |||
| 19ce4d4a2a | |||
| 0a95f3c860 | |||
| aaaf80cdbf | |||
| 2982741aad | |||
| 63c9a9c4ce | |||
| 4e4d1bb6b1 | |||
| dd41f0c25f | |||
| 76fce2dc85 | |||
| 9150cdc5ee | |||
| 37e61c646b | |||
| 9d2781b52c | |||
| fe39fcb229 | |||
| 6eca514777 | |||
| 5c1c583164 | |||
| 2d7cf3eb39 | |||
| bb88fd984b | |||
| 07f1c7842c | |||
| 9c837cf285 | |||
| cdd15b3810 | |||
| 499aa0e038 | |||
| c20de182e1 | |||
| 6108845d99 | |||
| 1c6d9075ad | |||
| 27c30d7e50 | |||
| 24ff0ae66d | |||
| 1b5dca5ab3 | |||
| 658cbebce1 | |||
| 711e92ac4d | |||
| c237a6379b | |||
| 4147679e6b | |||
| e5f8fafc01 | |||
| 7f5542e305 | |||
| dbbb269696 | |||
| 4744ff052e | |||
| 54581d184f | |||
| d7ec13c43c | |||
| a2388b3ab2 | |||
| 7daec1c2e2 | |||
| b06a65e133 | |||
| b2f990b4e5 | |||
| 97237ed1a4 | |||
| d228c7ab1b | |||
| 28e517e6ee | |||
| b4c7e783bd | |||
| 89196a02c6 | |||
| 1a4da68f8d | |||
| a91ce78c3a | |||
| 37f3f3d3eb | |||
| 8aa20111b4 | |||
| f4d7c64e3c | |||
| 3f737132a2 | |||
| 787f90fd12 | |||
| b28ee72c67 | |||
| 01d33173a4 | |||
| af7e22ffc1 | |||
| 3523f2e3fe | |||
| f3c225ef49 | |||
| eee352d908 | |||
| c866142770 | |||
| bf837e598b |
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: django
|
working-directory: backends/mizan-django
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: react
|
working-directory: frontends/mizan-react
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -11,6 +11,10 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Rust — every crate's build dir, anywhere in the tree
|
||||||
|
target/
|
||||||
|
**/target/
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
@@ -21,9 +25,9 @@ package-lock.json
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
desktop/frontend/dist/
|
examples/django-react-desktop-app/frontend/dist/
|
||||||
e2e/harness/src/api/generated.*
|
examples/django-react-site/harness/src/api/generated.*
|
||||||
e2e/harness/test-results/
|
examples/django-react-site/harness/test-results/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
|||||||
107
INVARIANTS.md
Normal file
107
INVARIANTS.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Application Framework Interface Invariants
|
||||||
|
|
||||||
|
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
|
||||||
|
|
||||||
|
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
|
||||||
|
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
|
||||||
|
|
||||||
|
## Backend Adapters
|
||||||
|
|
||||||
|
Django (python)
|
||||||
|
FastAPI (python)
|
||||||
|
Typescript (generic)
|
||||||
|
Rust/Axum (generic)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
## Frontend Adapters
|
||||||
|
|
||||||
|
React (Typescript)
|
||||||
|
Vue (Typescript)
|
||||||
|
Svelte (Typescript)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
### Client Function RPC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
No REST endpoints.
|
||||||
|
|
||||||
|
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
|
||||||
|
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
|
||||||
|
|
||||||
|
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
|
||||||
|
|
||||||
|
### Named Contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
|
||||||
|
|
||||||
|
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
|
||||||
|
|
||||||
|
### Mutation Invalidation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
|
||||||
|
|
||||||
|
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
|
||||||
|
|
||||||
|
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
|
||||||
|
|
||||||
|
### API Shapes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A backend adapter supports the "API Shape" feature to the fullest extent:
|
||||||
|
|
||||||
|
- ORM Integration
|
||||||
|
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
|
||||||
|
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
|
||||||
|
|
||||||
|
### Canonical IR & Codegen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
|
||||||
|
|
||||||
|
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
|
||||||
|
|
||||||
|
### Client Kernel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
|
||||||
|
|
||||||
|
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
|
||||||
|
|
||||||
|
### SSR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
|
||||||
|
|
||||||
|
## Compositions
|
||||||
|
|
||||||
|
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
|
||||||
|
|
||||||
|
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
|
||||||
|
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.
|
||||||
23
ISSUES.md
Normal file
23
ISSUES.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Mizan — Known Issues
|
||||||
|
|
||||||
|
Status board against the current codebase: Rust codegen (`protocol/mizan-codegen`),
|
||||||
|
KDL IR, kernel-owned frontend state (`@mizan/base`). Issues that the earlier
|
||||||
|
expert-review board filed against the deleted JavaScript codegen and the
|
||||||
|
pre-kernel `mizan-react` provider have been removed — they audited files that
|
||||||
|
no longer exist.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||||
|
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||||
|
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||||
|
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
||||||
|
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||||
|
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||||
|
|
||||||
|
## Resolved this pass
|
||||||
|
|
||||||
|
- [x] **Codegen test suite compile break** — every `mizan-codegen` test constructed `SourceConfig` without the `rust`/`script` fields added alongside the Rust-backend work. Suite now compiles and is green.
|
||||||
|
- [x] **React parity baseline** — the emitter correctly drops the dead `initSession`/`MizanError` top-level imports (they are only re-exported, never used in the module body); baseline regenerated. Fixed the template whitespace artifact that indented the `} from '@mizan/base'` closing brace.
|
||||||
|
- [x] **Edge manifest non-determinism** — `generate_edge_manifest` iterated registration order; now sorts context and mutation keys, so the manifest is deterministic regardless of registration order.
|
||||||
|
- [x] **Dead code removed** — `workers/mizan-ssr/src/test-worker.tsx` (a relic of the rejected `registerComponent` registry), unused TS helpers `isResponseReturn` and `sortedStringify` (mizan-ts), the unused `IndexMap` import (`emit/python.rs`), the dead `debug_expose_names` Django setting, and the dead `package.json` exports + vite aliases (`./client/nextjs`, `./allauth`, `./allauth/nextjs`) pointing at source that does not exist.
|
||||||
95
LICENSE
Normal file
95
LICENSE
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
Copyright (c) 2026 Ryth Azhur
|
||||||
|
|
||||||
|
Elastic License 2.0
|
||||||
|
|
||||||
|
URL: https://www.elastic.co/licensing/elastic-license
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
By using the software, you agree to all of the terms and conditions below.
|
||||||
|
|
||||||
|
## Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
||||||
|
non-sublicensable, non-transferable license to use, copy, distribute, make
|
||||||
|
available, and prepare derivative works of the software, in each case subject to
|
||||||
|
the limitations and conditions below.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
You may not provide the software to third parties as a hosted or managed
|
||||||
|
service, where the service provides users with access to any substantial set of
|
||||||
|
the features or functionality of the software.
|
||||||
|
|
||||||
|
You may not move, change, disable, or circumvent the license key functionality
|
||||||
|
in the software, and you may not remove or obscure any functionality in the
|
||||||
|
software that is protected by the license key.
|
||||||
|
|
||||||
|
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
||||||
|
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
||||||
|
to applicable law.
|
||||||
|
|
||||||
|
## Patents
|
||||||
|
|
||||||
|
The licensor grants you a license, under any patent claims the licensor can
|
||||||
|
license, or becomes able to license, to make, have made, use, sell, offer for
|
||||||
|
sale, import and have imported the software, in each case subject to the
|
||||||
|
limitations and conditions in this license. This license does not cover any
|
||||||
|
patent claims that you cause to be infringed by modifications or additions to
|
||||||
|
the software. If you or your company make any written claim that the software
|
||||||
|
infringes or contributes to infringement of any patent, your patent license for
|
||||||
|
the software granted under these terms ends immediately. If your company makes
|
||||||
|
such a claim, your patent license ends immediately for work on behalf of your
|
||||||
|
company.
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of the software from you
|
||||||
|
also gets a copy of these terms.
|
||||||
|
|
||||||
|
If you modify the software, you must include in any modified copies of the
|
||||||
|
software prominent notices stating that you have modified the software.
|
||||||
|
|
||||||
|
## No Other Rights
|
||||||
|
|
||||||
|
These terms do not imply any licenses other than those expressly granted in
|
||||||
|
these terms.
|
||||||
|
|
||||||
|
## Termination
|
||||||
|
|
||||||
|
If you use the software in violation of these terms, such use is not licensed,
|
||||||
|
and your licenses will automatically terminate. If the licensor provides you
|
||||||
|
with a notice of your violation, and you cease all violation of this license no
|
||||||
|
later than 30 days after you receive that notice, your licenses will be
|
||||||
|
reinstated retroactively. However, if you violate these terms after such
|
||||||
|
reinstatement, any additional violation of these terms will cause your licenses
|
||||||
|
to terminate automatically and permanently.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
*As far as the law allows, the software comes as is, without any warranty or
|
||||||
|
condition, and the licensor will not be liable to you for any damages arising
|
||||||
|
out of these terms or the use or nature of the software, under any kind of
|
||||||
|
legal claim.*
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
The **licensor** is the entity offering these terms, and the **software** is the
|
||||||
|
software the licensor makes available under these terms, including any portion
|
||||||
|
of it.
|
||||||
|
|
||||||
|
**you** refers to the individual or entity agreeing to these terms.
|
||||||
|
|
||||||
|
**your company** is any legal entity, sole proprietorship, or other kind of
|
||||||
|
organization that you work for, plus all organizations that have control over,
|
||||||
|
are under the control of, or are under common control with that
|
||||||
|
organization. **control** means ownership of substantially all the assets of an
|
||||||
|
entity, or the power to direct its management and policies by vote, contract, or
|
||||||
|
otherwise. Control can be direct or indirect.
|
||||||
|
|
||||||
|
**your licenses** are all the licenses granted to you for the software under
|
||||||
|
these terms.
|
||||||
|
|
||||||
|
**use** means anything you do with the software requiring one of your licenses.
|
||||||
|
|
||||||
|
**trademark** means trademarks, service marks, and similar rights.
|
||||||
475
MIZAN.md
Normal file
475
MIZAN.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
# MIZAN — Named Contexts & Mutation Architecture
|
||||||
|
|
||||||
|
> **Historical design spec.** The original named-contexts / mutation design
|
||||||
|
> document from the January 2025 design conversation. Kept as a record of design
|
||||||
|
> intent, not as a description of the current build — names and surfaces here
|
||||||
|
> predate the implementation (the codegen is the Rust binary
|
||||||
|
> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For
|
||||||
|
> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen
|
||||||
|
> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`,
|
||||||
|
> `CACHE_KEYING.md`, `MWT_SPEC.md`).
|
||||||
|
|
||||||
|
## For Claude Code
|
||||||
|
|
||||||
|
This plan was written by Ryth's Claude.ai session after an extended design conversation
|
||||||
|
reviewing the full codebase, the original @compose discussion from January 2025, and
|
||||||
|
several rounds of architectural refinement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
MIZAN has three tiers of developer-facing API:
|
||||||
|
|
||||||
|
```
|
||||||
|
@client → a function I call from React
|
||||||
|
@client(context='global') → read-only data, fetched once, everywhere
|
||||||
|
@client(context='<name>') → read-only data, fetched when provider mounts
|
||||||
|
@client(affects='<name>') → a mutation that triggers context refresh
|
||||||
|
@client(affects=specific_function) → a mutation that triggers a specific function's refresh
|
||||||
|
ReactContext('<name>') with send → read-only data, class form
|
||||||
|
ReactContext('<name>') with send+receive → read/write data, explicit mutation logic
|
||||||
|
```
|
||||||
|
|
||||||
|
Decorators are the infantry — single-purpose, lightweight, composable.
|
||||||
|
Classes are the battleship — multi-surface, explicit, for tight read/write coupling.
|
||||||
|
|
||||||
|
The developer picks the tier that matches their complexity. Most apps never need
|
||||||
|
the class form. `@client` + `affects` covers 95% of cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Named Contexts (replacing context='local' and @compose)
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
Any string passed to `context=` becomes a named context. Functions and classes sharing
|
||||||
|
the same context string are automatically grouped into one provider, one fetch request,
|
||||||
|
and one set of hooks.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
@client(context='user')
|
||||||
|
def user_orders(request, user_id: int) -> list[OrderShape]:
|
||||||
|
return OrderShape.query(lambda qs: qs.filter(user__pk=user_id))
|
||||||
|
|
||||||
|
@client(context='user')
|
||||||
|
def user_friends(request, user_id: int) -> list[FlatUserShape]:
|
||||||
|
return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id))
|
||||||
|
```
|
||||||
|
|
||||||
|
Three functions, one context name, one generated provider.
|
||||||
|
|
||||||
|
### Context type resolution
|
||||||
|
- `context='global'` → special case. Collected into `<MizanContext/>` (the root provider).
|
||||||
|
Fetched once at app root. SSR-hydrated. No params.
|
||||||
|
- `context='<any other string>'` → generates `<XxxContext/>` provider. Fetched when mounted.
|
||||||
|
Accepts params as props.
|
||||||
|
- No `context` (default) → not a context. Just a callable function.
|
||||||
|
|
||||||
|
### What codegen produces for `context='user'`
|
||||||
|
- `<UserContext user_id={...}>` — provider component
|
||||||
|
- `useUserProfile()` — typed hook, returns `UserProfileShape`
|
||||||
|
- `useUserOrders()` — typed hook, returns `OrderShape[]`
|
||||||
|
- `useUserFriends()` — typed hook, returns `FlatUserShape[]`
|
||||||
|
|
||||||
|
### `context='global'` is just a named context
|
||||||
|
There is no separate mechanism for global contexts. `'global'` is a reserved context
|
||||||
|
name whose provider is automatically included in the root `<MizanContext/>`. All other
|
||||||
|
named contexts generate standalone providers the developer mounts themselves.
|
||||||
|
|
||||||
|
### Registration-time validation
|
||||||
|
- Duplicate function/class names within the same context → error
|
||||||
|
- Mixed WebSocket transport within a context (some `websocket=True`, some not) → error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Param Elevation
|
||||||
|
|
||||||
|
Functions sharing a context name have their parameters analyzed at codegen time.
|
||||||
|
Shared params elevate to required provider props. Non-shared params elevate to
|
||||||
|
optional provider props. Per-function overrides are available via `specify`.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
# All three take user_id
|
||||||
|
# Only orders and friends take page_size, page_index
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def user_orders(request, user_id: int, page_size: int, page_index: int) -> ...: ...
|
||||||
|
def user_friends(request, user_id: int, page_size: int, page_index: int) -> ...: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generated provider interface
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UserContextProps {
|
||||||
|
children: ReactNode
|
||||||
|
user_id: number // required — all functions need it
|
||||||
|
page_size?: number // optional — orders + friends only
|
||||||
|
page_index?: number // optional — orders + friends only
|
||||||
|
specify?: { // per-function overrides
|
||||||
|
user_orders?: { page_size?: number; page_index?: number }
|
||||||
|
user_friends?: { page_size?: number; page_index?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolution order (frontend runtime)
|
||||||
|
For each function in the context:
|
||||||
|
1. Start with elevated props from the provider component
|
||||||
|
2. Override with values from `specify[function_name]` if present
|
||||||
|
3. Runtime error if any required param for that function is still missing
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Simple — shared params cover everything
|
||||||
|
<UserContext user_id={user.id} page_size={20} page_index={0}>
|
||||||
|
<UserPage />
|
||||||
|
</UserContext>
|
||||||
|
|
||||||
|
// Override — different pagination per function
|
||||||
|
<UserContext user_id={user.id} specify={{
|
||||||
|
user_orders: { page_size: 20, page_index: ordersPage },
|
||||||
|
user_friends: { page_size: 10, page_index: friendsPage }
|
||||||
|
}}>
|
||||||
|
<UserPage />
|
||||||
|
</UserContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Bundling & Transport
|
||||||
|
|
||||||
|
### Context fetch
|
||||||
|
All functions in a named context are fetched in a single GET request.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/mizan/ctx/user/?user_id=123&page_size=20&page_index=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"data": {
|
||||||
|
"user_profile": { ... },
|
||||||
|
"user_orders": [ ... ],
|
||||||
|
"user_friends": [ ... ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server executes each function, bundles results, returns one response.
|
||||||
|
The frontend splits the response into individual hook states.
|
||||||
|
|
||||||
|
Context functions use GET because they are reads. This makes them CDN-cacheable,
|
||||||
|
edge-cacheable, and compatible with standard HTTP caching headers.
|
||||||
|
|
||||||
|
### Global context fetch
|
||||||
|
```
|
||||||
|
GET /api/mizan/ctx/global/
|
||||||
|
```
|
||||||
|
No params. Fetched once. SSR-hydrated.
|
||||||
|
|
||||||
|
### Mutation calls
|
||||||
|
Non-context `@client` functions (including those with `affects`) use the existing
|
||||||
|
POST endpoint:
|
||||||
|
```
|
||||||
|
POST /api/mizan/call/
|
||||||
|
{ "fn": "profile_edit", "args": { "name": "new name" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache key
|
||||||
|
The cache identity for a context is: context name + shared elevated params.
|
||||||
|
`user_id=123` is one cache entry. Per-function overrides via `specify` are
|
||||||
|
part of the request but do not change the cache identity.
|
||||||
|
|
||||||
|
### Wire format convention
|
||||||
|
All parameter names on the wire (HTTP headers, JSON keys, query params, manifest fields)
|
||||||
|
use `snake_case`. TypeScript adapters convert to `camelCase` at the boundary for local use
|
||||||
|
but emit `snake_case` in protocol-level artifacts (invalidation headers, manifest params).
|
||||||
|
This is a protocol rule, not a language convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mutation Invalidation with `affects`
|
||||||
|
|
||||||
|
This is the key feature. Mutations declare which contexts they invalidate.
|
||||||
|
The generated client code handles the refetch automatically.
|
||||||
|
|
||||||
|
### Declaration (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
# Mutation that invalidates the entire user context
|
||||||
|
@client(affects='user')
|
||||||
|
def profile_edit(request, name: str, email: str) -> dict:
|
||||||
|
User.objects.filter(pk=request.user.pk).update(name=name, email=email)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Mutation that invalidates only user_profile within the user context
|
||||||
|
@client(affects=user_profile)
|
||||||
|
def update_avatar(request, avatar_url: str) -> dict:
|
||||||
|
User.objects.filter(pk=request.user.pk).update(avatar=avatar_url)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Mutation that invalidates specific functions
|
||||||
|
@client(affects=[user_profile, user_orders])
|
||||||
|
def change_subscription(request, plan: str) -> dict:
|
||||||
|
update_plan(request.user, plan)
|
||||||
|
return {"ok": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `affects` options
|
||||||
|
- `affects='context_name'` — refetch the entire named context (all functions in it)
|
||||||
|
- `affects=function_ref` — refetch only that specific function within its context
|
||||||
|
- `affects=[fn1, fn2]` — refetch specific functions (can span multiple contexts)
|
||||||
|
|
||||||
|
### What codegen produces for a mutation with `affects`
|
||||||
|
|
||||||
|
The generated hook bakes the invalidation relationship into the client code.
|
||||||
|
The developer never writes cache invalidation logic.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Generated
|
||||||
|
export function useProfileEdit() {
|
||||||
|
const mizan = useMizan()
|
||||||
|
return async (input: { name: string; email: string }) => {
|
||||||
|
const result = await mizan.call('profile_edit', input)
|
||||||
|
// Auto-invalidate: refetch entire 'user' context
|
||||||
|
await mizan.invalidateContext('user')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalidation behavior
|
||||||
|
- Mutation fires via POST
|
||||||
|
- On success, the framework automatically refetches the affected context(s)
|
||||||
|
- If the affected context is not currently mounted, nothing happens
|
||||||
|
(no wasted requests for data nobody is looking at)
|
||||||
|
- On mutation failure, no invalidation occurs
|
||||||
|
|
||||||
|
### Parallel to React Query
|
||||||
|
This is the same model as TanStack Query (React Query), but the query keys and
|
||||||
|
invalidation relationships are declared server-side in Python and generated into
|
||||||
|
the client. The developer never manages cache keys, never calls `invalidateQueries`,
|
||||||
|
never wires up `onSuccess` callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ReactContext Classes (the read/write surface)
|
||||||
|
|
||||||
|
For cases where mutation logic is tightly coupled to the read shape — where the
|
||||||
|
frontend sends modified state back and the server diffs it — `ReactContext` classes
|
||||||
|
provide explicit `send` and `receive` methods.
|
||||||
|
|
||||||
|
This is the heavy weapon. Most apps don't need it. `@client` + `affects` covers
|
||||||
|
the common case. Use `ReactContext` when:
|
||||||
|
- The frontend edits data in place and commits changes (form-like behavior)
|
||||||
|
- The mutation needs to diff against current state (Shape diffing)
|
||||||
|
- The read and write logic are tightly coupled and belong in one place
|
||||||
|
|
||||||
|
### Definition
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape:
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0]
|
||||||
|
|
||||||
|
def receive(self, request, data: UserProfileShape):
|
||||||
|
if not request.user.is_staff and request.user.pk != data.id:
|
||||||
|
raise PermissionError("Cannot edit another user's profile")
|
||||||
|
|
||||||
|
diff = data.diff()
|
||||||
|
|
||||||
|
if not diff.changed:
|
||||||
|
return {"status": "no_changes"}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
User.objects.filter(pk=data.id).update(**diff.changed)
|
||||||
|
|
||||||
|
if 'email' in diff.changed:
|
||||||
|
transaction.on_commit(lambda: send_verification_email(data.email))
|
||||||
|
|
||||||
|
return {"status": "updated", "changed": list(diff.changed.keys())}
|
||||||
|
```
|
||||||
|
|
||||||
|
`send` — what goes out. The read surface. Same as a `@client(context=...)` function.
|
||||||
|
`receive` — what comes back. The write surface. The developer owns all logic: diffing,
|
||||||
|
validation, auth, transactions, side effects.
|
||||||
|
|
||||||
|
### Mixing with decorated functions
|
||||||
|
ReactContext classes and @client functions can share a context name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client(context='user')
|
||||||
|
def user_friends(request, user_id: int) -> list[FlatUserShape]:
|
||||||
|
return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id))
|
||||||
|
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def receive(self, request, data: UserProfileShape): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Both live in the `'user'` context. The generated `<UserContext/>` includes both.
|
||||||
|
Only `UserProfile` has a commit surface because only it defines `receive`.
|
||||||
|
|
||||||
|
### What codegen produces
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Read hook (from send)
|
||||||
|
const profile = useUserProfile()
|
||||||
|
|
||||||
|
// Commit function (from receive)
|
||||||
|
const commitProfile = useCommitUserProfile()
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const handleSave = async () => {
|
||||||
|
const result = await commitProfile(modifiedProfile)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/mizan/ctx/user/commit/
|
||||||
|
{
|
||||||
|
"user_profile": { ...modified shape data... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server routes `user_profile` data to `UserProfile.receive()`.
|
||||||
|
Multiple writable members can be committed in one request.
|
||||||
|
|
||||||
|
### Invalidation after commit
|
||||||
|
After a successful commit, the context automatically refetches all `send` methods
|
||||||
|
(and all @client context functions in the same named context). The frontend state
|
||||||
|
is guaranteed to reflect current DB state.
|
||||||
|
|
||||||
|
If the developer wants to avoid the extra round trip, they can return a Shape instance
|
||||||
|
from `receive` matching the `send` return type. The framework detects this and uses
|
||||||
|
it as the new state instead of refetching:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def receive(self, request, data: UserProfileShape):
|
||||||
|
User.objects.filter(pk=data.id).update(**data.dict(exclude={'id'}))
|
||||||
|
# Return fresh state — framework skips refetch for this function
|
||||||
|
return UserProfileShape.query(lambda qs: qs.filter(pk=data.id))[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### What the developer owns in receive()
|
||||||
|
Everything. The framework provides the Shape data as a typed Pydantic object.
|
||||||
|
The developer decides:
|
||||||
|
- Whether to diff (data.diff() or Shape.diff_many())
|
||||||
|
- How deep to diff
|
||||||
|
- What auth checks to perform
|
||||||
|
- What validation to run
|
||||||
|
- Whether to wrap in a transaction
|
||||||
|
- What side effects to trigger
|
||||||
|
- What to return
|
||||||
|
|
||||||
|
Mutation is business logic, not automation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Discovery and Registration
|
||||||
|
|
||||||
|
### @client functions
|
||||||
|
Discovered via `clients.py` convention (DjangoAppVisitor), same as current.
|
||||||
|
|
||||||
|
### ReactContext classes
|
||||||
|
Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are
|
||||||
|
registered automatically. The context name string and presence/absence of `receive`
|
||||||
|
are detected at registration time.
|
||||||
|
|
||||||
|
### Registration-time validation
|
||||||
|
- Duplicate names within same context → error
|
||||||
|
- Mixed WebSocket transport within context → error
|
||||||
|
- `receive` defined without `send` → error
|
||||||
|
- `affects` referencing a non-existent context name or function → error (or warning)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What to Remove / Deprecate
|
||||||
|
|
||||||
|
- `context='local'` → replaced by any non-'global' context string
|
||||||
|
- `@compose` decorator → replaced by shared context names
|
||||||
|
- `ComposedContext` class → remove from public API
|
||||||
|
- `on_server` flag → default behavior (contexts always bundled)
|
||||||
|
- `share` prop pattern → replaced by param elevation + `specify`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Order
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
The framework generates the client, handles the caching, and runs the invalidation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# I read data
|
||||||
|
@client(context='user')
|
||||||
|
def user_profile(request, user_id: int) -> UserProfileShape: ...
|
||||||
|
|
||||||
|
# I mutate data and declare what it affects
|
||||||
|
@client(affects='user')
|
||||||
|
def edit_profile(request, name: str) -> dict: ...
|
||||||
|
|
||||||
|
# I need tight read/write coupling (rare, powerful)
|
||||||
|
class UserProfile(ReactContext('user')):
|
||||||
|
def send(self, request, user_id: int) -> UserProfileShape: ...
|
||||||
|
def receive(self, request, data: UserProfileShape): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// I use the data
|
||||||
|
const profile = useUserProfile()
|
||||||
|
|
||||||
|
// I call the mutation — invalidation is automatic
|
||||||
|
const editProfile = useEditProfile()
|
||||||
|
await editProfile({ name: 'new name' })
|
||||||
|
// useUserProfile() updates automatically. I wrote zero invalidation code.
|
||||||
|
```
|
||||||
|
|
||||||
|
No REST. No CRUD. No cache keys. No manual invalidation.
|
||||||
|
The decorator is the declaration. The framework is the execution.
|
||||||
47
Makefile
47
Makefile
@@ -1,37 +1,56 @@
|
|||||||
.PHONY: install test test-django test-react test-integration docker-up docker-down clean
|
.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
|
||||||
|
|
||||||
|
CORE = cores/mizan-python
|
||||||
|
DJANGO = backends/mizan-django
|
||||||
|
FASTAPI = backends/mizan-fastapi
|
||||||
|
REACT = frontends/mizan-react
|
||||||
|
AFI = tests/afi
|
||||||
|
|
||||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cd django && pip install -e ".[dev,channels]"
|
cd $(CORE) && uv pip install -e .
|
||||||
cd react && npm install
|
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||||
|
cd $(FASTAPI) && uv pip install -e ".[dev]"
|
||||||
|
cd $(REACT) && npm install
|
||||||
|
|
||||||
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test: test-django test-react
|
test: test-core test-django test-fastapi test-react test-afi
|
||||||
|
|
||||||
|
test-core:
|
||||||
|
cd $(CORE) && uv run --extra dev pytest
|
||||||
|
|
||||||
test-django:
|
test-django:
|
||||||
cd django && pytest
|
cd $(DJANGO) && uv run pytest
|
||||||
|
|
||||||
|
test-fastapi:
|
||||||
|
cd $(FASTAPI) && uv run pytest
|
||||||
|
|
||||||
test-react:
|
test-react:
|
||||||
cd react && npm test
|
cd $(REACT) && npm test
|
||||||
|
|
||||||
|
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
|
||||||
|
# schemas for the same @client fixture. Substrate-level gate, not e2e.
|
||||||
|
test-afi:
|
||||||
|
cd $(AFI) && uv run pytest
|
||||||
|
|
||||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test-integration: docker-up
|
test-integration: docker-up
|
||||||
@echo "Waiting for backend..."
|
@echo "Waiting for backend..."
|
||||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done'
|
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||||
cd react && npm run test:integration
|
cd $(REACT) && npm run test:integration
|
||||||
@$(MAKE) docker-down
|
@$(MAKE) docker-down
|
||||||
|
|
||||||
# ─── Docker ──────────────────────────────────────────────────────────────────
|
# ─── Docker ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose -f docker-compose.test.yml up -d --build
|
docker compose -f examples/django-react-site/docker-compose.test.yml up -d --build
|
||||||
@echo "Backend starting at http://localhost:8000"
|
@echo "Backend starting at http://localhost:8000"
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
docker compose -f docker-compose.test.yml down
|
docker compose -f examples/django-react-site/docker-compose.test.yml down
|
||||||
|
|
||||||
# ─── All ─────────────────────────────────────────────────────────────────────
|
# ─── All ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -40,7 +59,7 @@ test-all: test test-integration
|
|||||||
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||||
rm -rf django/src/djarea.egg-info django/dist django/build
|
rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
|
||||||
rm -rf react/dist react/node_modules
|
rm -rf $(REACT)/dist $(REACT)/node_modules
|
||||||
rm -f example/db.sqlite3
|
rm -f examples/django-react-site/backend/db.sqlite3
|
||||||
|
|||||||
481
README.md
481
README.md
@@ -1,369 +1,122 @@
|
|||||||
# DJAREA
|
# Mizan
|
||||||
|
|
||||||
A modern Django + React Framework for perfectionists with deadlines.
|
Mizan is an Application Framework Interface (AFI). A single `@client` decorator on a
|
||||||
|
server function generates a typed frontend client; cache invalidation and caching are
|
||||||
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
|
handled by the protocol.
|
||||||
|
|
||||||
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
|
|
||||||
|
|
||||||
No API routing, no serializers, no REST/CRUD bullshit.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@client
|
from mizan import client, ReactContext
|
||||||
def current_user(request) -> UserShape:
|
|
||||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
UserContext = ReactContext('user')
|
||||||
|
|
||||||
|
# Context function — bundled into GET /api/mizan/ctx/user/
|
||||||
|
@client(context=UserContext)
|
||||||
|
def user_profile(request, user_id: int) -> UserShape:
|
||||||
|
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
|
||||||
|
|
||||||
|
# Mutation — invalidation scoped automatically by matching param name
|
||||||
|
@client(affects=UserContext)
|
||||||
|
def update_profile(request, user_id: int, name: str) -> dict:
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
|
||||||
```tsx
|
reference implementation; per-adapter support is inventoried below.
|
||||||
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
|
|
||||||
```
|
> **Status:** Mizan is not production-tested. It passes its own test suites but has not
|
||||||
|
> been run in a production deployment. Treat it as pre-release.
|
||||||
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
|
|
||||||
|
## Documentation
|
||||||
Starts with session auth and upgrades to JWT on login. **It just works**.
|
|
||||||
|
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
||||||
## What Djarea does
|
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
|
||||||
|
|
||||||
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection.
|
## Backend adapters
|
||||||
|
|
||||||
```python
|
Every adapter implements the same AFI wire protocol. The matrix below inventories
|
||||||
class ArticleShape(Shape[Article]):
|
support per adapter, grouped to separate protocol guarantees from Django-specific
|
||||||
id: int | None = None
|
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
|
||||||
title: str
|
when that adapter wires the capability into its own dispatch surface, not merely that a
|
||||||
author: FlatAuthorShape
|
shared core primitive exists.
|
||||||
tags: list[TagShape] = []
|
|
||||||
```
|
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
|
||||||
|
|
||||||
One Djarea **Shape** does three things simultaneously:
|
### Protocol core
|
||||||
- Defines the Pydantic model for validation and serialization
|
|
||||||
- Generates a django-readers spec for a lean, field-scoped SQL query
|
The surface every Mizan adapter implements.
|
||||||
- Produces the TypeScript interface on the React side
|
|
||||||
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
|
||||||
## Quick start
|
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
### 1. Django setup
|
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
```python
|
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||||
# settings.py
|
|
||||||
INSTALLED_APPS = [
|
### Edge, cache & enforcement
|
||||||
"djarea",
|
|
||||||
"myapp",
|
Protocol transports and guarantees co-equal with the body channel in the spec.
|
||||||
]
|
|
||||||
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
# urls.py
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
from django.urls import include, path
|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
||||||
urlpatterns = [
|
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
||||||
path("api/djarea/", include("djarea.urls")),
|
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||||
]
|
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
|
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
# asgi.py (for WebSocket support)
|
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
||||||
from djarea import wrap_asgi
|
|
||||||
from django.core.asgi import get_asgi_application
|
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
|
||||||
application = wrap_asgi(get_asgi_application())
|
> it — do not rely on `auth=` for access control on those adapters.
|
||||||
```
|
|
||||||
|
### Stack extensions (Django)
|
||||||
### 2. Define your client functions
|
|
||||||
|
Django ecosystem features Mizan wraps. Other adapters provide these only where the
|
||||||
```python
|
target stack calls for them.
|
||||||
# myapp/clients.py
|
|
||||||
from djarea.client import client
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
from djarea.shapes import Shape
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
from pydantic import BaseModel
|
| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ |
|
||||||
|
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
|
||||||
class EchoOutput(BaseModel):
|
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
message: str
|
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
|
||||||
|
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
@client
|
| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
|
||||||
def echo(request, text: str) -> EchoOutput:
|
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
|
||||||
return EchoOutput(message=text)
|
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
```
|
|
||||||
|
**Notes**
|
||||||
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
|
|
||||||
|
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||||
### 3. Generate TypeScript
|
Invalidation rides in the JSON response body; there is no header channel.
|
||||||
|
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
|
||||||
To get your generated React client, set this up in your frontend root:
|
WebSocket handler yet.
|
||||||
|
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
|
||||||
```javascript
|
4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
||||||
// django.config.mjs
|
adapter carries typed input/output through the KDL IR; the projection primitive
|
||||||
export default {
|
itself is Django-only.
|
||||||
source: {
|
5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
|
||||||
django: {
|
enforce them. Rust/Axum has no enforcement either.
|
||||||
managePath: '../backend/manage.py',
|
6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
||||||
command: ['uv', 'run', 'python'],
|
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
||||||
},
|
rather than fetching over HTTP.
|
||||||
},
|
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||||
output: 'src/api/generated.ts',
|
parity; CSRF is Django-only.
|
||||||
}
|
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||||
```
|
codegen source — it demonstrates the cache + invalidation protocol is
|
||||||
|
language-agnostic.
|
||||||
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
|
|
||||||
|
## Conformance
|
||||||
```bash
|
|
||||||
npx djarea-generate
|
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
|
||||||
```
|
currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
|
||||||
|
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
|
||||||
### 4. Use in React
|
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
|
||||||
|
|
||||||
```tsx
|
## License
|
||||||
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
|
|
||||||
|
Mizan is licensed under the [Elastic License 2.0](LICENSE) (SPDX: `Elastic-2.0`). You
|
||||||
// layout.tsx — one provider, handles everything
|
may use, copy, modify, and distribute it freely, including in commercial products you
|
||||||
export default function Layout({ children }) {
|
build on top of it. You may **not** provide Mizan to third parties as a hosted or
|
||||||
return <DjangoContext>{children}</DjangoContext>
|
managed service that exposes a substantial set of its features.
|
||||||
}
|
|
||||||
|
|
||||||
// page.tsx
|
|
||||||
function MyComponent() {
|
|
||||||
const user = useCurrentUser()
|
|
||||||
const echo = useEcho()
|
|
||||||
|
|
||||||
const handleClick = async () => {
|
|
||||||
try {
|
|
||||||
const result = await echo({ text: 'hello' })
|
|
||||||
console.log(result.message) // typed
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DjangoError) {
|
|
||||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
|
||||||
e.getFieldErrors('email') // field-level errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shapes
|
|
||||||
|
|
||||||
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Full detail page — joins books with chapters
|
|
||||||
class AuthorDetailShape(Shape[Author]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
bio: str
|
|
||||||
books: list[BookShape] = []
|
|
||||||
|
|
||||||
# Dropdown menu — two columns, no joins
|
|
||||||
class FlatAuthorShape(Shape[Author]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Detail page: SELECT id, name, bio + prefetch books
|
|
||||||
authors = AuthorDetailShape.query()
|
|
||||||
|
|
||||||
# Dropdown: SELECT id, name. That's it.
|
|
||||||
authors = FlatAuthorShape.query()
|
|
||||||
```
|
|
||||||
|
|
||||||
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@client
|
|
||||||
def update_articles(request, articles: list[ArticleShape]) -> dict:
|
|
||||||
for article, diff in ArticleShape.diff_many(articles):
|
|
||||||
if diff.is_new:
|
|
||||||
create_article(article)
|
|
||||||
elif diff.changed:
|
|
||||||
update_fields(article, diff.changed)
|
|
||||||
for tag in diff.tags.created:
|
|
||||||
add_tag(article, tag)
|
|
||||||
for tag_id in diff.tags.deleted:
|
|
||||||
remove_tag(article, tag_id)
|
|
||||||
return {"ok": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
|
|
||||||
|
|
||||||
## The `@client` decorator
|
|
||||||
|
|
||||||
The decorator controls transport, caching, auth, and SSR behavior:
|
|
||||||
|
|
||||||
| Decorator | React hook | What it does |
|
|
||||||
|-----------|-----------|--------------|
|
|
||||||
| `@client` | `useEcho()` | HTTP call, returns typed result |
|
|
||||||
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
|
|
||||||
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
|
|
||||||
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
|
|
||||||
| `@client(auth=True)` | — | Requires authentication |
|
|
||||||
| `@client(auth='staff')` | — | Requires staff status |
|
|
||||||
| `@client(auth=my_check)` | — | Custom auth callable |
|
|
||||||
|
|
||||||
## Forms
|
|
||||||
|
|
||||||
Django forms become typed React hooks with client-side Zod validation:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
|
||||||
djarea = DjareaFormMeta(
|
|
||||||
name="contact",
|
|
||||||
title="Contact Us",
|
|
||||||
submit_label="Send",
|
|
||||||
live_validation=True,
|
|
||||||
)
|
|
||||||
name = forms.CharField(max_length=100)
|
|
||||||
email = forms.EmailField()
|
|
||||||
message = forms.CharField(widget=forms.Textarea)
|
|
||||||
|
|
||||||
def on_submit_success(self, request):
|
|
||||||
send_email(self.cleaned_data)
|
|
||||||
return {"sent": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const form = useContactForm()
|
|
||||||
|
|
||||||
form.schema // field metadata, title, submit label
|
|
||||||
form.data // { name: '', email: '', message: '' }
|
|
||||||
form.set('email', v) // typed setter
|
|
||||||
form.errors // field-level errors (Zod + server)
|
|
||||||
form.submit() // → { success: true, data: { sent: true } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
|
|
||||||
|
|
||||||
## Channels
|
|
||||||
|
|
||||||
WebSocket channels with typed messages:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ChatChannel(ReactChannel):
|
|
||||||
class Params(BaseModel):
|
|
||||||
room: str
|
|
||||||
class ReactMessage(BaseModel):
|
|
||||||
text: str
|
|
||||||
class DjangoMessage(BaseModel):
|
|
||||||
text: str
|
|
||||||
user: str
|
|
||||||
|
|
||||||
def authorize(self, params):
|
|
||||||
return self.user.is_authenticated
|
|
||||||
|
|
||||||
def group(self, params):
|
|
||||||
return f"chat_{params.room}"
|
|
||||||
|
|
||||||
def receive(self, params, msg):
|
|
||||||
return self.DjangoMessage(text=msg.text, user=self.user.email)
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const chat = useChatChannel({ room: 'general' })
|
|
||||||
|
|
||||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
|
||||||
chat.messages // ChatDjangoMessage[]
|
|
||||||
chat.send({ text: 'hello' })
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
React app
|
|
||||||
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
|
|
||||||
├─ useCurrentUser() ← context hook (SSR-hydrated)
|
|
||||||
├─ useEcho() ← function hook
|
|
||||||
├─ useContactForm() ← form hook (Zod + server validation)
|
|
||||||
└─ useChatChannel() ← channel hook (WebSocket)
|
|
||||||
│
|
|
||||||
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
|
|
||||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
|
||||||
│
|
|
||||||
Django executor
|
|
||||||
├─ Pydantic input validation
|
|
||||||
├─ Auth check
|
|
||||||
├─ Function execution
|
|
||||||
└─ Pydantic output serialization
|
|
||||||
```
|
|
||||||
|
|
||||||
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
|
||||||
|
|
||||||
## Code generation
|
|
||||||
|
|
||||||
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `generated.djarea.ts` | Pydantic model types |
|
|
||||||
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
|
|
||||||
| `generated.django.server.ts` | SSR hydration helper |
|
|
||||||
| `generated.forms.ts` | Form hooks with Zod schemas |
|
|
||||||
| `generated.channels.ts` | Channel message types |
|
|
||||||
| `generated.channels.hooks.tsx` | Channel hooks |
|
|
||||||
| `index.ts` | Re-exports |
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
All errors from server functions throw as `DjangoError`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
if (e instanceof DjangoError) {
|
|
||||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
|
|
||||||
e.message // human-readable
|
|
||||||
e.details // field-level validation errors
|
|
||||||
e.isAuthError()
|
|
||||||
e.isValidationError()
|
|
||||||
e.getFieldErrors('email')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why RPC instead of REST
|
|
||||||
|
|
||||||
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
|
|
||||||
|
|
||||||
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
|
|
||||||
|
|
||||||
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Install |
|
|
||||||
|---------|---------|
|
|
||||||
| `djarea` (Python) | `pip install djarea` |
|
|
||||||
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
|
|
||||||
|
|
||||||
For WebSocket support: `pip install "djarea[channels]"`
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Django
|
|
||||||
cd django && uv run pytest
|
|
||||||
|
|
||||||
# React
|
|
||||||
cd react && npm test
|
|
||||||
|
|
||||||
# E2E (Playwright, real browser + real backend)
|
|
||||||
docker compose -f docker-compose.test.yml up -d
|
|
||||||
cd e2e/harness && npx djarea-generate && npx playwright test
|
|
||||||
|
|
||||||
# Everything
|
|
||||||
make test-all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
|
||||||
djarea/
|
|
||||||
django/ Python package
|
|
||||||
react/ TypeScript package
|
|
||||||
example/ Integration test backend
|
|
||||||
e2e/ Playwright E2E tests
|
|
||||||
Makefile Test orchestration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Disclosure
|
|
||||||
|
|
||||||
Djarea was developed with the assistance of IDE AI Assistance and later with Claude Code.
|
|
||||||
|
|
||||||
The architecture, design decisions, developer experience standards and technical direction are mine. I've been programming for 16 years and have a lot of opinions!
|
|
||||||
|
|
||||||
DX ideas are inspired by the amazing work of these projects and the hardworking folks behind them:
|
|
||||||
- Django Ninja
|
|
||||||
- Django Readers
|
|
||||||
- Django RAPID Architecture
|
|
||||||
- React
|
|
||||||
- Next.js
|
|
||||||
|
|||||||
93
ROADMAP.md
Normal file
93
ROADMAP.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Mizan Roadmap
|
||||||
|
|
||||||
|
## v1 — Django + Multi-Framework (React, Vue, Svelte)
|
||||||
|
|
||||||
|
### Done
|
||||||
|
|
||||||
|
- [x] **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
|
||||||
|
- [x] **`ReactContext` class** — type-safe context/affects references with linting
|
||||||
|
- [x] **Named contexts** — functions sharing a context name grouped into one provider and one fetch
|
||||||
|
- [x] **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
|
||||||
|
- [x] **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
|
||||||
|
- [x] **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
|
||||||
|
- [x] **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
|
||||||
|
- [x] **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form
|
||||||
|
- [x] **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
|
||||||
|
- [x] **JWT + session auth** — auto-detected, CSRF handled
|
||||||
|
- [x] **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
|
||||||
|
- [x] **Shapes** — Pydantic + django-readers for typed query projections
|
||||||
|
- [x] **WebSocket channels** — real-time bidirectional communication
|
||||||
|
- [x] **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
|
||||||
|
- [x] **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions; deterministic (sorted) output
|
||||||
|
- [x] **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC; the worker resolves components by file path (`import(file)` + `renderToString`)
|
||||||
|
- [x] **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
|
||||||
|
- [x] **Rust codegen** — `protocol/mizan-codegen`, a Rust binary reading KDL IR and emitting per-target clients (react, vue, svelte, channels, stage1, python, rust), each byte-parity-tested. `mizan-generate` is the thin npm launcher.
|
||||||
|
- [x] **React wrapper layer** — codegen emits the `MizanContext` root provider, `useMizan` escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`
|
||||||
|
- [x] **Additional backend adapters** — `mizan-ts` (TypeScript), `mizan-rust-axum` (Rust/Axum with three-way parity), `mizan-tauri`
|
||||||
|
- [x] **Frontend transports** — `mizan-tauri-transport`, `mizan-webview-transport`, `mizan-webview-channels`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
- [ ] **Vue / Svelte runtime packages** — `frontends/mizan-vue` and `frontends/mizan-svelte` are unimplemented stubs. The codegen emits their clients (byte-parity-tested), but a kernel-adapter runtime package and a live-backend example are owed for each.
|
||||||
|
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
||||||
|
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
||||||
|
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
||||||
|
- [ ] **Cache hardening** — purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
||||||
|
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 Edge
|
||||||
|
|
||||||
|
Cloudflare Workers for automatic edge caching.
|
||||||
|
|
||||||
|
- Reads the edge manifest to configure cache rules
|
||||||
|
- Context GETs cached at edge, keyed by context name + params
|
||||||
|
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
|
||||||
|
- Reads JSON `invalidate` key from RPC responses for the same purpose
|
||||||
|
- Resolves URL patterns from manifest to purge view pages
|
||||||
|
- Zero configuration — the manifest IS the cache policy
|
||||||
|
|
||||||
|
### Mizan Render
|
||||||
|
|
||||||
|
SSR at the edge via Cloudflare Workers.
|
||||||
|
|
||||||
|
- The Bun SSR bridge, running on Cloudflare instead of colocated with Django
|
||||||
|
- Context data fetched from Django (or edge cache), rendered at the edge
|
||||||
|
- HTML response streamed to the user from the nearest PoP
|
||||||
|
|
||||||
|
### Mizan Deploy
|
||||||
|
|
||||||
|
One-command deployment for Django + React apps.
|
||||||
|
|
||||||
|
- Container orchestration (AWS/Azure)
|
||||||
|
- Edge + Render auto-configured
|
||||||
|
- `mizan deploy` from the CLI
|
||||||
|
- The Vercel experience for Django
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Wire protocol shapes (context fetch, mutation call, invalidation transports) are documented in `CLAUDE.md`. Architectural details for specific subsystems live in `docs/`:
|
||||||
|
|
||||||
|
- `docs/AFI_ARCHITECTURE.md` — package architecture, kernel model, adapter strategy
|
||||||
|
- `docs/CACHE_KEYING.md` — HMAC cache key derivation
|
||||||
|
- `docs/MWT_SPEC.md` — Mizan Web Token format
|
||||||
|
- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge
|
||||||
|
- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer
|
||||||
|
- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers
|
||||||
206
backends/mizan-django/README.md
Normal file
206
backends/mizan-django/README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# mizan-django
|
||||||
|
|
||||||
|
Django backend adapter for the Mizan protocol. One decorator on a server
|
||||||
|
function. Typed React client generated. Invalidation automatic.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add "mizan[channels]"
|
||||||
|
# or with allauth integration:
|
||||||
|
uv add "mizan[channels,allauth]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
INSTALLED_APPS = ["mizan", "myapp", ...]
|
||||||
|
|
||||||
|
MIZAN_CACHE_SECRET = "..." # 32-byte HMAC signing key
|
||||||
|
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
|
||||||
|
MIZAN_MWT_SECRET = "..." # MWT signing key (separate from cache + JWT)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# urls.py
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("api/mizan/", include("mizan.urls")),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# asgi.py — for WebSocket / Channels support
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
from mizan import wrap_asgi
|
||||||
|
|
||||||
|
application = wrap_asgi(get_asgi_application())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define server functions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# myapp/clients.py
|
||||||
|
from mizan.client import client
|
||||||
|
from mizan.setup import register
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@client
|
||||||
|
def echo(request, text: str) -> EchoOutput:
|
||||||
|
return EchoOutput(message=text)
|
||||||
|
|
||||||
|
|
||||||
|
register(echo, "echo")
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-discover `clients.py` modules from each Django app:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# myapp/apps.py
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MyAppConfig(AppConfig):
|
||||||
|
name = "myapp"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
from mizan.setup import mizan_clients
|
||||||
|
mizan_clients("myapp") # imports myapp/clients.py — triggers @client side effects
|
||||||
|
```
|
||||||
|
|
||||||
|
## `@client` parameters
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client # plain RPC function
|
||||||
|
@client(context="global") # singleton context — fetched once, SSR-hydrated
|
||||||
|
@client(context="user") # named context — fetched per provider mount
|
||||||
|
@client(affects="user") # mutation — invalidates the user context
|
||||||
|
@client(affects=user_profile) # mutation — invalidates a specific function
|
||||||
|
@client(websocket=True) # WebSocket transport (requires channels)
|
||||||
|
@client(auth=True) # requires authentication
|
||||||
|
@client(auth="staff") # requires is_staff
|
||||||
|
@client(auth="superuser") # requires is_superuser
|
||||||
|
@client(auth=lambda req: ...) # custom predicate
|
||||||
|
@client(route="/profile/<id>/") # view-path function (returns HttpResponse)
|
||||||
|
@client(rev=2) # cache revision (busts on bump)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
Django Forms become server functions + typed React hooks with Zod validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django import forms
|
||||||
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
|
|
||||||
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(name="contact", title="Contact Us", submit_label="Send")
|
||||||
|
|
||||||
|
name = forms.CharField()
|
||||||
|
email = forms.EmailField()
|
||||||
|
message = forms.CharField(widget=forms.Textarea)
|
||||||
|
|
||||||
|
def on_submit_success(self, request):
|
||||||
|
send_email(self.cleaned_data)
|
||||||
|
return {"sent": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
||||||
|
gets `useContactForm()`.
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
WebSocket-native RPC via a flag flip:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
|
|
||||||
|
class ChatChannel(ReactChannel):
|
||||||
|
class Params(BaseModel):
|
||||||
|
room: str
|
||||||
|
|
||||||
|
class DjangoMessage(BaseModel):
|
||||||
|
text: str
|
||||||
|
user: str
|
||||||
|
|
||||||
|
def authorize(self, params):
|
||||||
|
return self.user.is_authenticated
|
||||||
|
|
||||||
|
def group(self, params):
|
||||||
|
return f"chat_{params.room}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend gets `useChatChannel({ room })`.
|
||||||
|
|
||||||
|
## Generate the frontend
|
||||||
|
|
||||||
|
The codegen is the `mizan-generate` Rust binary (source at
|
||||||
|
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
|
||||||
|
launcher that dispatches to the platform binary). From your frontend
|
||||||
|
project, point a `mizan.toml` at the Django backend and run the CLI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# frontend/mizan.toml
|
||||||
|
output = "src/api"
|
||||||
|
targets = ["react"]
|
||||||
|
|
||||||
|
[source.django]
|
||||||
|
manage_path = "../backend/manage.py"
|
||||||
|
command = ["uv", "run", "python"] # optional — defaults to ["python"]
|
||||||
|
|
||||||
|
[source.django.env]
|
||||||
|
PYTHONPATH = "../backend"
|
||||||
|
DJANGO_SETTINGS_MODULE = "myproject.settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mizan-generate --config mizan.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The codegen drives Django's management command (`export_mizan_ir`) under
|
||||||
|
the hood, parses the emitted KDL IR, then emits Stage 1 (typed
|
||||||
|
`callXxx`/`fetchXxx` over the runtime kernel) + Stage 2 (`<MizanContext>`
|
||||||
|
provider, per-context providers, `use{Hook}()` hooks) into `src/api/`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.tsx
|
||||||
|
import { MizanContext } from "./api"
|
||||||
|
|
||||||
|
export default function App({ children }) {
|
||||||
|
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// any component
|
||||||
|
import { useEcho, useCurrentUser } from "./api"
|
||||||
|
|
||||||
|
const echo = useEcho()
|
||||||
|
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
|
||||||
|
|
||||||
|
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev --extra channels
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
mizan-django is one of two reference backend adapters (the other is
|
||||||
|
`backends/mizan-fastapi`). Both implement the same Mizan protocol on top of
|
||||||
|
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
|
||||||
|
keys). See `docs/AFI_ARCHITECTURE.md`.
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "djarea"
|
name = "mizan"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
license = "Elastic-2.0"
|
||||||
description = "Django + React server functions framework"
|
description = "Django + React server functions framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"mizan-core",
|
||||||
"django>=5.0",
|
"django>=5.0",
|
||||||
"django-ninja>=1.0",
|
"django-ninja>=1.0",
|
||||||
"django-readers>=2.0",
|
"django-readers>=2.0",
|
||||||
@@ -12,7 +14,13 @@ dependencies = [
|
|||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
cache = [
|
||||||
|
"redis>=5.0",
|
||||||
|
]
|
||||||
channels = [
|
channels = [
|
||||||
"channels>=4.0",
|
"channels>=4.0",
|
||||||
"channels-redis>=4.0",
|
"channels-redis>=4.0",
|
||||||
@@ -36,11 +44,11 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/djarea"]
|
packages = ["src/mizan"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
pythonpath = ["src", "."]
|
pythonpath = ["src", "."]
|
||||||
testpaths = ["src/djarea/tests"]
|
testpaths = ["src/mizan/tests"]
|
||||||
python_classes = ["*Tests", "*Test", "Test*"]
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea - Django + React unified framework
|
mizan - Django + React unified framework
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
Server functions are the core primitive. Everything else builds on them.
|
||||||
|
|
||||||
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
|
|||||||
|
|
||||||
### 1. urls.py - HTTP endpoint
|
### 1. urls.py - HTTP endpoint
|
||||||
```python
|
```python
|
||||||
from djarea import urls as djarea_urls
|
from mizan import urls as mizan_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('api/djarea/', include(djarea_urls)),
|
path('api/mizan/', include(mizan_urls)),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. asgi.py - WebSocket support (optional)
|
### 2. asgi.py - WebSocket support (optional)
|
||||||
```python
|
```python
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
|
|||||||
### 3. Define server functions
|
### 3. Define server functions
|
||||||
```python
|
```python
|
||||||
# apps/myapp/clients.py
|
# apps/myapp/clients.py
|
||||||
from djarea import client
|
from mizan import client
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
class EchoOutput(BaseModel):
|
||||||
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
|
|||||||
```python
|
```python
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from djarea.setup import djarea_clients
|
from mizan.setup import mizan_clients
|
||||||
djarea_clients('apps')
|
mizan_clients('apps')
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Frontend - generate types and use
|
### 5. Frontend - generate types and use
|
||||||
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
|
|||||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||||
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP |
|
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -88,12 +88,13 @@ from . import forms
|
|||||||
from . import setup
|
from . import setup
|
||||||
from .channels import ReactChannel
|
from .channels import ReactChannel
|
||||||
from .channels import register as register_channel
|
from .channels import register as register_channel
|
||||||
from .client import ComposedContext, ServerFunction, client, compose
|
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
from .setup import (
|
from .setup import (
|
||||||
djarea_clients,
|
mizan_clients,
|
||||||
djarea_module,
|
mizan_module,
|
||||||
get_channel,
|
get_channel,
|
||||||
get_function,
|
get_function,
|
||||||
register,
|
register,
|
||||||
@@ -104,9 +105,9 @@ 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 djarea_patterns
|
from .urls import urlpatterns as mizan_patterns
|
||||||
|
|
||||||
return djarea_patterns
|
return mizan_patterns
|
||||||
if name == "Shape":
|
if name == "Shape":
|
||||||
from .shapes import Shape
|
from .shapes import Shape
|
||||||
|
|
||||||
@@ -116,11 +117,11 @@ def __getattr__(name):
|
|||||||
|
|
||||||
def wrap_asgi(http_application):
|
def wrap_asgi(http_application):
|
||||||
"""
|
"""
|
||||||
Wrap an ASGI application with Djarea WebSocket support.
|
Wrap an ASGI application with mizan WebSocket support.
|
||||||
|
|
||||||
Usage in asgi.py:
|
Usage in asgi.py:
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
|
|
||||||
@@ -156,14 +157,16 @@ def wrap_asgi(http_application):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Decorators
|
# Decorators & Contexts
|
||||||
"client",
|
"client",
|
||||||
"compose",
|
"compose",
|
||||||
|
"ReactContext",
|
||||||
|
"GlobalContext",
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
# Setup
|
# Setup
|
||||||
"djarea_clients",
|
"mizan_clients",
|
||||||
"djarea_module",
|
"mizan_module",
|
||||||
"register",
|
"register",
|
||||||
"register_as",
|
"register_as",
|
||||||
"get_function",
|
"get_function",
|
||||||
40
backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md
vendored
Normal file
40
backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Cache Module — Known Issues
|
||||||
|
|
||||||
|
Open issues against the current cache implementation. Resolved items are
|
||||||
|
removed once their fix lands.
|
||||||
|
|
||||||
|
## Correctness
|
||||||
|
|
||||||
|
### Purge race condition (non-atomic index operations)
|
||||||
|
`cache_purge` reads the index and deletes as separate operations. A
|
||||||
|
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||||
|
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||||
|
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||||
|
|
||||||
|
### Cross-language stringification divergence
|
||||||
|
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||||
|
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||||
|
value types are not yet pinned in the protocol spec — so Python and
|
||||||
|
TypeScript HMAC keys can still diverge on an un-normalized type.
|
||||||
|
|
||||||
|
## Performance / Operability
|
||||||
|
|
||||||
|
### Broad purge leaves per-param sub-indexes
|
||||||
|
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||||
|
sub-indexes — a slow Redis memory leak.
|
||||||
|
|
||||||
|
### No thundering-herd protection
|
||||||
|
Concurrent cold misses on the same key all execute and write. No
|
||||||
|
single-flight / request-coalescing.
|
||||||
|
|
||||||
|
## API shape
|
||||||
|
|
||||||
|
### cache_get / cache_put argument inconsistency
|
||||||
|
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||||
|
inputs from module globals — two access patterns for one concern.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
### RedisCache lacks test coverage
|
||||||
|
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||||
|
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||||
142
backends/mizan-django/src/mizan/cache/__init__.py
vendored
Normal file
142
backends/mizan-django/src/mizan/cache/__init__.py
vendored
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
||||||
|
|
||||||
|
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
||||||
|
Scoped purge recomputes the key and deletes directly.
|
||||||
|
Broad purge uses key-prefix scan (rare operation).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mizan_core.cache.backend import CacheBackend, MemoryCache, RedisCache
|
||||||
|
from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX
|
||||||
|
|
||||||
|
logger = logging.getLogger("mizan.cache")
|
||||||
|
|
||||||
|
_cache_instance: CacheBackend | None = None
|
||||||
|
_initialized = False
|
||||||
|
_init_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache() -> CacheBackend | None:
|
||||||
|
"""
|
||||||
|
Get the configured cache backend, or None if caching is disabled.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
global _cache_instance, _initialized
|
||||||
|
if _initialized:
|
||||||
|
return _cache_instance
|
||||||
|
|
||||||
|
with _init_lock:
|
||||||
|
if _initialized:
|
||||||
|
return _cache_instance
|
||||||
|
|
||||||
|
_initialized = True
|
||||||
|
try:
|
||||||
|
from mizan.setup.settings import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if settings.cache_secret and settings.cache_redis_url:
|
||||||
|
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||||
|
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||||
|
elif settings.cache_secret and not settings.cache_redis_url:
|
||||||
|
logger.warning(
|
||||||
|
"MIZAN_CACHE_SECRET is set but MIZAN_CACHE_REDIS_URL is missing. "
|
||||||
|
"Cache is disabled."
|
||||||
|
)
|
||||||
|
elif settings.cache_redis_url and not settings.cache_secret:
|
||||||
|
logger.warning(
|
||||||
|
"MIZAN_CACHE_REDIS_URL is set but MIZAN_CACHE_SECRET is missing. "
|
||||||
|
"Cache is disabled."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to initialize Mizan cache", exc_info=True)
|
||||||
|
_cache_instance = None
|
||||||
|
|
||||||
|
return _cache_instance
|
||||||
|
|
||||||
|
|
||||||
|
def set_cache(backend: CacheBackend | None) -> None:
|
||||||
|
"""Override the cache backend. For testing."""
|
||||||
|
global _cache_instance, _initialized
|
||||||
|
_cache_instance = backend
|
||||||
|
_initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cache() -> None:
|
||||||
|
"""Reset to uninitialized state. For testing teardown."""
|
||||||
|
global _cache_instance, _initialized
|
||||||
|
_cache_instance = None
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(
|
||||||
|
secret: str,
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
user_id: str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Look up a cached context response."""
|
||||||
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
|
return backend.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_put(
|
||||||
|
secret: str,
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
value: bytes,
|
||||||
|
user_id: str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Store a context response in the cache."""
|
||||||
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
|
backend.set(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_purge(
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Purge cached entries for a context.
|
||||||
|
|
||||||
|
Scoped purge (params provided): recomputes the HMAC key and deletes
|
||||||
|
it directly. One DELETE, no index needed.
|
||||||
|
|
||||||
|
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
||||||
|
This is a rare operation (Tier 3 fallback in invalidation).
|
||||||
|
"""
|
||||||
|
if params is not None and len(params) > 0 and secret:
|
||||||
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
|
return 1 if backend.delete(key) else 0
|
||||||
|
else:
|
||||||
|
prefix = f"{CONTEXT_KEY_PREFIX}{context}:"
|
||||||
|
return backend.delete_by_prefix(prefix)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CacheBackend",
|
||||||
|
"MemoryCache",
|
||||||
|
"RedisCache",
|
||||||
|
"get_cache",
|
||||||
|
"set_cache",
|
||||||
|
"reset_cache",
|
||||||
|
"cache_get",
|
||||||
|
"cache_put",
|
||||||
|
"cache_purge",
|
||||||
|
]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.channels - Real-time WebSocket communication.
|
mizan.channels - Real-time WebSocket communication.
|
||||||
|
|
||||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||||
Hooks are auto-generated with full TypeScript types.
|
Hooks are auto-generated with full TypeScript types.
|
||||||
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
|
|||||||
```python
|
```python
|
||||||
# channels.py
|
# channels.py
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
class ChatChannel(channels.ReactChannel):
|
class ChatChannel(channels.ReactChannel):
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# asgi.py
|
# asgi.py
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Base Classes
|
# Base Classes
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ReactChannel:
|
class ReactChannel:
|
||||||
"""
|
"""
|
||||||
Base class for WebSocket channels.
|
Base class for WebSocket channels.
|
||||||
@@ -140,9 +141,7 @@ class ReactChannel:
|
|||||||
|
|
||||||
Messages returned from receive() are broadcast to this group.
|
Messages returned from receive() are broadcast to this group.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -191,9 +190,9 @@ class ReactChannel:
|
|||||||
"type": "channel.message",
|
"type": "channel.message",
|
||||||
"channel": self._registered_name,
|
"channel": self._registered_name,
|
||||||
"params": self._params_dict,
|
"params": self._params_dict,
|
||||||
"data": message.model_dump(mode='json'),
|
"data": message.model_dump(mode="json"),
|
||||||
"message_type": message.__class__.__name__,
|
"message_type": message.__class__.__name__,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -215,7 +214,9 @@ class ReactChannel:
|
|||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
logger.warning(f"No channel layer configured, cannot push to {cls.__name__}")
|
logger.warning(
|
||||||
|
f"No channel layer configured, cannot push to {cls.__name__}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build params model if defined
|
# Build params model if defined
|
||||||
@@ -234,9 +235,9 @@ class ReactChannel:
|
|||||||
"type": "channel.message",
|
"type": "channel.message",
|
||||||
"channel": cls._registered_name,
|
"channel": cls._registered_name,
|
||||||
"params": params,
|
"params": params,
|
||||||
"data": message.model_dump(mode='json'),
|
"data": message.model_dump(mode="json"),
|
||||||
"message_type": message.__class__.__name__,
|
"message_type": message.__class__.__name__,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
|
|||||||
channel_class._registered_name = name
|
channel_class._registered_name = name
|
||||||
|
|
||||||
# Validate the channel class
|
# Validate the channel class
|
||||||
if not hasattr(channel_class, 'authorize'):
|
if not hasattr(channel_class, "authorize"):
|
||||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||||
if not hasattr(channel_class, 'group'):
|
if not hasattr(channel_class, "group"):
|
||||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||||
|
|
||||||
_registry[name] = channel_class
|
_registry[name] = channel_class
|
||||||
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
|||||||
# WebSocket Consumer
|
# WebSocket Consumer
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_websocket_application():
|
def get_websocket_application():
|
||||||
"""
|
"""
|
||||||
Get the WebSocket application for ASGI.
|
Get the WebSocket application for ASGI.
|
||||||
|
|
||||||
Usage in asgi.py:
|
Usage in asgi.py:
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
@@ -309,9 +311,11 @@ def get_websocket_application():
|
|||||||
from .connection import DjangoReactConsumer
|
from .connection import DjangoReactConsumer
|
||||||
|
|
||||||
return AuthMiddlewareStack(
|
return AuthMiddlewareStack(
|
||||||
URLRouter([
|
URLRouter(
|
||||||
|
[
|
||||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -319,15 +323,14 @@ def get_websocket_application():
|
|||||||
# Schema Export (for TypeScript generation)
|
# Schema Export (for TypeScript generation)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_channels_schema() -> dict:
|
def get_channels_schema() -> dict:
|
||||||
"""
|
"""
|
||||||
Get schema for all registered channels (for TypeScript generation).
|
Get schema for all registered channels (for TypeScript generation).
|
||||||
|
|
||||||
Returns a dict suitable for the frontend code generator.
|
Returns a dict suitable for the frontend code generator.
|
||||||
"""
|
"""
|
||||||
schema = {
|
schema = {"channels": {}}
|
||||||
"channels": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
channel_schema = {
|
channel_schema = {
|
||||||
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Extract Params schema
|
# Extract Params schema
|
||||||
if hasattr(channel_class, 'Params') and 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
|
# Extract ReactMessage schema
|
||||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||||
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"reactMessage"
|
||||||
|
] = channel_class.ReactMessage.model_json_schema()
|
||||||
|
|
||||||
# Extract DjangoMessage schema
|
# Extract DjangoMessage schema
|
||||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||||
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"djangoMessage"
|
||||||
|
] = channel_class.DjangoMessage.model_json_schema()
|
||||||
|
|
||||||
schema["channels"][name] = channel_schema
|
schema["channels"][name] = channel_schema
|
||||||
|
|
||||||
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
|
|
||||||
def endpoint(request, data):
|
def endpoint(request, data):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
endpoint.__annotations__ = {"data": input_cls}
|
endpoint.__annotations__ = {"data": input_cls}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def endpoint(request):
|
def endpoint(request):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||||
|
endpoint
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_channels_openapi_schema() -> dict:
|
def get_channels_openapi_schema() -> dict:
|
||||||
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
|
|
||||||
# Create temporary Ninja API for schema generation only
|
# Create temporary Ninja API for schema generation only
|
||||||
schema_api = NinjaAPI(
|
schema_api = NinjaAPI(
|
||||||
title="Djarea Channels",
|
title="mizan Channels",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="Auto-generated schema for djarea channels",
|
description="Auto-generated schema for mizan channels",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Register Params type
|
# Register Params type
|
||||||
if hasattr(channel_class, 'Params') and 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
|
||||||
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register ReactMessage type
|
# Register ReactMessage type
|
||||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||||
react_name = f"{pascal_name}ReactMessage"
|
react_name = f"{pascal_name}ReactMessage"
|
||||||
schema_classes[react_name] = type(react_name, (channel_class.ReactMessage,), {})
|
schema_classes[react_name] = type(
|
||||||
|
react_name, (channel_class.ReactMessage,), {}
|
||||||
|
)
|
||||||
channel_meta["hasReactMessage"] = True
|
channel_meta["hasReactMessage"] = True
|
||||||
channel_meta["reactMessageType"] = react_name
|
channel_meta["reactMessageType"] = react_name
|
||||||
|
|
||||||
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register DjangoMessage type
|
# Register DjangoMessage type
|
||||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||||
django_name = f"{pascal_name}DjangoMessage"
|
django_name = f"{pascal_name}DjangoMessage"
|
||||||
schema_classes[django_name] = type(django_name, (channel_class.DjangoMessage,), {})
|
schema_classes[django_name] = type(
|
||||||
|
django_name, (channel_class.DjangoMessage,), {}
|
||||||
|
)
|
||||||
channel_meta["hasDjangoMessage"] = True
|
channel_meta["hasDjangoMessage"] = True
|
||||||
channel_meta["djangoMessageType"] = django_name
|
channel_meta["djangoMessageType"] = django_name
|
||||||
|
|
||||||
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||||
|
|
||||||
# Add channel metadata extension
|
# Add channel metadata extension
|
||||||
schema["x-djarea-channels"] = channel_metadata
|
schema["x-mizan-channels"] = channel_metadata
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
@@ -507,6 +523,47 @@ def __getattr__(name):
|
|||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Core Registry Extension
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class _ChannelsExtension:
|
||||||
|
"""
|
||||||
|
Plugs the channel registry into mizan_core.registry as the 'channels'
|
||||||
|
extension. Schema output goes under schema['channels'] in the unified
|
||||||
|
registry export consumed by codegen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def all(self) -> dict:
|
||||||
|
return dict(_registry)
|
||||||
|
|
||||||
|
def schema(self) -> dict:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for name, channel_class in _registry.items():
|
||||||
|
channel_schema: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"type": "channel",
|
||||||
|
"bidirectional": False,
|
||||||
|
}
|
||||||
|
if getattr(channel_class, "Params", None):
|
||||||
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
|
if getattr(channel_class, "ReactMessage", None):
|
||||||
|
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||||
|
channel_schema["bidirectional"] = True
|
||||||
|
if getattr(channel_class, "DjangoMessage", None):
|
||||||
|
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||||
|
out[name] = channel_schema
|
||||||
|
return out
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
|
from mizan_core.registry import register_extension as _register_extension
|
||||||
|
_register_extension("channels", _ChannelsExtension())
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Exports
|
# Exports
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket consumer for djarea.channels.
|
WebSocket consumer for mizan.channels.
|
||||||
|
|
||||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||||
|
|
||||||
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
await self._try_jwt_auth()
|
await self._try_jwt_auth()
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}")
|
logger.debug(
|
||||||
|
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _try_jwt_auth(self):
|
async def _try_jwt_auth(self):
|
||||||
"""
|
"""
|
||||||
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Validate JWT and create JWTUser (no DB query)
|
# Validate JWT and create JWTUser (no DB query)
|
||||||
try:
|
try:
|
||||||
from djarea.client.jwt import decode_token
|
from mizan.client.jwt import decode_token
|
||||||
from djarea.jwt.tokens import JWTUser
|
from mizan.jwt.tokens import JWTUser
|
||||||
|
|
||||||
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:
|
||||||
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
elif action == "rpc":
|
elif action == "rpc":
|
||||||
await self._handle_rpc(content)
|
await self._handle_rpc(content)
|
||||||
else:
|
else:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Unknown action: {action}",
|
"error": f"Unknown action: {action}",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_subscribe(self, content: dict):
|
async def _handle_subscribe(self, content: dict):
|
||||||
"""Handle subscription request."""
|
"""Handle subscription request."""
|
||||||
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
# Get channel class
|
# 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(
|
||||||
|
{
|
||||||
"error": f"Unknown channel: {channel_name}",
|
"error": f"Unknown channel: {channel_name}",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create subscription key
|
# Create subscription key
|
||||||
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Check if already subscribed
|
# Check if already subscribed
|
||||||
if sub_key in self._subscriptions:
|
if sub_key in self._subscriptions:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Already subscribed to {channel_name}",
|
"error": f"Already subscribed to {channel_name}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create channel instance
|
# Create channel instance
|
||||||
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
try:
|
try:
|
||||||
params_obj = channel_class.Params(**params_dict)
|
params_obj = channel_class.Params(**params_dict)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Invalid params: {e}",
|
"error": f"Invalid params: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check authorization
|
# Check authorization
|
||||||
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
authorized = instance.authorize()
|
authorized = instance.authorize()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Authorization error for {channel_name}: {e}")
|
logger.error(f"Authorization error for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "Authorization failed",
|
"error": "Authorization failed",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not authorized:
|
if not authorized:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "Not authorized",
|
"error": "Not authorized",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get group and join
|
# Get group and join
|
||||||
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
await instance._join_group(group_name)
|
await instance._join_group(group_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to join group for {channel_name}: {e}")
|
logger.error(f"Failed to join group for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Failed to subscribe: {e}",
|
"error": f"Failed to subscribe: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store subscription
|
# Store subscription
|
||||||
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||||
|
|
||||||
# Confirm subscription
|
# Confirm subscription
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"subscribed": True,
|
"subscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||||
|
|
||||||
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during unsubscribe: {e}")
|
logger.error(f"Error during unsubscribe: {e}")
|
||||||
|
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"unsubscribed": True,
|
"unsubscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"Unsubscribed from {channel_name}")
|
logger.debug(f"Unsubscribed from {channel_name}")
|
||||||
|
|
||||||
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
instance = self._subscriptions.get(sub_key)
|
instance = self._subscriptions.get(sub_key)
|
||||||
if not instance:
|
if not instance:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Not subscribed to {channel_name}",
|
"error": f"Not subscribed to {channel_name}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_class = instance.__class__
|
channel_class = instance.__class__
|
||||||
|
|
||||||
# Check if channel accepts messages
|
# Check if channel accepts messages
|
||||||
if not channel_class.ReactMessage:
|
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",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse message
|
# Parse message
|
||||||
try:
|
try:
|
||||||
msg = channel_class.ReactMessage(**data)
|
msg = channel_class.ReactMessage(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Invalid message: {e}",
|
"error": f"Invalid message: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse params
|
# Parse params
|
||||||
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling message for {channel_name}: {e}")
|
logger.error(f"Error handling message for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Message handling failed: {e}",
|
"error": f"Message handling failed: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_rpc(self, content: dict):
|
async def _handle_rpc(self, content: dict):
|
||||||
"""
|
"""
|
||||||
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
- Function must be explicitly registered (no arbitrary code execution)
|
- Function must be explicitly registered (no arbitrary code execution)
|
||||||
- User context from WebSocket session is passed to function
|
- User context from WebSocket session is passed to function
|
||||||
"""
|
"""
|
||||||
from djarea.client.executor import execute_function, FunctionError
|
from mizan.client.executor import execute_function, FunctionError
|
||||||
from djarea.setup.registry import get_function
|
from mizan_core.registry import get_function
|
||||||
|
|
||||||
request_id = content.get("id")
|
request_id = content.get("id")
|
||||||
fn_name = content.get("fn")
|
fn_name = content.get("fn")
|
||||||
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Validate request structure
|
# Validate request structure
|
||||||
if not request_id:
|
if not request_id:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "RPC request missing 'id' field",
|
"error": "RPC request missing 'id' field",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "BAD_REQUEST",
|
"code": "BAD_REQUEST",
|
||||||
"message": "Missing 'fn' field",
|
"message": "Missing 'fn' field",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if function exists and has websocket=True
|
# 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(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "NOT_FOUND",
|
"code": "NOT_FOUND",
|
||||||
"message": f"Function '{fn_name}' not found",
|
"message": f"Function '{fn_name}' not found",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only allow functions explicitly marked with websocket=True
|
# 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(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "FORBIDDEN",
|
"code": "FORBIDDEN",
|
||||||
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.",
|
"message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create request adapter from WebSocket scope
|
# Create request adapter from WebSocket scope
|
||||||
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None))
|
ws_request = WebSocketRequest(
|
||||||
|
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||||
|
)
|
||||||
|
|
||||||
# Execute function (Pydantic validation happens inside execute_function)
|
# Execute function (Pydantic validation happens inside execute_function)
|
||||||
# This is sync, so we need to run it in a thread pool
|
# This is sync, so we need to run it in a thread pool
|
||||||
@@ -435,7 +473,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Send response
|
# Send response
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
@@ -443,13 +482,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
"message": result.message,
|
"message": result.message,
|
||||||
**({"details": result.details} if result.details else {}),
|
**({"details": result.details} if result.details else {}),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"data": result.data,
|
"data": result.data,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def channel_message(self, event: dict):
|
async def channel_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
Called when channel_layer.group_send() is used.
|
Called when channel_layer.group_send() is used.
|
||||||
Includes channel name and params so the client can route the message.
|
Includes channel name and params so the client can route the message.
|
||||||
"""
|
"""
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"channel": event.get("channel"),
|
"channel": event.get("channel"),
|
||||||
"params": event.get("params", {}),
|
"params": event.get("params", {}),
|
||||||
"type": event.get("message_type", "message"),
|
"type": event.get("message_type", "message"),
|
||||||
"data": event.get("data", {}),
|
"data": event.get("data", {}),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def push_message(self, event: dict):
|
async def push_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
Protocol:
|
Protocol:
|
||||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||||
"""
|
"""
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"type": "push",
|
"type": "push",
|
||||||
"topic": event.get("topic"),
|
"topic": event.get("topic"),
|
||||||
"data": event.get("data", {}),
|
"data": event.get("data", {}),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Push - Server-initiated messages to clients.
|
mizan Push - Server-initiated messages to clients.
|
||||||
|
|
||||||
Simple API for pushing data to subscribed WebSocket connections.
|
Simple API for pushing data to subscribed WebSocket connections.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# In a server function - push to all subscribers
|
# In a server function - push to all subscribers
|
||||||
from djarea.push import push
|
from mizan.push import push
|
||||||
|
|
||||||
push("room:42", {"type": "new_message", "data": {...}})
|
push("room:42", {"type": "new_message", "data": {...}})
|
||||||
|
|
||||||
# Subscribe a connection to a topic (call during context fetch)
|
# Subscribe a connection to a topic (call during context fetch)
|
||||||
from djarea.push import subscribe
|
from mizan.push import subscribe
|
||||||
|
|
||||||
subscribe(request, "room:42")
|
subscribe(request, "room:42")
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
|||||||
"""Get channel layer, returning None if channels is not installed."""
|
"""Get channel layer, returning None if channels is not installed."""
|
||||||
try:
|
try:
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
return get_channel_layer()
|
return get_channel_layer()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
return None
|
||||||
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
|||||||
def _async_to_sync(coro):
|
def _async_to_sync(coro):
|
||||||
"""Wrapper for async_to_sync that handles missing channels."""
|
"""Wrapper for async_to_sync that handles missing channels."""
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
return async_to_sync(coro)
|
return async_to_sync(coro)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).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
|
||||||
)
|
)
|
||||||
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
"type": "push.message", # Maps to push_message handler in consumer
|
"type": "push.message", # Maps to push_message handler in consumer
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
|
|||||||
"type": "push.message",
|
"type": "push.message",
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
djarea.client - Server function implementation.
|
mizan.client - Server function implementation.
|
||||||
|
|
||||||
This subpackage contains everything needed to make server functions work:
|
This subpackage contains everything needed to make server functions work:
|
||||||
- The @client decorator
|
- The @client decorator (lives in mizan_core.client.function)
|
||||||
- ServerFunction base class
|
- ServerFunction base class (mizan_core.client.function)
|
||||||
- Function execution logic
|
- Function execution logic (.executor — Django-specific dispatch)
|
||||||
- JWT authentication (integral to server functions)
|
- JWT authentication (.jwt — Django-specific session integration)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from djarea.client import client, ServerFunction, compose
|
from mizan.client import client, ServerFunction, compose
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .function import (
|
# Register the Django framework response base so view-path detection works
|
||||||
|
# in mizan_core.client.function. Has to happen before any @client-decorated
|
||||||
|
# code is evaluated.
|
||||||
|
from django.http import HttpResponseBase as _HttpResponseBase
|
||||||
|
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||||
|
_set_response_base(_HttpResponseBase)
|
||||||
|
|
||||||
|
|
||||||
|
from mizan_core.client.function import (
|
||||||
# Decorator
|
# Decorator
|
||||||
client,
|
client,
|
||||||
|
# Context markers
|
||||||
|
ReactContext,
|
||||||
|
GlobalContext,
|
||||||
# Base classes
|
# Base classes
|
||||||
ServerFunction,
|
ServerFunction,
|
||||||
ComposedContext,
|
ComposedContext,
|
||||||
@@ -39,6 +50,9 @@ from .executor import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Decorator
|
# Decorator
|
||||||
"client",
|
"client",
|
||||||
|
# Context markers
|
||||||
|
"ReactContext",
|
||||||
|
"GlobalContext",
|
||||||
# Base classes
|
# Base classes
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
1009
backends/mizan-django/src/mizan/client/executor.py
Normal file
1009
backends/mizan-django/src/mizan/client/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.client.jwt - JWT authentication for server functions.
|
mizan.client.jwt - JWT authentication for server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
- Server functions for obtaining/refreshing JWT tokens
|
||||||
@@ -9,12 +9,12 @@ Server Functions:
|
|||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
- jwt_refresh: Refresh tokens using a refresh token
|
||||||
|
|
||||||
Note: This module is purpose-built for Djarea server functions.
|
Note: This module is purpose-built for mizan server functions.
|
||||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Token utilities (re-exports from django_jwt_session)
|
# Token utilities (re-exports from django_jwt_session)
|
||||||
from djarea.jwt.tokens import (
|
from mizan.jwt.tokens import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
from djarea.jwt.settings import get_settings, JWTSettings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Token utilities
|
# Token utilities
|
||||||
156
backends/mizan-django/src/mizan/export/__init__.py
Normal file
156
backends/mizan-django/src/mizan/export/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Mizan Edge Manifest Generator.
|
||||||
|
|
||||||
|
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||||
|
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||||
|
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||||
|
codegen, the manifest drives CDN purging.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_registry
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"generate_edge_manifest",
|
||||||
|
"generate_edge_manifest_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||||
|
patterns and params for CDN cache purging.
|
||||||
|
|
||||||
|
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||||
|
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||||
|
1. Looks up 'user' in the manifest
|
||||||
|
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||||
|
3. Purges the resolved URLs + the context API endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
|
view_urls: Optional mapping of context names to URL patterns for
|
||||||
|
view-path functions. These are URLs that Edge should
|
||||||
|
also purge when a context is invalidated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
groups = get_context_groups()
|
||||||
|
registry = get_registry()
|
||||||
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in sorted(groups.items()):
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
page_routes: list[str] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
for param_name in input_cls.model_fields:
|
||||||
|
param_names.add(param_name)
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
route = meta.get("route")
|
||||||
|
view_path = meta.get("view_path")
|
||||||
|
|
||||||
|
fn_entry: dict[str, Any] = {
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if view_path else "rpc",
|
||||||
|
}
|
||||||
|
if route:
|
||||||
|
fn_entry["route"] = route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(route)
|
||||||
|
if meta.get("rev"):
|
||||||
|
fn_entry["rev"] = meta["rev"]
|
||||||
|
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||||
|
fn_entry["cache"] = meta["cache"]
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
sorted_params = sorted(param_names)
|
||||||
|
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted_params,
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
|
}
|
||||||
|
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
if not meta.get("affects"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||||
|
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||||
|
|
||||||
|
# Auto-scoped params — function params that match context params
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||||
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
auto_scoped: list[str] = []
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_param_names: set[str] = set()
|
||||||
|
ctx_fns = groups.get(ctx_name, [])
|
||||||
|
for ctx_fn_name in ctx_fns:
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls is None:
|
||||||
|
continue
|
||||||
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
|
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||||
|
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||||
|
for p in fn_params:
|
||||||
|
if p in ctx_param_names and p not in auto_scoped:
|
||||||
|
auto_scoped.append(p)
|
||||||
|
if auto_scoped:
|
||||||
|
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||||
|
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation["route"] = meta["route"]
|
||||||
|
mutation["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
indent: int = 2,
|
||||||
|
) -> str:
|
||||||
|
"""JSON-serialize the Edge manifest."""
|
||||||
|
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
DjareaFormMixin - Turn Django Forms into server functions.
|
mizanFormMixin - Turn Django Forms into server functions.
|
||||||
|
|
||||||
This mixin transforms any Django Form into Djarea server functions,
|
This mixin transforms any Django Form into mizan server functions,
|
||||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||||
while exposing them through the unified server function API.
|
while exposing them through the unified server function API.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from django import forms
|
from django import forms
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
submit_label="Send",
|
submit_label="Send",
|
||||||
@@ -98,7 +98,7 @@ def _create_form_input_schema(
|
|||||||
form = form_class()
|
form = form_class()
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||||
fields_dict = getattr(form_class, 'base_fields', {})
|
fields_dict = getattr(form_class, "base_fields", {})
|
||||||
else:
|
else:
|
||||||
fields_dict = form.fields
|
fields_dict = form.fields
|
||||||
|
|
||||||
@@ -125,9 +125,9 @@ def _create_form_input_schema(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
class DjareaFormMeta(BaseModel):
|
class mizanFormMeta(BaseModel):
|
||||||
"""
|
"""
|
||||||
Configuration for a Djarea form.
|
Configuration for a mizan form.
|
||||||
|
|
||||||
This Pydantic model provides type-safe configuration with full LSP support,
|
This Pydantic model provides type-safe configuration with full LSP support,
|
||||||
and serializes to JSON for the frontend schema.
|
and serializes to JSON for the frontend schema.
|
||||||
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
|
|||||||
enable_formset: bool = False
|
enable_formset: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DjareaFormMixin:
|
class mizanFormMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that exposes a Django Form as Djarea server functions.
|
Mixin that exposes a Django Form as mizan server functions.
|
||||||
|
|
||||||
Add this mixin to any Django Form class along with a `djarea` configuration:
|
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
)
|
)
|
||||||
@@ -197,10 +197,10 @@ class DjareaFormMixin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Configuration - subclasses must define this
|
# Configuration - subclasses must define this
|
||||||
djarea: ClassVar[DjareaFormMeta]
|
mizan: ClassVar[mizanFormMeta]
|
||||||
|
|
||||||
# Track registered forms to avoid duplicate registration
|
# Track registered forms to avoid duplicate registration
|
||||||
_djarea_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]:
|
||||||
@@ -236,9 +236,7 @@ class DjareaFormMixin:
|
|||||||
return result
|
return result
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_submit_failure(
|
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||||
self, request: HttpRequest, errors: "FormValidation"
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Called after form validation fails.
|
Called after form validation fails.
|
||||||
|
|
||||||
@@ -250,23 +248,23 @@ class DjareaFormMixin:
|
|||||||
"""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 djarea config defined
|
# Only register concrete forms with mizan config defined
|
||||||
if _is_concrete_djarea_form(cls):
|
if _is_concrete_mizan_form(cls):
|
||||||
_register_form_as_server_functions(cls)
|
_register_form_as_server_functions(cls)
|
||||||
|
|
||||||
|
|
||||||
def _is_concrete_djarea_form(cls: type) -> bool:
|
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a class is a concrete Djarea form ready for registration.
|
Check if a class is a concrete mizan form ready for registration.
|
||||||
|
|
||||||
A form is concrete if:
|
A form is concrete if:
|
||||||
1. It has a `djarea` attribute that is a DjareaFormMeta instance
|
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||||
2. It inherits from Django's BaseForm
|
2. It inherits from Django's BaseForm
|
||||||
3. It hasn't been registered yet (for this class definition)
|
3. It hasn't been registered yet (for this class definition)
|
||||||
"""
|
"""
|
||||||
# Must have djarea config (check cls.__dict__ to avoid inheriting)
|
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||||
djarea_config = cls.__dict__.get("djarea")
|
mizan_config = cls.__dict__.get("mizan")
|
||||||
if not isinstance(djarea_config, DjareaFormMeta):
|
if not isinstance(mizan_config, mizanFormMeta):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Must be a Django form
|
# Must be a Django form
|
||||||
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if already registered (handle re-imports gracefully)
|
# Check if already registered (handle re-imports gracefully)
|
||||||
if cls.__dict__.get("_djarea_registered", False):
|
if cls.__dict__.get("_mizan_registered", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -282,7 +280,7 @@ def _is_concrete_djarea_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 Djarea server functions.
|
Register a Django Form class as mizan server functions.
|
||||||
|
|
||||||
Creates and registers:
|
Creates and registers:
|
||||||
- {name}.schema - Returns form field definitions
|
- {name}.schema - Returns form field definitions
|
||||||
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import validate_form_instance
|
from .validation_utils import validate_form_instance
|
||||||
from djarea.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from djarea.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
config: DjareaFormMeta = form_class.djarea
|
config: mizanFormMeta = form_class.mizan
|
||||||
form_name = config.name
|
form_name = config.name
|
||||||
|
|
||||||
# Mark as registered
|
# Mark as registered
|
||||||
form_class._djarea_registered = True
|
form_class._mizan_registered = True
|
||||||
|
|
||||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
pascal_name = "".join(
|
||||||
|
word.capitalize()
|
||||||
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||||
@@ -346,7 +347,7 @@ 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 DjareaFormMeta values
|
# Override with mizanFormMeta values
|
||||||
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:
|
||||||
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
request = self.request
|
request = self.request
|
||||||
|
|
||||||
# Check if we have multipart data from executor
|
# Check if we have multipart data from executor
|
||||||
if hasattr(request, "_djarea_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
data = request._djarea_form_data
|
data = request._mizan_form_data
|
||||||
files = request._djarea_form_files
|
files = request._mizan_form_files
|
||||||
elif input is not None:
|
elif input is not None:
|
||||||
# JSON input - already a dict
|
# 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()
|
||||||
@@ -474,17 +475,25 @@ def _register_formset_functions(
|
|||||||
"""Register formset server functions for a form."""
|
"""Register formset server functions for a form."""
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
|
||||||
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation
|
from .schemas import (
|
||||||
|
FormsetSchema,
|
||||||
|
FormsetSubmitFail,
|
||||||
|
FormsetSubmitPass,
|
||||||
|
FormsetValidation,
|
||||||
|
)
|
||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import build_formset_validation
|
from .validation_utils import build_formset_validation
|
||||||
from .formset_utils import forms_to_formset_post_data
|
from .formset_utils import forms_to_formset_post_data
|
||||||
from djarea.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from djarea.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
|
# Generate PascalCase name for schemas
|
||||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
pascal_name = "".join(
|
||||||
|
word.capitalize()
|
||||||
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||||
@@ -590,10 +599,10 @@ def _register_formset_functions(
|
|||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Handle multipart vs JSON
|
# Handle multipart vs JSON
|
||||||
if hasattr(request, "_djarea_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
post_data = request._djarea_form_data
|
post_data = request._mizan_form_data
|
||||||
files = request._djarea_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
|
# 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)
|
||||||
@@ -621,3 +630,48 @@ def _register_formset_functions(
|
|||||||
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
|
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
|
||||||
FormsetSubmitFunction.Output = FormsetSubmitPass
|
FormsetSubmitFunction.Output = FormsetSubmitPass
|
||||||
register(FormsetSubmitFunction, f"{form_name}.formset.submit")
|
register(FormsetSubmitFunction, f"{form_name}.formset.submit")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def register_form(
|
||||||
|
form_class: type,
|
||||||
|
name: str,
|
||||||
|
submit_handler: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a Django Form class as Mizan server functions.
|
||||||
|
|
||||||
|
Creates and registers `{name}.schema`, `{name}.validate`, and
|
||||||
|
`{name}.submit` (if a submit_handler is provided).
|
||||||
|
"""
|
||||||
|
from mizan_core.client.function import create_form_functions
|
||||||
|
from mizan_core.registry import register
|
||||||
|
|
||||||
|
schema_fn, validate_fn, submit_fn = create_form_functions(
|
||||||
|
form_class, name, submit_handler
|
||||||
|
)
|
||||||
|
register(schema_fn, f"{name}.schema")
|
||||||
|
register(validate_fn, f"{name}.validate")
|
||||||
|
if submit_fn:
|
||||||
|
register(submit_fn, f"{name}.submit")
|
||||||
|
|
||||||
|
|
||||||
|
def get_forms() -> dict[str, list]:
|
||||||
|
"""
|
||||||
|
Group registered form-related functions by their form name.
|
||||||
|
|
||||||
|
Returns a mapping like:
|
||||||
|
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
||||||
|
"""
|
||||||
|
from mizan_core.registry import get_all_functions
|
||||||
|
|
||||||
|
forms: dict[str, list] = {}
|
||||||
|
for name, cls in get_all_functions().items():
|
||||||
|
meta = getattr(cls, "_meta", {})
|
||||||
|
if not meta.get("form"):
|
||||||
|
continue
|
||||||
|
form_name = meta.get("form_name")
|
||||||
|
forms.setdefault(form_name, []).append(cls)
|
||||||
|
return forms
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Allauth Integration
|
mizan Allauth Integration
|
||||||
|
|
||||||
Backend support for django-allauth with Djarea server functions.
|
Backend support for django-allauth with mizan server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
- Auth contexts (auth_status, user) - required by frontend allauth module
|
||||||
@@ -11,8 +11,8 @@ Usage:
|
|||||||
# In your app's apps.py
|
# In your app's apps.py
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import djarea.allauth.forms # noqa - registers forms
|
import mizan.allauth.forms # noqa - registers forms
|
||||||
import djarea.allauth.contexts # noqa - registers contexts
|
import mizan.allauth.contexts # noqa - registers contexts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Auth contexts for Djarea Allauth integration.
|
Auth contexts for mizan Allauth integration.
|
||||||
|
|
||||||
These are the core auth primitives that the frontend allauth module depends on.
|
These are the core auth primitives that the frontend allauth module depends on.
|
||||||
Separated into two concerns:
|
Separated into two concerns:
|
||||||
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -23,13 +23,14 @@ from djarea.client import client
|
|||||||
|
|
||||||
class AuthStatusOutput(BaseModel):
|
class AuthStatusOutput(BaseModel):
|
||||||
"""Authentication status and permission guards."""
|
"""Authentication status and permission guards."""
|
||||||
|
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
is_staff: bool = False
|
is_staff: bool = False
|
||||||
is_superuser: bool = False
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
@client(context='global')
|
@client(context="global")
|
||||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||||
"""
|
"""
|
||||||
Auth status context - provides authentication state and guards.
|
Auth status context - provides authentication state and guards.
|
||||||
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
|||||||
|
|
||||||
class UserOutput(BaseModel):
|
class UserOutput(BaseModel):
|
||||||
"""Full user profile data."""
|
"""Full user profile data."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
first_name: str = ""
|
first_name: str = ""
|
||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
@client(context='global')
|
@client(context="global")
|
||||||
def user(request: HttpRequest) -> UserOutput | None:
|
def user(request: HttpRequest) -> UserOutput | None:
|
||||||
"""
|
"""
|
||||||
User profile context - provides full user data.
|
User profile context - provides full user data.
|
||||||
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if we have full user data or just JWT claims
|
# Check if we have full user data or just JWT claims
|
||||||
if hasattr(req_user, 'email') and req_user.email:
|
if hasattr(req_user, "email") and req_user.email:
|
||||||
# Full User object (session auth)
|
# Full User object (session auth)
|
||||||
return UserOutput(
|
return UserOutput(
|
||||||
id=req_user.id,
|
id=req_user.id,
|
||||||
email=req_user.email,
|
email=req_user.email,
|
||||||
first_name=getattr(req_user, 'first_name', '') or '',
|
first_name=getattr(req_user, "first_name", "") or "",
|
||||||
last_name=getattr(req_user, 'last_name', '') or '',
|
last_name=getattr(req_user, "last_name", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# JWTUser - need to fetch from DB
|
# JWTUser - need to fetch from DB
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
|
|||||||
return UserOutput(
|
return UserOutput(
|
||||||
id=db_user.id,
|
id=db_user.id,
|
||||||
email=db_user.email,
|
email=db_user.email,
|
||||||
first_name=db_user.first_name or '',
|
first_name=db_user.first_name or "",
|
||||||
last_name=db_user.last_name or '',
|
last_name=db_user.last_name or "",
|
||||||
)
|
)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Allauth forms as Djarea server functions.
|
Allauth forms as mizan server functions.
|
||||||
|
|
||||||
This module wraps allauth forms with DjareaFormMixin, exposing them as
|
This module wraps allauth forms with mizanFormMixin, exposing them as
|
||||||
typed server functions for the React frontend.
|
typed server functions for the React frontend.
|
||||||
|
|
||||||
Each form becomes three server functions:
|
Each form becomes three server functions:
|
||||||
@@ -13,7 +13,7 @@ Import this module in your app's ready() to register the forms:
|
|||||||
|
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import djarea.allauth.forms # noqa
|
import mizan.allauth.forms # noqa
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
# Account forms
|
# Account forms
|
||||||
from allauth.account.forms import (
|
from allauth.account.forms import (
|
||||||
@@ -41,6 +41,7 @@ from allauth.account.forms import (
|
|||||||
# Password reauthentication form - conditionally import
|
# Password reauthentication form - conditionally import
|
||||||
try:
|
try:
|
||||||
from allauth.account.forms import ReauthenticateForm
|
from allauth.account.forms import ReauthenticateForm
|
||||||
|
|
||||||
HAS_REAUTH = True
|
HAS_REAUTH = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_REAUTH = False
|
HAS_REAUTH = False
|
||||||
@@ -51,6 +52,7 @@ try:
|
|||||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
||||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
||||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
||||||
|
|
||||||
HAS_MFA = True
|
HAS_MFA = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_MFA = False
|
HAS_MFA = False
|
||||||
@@ -58,22 +60,24 @@ except ImportError:
|
|||||||
# WebAuthn forms (if available)
|
# WebAuthn forms (if available)
|
||||||
try:
|
try:
|
||||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
||||||
|
|
||||||
HAS_WEBAUTHN = True
|
HAS_WEBAUTHN = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_WEBAUTHN = False
|
HAS_WEBAUTHN = False
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from djarea.forms.schemas import FormValidation
|
from mizan.forms.schemas import FormValidation
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Account Forms
|
# Account Forms
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
|
||||||
|
class mizanLoginForm(LoginForm, mizanFormMixin):
|
||||||
"""Sign in with email and password."""
|
"""Sign in with email and password."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="login",
|
name="login",
|
||||||
title="Sign In",
|
title="Sign In",
|
||||||
subtitle="Welcome back. Enter your credentials to continue.",
|
subtitle="Welcome back. Enter your credentials to continue.",
|
||||||
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
class mizanSignupForm(SignupForm, mizanFormMixin):
|
||||||
"""Create a new account."""
|
"""Create a new account."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="signup",
|
name="signup",
|
||||||
title="Create Account",
|
title="Create Account",
|
||||||
subtitle="Enter your details to get started.",
|
subtitle="Enter your details to get started.",
|
||||||
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
||||||
"""Add another email address to your account."""
|
"""Add another email address to your account."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="add_email",
|
name="add_email",
|
||||||
title="Add Email Address",
|
title="Add Email Address",
|
||||||
subtitle="Add another email address to your account.",
|
subtitle="Add another email address to your account.",
|
||||||
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
||||||
"""Change your account password."""
|
"""Change your account password."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="change_password",
|
name="change_password",
|
||||||
title="Change Password",
|
title="Change Password",
|
||||||
subtitle="Update your password to keep your account secure.",
|
subtitle="Update your password to keep your account secure.",
|
||||||
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
||||||
"""Set a password for accounts created via social login."""
|
"""Set a password for accounts created via social login."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="set_password",
|
name="set_password",
|
||||||
title="Set Password",
|
title="Set Password",
|
||||||
subtitle="Create a password for your account.",
|
subtitle="Create a password for your account.",
|
||||||
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
||||||
"""Request a password reset email."""
|
"""Request a password reset email."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reset_password",
|
name="reset_password",
|
||||||
title="Reset Password",
|
title="Reset Password",
|
||||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
||||||
@@ -185,10 +189,10 @@ class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
||||||
"""Set a new password using a reset key."""
|
"""Set a new password using a reset key."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reset_password_from_key",
|
name="reset_password_from_key",
|
||||||
title="Set New Password",
|
title="Set New Password",
|
||||||
subtitle="Enter your new password below.",
|
subtitle="Enter your new password below.",
|
||||||
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
||||||
"""Request a login code via email."""
|
"""Request a login code via email."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="request_login_code",
|
name="request_login_code",
|
||||||
title="Sign In with Code",
|
title="Sign In with Code",
|
||||||
subtitle="Enter your email address and we'll send you a login code.",
|
subtitle="Enter your email address and we'll send you a login code.",
|
||||||
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
||||||
"""Confirm a login code."""
|
"""Confirm a login code."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="confirm_login_code",
|
name="confirm_login_code",
|
||||||
title="Enter Code",
|
title="Enter Code",
|
||||||
subtitle="Enter the code we sent to your email.",
|
subtitle="Enter the code we sent to your email.",
|
||||||
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
||||||
"""Verify an email with a token."""
|
"""Verify an email with a token."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="user_token",
|
name="user_token",
|
||||||
title="Verify Email",
|
title="Verify Email",
|
||||||
subtitle="Enter the verification code from your email.",
|
subtitle="Enter the verification code from your email.",
|
||||||
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
|||||||
|
|
||||||
# Password reauthentication - conditionally define
|
# Password reauthentication - conditionally define
|
||||||
if HAS_REAUTH:
|
if HAS_REAUTH:
|
||||||
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
|
|
||||||
|
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
||||||
"""Re-authenticate with password for sensitive actions."""
|
"""Re-authenticate with password for sensitive actions."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reauthenticate",
|
name="reauthenticate",
|
||||||
title="Confirm Your Identity",
|
title="Confirm Your Identity",
|
||||||
subtitle="Please enter your password to continue.",
|
subtitle="Please enter your password to continue.",
|
||||||
@@ -280,6 +285,7 @@ if HAS_REAUTH:
|
|||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||||
from allauth.account.internal.flows import reauthentication
|
from allauth.account.internal.flows import reauthentication
|
||||||
|
|
||||||
reauthentication.reauthenticate_by_password(request)
|
reauthentication.reauthenticate_by_password(request)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -289,10 +295,11 @@ if HAS_REAUTH:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
if HAS_MFA:
|
if HAS_MFA:
|
||||||
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
|
|
||||||
|
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
||||||
"""Authenticate with MFA during login."""
|
"""Authenticate with MFA during login."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="mfa_authenticate",
|
name="mfa_authenticate",
|
||||||
title="Two-Factor Authentication",
|
title="Two-Factor Authentication",
|
||||||
subtitle="Enter your authentication code to continue.",
|
subtitle="Enter your authentication code to continue.",
|
||||||
@@ -307,10 +314,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin):
|
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
||||||
"""Re-authenticate with MFA for sensitive actions."""
|
"""Re-authenticate with MFA for sensitive actions."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="mfa_reauthenticate",
|
name="mfa_reauthenticate",
|
||||||
title="Confirm Your Identity",
|
title="Confirm Your Identity",
|
||||||
subtitle="Enter your authentication code to continue.",
|
subtitle="Enter your authentication code to continue.",
|
||||||
@@ -325,10 +332,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin):
|
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
||||||
"""Activate TOTP authenticator."""
|
"""Activate TOTP authenticator."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="activate_totp",
|
name="activate_totp",
|
||||||
title="Set Up Authenticator",
|
title="Set Up Authenticator",
|
||||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
subtitle="Enter the code from your authenticator app to complete setup.",
|
||||||
@@ -343,10 +350,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin):
|
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
||||||
"""Deactivate TOTP authenticator."""
|
"""Deactivate TOTP authenticator."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="deactivate_totp",
|
name="deactivate_totp",
|
||||||
title="Disable Authenticator",
|
title="Disable Authenticator",
|
||||||
subtitle="Enter your password to disable two-factor authentication.",
|
subtitle="Enter your password to disable two-factor authentication.",
|
||||||
@@ -361,10 +368,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin):
|
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
||||||
"""Generate new recovery codes."""
|
"""Generate new recovery codes."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="generate_recovery_codes",
|
name="generate_recovery_codes",
|
||||||
title="Recovery Codes",
|
title="Recovery Codes",
|
||||||
subtitle="Generate new recovery codes for your account.",
|
subtitle="Generate new recovery codes for your account.",
|
||||||
@@ -381,10 +388,11 @@ if HAS_MFA:
|
|||||||
|
|
||||||
|
|
||||||
if HAS_WEBAUTHN:
|
if HAS_WEBAUTHN:
|
||||||
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
|
|
||||||
|
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
||||||
"""Authenticate with WebAuthn security key."""
|
"""Authenticate with WebAuthn security key."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="webauthn_authenticate",
|
name="webauthn_authenticate",
|
||||||
title="Security Key",
|
title="Security Key",
|
||||||
subtitle="Use your security key to authenticate.",
|
subtitle="Use your security key to authenticate.",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.jwt - JWT authentication for server functions.
|
mizan.jwt - JWT authentication for server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
- Server functions for obtaining/refreshing JWT tokens
|
||||||
@@ -10,10 +10,10 @@ Server Functions:
|
|||||||
- jwt_refresh: Refresh tokens using a refresh token
|
- jwt_refresh: Refresh tokens using a refresh token
|
||||||
|
|
||||||
Usage in apps.py or urls.py (to register the functions):
|
Usage in apps.py or urls.py (to register the functions):
|
||||||
import djarea.jwt.functions # noqa: F401
|
import mizan.jwt.functions # noqa: F401
|
||||||
|
|
||||||
Note: This module is purpose-built for Djarea server functions.
|
Note: This module is purpose-built for mizan server functions.
|
||||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Server functions (import to register with @client decorator)
|
# Server functions (import to register with @client decorator)
|
||||||
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
|
|||||||
|
|
||||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||||
# django-ninja's settings access at module load time.
|
# django-ninja's settings access at module load time.
|
||||||
# Use: from djarea.jwt.security import jwt_auth
|
# 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 .security import JWTAuth, jwt_auth
|
||||||
|
|
||||||
globals()["JWTAuth"] = JWTAuth
|
globals()["JWTAuth"] = JWTAuth
|
||||||
globals()["jwt_auth"] = jwt_auth
|
globals()["jwt_auth"] = jwt_auth
|
||||||
return globals()[name]
|
return globals()[name]
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
JWT Server Functions
|
JWT & MWT Server Functions
|
||||||
|
|
||||||
JWT token operations exposed as djarea server functions.
|
Token operations exposed as mizan server functions.
|
||||||
Works over WebSocket RPC (primary) or HTTP fallback.
|
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.jwt.tokens import create_token_pair, refresh_tokens
|
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||||
|
from mizan_core.mwt import create_mwt
|
||||||
|
|
||||||
|
|
||||||
class TokenPairOutput(BaseModel):
|
class TokenPairOutput(BaseModel):
|
||||||
"""JWT token pair response."""
|
"""JWT token pair response."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
expires_in: int
|
expires_in: int
|
||||||
@@ -21,6 +23,7 @@ class TokenPairOutput(BaseModel):
|
|||||||
|
|
||||||
class JWTError(BaseModel):
|
class JWTError(BaseModel):
|
||||||
"""JWT operation error."""
|
"""JWT operation error."""
|
||||||
|
|
||||||
error: str
|
error: str
|
||||||
|
|
||||||
|
|
||||||
@@ -45,10 +48,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
|||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
|
|
||||||
# Get session key - for WebSocket, this comes from the scope
|
# Get session key - for WebSocket, this comes from the scope
|
||||||
session = getattr(request, 'session', None)
|
session = getattr(request, "session", None)
|
||||||
if session is None:
|
if session is None:
|
||||||
# WebSocket request adapter - session is a dict, not SessionBase
|
# WebSocket request adapter - session is a dict, not SessionBase
|
||||||
session_key = getattr(request, '_scope', {}).get('session', {}).get('_session_key')
|
session_key = (
|
||||||
|
getattr(request, "_scope", {}).get("session", {}).get("_session_key")
|
||||||
|
)
|
||||||
if not session_key:
|
if not session_key:
|
||||||
raise PermissionError("No session available")
|
raise PermissionError("No session available")
|
||||||
else:
|
else:
|
||||||
@@ -61,8 +66,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
|||||||
tokens = create_token_pair(
|
tokens = create_token_pair(
|
||||||
user.pk,
|
user.pk,
|
||||||
session_key,
|
session_key,
|
||||||
is_staff=getattr(user, 'is_staff', False),
|
is_staff=getattr(user, "is_staff", False),
|
||||||
is_superuser=getattr(user, 'is_superuser', False),
|
is_superuser=getattr(user, "is_superuser", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
return TokenPairOutput(
|
return TokenPairOutput(
|
||||||
@@ -95,3 +100,42 @@ def jwt_refresh(request: HttpRequest, refresh_token: str) -> TokenPairOutput:
|
|||||||
refresh_token=tokens.refresh_token,
|
refresh_token=tokens.refresh_token,
|
||||||
expires_in=tokens.expires_in,
|
expires_in=tokens.expires_in,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── MWT (Mizan Web Token) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MWTOutput(BaseModel):
|
||||||
|
"""MWT token response."""
|
||||||
|
token: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@client
|
||||||
|
def mwt_obtain(request: HttpRequest) -> MWTOutput:
|
||||||
|
"""
|
||||||
|
Obtain a Mizan Web Token from an authenticated session.
|
||||||
|
|
||||||
|
Requires session authentication (cookie-based login).
|
||||||
|
Returns an MWT for the X-Mizan-Token header — stateless,
|
||||||
|
cache-aware authentication with permission staleness detection.
|
||||||
|
|
||||||
|
Usage (from frontend):
|
||||||
|
const { token, expires_in } = await call('mwt_obtain')
|
||||||
|
// Use token in X-Mizan-Token header
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
raise PermissionError("Authentication required")
|
||||||
|
|
||||||
|
from mizan.setup.settings import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not settings.mwt_secret:
|
||||||
|
raise ValueError(
|
||||||
|
"MIZAN_MWT_SECRET is not configured. MWT requires a signing secret."
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_mwt(user, settings.mwt_secret, ttl=settings.mwt_ttl)
|
||||||
|
return MWTOutput(token=token, expires_in=settings.mwt_ttl)
|
||||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from djarea.channels import get_channels_openapi_schema
|
from mizan.channels import get_channels_openapi_schema
|
||||||
|
|
||||||
schema = get_channels_openapi_schema()
|
schema = get_channels_openapi_schema()
|
||||||
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Export Djarea Schema
|
Export Edge Manifest
|
||||||
|
|
||||||
Management command to export the djarea OpenAPI schema for TypeScript code generation.
|
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
||||||
The schema is consumed by openapi-typescript for robust type generation.
|
to configure CDN cache rules and invalidation routing.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py export_djarea_schema # Output to stdout
|
python manage.py export_edge_manifest
|
||||||
python manage.py export_djarea_schema --output schema.json # Output to file
|
python manage.py export_edge_manifest --output mizan-manifest.json
|
||||||
|
python manage.py export_edge_manifest --base-url /api/mizan
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -14,11 +15,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from djarea.export import generate_openapi_schema
|
from mizan.export import generate_edge_manifest
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Export djarea OpenAPI schema for TypeScript code generation"
|
help = "Export Edge manifest for CDN cache invalidation"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -34,18 +35,22 @@ class Command(BaseCommand):
|
|||||||
default=2,
|
default=2,
|
||||||
help="JSON indentation level (0 for compact output)",
|
help="JSON indentation level (0 for compact output)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
type=str,
|
||||||
|
default="/api/mizan",
|
||||||
|
help="Mizan API mount point (default: /api/mizan)",
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
schema = generate_openapi_schema()
|
manifest = generate_edge_manifest(base_url=options["base_url"])
|
||||||
indent = options["indent"] if options["indent"] > 0 else None
|
indent = options["indent"] if options["indent"] > 0 else None
|
||||||
json_output = json.dumps(schema, indent=indent)
|
json_output = json.dumps(manifest, indent=indent, sort_keys=True)
|
||||||
|
|
||||||
if options["output"]:
|
if options["output"]:
|
||||||
output_path = Path(options["output"])
|
output_path = Path(options["output"])
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_path.write_text(json_output)
|
output_path.write_text(json_output)
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Manifest written to {output_path}"))
|
||||||
self.style.SUCCESS(f"Schema written to {output_path}")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.stdout.write(json_output)
|
self.stdout.write(json_output)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Mizan IR (KDL) export — Django management command.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py export_mizan_ir
|
||||||
|
|
||||||
|
Triggers Mizan client discovery to populate the registry, then writes
|
||||||
|
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
||||||
|
consumes this directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||||
|
|
||||||
|
def handle(self, *args, **options) -> None:
|
||||||
|
# Load every project-side @client function so the registry is
|
||||||
|
# populated before we emit. Conventionally apps/*/clients.py.
|
||||||
|
from mizan.setup.discovery import mizan_clients
|
||||||
|
|
||||||
|
mizan_clients("apps")
|
||||||
|
self.stdout.write(build_ir(), ending="")
|
||||||
@@ -1,41 +1,47 @@
|
|||||||
"""
|
"""
|
||||||
djarea.setup - Integration and registration utilities.
|
mizan.setup - Django integration helpers.
|
||||||
|
|
||||||
This subpackage contains everything developers need to integrate Djarea:
|
The function/composition registry now lives in `mizan_core.registry`.
|
||||||
- Registry for server functions and channels
|
Channels register themselves through the channel-specific registry in
|
||||||
- Auto-discovery for apps
|
`mizan.channels`. Forms register through `mizan.forms`. This module
|
||||||
- Configuration settings
|
re-exports the helpers that Django mizan users typically reach for, so
|
||||||
|
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
||||||
Usage:
|
working as a single curated surface.
|
||||||
from djarea.setup import djarea_clients, register, get_function
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .registry import (
|
from mizan_core.registry import (
|
||||||
register,
|
register,
|
||||||
register_as,
|
register_as,
|
||||||
register_form,
|
|
||||||
register_compose,
|
register_compose,
|
||||||
get_function,
|
get_function,
|
||||||
get_channel,
|
|
||||||
get_compose,
|
get_compose,
|
||||||
get_view,
|
|
||||||
get_all_functions,
|
get_all_functions,
|
||||||
get_all_channels,
|
|
||||||
get_all_compositions,
|
get_all_compositions,
|
||||||
get_registry,
|
get_registry,
|
||||||
get_schema,
|
get_schema,
|
||||||
get_contexts,
|
get_contexts,
|
||||||
get_forms,
|
get_context_groups,
|
||||||
|
validate_registry,
|
||||||
clear_registry,
|
clear_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from mizan.channels import (
|
||||||
|
get_channel,
|
||||||
|
get_registered_channels as get_all_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
from mizan.forms import (
|
||||||
|
register_form,
|
||||||
|
get_forms,
|
||||||
|
)
|
||||||
|
|
||||||
from .discovery import (
|
from .discovery import (
|
||||||
djarea_clients,
|
mizan_clients,
|
||||||
djarea_module,
|
mizan_module,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .settings import (
|
from .settings import (
|
||||||
DjareaSettings,
|
mizanSettings,
|
||||||
get_settings,
|
get_settings,
|
||||||
clear_settings_cache,
|
clear_settings_cache,
|
||||||
)
|
)
|
||||||
@@ -50,20 +56,21 @@ __all__ = [
|
|||||||
"get_function",
|
"get_function",
|
||||||
"get_channel",
|
"get_channel",
|
||||||
"get_compose",
|
"get_compose",
|
||||||
"get_view",
|
|
||||||
"get_all_functions",
|
"get_all_functions",
|
||||||
"get_all_channels",
|
"get_all_channels",
|
||||||
"get_all_compositions",
|
"get_all_compositions",
|
||||||
"get_registry",
|
"get_registry",
|
||||||
"get_schema",
|
"get_schema",
|
||||||
"get_contexts",
|
"get_contexts",
|
||||||
|
"get_context_groups",
|
||||||
"get_forms",
|
"get_forms",
|
||||||
|
"validate_registry",
|
||||||
"clear_registry",
|
"clear_registry",
|
||||||
# Discovery
|
# Discovery
|
||||||
"djarea_clients",
|
"mizan_clients",
|
||||||
"djarea_module",
|
"mizan_module",
|
||||||
# Settings
|
# Settings
|
||||||
"DjareaSettings",
|
"mizanSettings",
|
||||||
"get_settings",
|
"get_settings",
|
||||||
"clear_settings_cache",
|
"clear_settings_cache",
|
||||||
]
|
]
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Auto-Discovery
|
mizan Auto-Discovery
|
||||||
|
|
||||||
Scans Django apps for server functions following the 'clients' layer convention:
|
Scans Django apps for server functions following the 'clients' layer convention:
|
||||||
- <app>/clients.py
|
- <app>/clients.py
|
||||||
- <app>/clients/**/*.py
|
- <app>/clients/**/*.py
|
||||||
|
|
||||||
Usage in urls.py:
|
Usage in urls.py:
|
||||||
from djarea.setup.discovery import djarea_clients
|
from mizan.setup.discovery import mizan_clients
|
||||||
|
|
||||||
djarea_clients('apps') # Scans apps/*/clients.py
|
mizan_clients('apps') # Scans apps/*/clients.py
|
||||||
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py
|
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
||||||
|
|
||||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
This replaces manual "import to register" patterns with explicit auto-discovery.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members
|
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||||
|
|
||||||
from .registry import register, get_function
|
from mizan_core.registry import register, get_function
|
||||||
from djarea.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
class _RegisterServerFunctions:
|
||||||
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
|
|||||||
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
|
# 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)
|
# Skip already registered (idempotent)
|
||||||
if get_function(fn_name) is member:
|
if get_function(fn_name) is member:
|
||||||
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||||
"""
|
"""
|
||||||
Discover and register server functions from Django apps.
|
Discover and register server functions from Django apps.
|
||||||
|
|
||||||
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
# In urls.py
|
# In urls.py
|
||||||
djarea_clients('apps') # Scans apps/*/clients.py
|
mizan_clients('apps') # Scans apps/*/clients.py
|
||||||
djarea_clients('apps', 'functions') # Scans apps/*/functions.py
|
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||||
"""
|
"""
|
||||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||||
visitor.visit(_RegisterServerFunctions())
|
visitor.visit(_RegisterServerFunctions())
|
||||||
|
|
||||||
|
|
||||||
def djarea_module(module_path: str) -> None:
|
def mizan_module(module_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
Register server functions from a specific module.
|
Register server functions from a specific module.
|
||||||
|
|
||||||
Use this for library modules that don't follow the app convention.
|
Use this for library modules that don't follow the app convention.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
module_path: Full module path (e.g., 'djarea.integrations.allauth')
|
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
djarea_module('djarea.integrations.allauth')
|
mizan_module('mizan.integrations.allauth')
|
||||||
djarea_module('djarea.jwt.functions')
|
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)
|
||||||
49
backends/mizan-django/src/mizan/setup/settings.py
Normal file
49
backends/mizan-django/src/mizan/setup/settings.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
mizan Settings
|
||||||
|
|
||||||
|
Configuration is read from Django settings with sensible defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class mizanSettings:
|
||||||
|
"""mizan configuration."""
|
||||||
|
|
||||||
|
# Cache HMAC signing secret (required when cache is enabled)
|
||||||
|
cache_secret: str | None
|
||||||
|
|
||||||
|
# Redis URL for cache backend (None = cache disabled)
|
||||||
|
cache_redis_url: str | None
|
||||||
|
|
||||||
|
# MWT signing secret (separate from cache secret for blast radius containment)
|
||||||
|
mwt_secret: str | None
|
||||||
|
|
||||||
|
# MWT token lifetime in seconds (default: 300 = 5 minutes)
|
||||||
|
mwt_ttl: int
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> mizanSettings:
|
||||||
|
"""
|
||||||
|
Load mizan settings from Django settings.
|
||||||
|
|
||||||
|
Settings:
|
||||||
|
MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None)
|
||||||
|
MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None)
|
||||||
|
"""
|
||||||
|
return mizanSettings(
|
||||||
|
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
|
||||||
|
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None),
|
||||||
|
mwt_secret=getattr(django_settings, "MIZAN_MWT_SECRET", None),
|
||||||
|
mwt_ttl=getattr(django_settings, "MIZAN_MWT_TTL", 300),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_settings_cache():
|
||||||
|
"""Clear the settings cache (for testing)."""
|
||||||
|
get_settings.cache_clear()
|
||||||
3
backends/mizan-django/src/mizan/shapes/__init__.py
Normal file
3
backends/mizan-django/src/mizan/shapes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from mizan.shapes.core import Diff, NestedDiff, Shape
|
||||||
|
|
||||||
|
__all__ = ["Diff", "NestedDiff", "Shape"]
|
||||||
25
backends/mizan-django/src/mizan/ssr/__init__.py
Normal file
25
backends/mizan-django/src/mizan/ssr/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
mizan.ssr — Server-side rendering via Bun subprocess.
|
||||||
|
|
||||||
|
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||||
|
'OPTIONS': {
|
||||||
|
'worker_path': 'frontend/ssr-worker.tsx',
|
||||||
|
'timeout': 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
Then use Django's standard render():
|
||||||
|
|
||||||
|
return render(request, 'ProfilePage', {'user_id': 5})
|
||||||
|
|
||||||
|
The component name is the template name. The context dict becomes props.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .backend import MizanTemplates
|
||||||
|
|
||||||
|
__all__ = ["MizanTemplates"]
|
||||||
100
backends/mizan-django/src/mizan/ssr/backend.py
Normal file
100
backends/mizan-django/src/mizan/ssr/backend.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'frontend'],
|
||||||
|
'OPTIONS': {
|
||||||
|
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.template import TemplateDoesNotExist
|
||||||
|
from django.template.backends.base import BaseEngine
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from .bridge import SSRBridge
|
||||||
|
|
||||||
|
|
||||||
|
class MizanTemplate:
|
||||||
|
"""Renders a .tsx/.jsx file via the SSR bridge."""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str, bridge: SSRBridge) -> None:
|
||||||
|
self.file_path = file_path
|
||||||
|
self.origin = None
|
||||||
|
self._bridge = bridge
|
||||||
|
|
||||||
|
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
props = dict(context) if context else {}
|
||||||
|
props.pop("request", None)
|
||||||
|
props.pop("csrf_token", None)
|
||||||
|
|
||||||
|
result = self._bridge.render(self.file_path, props)
|
||||||
|
|
||||||
|
# Serialize props as hydration data for client-side React
|
||||||
|
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
return mark_safe(
|
||||||
|
f'<div id="mizan-root">{result.html}</div>'
|
||||||
|
f'<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MizanTemplates(BaseEngine):
|
||||||
|
"""
|
||||||
|
Django template backend that renders React components via Bun.
|
||||||
|
|
||||||
|
Template names are file paths resolved against DIRS.
|
||||||
|
Same model as Django's built-in template engines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, params: dict[str, Any]) -> None:
|
||||||
|
options = params.pop("OPTIONS", {})
|
||||||
|
params.setdefault("NAME", "mizan")
|
||||||
|
params.setdefault("APP_DIRS", False)
|
||||||
|
super().__init__(params)
|
||||||
|
|
||||||
|
self._worker = options.get("worker")
|
||||||
|
self._timeout = options.get("timeout", 5)
|
||||||
|
self._bridge: SSRBridge | None = None
|
||||||
|
|
||||||
|
if not self._worker:
|
||||||
|
raise ValueError(
|
||||||
|
"MizanTemplates requires OPTIONS['worker'] — "
|
||||||
|
"the path to mizan-ssr's worker.tsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_bridge(self) -> SSRBridge:
|
||||||
|
if self._bridge is None:
|
||||||
|
self._bridge = SSRBridge(
|
||||||
|
worker_path=self._worker,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
return self._bridge
|
||||||
|
|
||||||
|
def get_template(self, template_name: str) -> MizanTemplate:
|
||||||
|
for dir_path in self.dirs:
|
||||||
|
file_path = os.path.join(dir_path, template_name)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
return MizanTemplate(
|
||||||
|
os.path.abspath(file_path),
|
||||||
|
self.get_bridge(),
|
||||||
|
)
|
||||||
|
raise TemplateDoesNotExist(template_name)
|
||||||
|
|
||||||
|
def from_string(self, template_code: str) -> MizanTemplate:
|
||||||
|
raise TemplateDoesNotExist(
|
||||||
|
"MizanTemplates renders .tsx files, not template strings."
|
||||||
|
)
|
||||||
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""
|
||||||
|
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||||
|
|
||||||
|
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||||
|
|
||||||
|
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||||
|
Response: {"id": 1, "html": "<div>...</div>"}
|
||||||
|
|
||||||
|
The subprocess stays alive across requests. It is started on first use
|
||||||
|
and restarted automatically if it crashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("mizan.ssr")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderResult:
|
||||||
|
"""Result of an SSR render call."""
|
||||||
|
html: str
|
||||||
|
|
||||||
|
|
||||||
|
class SSRBridge:
|
||||||
|
"""
|
||||||
|
Manages a persistent Bun subprocess for server-side rendering.
|
||||||
|
|
||||||
|
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||||
|
Request-response matching via message IDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||||
|
self._worker_path = worker_path
|
||||||
|
self._timeout = timeout
|
||||||
|
self._proc: subprocess.Popen | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||||
|
self._counter = 0
|
||||||
|
self._pending: dict[int, threading.Event] = {}
|
||||||
|
self._results: dict[int, dict] = {}
|
||||||
|
self._reader_thread: threading.Thread | None = None
|
||||||
|
self._ready = threading.Event()
|
||||||
|
|
||||||
|
# Ensure cleanup on process exit
|
||||||
|
atexit.register(self.shutdown)
|
||||||
|
|
||||||
|
def _ensure_running(self) -> None:
|
||||||
|
"""Start the Bun subprocess if it's not running."""
|
||||||
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._proc is not None:
|
||||||
|
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
|
||||||
|
|
||||||
|
self._ready.clear()
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
["bun", "run", self._worker_path],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Wait for the "ready" signal from the worker
|
||||||
|
if not self._ready.wait(timeout=self._timeout):
|
||||||
|
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||||
|
self.shutdown()
|
||||||
|
raise TimeoutError("SSR worker failed to start")
|
||||||
|
|
||||||
|
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||||
|
|
||||||
|
def _read_responses(self) -> None:
|
||||||
|
"""Background thread that reads JSON responses from stdout."""
|
||||||
|
try:
|
||||||
|
for line in self._proc.stdout:
|
||||||
|
if isinstance(line, bytes):
|
||||||
|
line = line.decode("utf-8")
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_id = msg.get("id")
|
||||||
|
|
||||||
|
# Ready signal (id=0)
|
||||||
|
if msg_id == 0 and msg.get("ready"):
|
||||||
|
self._ready.set()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg_id is not None and msg_id in self._pending:
|
||||||
|
self._results[msg_id] = msg
|
||||||
|
self._pending[msg_id].set()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("SSR reader thread exited", exc_info=True)
|
||||||
|
|
||||||
|
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||||
|
"""
|
||||||
|
Render a React component to HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Absolute path to the .tsx/.jsx file to render.
|
||||||
|
props: Props to pass to the component.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RenderResult with the HTML string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: If the render takes longer than the configured timeout.
|
||||||
|
RuntimeError: If the render fails.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._ensure_running()
|
||||||
|
self._counter += 1
|
||||||
|
msg_id = self._counter
|
||||||
|
|
||||||
|
event = threading.Event()
|
||||||
|
self._pending[msg_id] = event
|
||||||
|
|
||||||
|
request = json.dumps({
|
||||||
|
"id": msg_id,
|
||||||
|
"method": "render",
|
||||||
|
"params": {"file": file, "props": props or {}},
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||||
|
with self._write_lock:
|
||||||
|
try:
|
||||||
|
self._proc.stdin.write(request.encode("utf-8"))
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError) as e:
|
||||||
|
self._pending.pop(msg_id, None)
|
||||||
|
raise RuntimeError(f"SSR worker pipe broken: {e}")
|
||||||
|
|
||||||
|
if not event.wait(self._timeout):
|
||||||
|
self._pending.pop(msg_id, None)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"SSR render of '{file}' timed out after {self._timeout}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._pending.pop(msg_id, None)
|
||||||
|
result = self._results.pop(msg_id)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise RuntimeError(f"SSR render failed: {result['error']}")
|
||||||
|
|
||||||
|
return RenderResult(html=result["html"])
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Stop the Bun subprocess."""
|
||||||
|
if self._proc is not None:
|
||||||
|
try:
|
||||||
|
self._proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
logger.info("Bun SSR worker stopped")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Authentication Tests for Djarea Server Functions
|
Authentication Tests for mizan Server Functions
|
||||||
|
|
||||||
Tests all combinations of:
|
Tests all combinations of:
|
||||||
- Transport: HTTP vs WebSocket RPC
|
- Transport: HTTP vs WebSocket RPC
|
||||||
@@ -19,20 +19,20 @@ from django.contrib.sessions.backends.db import SessionStore
|
|||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from djarea.jwt.tokens import (
|
from mizan.jwt.tokens import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
decode_token,
|
decode_token,
|
||||||
JWTUser,
|
JWTUser,
|
||||||
)
|
)
|
||||||
from djarea.client.executor import (
|
from mizan.client.executor import (
|
||||||
_try_jwt_auth,
|
_try_jwt_auth,
|
||||||
execute_function,
|
execute_function,
|
||||||
FunctionError,
|
FunctionError,
|
||||||
FunctionResult,
|
FunctionResult,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
)
|
)
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.setup.registry import clear_registry, register
|
from mizan_core.registry import clear_registry, register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ User = get_user_model()
|
|||||||
# Test Output Models (proper Pydantic models, not raw dicts)
|
# Test Output Models (proper Pydantic models, not raw dicts)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class WhoamiOutput(BaseModel):
|
class WhoamiOutput(BaseModel):
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
user_id: int | None
|
user_id: int | None
|
||||||
@@ -62,6 +63,7 @@ class UserTypeOutput(BaseModel):
|
|||||||
# Test Server Functions - defined as plain functions, registered in setUp
|
# Test Server Functions - defined as plain functions, registered in setUp
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _whoami_fn(request) -> WhoamiOutput:
|
def _whoami_fn(request) -> WhoamiOutput:
|
||||||
"""Returns info about the authenticated user."""
|
"""Returns info about the authenticated user."""
|
||||||
user = request.user
|
user = request.user
|
||||||
@@ -104,6 +106,7 @@ class HTTPAuthTests(TestCase):
|
|||||||
user_type=type(user).__name__,
|
user_type=type(user).__name__,
|
||||||
is_staff=getattr(user, "is_staff", False),
|
is_staff=getattr(user, "is_staff", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
register(whoami, "whoami")
|
register(whoami, "whoami")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -168,7 +171,7 @@ 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
|
# Create token with past expiration by mocking time
|
||||||
with patch("djarea.jwt.tokens.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,
|
||||||
@@ -248,7 +251,7 @@ class JWTUserTests(TestCase):
|
|||||||
|
|
||||||
def test_jwt_user_attributes(self):
|
def test_jwt_user_attributes(self):
|
||||||
"""JWTUser has expected attributes."""
|
"""JWTUser has expected attributes."""
|
||||||
from djarea.jwt.tokens import TokenPayload
|
from mizan.jwt.tokens import TokenPayload
|
||||||
|
|
||||||
payload = TokenPayload(
|
payload = TokenPayload(
|
||||||
user_id=42,
|
user_id=42,
|
||||||
@@ -272,7 +275,7 @@ class JWTUserTests(TestCase):
|
|||||||
|
|
||||||
def test_jwt_user_string_id(self):
|
def test_jwt_user_string_id(self):
|
||||||
"""JWTUser handles string user_id (converted to int)."""
|
"""JWTUser handles string user_id (converted to int)."""
|
||||||
from djarea.jwt.tokens import TokenPayload
|
from mizan.jwt.tokens import TokenPayload
|
||||||
|
|
||||||
payload = TokenPayload(
|
payload = TokenPayload(
|
||||||
user_id="42", # String, as stored in JWT
|
user_id="42", # String, as stored in JWT
|
||||||
@@ -333,6 +336,7 @@ class AuthDecoratorTests(TestCase):
|
|||||||
@client(auth=True)
|
@client(auth=True)
|
||||||
def protected_fn(request) -> OkOutput:
|
def protected_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(protected_fn, "protected_fn")
|
register(protected_fn, "protected_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -345,9 +349,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_required_with_authenticated(self):
|
def test_auth_required_with_authenticated(self):
|
||||||
"""@client(auth=True) allows authenticated users."""
|
"""@client(auth=True) allows authenticated users."""
|
||||||
|
|
||||||
@client(auth=True)
|
@client(auth=True)
|
||||||
def protected_fn2(request) -> OkOutput:
|
def protected_fn2(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(protected_fn2, "protected_fn2")
|
register(protected_fn2, "protected_fn2")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -360,9 +366,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_staff_with_regular_user(self):
|
def test_auth_staff_with_regular_user(self):
|
||||||
"""@client(auth='staff') rejects non-staff users."""
|
"""@client(auth='staff') rejects non-staff users."""
|
||||||
@client(auth='staff')
|
|
||||||
|
@client(auth="staff")
|
||||||
def staff_fn(request) -> OkOutput:
|
def staff_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(staff_fn, "staff_fn")
|
register(staff_fn, "staff_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -375,9 +383,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_staff_with_staff_user(self):
|
def test_auth_staff_with_staff_user(self):
|
||||||
"""@client(auth='staff') allows staff users."""
|
"""@client(auth='staff') allows staff users."""
|
||||||
@client(auth='staff')
|
|
||||||
|
@client(auth="staff")
|
||||||
def staff_fn2(request) -> OkOutput:
|
def staff_fn2(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(staff_fn2, "staff_fn2")
|
register(staff_fn2, "staff_fn2")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -389,9 +399,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_superuser_with_staff(self):
|
def test_auth_superuser_with_staff(self):
|
||||||
"""@client(auth='superuser') rejects non-superusers."""
|
"""@client(auth='superuser') rejects non-superusers."""
|
||||||
@client(auth='superuser')
|
|
||||||
|
@client(auth="superuser")
|
||||||
def super_fn(request) -> OkOutput:
|
def super_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(super_fn, "super_fn")
|
register(super_fn, "super_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -404,9 +416,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_superuser_with_superuser(self):
|
def test_auth_superuser_with_superuser(self):
|
||||||
"""@client(auth='superuser') allows superusers."""
|
"""@client(auth='superuser') allows superusers."""
|
||||||
@client(auth='superuser')
|
|
||||||
|
@client(auth="superuser")
|
||||||
def super_fn2(request) -> OkOutput:
|
def super_fn2(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(super_fn2, "super_fn2")
|
register(super_fn2, "super_fn2")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -418,11 +432,12 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_with_jwt_user(self):
|
def test_auth_with_jwt_user(self):
|
||||||
"""Auth checks work with JWTUser (stateless)."""
|
"""Auth checks work with JWTUser (stateless)."""
|
||||||
from djarea.jwt.tokens import TokenPayload
|
from mizan.jwt.tokens import TokenPayload
|
||||||
|
|
||||||
@client(auth='staff')
|
@client(auth="staff")
|
||||||
def jwt_staff_fn(request) -> UserTypeOutput:
|
def jwt_staff_fn(request) -> UserTypeOutput:
|
||||||
return UserTypeOutput(user_type=type(request.user).__name__)
|
return UserTypeOutput(user_type=type(request.user).__name__)
|
||||||
|
|
||||||
register(jwt_staff_fn, "jwt_staff_fn")
|
register(jwt_staff_fn, "jwt_staff_fn")
|
||||||
|
|
||||||
# Create JWTUser with is_staff=True
|
# Create JWTUser with is_staff=True
|
||||||
@@ -448,7 +463,8 @@ class AuthDecoratorTests(TestCase):
|
|||||||
def test_auth_invalid_string_raises(self):
|
def test_auth_invalid_string_raises(self):
|
||||||
"""Invalid auth string raises ValueError at decoration time."""
|
"""Invalid auth string raises ValueError at decoration time."""
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
@client(auth='admin') # 'admin' is not valid
|
|
||||||
|
@client(auth="admin") # 'admin' is not valid
|
||||||
def bad_fn(request) -> OkOutput:
|
def bad_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
@@ -457,9 +473,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_callable_returns_true(self):
|
def test_auth_callable_returns_true(self):
|
||||||
"""Callable auth returning True allows access."""
|
"""Callable auth returning True allows access."""
|
||||||
@client(auth=lambda r: r.user.email.endswith('@example.com'))
|
|
||||||
|
@client(auth=lambda r: r.user.email.endswith("@example.com"))
|
||||||
def email_check_fn(request) -> OkOutput:
|
def email_check_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(email_check_fn, "email_check_fn")
|
register(email_check_fn, "email_check_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -472,9 +490,11 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_callable_returns_false(self):
|
def test_auth_callable_returns_false(self):
|
||||||
"""Callable auth returning False denies access."""
|
"""Callable auth returning False denies access."""
|
||||||
@client(auth=lambda r: r.user.email.endswith('@admin.com'))
|
|
||||||
|
@client(auth=lambda r: r.user.email.endswith("@admin.com"))
|
||||||
def admin_email_fn(request) -> OkOutput:
|
def admin_email_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(admin_email_fn, "admin_email_fn")
|
register(admin_email_fn, "admin_email_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -488,14 +508,16 @@ class AuthDecoratorTests(TestCase):
|
|||||||
|
|
||||||
def test_auth_callable_raises_permission_error(self):
|
def test_auth_callable_raises_permission_error(self):
|
||||||
"""Callable auth raising PermissionError uses custom message."""
|
"""Callable auth raising PermissionError uses custom message."""
|
||||||
|
|
||||||
def check_premium(request):
|
def check_premium(request):
|
||||||
if not getattr(request.user, 'is_premium', False):
|
if not getattr(request.user, "is_premium", False):
|
||||||
raise PermissionError("Premium subscription required")
|
raise PermissionError("Premium subscription required")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@client(auth=check_premium)
|
@client(auth=check_premium)
|
||||||
def premium_fn(request) -> OkOutput:
|
def premium_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(premium_fn, "premium_fn")
|
register(premium_fn, "premium_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -519,6 +541,7 @@ class AuthDecoratorTests(TestCase):
|
|||||||
@client(auth=must_be_authenticated)
|
@client(auth=must_be_authenticated)
|
||||||
def needs_login_fn(request) -> OkOutput:
|
def needs_login_fn(request) -> OkOutput:
|
||||||
return OkOutput(ok=True)
|
return OkOutput(ok=True)
|
||||||
|
|
||||||
register(needs_login_fn, "needs_login_fn")
|
register(needs_login_fn, "needs_login_fn")
|
||||||
|
|
||||||
request = self.factory.post("/")
|
request = self.factory.post("/")
|
||||||
@@ -5,7 +5,7 @@ Compares performance of HTTP POST vs WebSocket RPC for server function calls.
|
|||||||
Includes realistic scenarios with ORM queries.
|
Includes realistic scenarios with ORM queries.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py test djarea.tests.test_benchmarks --verbosity=2
|
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
These are not unit tests - they measure performance. Results are printed
|
These are not unit tests - they measure performance. Results are printed
|
||||||
@@ -26,9 +26,9 @@ from django.http import HttpRequest
|
|||||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client.executor import FunctionResult, execute_function, function_call_view
|
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||||
from djarea.setup.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
|
|||||||
|
|
||||||
def setup_benchmark_functions():
|
def setup_benchmark_functions():
|
||||||
"""Register benchmark server functions."""
|
"""Register benchmark server functions."""
|
||||||
from djarea.setup.registry import register
|
from mizan_core.registry import register
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
@@ -75,6 +75,7 @@ def setup_benchmark_functions():
|
|||||||
def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput:
|
def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput:
|
||||||
"""Simple addition - baseline with no I/O."""
|
"""Simple addition - baseline with no I/O."""
|
||||||
return SimpleOutput(value=a + b)
|
return SimpleOutput(value=a + b)
|
||||||
|
|
||||||
register(bench_simple, "bench_simple")
|
register(bench_simple, "bench_simple")
|
||||||
|
|
||||||
# 2. Single ORM query
|
# 2. Single ORM query
|
||||||
@@ -85,6 +86,7 @@ def setup_benchmark_functions():
|
|||||||
if user:
|
if user:
|
||||||
return UserOutput(id=user.id, email=user.email)
|
return UserOutput(id=user.id, email=user.email)
|
||||||
return UserOutput(id=0, email="")
|
return UserOutput(id=0, email="")
|
||||||
|
|
||||||
register(bench_get_user, "bench_get_user")
|
register(bench_get_user, "bench_get_user")
|
||||||
|
|
||||||
# 3. List query with limit
|
# 3. List query with limit
|
||||||
@@ -96,6 +98,7 @@ def setup_benchmark_functions():
|
|||||||
users=[{"id": u.id, "email": u.email} for u in users],
|
users=[{"id": u.id, "email": u.email} for u in users],
|
||||||
count=len(users),
|
count=len(users),
|
||||||
)
|
)
|
||||||
|
|
||||||
register(bench_list_users, "bench_list_users")
|
register(bench_list_users, "bench_list_users")
|
||||||
|
|
||||||
# 4. Aggregation query
|
# 4. Aggregation query
|
||||||
@@ -110,11 +113,14 @@ def setup_benchmark_functions():
|
|||||||
active_users=active,
|
active_users=active,
|
||||||
staff_count=staff,
|
staff_count=staff,
|
||||||
)
|
)
|
||||||
|
|
||||||
register(bench_user_stats, "bench_user_stats")
|
register(bench_user_stats, "bench_user_stats")
|
||||||
|
|
||||||
# 5. Complex query with joins
|
# 5. Complex query with joins
|
||||||
@client
|
@client
|
||||||
def bench_user_search(request: HttpRequest, email_contains: str, limit: int) -> UserListOutput:
|
def bench_user_search(
|
||||||
|
request: HttpRequest, email_contains: str, limit: int
|
||||||
|
) -> UserListOutput:
|
||||||
"""Search users by email pattern."""
|
"""Search users by email pattern."""
|
||||||
users = User.objects.filter(
|
users = User.objects.filter(
|
||||||
email__icontains=email_contains,
|
email__icontains=email_contains,
|
||||||
@@ -124,6 +130,7 @@ def setup_benchmark_functions():
|
|||||||
users=[{"id": u.id, "email": u.email} for u in users],
|
users=[{"id": u.id, "email": u.email} for u in users],
|
||||||
count=len(users),
|
count=len(users),
|
||||||
)
|
)
|
||||||
|
|
||||||
register(bench_user_search, "bench_user_search")
|
register(bench_user_search, "bench_user_search")
|
||||||
|
|
||||||
|
|
||||||
@@ -158,11 +165,13 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
# Create 100 test users
|
# Create 100 test users
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(User(
|
users.append(
|
||||||
|
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, # 90% active
|
||||||
is_staff=i < 5, # 5 staff
|
is_staff=i < 5, # 5 staff
|
||||||
))
|
)
|
||||||
|
)
|
||||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||||
self.test_user = User.objects.first()
|
self.test_user = User.objects.first()
|
||||||
|
|
||||||
@@ -170,12 +179,12 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
"""Create a request with optional JSON body."""
|
"""Create a request with optional JSON body."""
|
||||||
if body:
|
if body:
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
data=json.dumps(body),
|
data=json.dumps(body),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
request = self.factory.post("/api/djarea/call/")
|
request = self.factory.post("/api/mizan/call/")
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
return request
|
return request
|
||||||
@@ -245,12 +254,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
|||||||
print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}")
|
print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
for r in results:
|
for r in results:
|
||||||
print(f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms")
|
print(
|
||||||
|
f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms"
|
||||||
|
)
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
def _print_comparison(self, executor_stats: dict, http_stats: dict):
|
def _print_comparison(self, executor_stats: dict, http_stats: dict):
|
||||||
"""Print comparison between executor and HTTP."""
|
"""Print comparison between executor and HTTP."""
|
||||||
overhead = ((http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]) * 100
|
overhead = (
|
||||||
|
(http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]
|
||||||
|
) * 100
|
||||||
print(f" HTTP overhead vs Executor: {overhead:+.1f}%")
|
print(f" HTTP overhead vs Executor: {overhead:+.1f}%")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -400,18 +413,20 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
"""Create test users for benchmarks."""
|
"""Create test users for benchmarks."""
|
||||||
users = []
|
users = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
users.append(User(
|
users.append(
|
||||||
|
User(
|
||||||
email=f"bench{i}@example.com",
|
email=f"bench{i}@example.com",
|
||||||
is_active=i % 10 != 0,
|
is_active=i % 10 != 0,
|
||||||
is_staff=i < 5,
|
is_staff=i < 5,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||||
self.test_user = User.objects.first()
|
self.test_user = User.objects.first()
|
||||||
|
|
||||||
def _make_request(self, body: dict) -> HttpRequest:
|
def _make_request(self, body: dict) -> HttpRequest:
|
||||||
"""Create a POST request with JSON body."""
|
"""Create a POST request with JSON body."""
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
data=json.dumps(body),
|
data=json.dumps(body),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
@@ -470,7 +485,9 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
"""Throughput: Simple computation (no I/O)."""
|
"""Throughput: Simple computation (no I/O)."""
|
||||||
print("\n\n### THROUGHPUT: Simple Computation ###")
|
print("\n\n### THROUGHPUT: Simple Computation ###")
|
||||||
|
|
||||||
executor_rps = self._measure_throughput_executor("bench_simple", {"a": 1, "b": 2})
|
executor_rps = self._measure_throughput_executor(
|
||||||
|
"bench_simple", {"a": 1, "b": 2}
|
||||||
|
)
|
||||||
http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2})
|
http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2})
|
||||||
|
|
||||||
self._print_throughput("Simple (no I/O)", executor_rps, http_rps)
|
self._print_throughput("Simple (no I/O)", executor_rps, http_rps)
|
||||||
@@ -502,7 +519,9 @@ class ThroughputBenchmark(TransactionTestCase):
|
|||||||
"""Throughput: List query."""
|
"""Throughput: List query."""
|
||||||
print("\n\n### THROUGHPUT: List Query (10 users) ###")
|
print("\n\n### THROUGHPUT: List Query (10 users) ###")
|
||||||
|
|
||||||
executor_rps = self._measure_throughput_executor("bench_list_users", {"limit": 10})
|
executor_rps = self._measure_throughput_executor(
|
||||||
|
"bench_list_users", {"limit": 10}
|
||||||
|
)
|
||||||
http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10})
|
http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10})
|
||||||
|
|
||||||
self._print_throughput("List Query", executor_rps, http_rps)
|
self._print_throughput("List Query", executor_rps, http_rps)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Tests for djarea.channels module.
|
Tests for mizan.channels module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -8,7 +8,7 @@ from django.test import TestCase
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.channels import (
|
from mizan.channels import (
|
||||||
ReactChannel,
|
ReactChannel,
|
||||||
register,
|
register,
|
||||||
get_channel,
|
get_channel,
|
||||||
@@ -25,8 +25,10 @@ User = get_user_model()
|
|||||||
# Test Fixtures
|
# Test Fixtures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class MockUser:
|
class MockUser:
|
||||||
"""Mock user for testing."""
|
"""Mock user for testing."""
|
||||||
|
|
||||||
def __init__(self, is_authenticated=True, email="test@example.com"):
|
def __init__(self, is_authenticated=True, email="test@example.com"):
|
||||||
self.is_authenticated = is_authenticated
|
self.is_authenticated = is_authenticated
|
||||||
self.email = email
|
self.email = email
|
||||||
@@ -34,6 +36,7 @@ class MockUser:
|
|||||||
|
|
||||||
class MockAnonymousUser:
|
class MockAnonymousUser:
|
||||||
"""Mock anonymous user."""
|
"""Mock anonymous user."""
|
||||||
|
|
||||||
is_authenticated = False
|
is_authenticated = False
|
||||||
email = ""
|
email = ""
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ class MockAnonymousUser:
|
|||||||
# ReactChannel Base Class Tests
|
# ReactChannel Base Class Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ReactChannelBaseTests(TestCase):
|
class ReactChannelBaseTests(TestCase):
|
||||||
"""Tests for ReactChannel base class."""
|
"""Tests for ReactChannel base class."""
|
||||||
|
|
||||||
@@ -115,6 +119,7 @@ class ReactChannelBaseTests(TestCase):
|
|||||||
# Channel with Typed Messages Tests
|
# Channel with Typed Messages Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TypedMessagesTests(TestCase):
|
class TypedMessagesTests(TestCase):
|
||||||
"""Tests for channels with Pydantic message types."""
|
"""Tests for channels with Pydantic message types."""
|
||||||
|
|
||||||
@@ -179,9 +184,7 @@ class TypedMessagesTests(TestCase):
|
|||||||
|
|
||||||
# Test message model
|
# Test message model
|
||||||
msg = BroadcastChannel.DjangoMessage(
|
msg = BroadcastChannel.DjangoMessage(
|
||||||
user="john",
|
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||||
text="Hello world",
|
|
||||||
created_at="2024-01-15T10:00:00Z"
|
|
||||||
)
|
)
|
||||||
self.assertEqual(msg.user, "john")
|
self.assertEqual(msg.user, "john")
|
||||||
self.assertEqual(msg.text, "Hello world")
|
self.assertEqual(msg.text, "Hello world")
|
||||||
@@ -207,10 +210,7 @@ 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(
|
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
||||||
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")
|
||||||
@@ -229,6 +229,7 @@ class TypedMessagesTests(TestCase):
|
|||||||
# Registration Tests
|
# Registration Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(TestCase):
|
class RegistrationTests(TestCase):
|
||||||
"""Tests for channel registration."""
|
"""Tests for channel registration."""
|
||||||
|
|
||||||
@@ -336,6 +337,7 @@ class RegistrationTests(TestCase):
|
|||||||
# Schema Export Tests
|
# Schema Export Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class SchemaExportTests(TestCase):
|
class SchemaExportTests(TestCase):
|
||||||
"""Tests for channel schema export."""
|
"""Tests for channel schema export."""
|
||||||
|
|
||||||
@@ -482,6 +484,7 @@ class SchemaExportTests(TestCase):
|
|||||||
# Authorization Tests
|
# Authorization Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationTests(TestCase):
|
class AuthorizationTests(TestCase):
|
||||||
"""Tests for channel authorization."""
|
"""Tests for channel authorization."""
|
||||||
|
|
||||||
@@ -543,6 +546,7 @@ class AuthorizationTests(TestCase):
|
|||||||
# Group Tests
|
# Group Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class GroupTests(TestCase):
|
class GroupTests(TestCase):
|
||||||
"""Tests for channel group management."""
|
"""Tests for channel group management."""
|
||||||
|
|
||||||
@@ -586,6 +590,7 @@ class GroupTests(TestCase):
|
|||||||
# Async Methods Tests
|
# Async Methods Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class AsyncMethodsTests(TestCase):
|
class AsyncMethodsTests(TestCase):
|
||||||
"""Tests for async internal methods."""
|
"""Tests for async internal methods."""
|
||||||
|
|
||||||
@@ -727,6 +732,7 @@ class AsyncMethodsTests(TestCase):
|
|||||||
# Server Push Tests
|
# Server Push Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ServerPushTests(TestCase):
|
class ServerPushTests(TestCase):
|
||||||
"""Tests for server push functionality."""
|
"""Tests for server push functionality."""
|
||||||
|
|
||||||
@@ -752,13 +758,12 @@ class ServerPushTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "notifications"
|
return "notifications"
|
||||||
|
|
||||||
with patch('channels.layers.get_channel_layer') as mock_get_layer:
|
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||||
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.DjangoMessage(
|
||||||
title="Alert",
|
title="Alert", body="Something happened"
|
||||||
body="Something happened"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
@@ -789,7 +794,7 @@ class ServerPushTests(TestCase):
|
|||||||
def group(self, params):
|
def group(self, params):
|
||||||
return f"room_{params.room}"
|
return f"room_{params.room}"
|
||||||
|
|
||||||
with patch('channels.layers.get_channel_layer') as mock_get_layer:
|
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||||
mock_layer = AsyncMock()
|
mock_layer = AsyncMock()
|
||||||
mock_get_layer.return_value = mock_layer
|
mock_get_layer.return_value = mock_layer
|
||||||
|
|
||||||
@@ -821,24 +826,28 @@ class ServerPushTests(TestCase):
|
|||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "test"
|
return "test"
|
||||||
|
|
||||||
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.DjangoMessage(text="test")
|
||||||
|
|
||||||
with self.assertLogs('djarea.channels', level='WARNING') as cm:
|
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await TestChannel.push(message=message)
|
await TestChannel.push(message=message)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
self.assertTrue(any("No channel layer configured" in msg for msg in cm.output))
|
self.assertTrue(
|
||||||
|
any("No channel layer configured" in msg for msg in cm.output)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Management Command Tests
|
# Management Command Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ManagementCommandTests(TestCase):
|
class ManagementCommandTests(TestCase):
|
||||||
"""Tests for the export_channels_schema management command."""
|
"""Tests for the export_channels_schema management command."""
|
||||||
|
|
||||||
@@ -855,7 +864,7 @@ class ManagementCommandTests(TestCase):
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command('export_channels_schema', stdout=out)
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
@@ -863,7 +872,7 @@ class ManagementCommandTests(TestCase):
|
|||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
self.assertIn("openapi", schema)
|
self.assertIn("openapi", schema)
|
||||||
self.assertIn("x-djarea-channels", schema)
|
self.assertIn("x-mizan-channels", schema)
|
||||||
|
|
||||||
def test_export_command_includes_registered_channels(self):
|
def test_export_command_includes_registered_channels(self):
|
||||||
"""export_channels_schema should include registered channels."""
|
"""export_channels_schema should include registered channels."""
|
||||||
@@ -883,13 +892,13 @@ class ManagementCommandTests(TestCase):
|
|||||||
register(TestChannel, "export-test")
|
register(TestChannel, "export-test")
|
||||||
|
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command('export_channels_schema', stdout=out)
|
call_command("export_channels_schema", stdout=out)
|
||||||
|
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
schema = json.loads(output)
|
schema = json.loads(output)
|
||||||
|
|
||||||
# Check that channel is in x-djarea-channels metadata
|
# Check that channel is in x-mizan-channels metadata
|
||||||
channel_names = [c["name"] for c in schema["x-djarea-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_respects_indent(self):
|
def test_export_command_respects_indent(self):
|
||||||
@@ -899,11 +908,11 @@ class ManagementCommandTests(TestCase):
|
|||||||
|
|
||||||
# With indent
|
# 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)
|
# 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)
|
# 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()))
|
||||||
@@ -918,13 +927,14 @@ class WebSocketRPCTests(TestCase):
|
|||||||
"""Tests for WebSocket RPC functionality."""
|
"""Tests for WebSocket RPC functionality."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Clear djarea registry
|
# Clear mizan registry
|
||||||
from djarea.setup.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
# Register test functions
|
# Register test functions
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.setup.registry import register
|
from mizan_core.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
class EchoOutput(BaseModel):
|
||||||
@@ -936,11 +946,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
def rpc_echo(request, message: str) -> EchoOutput:
|
def rpc_echo(request, message: str) -> EchoOutput:
|
||||||
return EchoOutput(echo=f"Echo: {message}")
|
return EchoOutput(echo=f"Echo: {message}")
|
||||||
|
|
||||||
register(rpc_echo, "rpc_echo")
|
register(rpc_echo, "rpc_echo")
|
||||||
|
|
||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
def rpc_add(request, a: int, b: int) -> AddOutput:
|
def rpc_add(request, a: int, b: int) -> AddOutput:
|
||||||
return AddOutput(result=a + b)
|
return AddOutput(result=a + b)
|
||||||
|
|
||||||
register(rpc_add, "rpc_add")
|
register(rpc_add, "rpc_add")
|
||||||
|
|
||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
@@ -948,16 +960,18 @@ class WebSocketRPCTests(TestCase):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
return EchoOutput(echo=f"Hello, {request.user.email}")
|
return EchoOutput(echo=f"Hello, {request.user.email}")
|
||||||
|
|
||||||
register(rpc_auth_required, "rpc_auth_required")
|
register(rpc_auth_required, "rpc_auth_required")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
from djarea.setup.registry import clear_registry
|
from mizan_core.registry import clear_registry
|
||||||
|
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
def test_handle_rpc_success(self):
|
def test_handle_rpc_success(self):
|
||||||
"""_handle_rpc should execute function and return result."""
|
"""_handle_rpc should execute function and return result."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {
|
consumer.scope = {
|
||||||
@@ -971,11 +985,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "test-123",
|
"id": "test-123",
|
||||||
"fn": "rpc_echo",
|
"fn": "rpc_echo",
|
||||||
"args": {"message": "Hello"},
|
"args": {"message": "Hello"},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -989,7 +1005,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
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."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockUser()}
|
consumer.scope = {"user": MockUser()}
|
||||||
@@ -1001,11 +1017,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "add-123",
|
"id": "add-123",
|
||||||
"fn": "rpc_add",
|
"fn": "rpc_add",
|
||||||
"args": {"a": 5, "b": 3},
|
"args": {"a": 5, "b": 3},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1016,7 +1034,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
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."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockUser()}
|
consumer.scope = {"user": MockUser()}
|
||||||
@@ -1028,11 +1046,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "test-456",
|
"id": "test-456",
|
||||||
"fn": "nonexistent_function",
|
"fn": "nonexistent_function",
|
||||||
"args": {},
|
"args": {},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1044,7 +1064,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
def test_handle_rpc_validation_error(self):
|
def test_handle_rpc_validation_error(self):
|
||||||
"""_handle_rpc should return validation error for invalid input."""
|
"""_handle_rpc should return validation error for invalid input."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockUser()}
|
consumer.scope = {"user": MockUser()}
|
||||||
@@ -1056,11 +1076,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "test-789",
|
"id": "test-789",
|
||||||
"fn": "rpc_echo",
|
"fn": "rpc_echo",
|
||||||
"args": {}, # Missing required 'message' field
|
"args": {}, # Missing required 'message' field
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1072,7 +1094,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
def test_handle_rpc_missing_id(self):
|
def test_handle_rpc_missing_id(self):
|
||||||
"""_handle_rpc should return error when id is missing."""
|
"""_handle_rpc should return error when id is missing."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockUser()}
|
consumer.scope = {"user": MockUser()}
|
||||||
@@ -1084,11 +1106,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"fn": "rpc_echo",
|
"fn": "rpc_echo",
|
||||||
"args": {"message": "test"},
|
"args": {"message": "test"},
|
||||||
# Missing 'id'
|
# Missing 'id'
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1099,7 +1123,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
def test_handle_rpc_missing_fn(self):
|
def test_handle_rpc_missing_fn(self):
|
||||||
"""_handle_rpc should return error when fn is missing."""
|
"""_handle_rpc should return error when fn is missing."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockUser()}
|
consumer.scope = {"user": MockUser()}
|
||||||
@@ -1111,11 +1135,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "test-abc",
|
"id": "test-abc",
|
||||||
"args": {"message": "test"},
|
"args": {"message": "test"},
|
||||||
# Missing 'fn'
|
# Missing 'fn'
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1127,7 +1153,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
def test_handle_rpc_with_unauthenticated_user(self):
|
def test_handle_rpc_with_unauthenticated_user(self):
|
||||||
"""_handle_rpc should handle permission errors correctly."""
|
"""_handle_rpc should handle permission errors correctly."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": MockAnonymousUser()}
|
consumer.scope = {"user": MockAnonymousUser()}
|
||||||
@@ -1139,11 +1165,13 @@ class WebSocketRPCTests(TestCase):
|
|||||||
consumer.send_json = mock_send_json
|
consumer.send_json = mock_send_json
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
await consumer._handle_rpc({
|
await consumer._handle_rpc(
|
||||||
|
{
|
||||||
"id": "auth-test",
|
"id": "auth-test",
|
||||||
"fn": "rpc_auth_required",
|
"fn": "rpc_auth_required",
|
||||||
"args": {},
|
"args": {},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.get_event_loop().run_until_complete(test())
|
asyncio.get_event_loop().run_until_complete(test())
|
||||||
|
|
||||||
@@ -1154,7 +1182,7 @@ class WebSocketRPCTests(TestCase):
|
|||||||
|
|
||||||
def test_websocket_request_adapter(self):
|
def test_websocket_request_adapter(self):
|
||||||
"""WebSocketRequest should provide correct user and session."""
|
"""WebSocketRequest should provide correct user and session."""
|
||||||
from djarea.channels.connection import WebSocketRequest
|
from mizan.channels.connection import WebSocketRequest
|
||||||
|
|
||||||
mock_user = MockUser(email="ws@example.com")
|
mock_user = MockUser(email="ws@example.com")
|
||||||
scope = {
|
scope = {
|
||||||
3466
backends/mizan-django/src/mizan/tests/test_core.py
Normal file
3466
backends/mizan-django/src/mizan/tests/test_core.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Advanced Penetration Tests for Djarea Server Functions
|
Advanced Penetration Tests for mizan Server Functions
|
||||||
|
|
||||||
These tests simulate a professional security researcher attempting to break
|
These tests simulate a professional security researcher attempting to break
|
||||||
the protocol. Focus areas:
|
the protocol. Focus areas:
|
||||||
@@ -36,14 +36,14 @@ from django.http import HttpRequest
|
|||||||
from django.test import RequestFactory, TestCase, override_settings
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
from pydantic import BaseModel, field_validator, model_validator
|
from pydantic import BaseModel, field_validator, model_validator
|
||||||
|
|
||||||
from djarea.client.executor import (
|
from mizan.client.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
FunctionError,
|
FunctionError,
|
||||||
FunctionResult,
|
FunctionResult,
|
||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
from djarea.setup.registry import clear_registry, get_function, register
|
from mizan_core.registry import clear_registry, get_function, register
|
||||||
from djarea.client import ServerFunction, client
|
from mizan.client import ServerFunction, client
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -86,16 +86,19 @@ class MemoryExhaustionTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def process_data(request: HttpRequest, data: dict) -> SimpleOutput:
|
def process_data(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||||
return SimpleOutput(value=str(len(str(data))))
|
return SimpleOutput(value=str(len(str(data))))
|
||||||
|
|
||||||
register(process_data, "process_data")
|
register(process_data, "process_data")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def process_string(request: HttpRequest, text: str) -> SimpleOutput:
|
def process_string(request: HttpRequest, text: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=f"len={len(text)}")
|
return SimpleOutput(value=f"len={len(text)}")
|
||||||
|
|
||||||
register(process_string, "process_string")
|
register(process_string, "process_string")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def process_list(request: HttpRequest, items: list) -> SimpleOutput:
|
def process_list(request: HttpRequest, items: list) -> SimpleOutput:
|
||||||
return SimpleOutput(value=str(len(items)))
|
return SimpleOutput(value=str(len(items)))
|
||||||
|
|
||||||
register(process_list, "process_list")
|
register(process_list, "process_list")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -141,7 +144,9 @@ class MemoryExhaustionTests(TestCase):
|
|||||||
def create_wide_nested(depth, width):
|
def create_wide_nested(depth, width):
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
return "leaf"
|
return "leaf"
|
||||||
return {f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)}
|
return {
|
||||||
|
f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)
|
||||||
|
}
|
||||||
|
|
||||||
# 5 levels deep, 10 wide = 10^5 = 100,000 nodes
|
# 5 levels deep, 10 wide = 10^5 = 100,000 nodes
|
||||||
wide_structure = create_wide_nested(5, 10)
|
wide_structure = create_wide_nested(5, 10)
|
||||||
@@ -225,16 +230,19 @@ class TypeConfusionTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def numeric_func(request: HttpRequest, value: float) -> NumericOutput:
|
def numeric_func(request: HttpRequest, value: float) -> NumericOutput:
|
||||||
return NumericOutput(result=value * 2)
|
return NumericOutput(result=value * 2)
|
||||||
|
|
||||||
register(numeric_func, "numeric_func")
|
register(numeric_func, "numeric_func")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def any_input(request: HttpRequest, data: Any) -> SimpleOutput:
|
def any_input(request: HttpRequest, data: Any) -> SimpleOutput:
|
||||||
return SimpleOutput(value=str(type(data).__name__))
|
return SimpleOutput(value=str(type(data).__name__))
|
||||||
|
|
||||||
register(any_input, "any_input")
|
register(any_input, "any_input")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput:
|
def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput:
|
||||||
return SimpleOutput(value="yes" if flag else "no")
|
return SimpleOutput(value="yes" if flag else "no")
|
||||||
|
|
||||||
register(bool_func, "bool_func")
|
register(bool_func, "bool_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -254,8 +262,9 @@ class TypeConfusionTests(TestCase):
|
|||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
# JSON doesn't support NaN directly, but we test the boundary
|
# JSON doesn't support NaN directly, but we test the boundary
|
||||||
result = execute_function(request, "numeric_func", {"value": float('nan')})
|
result = execute_function(request, "numeric_func", {"value": float("nan")})
|
||||||
|
|
||||||
# numeric_func doubles the value; NaN * 2 is still NaN
|
# numeric_func doubles the value; NaN * 2 is still NaN
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
@@ -267,21 +276,21 @@ class TypeConfusionTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
result = execute_function(request, "numeric_func", {"value": float('inf')})
|
result = execute_function(request, "numeric_func", {"value": float("inf")})
|
||||||
|
|
||||||
# inf * 2 is still inf
|
# inf * 2 is still inf
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["result"], float('inf'))
|
self.assertEqual(result.data["result"], float("inf"))
|
||||||
|
|
||||||
def test_negative_infinity_handling(self):
|
def test_negative_infinity_handling(self):
|
||||||
"""Test handling of negative infinity."""
|
"""Test handling of negative infinity."""
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
result = execute_function(request, "numeric_func", {"value": float('-inf')})
|
result = execute_function(request, "numeric_func", {"value": float("-inf")})
|
||||||
|
|
||||||
# -inf * 2 is still -inf
|
# -inf * 2 is still -inf
|
||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
self.assertEqual(result.data["result"], float('-inf'))
|
self.assertEqual(result.data["result"], float("-inf"))
|
||||||
|
|
||||||
def test_very_small_float(self):
|
def test_very_small_float(self):
|
||||||
"""Test handling of very small floats (denormalized)."""
|
"""Test handling of very small floats (denormalized)."""
|
||||||
@@ -304,7 +313,7 @@ class TypeConfusionTests(TestCase):
|
|||||||
result = execute_function(request, "numeric_func", {"value": huge})
|
result = execute_function(request, "numeric_func", {"value": huge})
|
||||||
# Doubling max float should overflow to inf
|
# Doubling max float should overflow to inf
|
||||||
if isinstance(result, FunctionResult):
|
if isinstance(result, FunctionResult):
|
||||||
self.assertEqual(result.data["result"], float('inf'))
|
self.assertEqual(result.data["result"], float("inf"))
|
||||||
|
|
||||||
def test_boolean_type_confusion(self):
|
def test_boolean_type_confusion(self):
|
||||||
"""
|
"""
|
||||||
@@ -401,12 +410,14 @@ class RaceConditionTests(TestCase):
|
|||||||
test_instance.executions.append(exec_time)
|
test_instance.executions.append(exec_time)
|
||||||
|
|
||||||
return TimingOutput(authenticated=is_auth, timestamp=exec_time)
|
return TimingOutput(authenticated=is_auth, timestamp=exec_time)
|
||||||
|
|
||||||
register(timed_auth_func, "timed_auth_func")
|
register(timed_auth_func, "timed_auth_func")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def counter_func(request: HttpRequest) -> SimpleOutput:
|
def counter_func(request: HttpRequest) -> SimpleOutput:
|
||||||
test_instance.call_count += 1
|
test_instance.call_count += 1
|
||||||
return SimpleOutput(value=str(test_instance.call_count))
|
return SimpleOutput(value=str(test_instance.call_count))
|
||||||
|
|
||||||
register(counter_func, "counter_func")
|
register(counter_func, "counter_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -454,6 +465,7 @@ class RaceConditionTests(TestCase):
|
|||||||
Simulates checking if the user authentication state could change
|
Simulates checking if the user authentication state could change
|
||||||
between validation and execution.
|
between validation and execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Create a user mock that changes state
|
# Create a user mock that changes state
|
||||||
class MutableUser:
|
class MutableUser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -510,6 +522,7 @@ class PydanticBypassTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput:
|
def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=f"{name}:{count}")
|
return SimpleOutput(value=f"{name}:{count}")
|
||||||
|
|
||||||
register(typed_func, "typed_func")
|
register(typed_func, "typed_func")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -518,6 +531,7 @@ class PydanticBypassTests(TestCase):
|
|||||||
if "@" not in email:
|
if "@" not in email:
|
||||||
raise ValueError("Invalid email format")
|
raise ValueError("Invalid email format")
|
||||||
return SimpleOutput(value=email)
|
return SimpleOutput(value=email)
|
||||||
|
|
||||||
register(strict_func, "strict_func")
|
register(strict_func, "strict_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -537,7 +551,9 @@ class PydanticBypassTests(TestCase):
|
|||||||
self.assertIsInstance(result, FunctionResult)
|
self.assertIsInstance(result, FunctionResult)
|
||||||
|
|
||||||
# Invalid type - dict for int
|
# Invalid type - dict for int
|
||||||
result = execute_function(request, "typed_func", {"count": {"nested": 1}, "name": "test"})
|
result = execute_function(
|
||||||
|
request, "typed_func", {"count": {"nested": 1}, "name": "test"}
|
||||||
|
)
|
||||||
self.assertIsInstance(result, FunctionError)
|
self.assertIsInstance(result, FunctionError)
|
||||||
self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
|
self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
|
||||||
|
|
||||||
@@ -606,13 +622,14 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def ws_func(request: HttpRequest, data: str) -> SimpleOutput:
|
def ws_func(request: HttpRequest, data: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=data)
|
return SimpleOutput(value=data)
|
||||||
|
|
||||||
register(ws_func, "ws_func")
|
register(ws_func, "ws_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
def _create_consumer(self, user=None):
|
def _create_consumer(self, user=None):
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
consumer.scope = {"user": user or AnonymousUser()}
|
consumer.scope = {"user": user or AnonymousUser()}
|
||||||
@@ -680,7 +697,7 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
"action": "rpc",
|
"action": "rpc",
|
||||||
"id": mal_id,
|
"id": mal_id,
|
||||||
"fn": "ws_func",
|
"fn": "ws_func",
|
||||||
"args": {"data": "test"}
|
"args": {"data": "test"},
|
||||||
}
|
}
|
||||||
async_to_sync(consumer.receive_json)(payload)
|
async_to_sync(consumer.receive_json)(payload)
|
||||||
|
|
||||||
@@ -693,8 +710,8 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
|
|
||||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||||
"""
|
"""
|
||||||
from djarea.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, ReactChannel
|
||||||
from djarea.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()
|
||||||
@@ -718,14 +735,12 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
|
|
||||||
# Rapid subscribe/unsubscribe
|
# Rapid subscribe/unsubscribe
|
||||||
for i in range(50):
|
for i in range(50):
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "test-channel",
|
{"channel": "test-channel", "params": {"room": f"room_{i}"}}
|
||||||
"params": {"room": f"room_{i}"}
|
)
|
||||||
})
|
async_to_sync(consumer._handle_unsubscribe)(
|
||||||
async_to_sync(consumer._handle_unsubscribe)({
|
{"channel": "test-channel", "params": {"room": f"room_{i}"}}
|
||||||
"channel": "test-channel",
|
)
|
||||||
"params": {"room": f"room_{i}"}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Should not have any lingering subscriptions
|
# Should not have any lingering subscriptions
|
||||||
self.assertEqual(len(consumer._subscriptions), 0)
|
self.assertEqual(len(consumer._subscriptions), 0)
|
||||||
@@ -736,8 +751,8 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
Test attempting to subscribe to the same channel twice.
|
Test attempting to subscribe to the same channel twice.
|
||||||
"""
|
"""
|
||||||
from djarea.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, ReactChannel
|
||||||
from djarea.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()
|
||||||
@@ -757,17 +772,15 @@ class WebSocketProtocolTests(TestCase):
|
|||||||
consumer, messages = self._create_consumer()
|
consumer, messages = self._create_consumer()
|
||||||
|
|
||||||
# First subscription
|
# First subscription
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "dup-channel",
|
{"channel": "dup-channel", "params": {}}
|
||||||
"params": {}
|
)
|
||||||
})
|
|
||||||
self.assertIn("subscribed", messages[-1])
|
self.assertIn("subscribed", messages[-1])
|
||||||
|
|
||||||
# Second subscription to same channel
|
# Second subscription to same channel
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "dup-channel",
|
{"channel": "dup-channel", "params": {}}
|
||||||
"params": {}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# Should return error about already subscribed
|
# Should return error about already subscribed
|
||||||
self.assertIn("error", messages[-1])
|
self.assertIn("error", messages[-1])
|
||||||
@@ -797,6 +810,7 @@ class TimingSideChannelTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def existing_func(request: HttpRequest) -> SimpleOutput:
|
def existing_func(request: HttpRequest) -> SimpleOutput:
|
||||||
return SimpleOutput(value="exists")
|
return SimpleOutput(value="exists")
|
||||||
|
|
||||||
register(existing_func, "existing_func")
|
register(existing_func, "existing_func")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -804,6 +818,7 @@ class TimingSideChannelTests(TestCase):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Auth required")
|
raise PermissionError("Auth required")
|
||||||
return SimpleOutput(value="authenticated")
|
return SimpleOutput(value="authenticated")
|
||||||
|
|
||||||
register(auth_func, "auth_func")
|
register(auth_func, "auth_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -910,6 +925,7 @@ class UnicodeNormalizationTests(TestCase):
|
|||||||
if username == "admin":
|
if username == "admin":
|
||||||
raise PermissionError("Reserved username")
|
raise PermissionError("Reserved username")
|
||||||
return SimpleOutput(value=f"Hello, {username}")
|
return SimpleOutput(value=f"Hello, {username}")
|
||||||
|
|
||||||
register(username_func, "username_func")
|
register(username_func, "username_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -1011,6 +1027,7 @@ class JSONParsingEdgeCaseTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def json_func(request: HttpRequest, data: dict) -> SimpleOutput:
|
def json_func(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||||
return SimpleOutput(value=json.dumps(data))
|
return SimpleOutput(value=json.dumps(data))
|
||||||
|
|
||||||
register(json_func, "json_func")
|
register(json_func, "json_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -1097,6 +1114,7 @@ class AuthorizationBoundaryTests(TestCase):
|
|||||||
if target_role not in allowed_roles:
|
if target_role not in allowed_roles:
|
||||||
raise PermissionError(f"Cannot escalate to {target_role}")
|
raise PermissionError(f"Cannot escalate to {target_role}")
|
||||||
return SimpleOutput(value=f"Role set to {target_role}")
|
return SimpleOutput(value=f"Role set to {target_role}")
|
||||||
|
|
||||||
register(escalation_func, "escalation_func")
|
register(escalation_func, "escalation_func")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -1160,8 +1178,8 @@ class RegistrationSecurityTests(TestCase):
|
|||||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
Note: Re-registration of the same function name IS allowed for hot reload.
|
||||||
But a DIFFERENT function cannot take over an existing name.
|
But a DIFFERENT function cannot take over an existing name.
|
||||||
"""
|
"""
|
||||||
from djarea.client import ServerFunction
|
from mizan.client import ServerFunction
|
||||||
from djarea.setup.registry import register
|
from mizan_core.registry import register
|
||||||
|
|
||||||
# Register first function
|
# Register first function
|
||||||
class OriginalFunc(ServerFunction):
|
class OriginalFunc(ServerFunction):
|
||||||
@@ -1196,6 +1214,7 @@ class RegistrationSecurityTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def normal_func_name(request: HttpRequest) -> SimpleOutput:
|
def normal_func_name(request: HttpRequest) -> SimpleOutput:
|
||||||
return SimpleOutput(value="ok")
|
return SimpleOutput(value="ok")
|
||||||
|
|
||||||
register(normal_func_name, "normal_func_name")
|
register(normal_func_name, "normal_func_name")
|
||||||
|
|
||||||
fn = get_function("normal_func_name")
|
fn = get_function("normal_func_name")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Security-focused E2E tests for Djarea server functions.
|
Security-focused E2E tests for mizan server functions.
|
||||||
|
|
||||||
These tests probe for potential vulnerabilities without running any
|
These tests probe for potential vulnerabilities without running any
|
||||||
malicious code - they simply verify that defenses work correctly.
|
malicious code - they simply verify that defenses work correctly.
|
||||||
@@ -22,16 +22,16 @@ 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, field_validator
|
||||||
|
|
||||||
from djarea.client.executor import (
|
from mizan.client.executor import (
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
FunctionError,
|
FunctionError,
|
||||||
FunctionResult,
|
FunctionResult,
|
||||||
execute_function,
|
execute_function,
|
||||||
function_call_view,
|
function_call_view,
|
||||||
)
|
)
|
||||||
from djarea.setup.registry import clear_registry, register, register_as, get_function
|
from mizan_core.registry import clear_registry, register, register_as, get_function
|
||||||
from djarea.client import ServerFunction, client
|
from mizan.client import ServerFunction, client
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -90,6 +90,7 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def echo_any(request: HttpRequest, message: str) -> SimpleOutput:
|
def echo_any(request: HttpRequest, message: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=message)
|
return SimpleOutput(value=message)
|
||||||
|
|
||||||
register(echo_any, "echo_any")
|
register(echo_any, "echo_any")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -97,16 +98,18 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
def count_depth(obj, depth=0):
|
def count_depth(obj, depth=0):
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return max(
|
return max(
|
||||||
(count_depth(v, depth + 1) for v in obj.values()),
|
(count_depth(v, depth + 1) for v in obj.values()), default=depth
|
||||||
default=depth
|
|
||||||
)
|
)
|
||||||
return depth
|
return depth
|
||||||
|
|
||||||
return DeeplyNestedOutput(depth=count_depth(data))
|
return DeeplyNestedOutput(depth=count_depth(data))
|
||||||
|
|
||||||
register(process_nested, "process_nested")
|
register(process_nested, "process_nested")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput:
|
def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=f"{name}:{age}")
|
return SimpleOutput(value=f"{name}:{age}")
|
||||||
|
|
||||||
register(typed_input, "typed_input")
|
register(typed_input, "typed_input")
|
||||||
|
|
||||||
def _make_request(self, user=None):
|
def _make_request(self, user=None):
|
||||||
@@ -184,8 +187,7 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
|
|
||||||
# Try to bypass integer validation with string
|
# Try to bypass integer validation with string
|
||||||
result = execute_function(
|
result = execute_function(
|
||||||
request, "typed_input",
|
request, "typed_input", {"age": "25; DROP TABLE users", "name": "test"}
|
||||||
{"age": "25; DROP TABLE users", "name": "test"}
|
|
||||||
)
|
)
|
||||||
# Pydantic should coerce "25; DROP TABLE users" and fail
|
# Pydantic should coerce "25; DROP TABLE users" and fail
|
||||||
# because it's not a valid integer
|
# because it's not a valid integer
|
||||||
@@ -208,8 +210,9 @@ class InputValidationSecurityTests(TestCase):
|
|||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
result = execute_function(
|
result = execute_function(
|
||||||
request, "echo_any",
|
request,
|
||||||
{"message": "test", "__proto__": "polluted", "extra": "ignored"}
|
"echo_any",
|
||||||
|
{"message": "test", "__proto__": "polluted", "extra": "ignored"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should succeed, extra fields ignored
|
# Should succeed, extra fields ignored
|
||||||
@@ -246,6 +249,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
return SensitiveOutput(secret="sensitive", user_id=request.user.id)
|
return SensitiveOutput(secret="sensitive", user_id=request.user.id)
|
||||||
|
|
||||||
register(requires_auth, "requires_auth")
|
register(requires_auth, "requires_auth")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -255,6 +259,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
if not request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
raise PermissionError("Admin access required")
|
raise PermissionError("Admin access required")
|
||||||
return AdminOnlyOutput(admin_data="secret admin data")
|
return AdminOnlyOutput(admin_data="secret admin data")
|
||||||
|
|
||||||
register(requires_admin, "requires_admin")
|
register(requires_admin, "requires_admin")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
@@ -264,6 +269,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
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")
|
||||||
|
|
||||||
register(leaky_auth_check, "leaky_auth_check")
|
register(leaky_auth_check, "leaky_auth_check")
|
||||||
|
|
||||||
def _make_request(self, user=None):
|
def _make_request(self, user=None):
|
||||||
@@ -316,6 +322,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_spoofed_is_authenticated_attribute(self):
|
def test_spoofed_is_authenticated_attribute(self):
|
||||||
"""Test that spoofing is_authenticated doesn't work."""
|
"""Test that spoofing is_authenticated doesn't work."""
|
||||||
|
|
||||||
# Create object that claims to be authenticated but isn't a real user
|
# Create object that claims to be authenticated but isn't a real user
|
||||||
class FakeUser:
|
class FakeUser:
|
||||||
is_authenticated = True
|
is_authenticated = True
|
||||||
@@ -330,6 +337,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_user_id_manipulation_blocked(self):
|
def test_user_id_manipulation_blocked(self):
|
||||||
"""Test that user can't access other users' data via input."""
|
"""Test that user can't access other users' data via input."""
|
||||||
|
|
||||||
@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
|
# Properly checking: can only access own data
|
||||||
@@ -338,6 +346,7 @@ class AuthorizationSecurityTests(TestCase):
|
|||||||
if request.user.id != target_user_id:
|
if request.user.id != target_user_id:
|
||||||
raise PermissionError("Cannot access other users' data")
|
raise PermissionError("Cannot access other users' data")
|
||||||
return SensitiveOutput(secret="data", user_id=target_user_id)
|
return SensitiveOutput(secret="data", user_id=target_user_id)
|
||||||
|
|
||||||
register(get_user_data, "get_user_data")
|
register(get_user_data, "get_user_data")
|
||||||
|
|
||||||
user = MagicMock()
|
user = MagicMock()
|
||||||
@@ -380,11 +389,12 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def public_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
def public_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=message)
|
return SimpleOutput(value=message)
|
||||||
|
|
||||||
register(public_echo, "public_echo")
|
register(public_echo, "public_echo")
|
||||||
|
|
||||||
def test_get_method_rejected(self):
|
def test_get_method_rejected(self):
|
||||||
"""Test that GET requests are rejected."""
|
"""Test that GET requests are rejected."""
|
||||||
request = self.factory.get("/api/djarea/call/")
|
request = self.factory.get("/api/mizan/call/")
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
response = function_call_view(request)
|
response = function_call_view(request)
|
||||||
@@ -396,7 +406,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_put_method_rejected(self):
|
def test_put_method_rejected(self):
|
||||||
"""Test that PUT requests are rejected."""
|
"""Test that PUT requests are rejected."""
|
||||||
request = self.factory.put("/api/djarea/call/")
|
request = self.factory.put("/api/mizan/call/")
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
||||||
|
|
||||||
@@ -406,7 +416,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_delete_method_rejected(self):
|
def test_delete_method_rejected(self):
|
||||||
"""Test that DELETE requests are rejected."""
|
"""Test that DELETE requests are rejected."""
|
||||||
request = self.factory.delete("/api/djarea/call/")
|
request = self.factory.delete("/api/mizan/call/")
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
||||||
|
|
||||||
@@ -417,9 +427,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
def test_invalid_json_rejected(self):
|
def test_invalid_json_rejected(self):
|
||||||
"""Test that invalid JSON is rejected gracefully."""
|
"""Test that invalid JSON is rejected gracefully."""
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||||
data="{invalid json",
|
|
||||||
content_type="application/json"
|
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
# Bypass CSRF for this test
|
# Bypass CSRF for this test
|
||||||
@@ -435,9 +443,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
def test_empty_body_rejected(self):
|
def test_empty_body_rejected(self):
|
||||||
"""Test that empty body is rejected (fn field required)."""
|
"""Test that empty body is rejected (fn field required)."""
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/", data="", content_type="application/json"
|
||||||
data="",
|
|
||||||
content_type="application/json"
|
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
@@ -450,9 +456,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
def test_missing_fn_field_rejected(self):
|
def test_missing_fn_field_rejected(self):
|
||||||
"""Test that request without fn field is rejected."""
|
"""Test that request without fn field is rejected."""
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
data='{"args": {"message": "test"}}',
|
data='{"args": {"message": "test"}}',
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
@@ -467,9 +473,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
def test_content_type_not_enforced(self):
|
def test_content_type_not_enforced(self):
|
||||||
"""Test behavior with wrong content type."""
|
"""Test behavior with wrong content type."""
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
data='{"fn": "public_echo", "args": {"message": "test"}}',
|
data='{"fn": "public_echo", "args": {"message": "test"}}',
|
||||||
content_type="text/plain"
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
@@ -491,9 +497,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
|||||||
|
|
||||||
for name in malicious_names:
|
for name in malicious_names:
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
data=json.dumps({"fn": name, "args": {}}),
|
data=json.dumps({"fn": name, "args": {}}),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
@@ -528,6 +534,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
def ws_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
def ws_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=message)
|
return SimpleOutput(value=message)
|
||||||
|
|
||||||
register(ws_echo, "ws_echo")
|
register(ws_echo, "ws_echo")
|
||||||
|
|
||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
@@ -535,11 +542,12 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise PermissionError("Auth required")
|
raise PermissionError("Auth required")
|
||||||
return SensitiveOutput(secret="data", user_id=request.user.id)
|
return SensitiveOutput(secret="data", user_id=request.user.id)
|
||||||
|
|
||||||
register(ws_auth_required, "ws_auth_required")
|
register(ws_auth_required, "ws_auth_required")
|
||||||
|
|
||||||
def test_rpc_without_id_field(self):
|
def test_rpc_without_id_field(self):
|
||||||
"""Test RPC call without required id field."""
|
"""Test RPC call without required id field."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -552,7 +560,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
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
|
# Call without id
|
||||||
async_to_sync(consumer._handle_rpc)({"fn": "ws_echo", "args": {"message": "test"}})
|
async_to_sync(consumer._handle_rpc)(
|
||||||
|
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||||
|
)
|
||||||
|
|
||||||
# Should return error about missing id
|
# Should return error about missing id
|
||||||
self.assertEqual(len(sent_messages), 1)
|
self.assertEqual(len(sent_messages), 1)
|
||||||
@@ -560,7 +570,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_rpc_without_fn_field(self):
|
def test_rpc_without_fn_field(self):
|
||||||
"""Test RPC call without function name."""
|
"""Test RPC call without function name."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -581,7 +591,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
|
|
||||||
def test_rpc_nonexistent_function(self):
|
def test_rpc_nonexistent_function(self):
|
||||||
"""Test RPC call to non-existent function."""
|
"""Test RPC call to non-existent function."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -592,18 +602,16 @@ 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))
|
||||||
|
|
||||||
async_to_sync(consumer._handle_rpc)({
|
async_to_sync(consumer._handle_rpc)(
|
||||||
"id": "123",
|
{"id": "123", "fn": "nonexistent_function", "args": {}}
|
||||||
"fn": "nonexistent_function",
|
)
|
||||||
"args": {}
|
|
||||||
})
|
|
||||||
|
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND")
|
self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND")
|
||||||
|
|
||||||
def test_rpc_validation_error_returned(self):
|
def test_rpc_validation_error_returned(self):
|
||||||
"""Test that validation errors are returned properly over RPC."""
|
"""Test that validation errors are returned properly over RPC."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -615,20 +623,20 @@ class WebSocketRPCSecurityTests(TestCase):
|
|||||||
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
|
# Call with wrong input type
|
||||||
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}, # Should be string
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Pydantic coerces int to string, so this actually succeeds
|
# Pydantic coerces int to string, so this actually succeeds
|
||||||
# Let's test with missing required field instead
|
# 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",
|
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
||||||
"fn": "ws_echo",
|
)
|
||||||
"args": {} # Missing message
|
|
||||||
})
|
|
||||||
|
|
||||||
self.assertEqual(sent_messages[0]["ok"], False)
|
self.assertEqual(sent_messages[0]["ok"], False)
|
||||||
self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR")
|
self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR")
|
||||||
@@ -662,11 +670,13 @@ class InformationDisclosureTests(TestCase):
|
|||||||
# Simulate accessing sensitive config that might leak in error
|
# 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}")
|
||||||
|
|
||||||
register(error_with_sensitive_data, "error_with_sensitive_data")
|
register(error_with_sensitive_data, "error_with_sensitive_data")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def working_function(request: HttpRequest) -> SimpleOutput:
|
def working_function(request: HttpRequest) -> SimpleOutput:
|
||||||
return SimpleOutput(value="works")
|
return SimpleOutput(value="works")
|
||||||
|
|
||||||
register(working_function, "working_function")
|
register(working_function, "working_function")
|
||||||
|
|
||||||
def _make_request(self, user=None):
|
def _make_request(self, user=None):
|
||||||
@@ -712,9 +722,11 @@ class InformationDisclosureTests(TestCase):
|
|||||||
|
|
||||||
def test_validation_errors_dont_leak_internals(self):
|
def test_validation_errors_dont_leak_internals(self):
|
||||||
"""Test that validation errors only show field-level info."""
|
"""Test that validation errors only show field-level info."""
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput:
|
def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput:
|
||||||
return SimpleOutput(value=secret_field)
|
return SimpleOutput(value=secret_field)
|
||||||
|
|
||||||
register(validated_func, "validated_func")
|
register(validated_func, "validated_func")
|
||||||
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
@@ -758,11 +770,13 @@ class InjectionPreventionTests(TestCase):
|
|||||||
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
|
# 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")
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def process_dict(request: HttpRequest, data: dict) -> SimpleOutput:
|
def process_dict(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||||
return SimpleOutput(value=str(len(data)))
|
return SimpleOutput(value=str(len(data)))
|
||||||
|
|
||||||
register(process_dict, "process_dict")
|
register(process_dict, "process_dict")
|
||||||
|
|
||||||
def _make_request(self, user=None):
|
def _make_request(self, user=None):
|
||||||
@@ -847,8 +861,7 @@ class InjectionPreventionTests(TestCase):
|
|||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
|
|
||||||
result = execute_function(
|
result = execute_function(
|
||||||
request, "process_dict",
|
request, "process_dict", {"data": {"__proto__": {"admin": True}}}
|
||||||
{"data": {"__proto__": {"admin": True}}}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should succeed - it's just a dict with a key named "__proto__"
|
# Should succeed - it's just a dict with a key named "__proto__"
|
||||||
@@ -870,18 +883,20 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
# Also clear the channels registry
|
# Also clear the channels registry
|
||||||
from djarea.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
self._register_test_channels()
|
self._register_test_channels()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
from djarea.channels import _registry as channels_registry
|
from mizan.channels import _registry as channels_registry
|
||||||
|
|
||||||
channels_registry.clear()
|
channels_registry.clear()
|
||||||
|
|
||||||
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 djarea.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, ReactChannel
|
||||||
|
|
||||||
class PublicChannel(ReactChannel):
|
class PublicChannel(ReactChannel):
|
||||||
class DjangoMessage(BaseModel):
|
class DjangoMessage(BaseModel):
|
||||||
@@ -923,8 +938,8 @@ 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 djarea.channels import register as register_channel, ReactChannel
|
from mizan.channels import register as register_channel, ReactChannel
|
||||||
from djarea.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(ReactChannel):
|
||||||
@@ -947,10 +962,9 @@ class ChannelAuthorizationTests(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))
|
||||||
|
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "error-channel",
|
{"channel": "error-channel", "params": {}}
|
||||||
"params": {}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# Should return error, not crash
|
# Should return error, not crash
|
||||||
self.assertEqual(len(sent_messages), 1)
|
self.assertEqual(len(sent_messages), 1)
|
||||||
@@ -958,7 +972,7 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def test_authorize_false_blocks_subscription(self):
|
def test_authorize_false_blocks_subscription(self):
|
||||||
"""Test that returning False from authorize blocks subscription."""
|
"""Test that returning False from authorize blocks subscription."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -969,10 +983,9 @@ class ChannelAuthorizationTests(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))
|
||||||
|
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "auth-channel",
|
{"channel": "auth-channel", "params": {}}
|
||||||
"params": {}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# Should be rejected
|
# Should be rejected
|
||||||
self.assertIn("error", sent_messages[0])
|
self.assertIn("error", sent_messages[0])
|
||||||
@@ -980,7 +993,7 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
|
|
||||||
def test_param_validation_before_authorize(self):
|
def test_param_validation_before_authorize(self):
|
||||||
"""Test that params are validated before authorize is called."""
|
"""Test that params are validated before authorize is called."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -992,17 +1005,16 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Invalid params (string instead of int)
|
# Invalid params (string instead of int)
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "room-channel",
|
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||||
"params": {"room_id": "not_an_int"}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# Should fail validation
|
# 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):
|
||||||
"""Test that room-level authorization is enforced."""
|
"""Test that room-level authorization is enforced."""
|
||||||
from djarea.channels.connection import DjangoReactConsumer
|
from mizan.channels.connection import DjangoReactConsumer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
consumer = DjangoReactConsumer()
|
consumer = DjangoReactConsumer()
|
||||||
@@ -1015,17 +1027,15 @@ class ChannelAuthorizationTests(TestCase):
|
|||||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||||
|
|
||||||
# Room 1 - allowed
|
# Room 1 - allowed
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "room-channel",
|
{"channel": "room-channel", "params": {"room_id": 1}}
|
||||||
"params": {"room_id": 1}
|
)
|
||||||
})
|
|
||||||
self.assertIn("subscribed", sent_messages[-1])
|
self.assertIn("subscribed", sent_messages[-1])
|
||||||
|
|
||||||
# Room 999 - not allowed
|
# Room 999 - not allowed
|
||||||
async_to_sync(consumer._handle_subscribe)({
|
async_to_sync(consumer._handle_subscribe)(
|
||||||
"channel": "room-channel",
|
{"channel": "room-channel", "params": {"room_id": 999}}
|
||||||
"params": {"room_id": 999}
|
)
|
||||||
})
|
|
||||||
self.assertIn("error", sent_messages[-1])
|
self.assertIn("error", sent_messages[-1])
|
||||||
|
|
||||||
|
|
||||||
@@ -1057,6 +1067,7 @@ class AbusePreventionTests(TestCase):
|
|||||||
@client
|
@client
|
||||||
def simple_func(request: HttpRequest) -> SimpleOutput:
|
def simple_func(request: HttpRequest) -> SimpleOutput:
|
||||||
return SimpleOutput(value="ok")
|
return SimpleOutput(value="ok")
|
||||||
|
|
||||||
register(simple_func, "simple_func")
|
register(simple_func, "simple_func")
|
||||||
|
|
||||||
def _make_request(self, user=None):
|
def _make_request(self, user=None):
|
||||||
@@ -1081,9 +1092,11 @@ class AbusePreventionTests(TestCase):
|
|||||||
|
|
||||||
def test_large_batch_execution(self):
|
def test_large_batch_execution(self):
|
||||||
"""Test handling of large batch of different inputs."""
|
"""Test handling of large batch of different inputs."""
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def batch_func(request: HttpRequest, idx: int) -> SimpleOutput:
|
def batch_func(request: HttpRequest, idx: int) -> SimpleOutput:
|
||||||
return SimpleOutput(value=f"item_{idx}")
|
return SimpleOutput(value=f"item_{idx}")
|
||||||
|
|
||||||
register(batch_func, "batch_func")
|
register(batch_func, "batch_func")
|
||||||
|
|
||||||
request = self._make_request()
|
request = self._make_request()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Stress tests for djarea.shapes — edge cases and deep nesting.
|
Stress tests for mizan.shapes — edge cases and deep nesting.
|
||||||
|
|
||||||
Models: Publisher → Author → Book → Chapter → Section (5 levels deep),
|
Models: Publisher → Author → Book → Chapter → Section (5 levels deep),
|
||||||
two FKs to same model, slug PK, UUID PK, self-referential FK, M2M,
|
two FKs to same model, slug PK, UUID PK, self-referential FK, M2M,
|
||||||
@@ -11,12 +11,18 @@ from typing import get_type_hints
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from djarea.shapes import Shape, Diff, NestedDiff
|
from mizan.shapes import Shape, Diff, NestedDiff
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from tests.models import (
|
from tests.models import (
|
||||||
Publisher, Author, Book, Chapter, Section, Tag, Category,
|
Publisher,
|
||||||
|
Author,
|
||||||
|
Book,
|
||||||
|
Chapter,
|
||||||
|
Section,
|
||||||
|
Tag,
|
||||||
|
Category,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +105,7 @@ class PublisherDetailShape(Shape[Publisher]):
|
|||||||
|
|
||||||
class BookWithEditorShape(Shape[Book]):
|
class BookWithEditorShape(Shape[Book]):
|
||||||
"""Two FKs to the same model (author + editor)."""
|
"""Two FKs to the same model (author + editor)."""
|
||||||
|
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
title: str
|
title: str
|
||||||
author: FlatAuthorShape
|
author: FlatAuthorShape
|
||||||
@@ -117,7 +124,6 @@ class CategoryShape(Shape[Category]):
|
|||||||
|
|
||||||
|
|
||||||
class TestShapeClassCreation(TestCase):
|
class TestShapeClassCreation(TestCase):
|
||||||
|
|
||||||
def test_flat_shape_has_no_nested(self):
|
def test_flat_shape_has_no_nested(self):
|
||||||
self.assertEqual(FlatAuthorShape._nested, {})
|
self.assertEqual(FlatAuthorShape._nested, {})
|
||||||
self.assertEqual(FlatAuthorShape._field_names, ["id", "name"])
|
self.assertEqual(FlatAuthorShape._field_names, ["id", "name"])
|
||||||
@@ -171,7 +177,9 @@ class TestShapeClassCreation(TestCase):
|
|||||||
self.assertIs(CategoryShape._nested["children"], CategoryShape)
|
self.assertIs(CategoryShape._nested["children"], CategoryShape)
|
||||||
|
|
||||||
def test_multiple_shapes_same_model_independent(self):
|
def test_multiple_shapes_same_model_independent(self):
|
||||||
self.assertLess(len(FlatBookShape._field_names), len(BookDetailShape._field_names))
|
self.assertLess(
|
||||||
|
len(FlatBookShape._field_names), len(BookDetailShape._field_names)
|
||||||
|
)
|
||||||
self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec)
|
self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +189,6 @@ class TestShapeClassCreation(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestShapeQuery(TestCase):
|
class TestShapeQuery(TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.publisher = Publisher.objects.create(name="Orbit", country="UK")
|
cls.publisher = Publisher.objects.create(name="Orbit", country="UK")
|
||||||
@@ -189,8 +196,10 @@ class TestShapeQuery(TestCase):
|
|||||||
name="Ursula", bio="Legend", publisher=cls.publisher
|
name="Ursula", bio="Legend", publisher=cls.publisher
|
||||||
)
|
)
|
||||||
cls.author = Author.objects.create(
|
cls.author = Author.objects.create(
|
||||||
name="Ann Leckie", bio="Imperial Radch",
|
name="Ann Leckie",
|
||||||
publisher=cls.publisher, mentor=cls.mentor,
|
bio="Imperial Radch",
|
||||||
|
publisher=cls.publisher,
|
||||||
|
mentor=cls.mentor,
|
||||||
)
|
)
|
||||||
cls.editor = Author.objects.create(
|
cls.editor = Author.objects.create(
|
||||||
name="Devi Pillai", bio="Editor", publisher=cls.publisher
|
name="Devi Pillai", bio="Editor", publisher=cls.publisher
|
||||||
@@ -199,9 +208,12 @@ class TestShapeQuery(TestCase):
|
|||||||
cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera")
|
cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera")
|
||||||
|
|
||||||
cls.book = Book.objects.create(
|
cls.book = Book.objects.create(
|
||||||
title="Ancillary Justice", isbn="9780316246620",
|
title="Ancillary Justice",
|
||||||
page_count=386, is_published=True,
|
isbn="9780316246620",
|
||||||
author=cls.author, editor=cls.editor,
|
page_count=386,
|
||||||
|
is_published=True,
|
||||||
|
author=cls.author,
|
||||||
|
editor=cls.editor,
|
||||||
)
|
)
|
||||||
cls.book.tags.add(cls.tag_sf, cls.tag_space)
|
cls.book.tags.add(cls.tag_sf, cls.tag_space)
|
||||||
|
|
||||||
@@ -211,8 +223,12 @@ class TestShapeQuery(TestCase):
|
|||||||
cls.ch2 = Chapter.objects.create(
|
cls.ch2 = Chapter.objects.create(
|
||||||
book=cls.book, number=2, title="The Ship", word_count=4800
|
book=cls.book, number=2, title="The Ship", word_count=4800
|
||||||
)
|
)
|
||||||
Section.objects.create(chapter=cls.ch1, heading="Opening", body="...", position=0)
|
Section.objects.create(
|
||||||
Section.objects.create(chapter=cls.ch1, heading="Discovery", body="...", position=1)
|
chapter=cls.ch1, heading="Opening", body="...", position=0
|
||||||
|
)
|
||||||
|
Section.objects.create(
|
||||||
|
chapter=cls.ch1, heading="Discovery", body="...", position=1
|
||||||
|
)
|
||||||
|
|
||||||
cls.root_cat = Category.objects.create(name="Fiction")
|
cls.root_cat = Category.objects.create(name="Fiction")
|
||||||
cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat)
|
cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat)
|
||||||
@@ -279,9 +295,12 @@ class TestShapeQuery(TestCase):
|
|||||||
|
|
||||||
def test_nullable_fk_returns_none(self):
|
def test_nullable_fk_returns_none(self):
|
||||||
book_no_editor = Book.objects.create(
|
book_no_editor = Book.objects.create(
|
||||||
title="Provenance", isbn="9780316246699",
|
title="Provenance",
|
||||||
page_count=448, is_published=True,
|
isbn="9780316246699",
|
||||||
author=self.author, editor=None,
|
page_count=448,
|
||||||
|
is_published=True,
|
||||||
|
author=self.author,
|
||||||
|
editor=None,
|
||||||
)
|
)
|
||||||
results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk))
|
results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk))
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
@@ -330,7 +349,6 @@ class TestShapeQuery(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestDiff(TestCase):
|
class TestDiff(TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.publisher = Publisher.objects.create(name="Tor", country="US")
|
cls.publisher = Publisher.objects.create(name="Tor", country="US")
|
||||||
@@ -338,8 +356,11 @@ class TestDiff(TestCase):
|
|||||||
name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher
|
name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher
|
||||||
)
|
)
|
||||||
cls.book = Book.objects.create(
|
cls.book = Book.objects.create(
|
||||||
title="Mistborn", isbn="9780765311788",
|
title="Mistborn",
|
||||||
page_count=541, is_published=True, author=cls.author,
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
|
author=cls.author,
|
||||||
)
|
)
|
||||||
cls.ch1 = Chapter.objects.create(
|
cls.ch1 = Chapter.objects.create(
|
||||||
book=cls.book, number=1, title="Ash", word_count=6000
|
book=cls.book, number=1, title="Ash", word_count=6000
|
||||||
@@ -352,8 +373,11 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_diff_no_changes(self):
|
def test_diff_no_changes(self):
|
||||||
shape = BookCardShape(
|
shape = BookCardShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
)
|
)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
@@ -362,8 +386,11 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_diff_detects_field_change(self):
|
def test_diff_detects_field_change(self):
|
||||||
shape = BookCardShape(
|
shape = BookCardShape(
|
||||||
id=self.book.pk, title="Mistborn: The Final Empire",
|
id=self.book.pk,
|
||||||
isbn="9780765311788", page_count=541, is_published=True,
|
title="Mistborn: The Final Empire",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
)
|
)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
@@ -372,8 +399,11 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_diff_multiple_field_changes(self):
|
def test_diff_multiple_field_changes(self):
|
||||||
shape = BookCardShape(
|
shape = BookCardShape(
|
||||||
id=self.book.pk, title="Mistborn: TFE",
|
id=self.book.pk,
|
||||||
isbn="9780765311788", page_count=600, is_published=True,
|
title="Mistborn: TFE",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=600,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
)
|
)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
@@ -396,12 +426,23 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_nested_diff_detects_updated_chapter(self):
|
def test_nested_diff_detects_updated_chapter(self):
|
||||||
shape = BookDetailShape(
|
shape = BookDetailShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
chapters=[
|
chapters=[
|
||||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash Falls", word_count=6000, sections=[]),
|
ChapterShape(
|
||||||
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]),
|
id=self.ch1.pk,
|
||||||
|
number=1,
|
||||||
|
title="Ash Falls",
|
||||||
|
word_count=6000,
|
||||||
|
sections=[],
|
||||||
|
),
|
||||||
|
ChapterShape(
|
||||||
|
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
tags=[],
|
tags=[],
|
||||||
)
|
)
|
||||||
@@ -411,13 +452,22 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_nested_diff_detects_created(self):
|
def test_nested_diff_detects_created(self):
|
||||||
shape = BookDetailShape(
|
shape = BookDetailShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
chapters=[
|
chapters=[
|
||||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]),
|
ChapterShape(
|
||||||
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]),
|
id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
|
||||||
ChapterShape(id=None, number=3, title="New Chapter", word_count=0, sections=[]),
|
),
|
||||||
|
ChapterShape(
|
||||||
|
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
|
||||||
|
),
|
||||||
|
ChapterShape(
|
||||||
|
id=None, number=3, title="New Chapter", word_count=0, sections=[]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
tags=[],
|
tags=[],
|
||||||
)
|
)
|
||||||
@@ -426,11 +476,16 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_nested_diff_detects_deleted(self):
|
def test_nested_diff_detects_deleted(self):
|
||||||
shape = BookDetailShape(
|
shape = BookDetailShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
chapters=[
|
chapters=[
|
||||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]),
|
ChapterShape(
|
||||||
|
id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
tags=[],
|
tags=[],
|
||||||
)
|
)
|
||||||
@@ -439,12 +494,23 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_nested_diff_combined_operations(self):
|
def test_nested_diff_combined_operations(self):
|
||||||
shape = BookDetailShape(
|
shape = BookDetailShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
chapters=[
|
chapters=[
|
||||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash Rewritten", word_count=7000, sections=[]),
|
ChapterShape(
|
||||||
ChapterShape(id=None, number=3, title="Epilogue", word_count=2000, sections=[]),
|
id=self.ch1.pk,
|
||||||
|
number=1,
|
||||||
|
title="Ash Rewritten",
|
||||||
|
word_count=7000,
|
||||||
|
sections=[],
|
||||||
|
),
|
||||||
|
ChapterShape(
|
||||||
|
id=None, number=3, title="Epilogue", word_count=2000, sections=[]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
tags=[],
|
tags=[],
|
||||||
)
|
)
|
||||||
@@ -469,10 +535,14 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_diff_strict_shows_valid_names(self):
|
def test_diff_strict_shows_valid_names(self):
|
||||||
shape = BookDetailShape(
|
shape = BookDetailShape(
|
||||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
id=self.book.pk,
|
||||||
page_count=541, is_published=True,
|
title="Mistborn",
|
||||||
|
isbn="9780765311788",
|
||||||
|
page_count=541,
|
||||||
|
is_published=True,
|
||||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||||
chapters=[], tags=[],
|
chapters=[],
|
||||||
|
tags=[],
|
||||||
)
|
)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
with self.assertRaises(AttributeError) as ctx:
|
with self.assertRaises(AttributeError) as ctx:
|
||||||
@@ -506,8 +576,11 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
def test_diff_many_batched_query(self):
|
def test_diff_many_batched_query(self):
|
||||||
book2 = Book.objects.create(
|
book2 = Book.objects.create(
|
||||||
title="Warbreaker", isbn="9780765320308",
|
title="Warbreaker",
|
||||||
page_count=592, is_published=True, author=self.author,
|
isbn="9780765320308",
|
||||||
|
page_count=592,
|
||||||
|
is_published=True,
|
||||||
|
author=self.author,
|
||||||
)
|
)
|
||||||
items = [
|
items = [
|
||||||
FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True),
|
FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True),
|
||||||
@@ -526,7 +599,6 @@ class TestDiff(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestEdgeCases(TestCase):
|
class TestEdgeCases(TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX")
|
cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX")
|
||||||
@@ -545,16 +617,22 @@ class TestEdgeCases(TestCase):
|
|||||||
|
|
||||||
def test_boolean_false_is_not_missing(self):
|
def test_boolean_false_is_not_missing(self):
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title="Unpublished", isbn="0000000000000",
|
title="Unpublished",
|
||||||
page_count=0, is_published=False, author=self.author,
|
isbn="0000000000000",
|
||||||
|
page_count=0,
|
||||||
|
is_published=False,
|
||||||
|
author=self.author,
|
||||||
)
|
)
|
||||||
results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk))
|
results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk))
|
||||||
self.assertIs(results[0].is_published, False)
|
self.assertIs(results[0].is_published, False)
|
||||||
|
|
||||||
def test_zero_integer_is_not_missing(self):
|
def test_zero_integer_is_not_missing(self):
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title="Empty", isbn="0000000000001",
|
title="Empty",
|
||||||
page_count=0, is_published=False, author=self.author,
|
isbn="0000000000001",
|
||||||
|
page_count=0,
|
||||||
|
is_published=False,
|
||||||
|
author=self.author,
|
||||||
)
|
)
|
||||||
results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk))
|
results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk))
|
||||||
self.assertEqual(results[0].page_count, 0)
|
self.assertEqual(results[0].page_count, 0)
|
||||||
@@ -562,8 +640,10 @@ class TestEdgeCases(TestCase):
|
|||||||
def test_large_queryset(self):
|
def test_large_queryset(self):
|
||||||
books = [
|
books = [
|
||||||
Book(
|
Book(
|
||||||
title=f"Book {i}", isbn=f"{i:013d}",
|
title=f"Book {i}",
|
||||||
page_count=i * 10, is_published=i % 2 == 0,
|
isbn=f"{i:013d}",
|
||||||
|
page_count=i * 10,
|
||||||
|
is_published=i % 2 == 0,
|
||||||
author=self.author,
|
author=self.author,
|
||||||
)
|
)
|
||||||
for i in range(100)
|
for i in range(100)
|
||||||
@@ -574,8 +654,11 @@ class TestEdgeCases(TestCase):
|
|||||||
|
|
||||||
def test_diff_on_boolean_change(self):
|
def test_diff_on_boolean_change(self):
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title="Toggle", isbn="1111111111111",
|
title="Toggle",
|
||||||
page_count=100, is_published=False, author=self.author,
|
isbn="1111111111111",
|
||||||
|
page_count=100,
|
||||||
|
is_published=False,
|
||||||
|
author=self.author,
|
||||||
)
|
)
|
||||||
shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True)
|
shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
@@ -584,8 +667,11 @@ class TestEdgeCases(TestCase):
|
|||||||
|
|
||||||
def test_diff_unchanged_returns_empty(self):
|
def test_diff_unchanged_returns_empty(self):
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title="Same", isbn="2222222222222",
|
title="Same",
|
||||||
page_count=200, is_published=True, author=self.author,
|
isbn="2222222222222",
|
||||||
|
page_count=200,
|
||||||
|
is_published=True,
|
||||||
|
author=self.author,
|
||||||
)
|
)
|
||||||
shape = FlatBookShape(id=book.pk, title="Same", is_published=True)
|
shape = FlatBookShape(id=book.pk, title="Same", is_published=True)
|
||||||
d = shape.diff()
|
d = shape.diff()
|
||||||
162
backends/mizan-django/src/mizan/tests/test_ssr.py
Normal file
162
backends/mizan-django/src/mizan/tests/test_ssr.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Mizan SSR bridge and template backend.
|
||||||
|
|
||||||
|
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
||||||
|
Tests skip gracefully if Bun is not available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, RequestFactory
|
||||||
|
|
||||||
|
# Path to the test worker
|
||||||
|
_SSR_WORKER = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..", "..", "..", "..", "..", # up to repo root
|
||||||
|
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
||||||
|
)
|
||||||
|
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
||||||
|
|
||||||
|
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||||
|
_SKIP_MSG = "Bun not available"
|
||||||
|
|
||||||
|
|
||||||
|
class SSRBridgeTests(SimpleTestCase):
|
||||||
|
"""Tests for the SSR bridge subprocess manager."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
if not _BUN_AVAILABLE:
|
||||||
|
self.skipTest(_SKIP_MSG)
|
||||||
|
if not os.path.exists(_SSR_WORKER):
|
||||||
|
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
|
from mizan.ssr.bridge import SSRBridge
|
||||||
|
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if hasattr(self, "bridge"):
|
||||||
|
self.bridge.shutdown()
|
||||||
|
|
||||||
|
def test_ping(self):
|
||||||
|
"""Worker starts and responds to ping."""
|
||||||
|
self.assertTrue(self.bridge.ping())
|
||||||
|
|
||||||
|
def test_render_simple(self):
|
||||||
|
"""Renders a simple component to HTML."""
|
||||||
|
result = self.bridge.render("Hello", {"name": "World"})
|
||||||
|
self.assertIn("Hello,", result.html)
|
||||||
|
self.assertIn("World", result.html)
|
||||||
|
|
||||||
|
def test_render_with_props(self):
|
||||||
|
"""Renders a component with multiple props."""
|
||||||
|
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
||||||
|
self.assertIn("Alice", result.html)
|
||||||
|
self.assertIn("42", result.html)
|
||||||
|
|
||||||
|
def test_render_missing_component(self):
|
||||||
|
"""Rendering an unregistered component raises RuntimeError."""
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.bridge.render("NonExistent", {})
|
||||||
|
self.assertIn("not registered", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_render_error(self):
|
||||||
|
"""Component that throws during render raises RuntimeError."""
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.bridge.render("Broken", {})
|
||||||
|
self.assertIn("Render error", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_crash_recovery(self):
|
||||||
|
"""Bridge restarts the worker if it dies."""
|
||||||
|
# First render works
|
||||||
|
result = self.bridge.render("Hello", {"name": "Before"})
|
||||||
|
self.assertIn("Before", result.html)
|
||||||
|
|
||||||
|
# Kill the subprocess
|
||||||
|
self.bridge._proc.kill()
|
||||||
|
self.bridge._proc.wait()
|
||||||
|
|
||||||
|
# Next render should restart and work
|
||||||
|
result = self.bridge.render("Hello", {"name": "After"})
|
||||||
|
self.assertIn("After", result.html)
|
||||||
|
|
||||||
|
def test_concurrent_renders(self):
|
||||||
|
"""Multiple threads can render simultaneously."""
|
||||||
|
results = {}
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
def render_in_thread(name: str, idx: int):
|
||||||
|
try:
|
||||||
|
result = self.bridge.render("Hello", {"name": name})
|
||||||
|
results[idx] = result.html
|
||||||
|
except Exception as e:
|
||||||
|
errors[idx] = e
|
||||||
|
|
||||||
|
threads = []
|
||||||
|
for i in range(5):
|
||||||
|
t = threading.Thread(target=render_in_thread, args=(f"User{i}", i))
|
||||||
|
threads.append(t)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
||||||
|
self.assertEqual(len(results), 5)
|
||||||
|
for i in range(5):
|
||||||
|
self.assertIn(f"User{i}", results[i])
|
||||||
|
|
||||||
|
|
||||||
|
class SSRTemplateBackendTests(SimpleTestCase):
|
||||||
|
"""Tests for the MizanTemplates Django template backend."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
if not _BUN_AVAILABLE:
|
||||||
|
self.skipTest(_SKIP_MSG)
|
||||||
|
if not os.path.exists(_SSR_WORKER):
|
||||||
|
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||||
|
|
||||||
|
from mizan.ssr.backend import MizanTemplates
|
||||||
|
self.engine = MizanTemplates({
|
||||||
|
"NAME": "mizan-test",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": False,
|
||||||
|
"OPTIONS": {
|
||||||
|
"worker_path": _SSR_WORKER,
|
||||||
|
"timeout": 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||||
|
self.engine._bridge.shutdown()
|
||||||
|
|
||||||
|
def test_get_template(self):
|
||||||
|
"""get_template returns a MizanTemplate."""
|
||||||
|
from mizan.ssr.backend import MizanTemplate
|
||||||
|
template = self.engine.get_template("Hello")
|
||||||
|
self.assertIsInstance(template, MizanTemplate)
|
||||||
|
self.assertEqual(template.component_name, "Hello")
|
||||||
|
|
||||||
|
def test_template_render(self):
|
||||||
|
"""MizanTemplate.render() produces HTML."""
|
||||||
|
template = self.engine.get_template("Hello")
|
||||||
|
html = template.render({"name": "Django"})
|
||||||
|
self.assertIn("Hello,", html)
|
||||||
|
self.assertIn("Django", html)
|
||||||
|
self.assertIn('data-mizan-component="Hello"', html)
|
||||||
|
|
||||||
|
def test_template_render_strips_django_internals(self):
|
||||||
|
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
||||||
|
template = self.engine.get_template("Hello")
|
||||||
|
request = self.factory.get("/")
|
||||||
|
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
||||||
|
self.assertIn("Test", html)
|
||||||
|
|
||||||
|
def test_from_string_raises(self):
|
||||||
|
"""from_string is not supported."""
|
||||||
|
from django.template import TemplateDoesNotExist
|
||||||
|
with self.assertRaises(TemplateDoesNotExist):
|
||||||
|
self.engine.from_string("<div>Not supported</div>")
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Djarea URL Configuration
|
mizan URL Configuration
|
||||||
|
|
||||||
Single integration point for all djarea HTTP endpoints:
|
HTTP endpoints:
|
||||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
||||||
- POST /call/ - Server function calls (HTTP transport)
|
- POST /call/ - Server function calls (HTTP transport)
|
||||||
|
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
|
||||||
|
|
||||||
Security:
|
Security:
|
||||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
||||||
- Use the management command instead: python manage.py export_djarea_schema
|
- Use the management command instead: python manage.py export_mizan_ir
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -15,9 +16,9 @@ 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
|
from .client.executor import function_call_view, context_fetch_view
|
||||||
|
|
||||||
app_name = "djarea"
|
app_name = "mizan"
|
||||||
|
|
||||||
|
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
@@ -37,4 +38,5 @@ def session_init_view(request):
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("session/", session_init_view, name="session-init"),
|
path("session/", session_init_view, name="session-init"),
|
||||||
path("call/", function_call_view, name="function-call"),
|
path("call/", function_call_view, name="function-call"),
|
||||||
|
path("ctx/<str:context_name>/", context_fetch_view, name="context-fetch"),
|
||||||
]
|
]
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
from django.contrib.auth.models import (
|
||||||
|
AbstractBaseUser,
|
||||||
|
BaseUserManager,
|
||||||
|
PermissionsMixin,
|
||||||
|
)
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +27,7 @@ class EmailUserManager(BaseUserManager):
|
|||||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||||
"""Minimal user model with email as USERNAME_FIELD.
|
"""Minimal user model with email as USERNAME_FIELD.
|
||||||
|
|
||||||
Matches the calling convention used in djarea's test suite:
|
Matches the calling convention used in mizan's test suite:
|
||||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
User.objects.create_user(email="...", password="...", is_staff=True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -90,7 +94,11 @@ class Book(TimestampMixin):
|
|||||||
is_published = models.BooleanField(default=False)
|
is_published = models.BooleanField(default=False)
|
||||||
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
|
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
|
||||||
editor = models.ForeignKey(
|
editor = models.ForeignKey(
|
||||||
Author, on_delete=models.SET_NULL, null=True, blank=True, related_name="edited_books",
|
Author,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="edited_books",
|
||||||
)
|
)
|
||||||
tags = models.ManyToManyField(Tag, blank=True, related_name="books")
|
tags = models.ManyToManyField(Tag, blank=True, related_name="books")
|
||||||
|
|
||||||
@@ -112,7 +120,9 @@ class Chapter(TimestampMixin):
|
|||||||
|
|
||||||
class Section(models.Model):
|
class Section(models.Model):
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="sections")
|
chapter = models.ForeignKey(
|
||||||
|
Chapter, on_delete=models.CASCADE, related_name="sections"
|
||||||
|
)
|
||||||
heading = models.CharField(max_length=300)
|
heading = models.CharField(max_length=300)
|
||||||
body = models.TextField(default="")
|
body = models.TextField(default="")
|
||||||
position = models.IntegerField(default=0)
|
position = models.IntegerField(default=0)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Django settings for running djarea's test suite standalone.
|
Django settings for running mizan's test suite standalone.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
cd django/
|
cd django/
|
||||||
@@ -22,7 +22,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"djarea",
|
"mizan",
|
||||||
"tests",
|
"tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/djarea/", include("djarea.urls")),
|
path("api/mizan/", include("mizan.urls")),
|
||||||
]
|
]
|
||||||
186
backends/mizan-fastapi/README.md
Normal file
186
backends/mizan-fastapi/README.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# mizan-fastapi
|
||||||
|
|
||||||
|
FastAPI backend adapter for the Mizan protocol. One decorator on a server
|
||||||
|
function. Typed React client generated. Invalidation automatic.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context
|
||||||
|
bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes,
|
||||||
|
and SSR are out of scope for the FastAPI adapter — FastAPI projects use
|
||||||
|
native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's
|
||||||
|
own SSR ecosystem).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add mizan-fastapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
# main.py
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
from mizan_fastapi import (
|
||||||
|
MizanError,
|
||||||
|
mizan_exception_handler,
|
||||||
|
mizan_validation_handler,
|
||||||
|
router as mizan_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(mizan_router, prefix="/api/mizan")
|
||||||
|
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
|
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
The exception handlers render every error path through the Mizan envelope
|
||||||
|
(`{"error": {"code", "message", "details"}}`) so the kernel's `MizanError`
|
||||||
|
parses status + code on the frontend regardless of which failure happened.
|
||||||
|
|
||||||
|
## Define server functions
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import register
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@client
|
||||||
|
def echo(request, text: str) -> EchoOutput:
|
||||||
|
return EchoOutput(message=text)
|
||||||
|
|
||||||
|
|
||||||
|
register(echo, "echo")
|
||||||
|
```
|
||||||
|
|
||||||
|
mizan-fastapi has no auto-discovery (FastAPI doesn't have an app registry
|
||||||
|
to walk). Register every `@client`-decorated function explicitly. A typical
|
||||||
|
project keeps registrations in `main.py` (alongside the FastAPI app) or in
|
||||||
|
a dedicated `clients.py` imported during startup.
|
||||||
|
|
||||||
|
## `@client` parameters
|
||||||
|
|
||||||
|
```python
|
||||||
|
@client # plain RPC function
|
||||||
|
@client(context="global") # singleton context — fetched once, SSR-hydrated
|
||||||
|
@client(context="user") # named context — fetched per provider mount
|
||||||
|
@client(affects="user") # mutation — invalidates the user context
|
||||||
|
@client(affects=user_profile) # mutation — invalidates a specific function
|
||||||
|
@client(auth=True) # requires authentication
|
||||||
|
@client(auth="staff") # requires is_staff
|
||||||
|
@client(auth="superuser") # requires is_superuser
|
||||||
|
@client(auth=lambda req: ...) # custom predicate
|
||||||
|
@client(rev=2) # cache revision (busts on bump)
|
||||||
|
```
|
||||||
|
|
||||||
|
`websocket=True`, Forms, and Channels parameters are accepted by the
|
||||||
|
decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi —
|
||||||
|
those features only have effect when paired with mizan-django.
|
||||||
|
|
||||||
|
## Auth integration
|
||||||
|
|
||||||
|
The executor expects `request.state.user` to be populated by your FastAPI
|
||||||
|
middleware or dependency tree before dispatch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def attach_user(request: Request, call_next):
|
||||||
|
request.state.user = await resolve_user_from_token(request)
|
||||||
|
return await call_next(request)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `resolve_user_from_token` returns either a user object with
|
||||||
|
`is_authenticated`, `is_staff`, `is_superuser` attributes, or `None` for an
|
||||||
|
anonymous request. The executor branches on those for `auth=True`,
|
||||||
|
`auth="staff"`, `auth="superuser"` requirements.
|
||||||
|
|
||||||
|
## Generate the frontend
|
||||||
|
|
||||||
|
The codegen is the `mizan-generate` Rust binary (source at
|
||||||
|
`protocol/mizan-codegen/`; `protocol/mizan-generate/` is a thin npm
|
||||||
|
launcher that dispatches to the platform binary). Point a `mizan.toml` at
|
||||||
|
your FastAPI app and run the CLI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# frontend/mizan.toml
|
||||||
|
output = "src/api"
|
||||||
|
targets = ["react"]
|
||||||
|
|
||||||
|
[source.fastapi]
|
||||||
|
module = "main" # module to import for @client side effects
|
||||||
|
cwd = "../backend" # python cwd for module resolution
|
||||||
|
command = ["uv", "run", "python"] # optional — defaults to ["python"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mizan-generate --config mizan.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The codegen drives `python -m mizan_fastapi.ir <module>` under the hood,
|
||||||
|
parses the emitted KDL IR, then emits Stage 1 (typed `callXxx`/`fetchXxx`
|
||||||
|
over the runtime kernel) + Stage 2 (`<MizanContext>` provider, per-context
|
||||||
|
providers, `use{Hook}()` hooks) into `src/api/`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.tsx
|
||||||
|
import { MizanContext } from "./api"
|
||||||
|
|
||||||
|
export default function App({ children }) {
|
||||||
|
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// any component
|
||||||
|
import { useEcho, useCurrentUser } from "./api"
|
||||||
|
|
||||||
|
const echo = useEcho()
|
||||||
|
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
|
||||||
|
|
||||||
|
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema export CLI
|
||||||
|
|
||||||
|
For codegen consumption (or any tooling that wants the Mizan schema):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m mizan_fastapi.ir <module>
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports the named module (which must register every `@client` function as
|
||||||
|
import-time side effects), then prints the Mizan KDL IR to stdout.
|
||||||
|
Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes
|
||||||
|
either backend the same subprocess way.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
mizan-fastapi is one of two reference backend adapters (the other is
|
||||||
|
`backends/mizan-django`). Both implement the same Mizan protocol on top of
|
||||||
|
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
|
||||||
|
keys). The AFI conformance suite at `tests/afi/` gates that the two adapters
|
||||||
|
emit equivalent schemas for the same registered functions. See
|
||||||
|
`docs/AFI_ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
A live e2e harness exercises this adapter end-to-end at
|
||||||
|
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
|
||||||
|
→ FastAPI server, 14/14 Playwright tests).
|
||||||
33
backends/mizan-fastapi/pyproject.toml
Normal file
33
backends/mizan-fastapi/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "mizan-fastapi"
|
||||||
|
version = "0.1.0"
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
description = "Mizan FastAPI backend adapter — HTTP RPC dispatch + context bundling, built on mizan-core."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"mizan-core",
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mizan_fastapi"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
54
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
54
backends/mizan-fastapi/src/mizan_fastapi/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||||
|
|
||||||
|
HTTP RPC dispatch and context bundling on top of mizan-core's function
|
||||||
|
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
|
||||||
|
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
|
||||||
|
SSR frameworks).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix="/api/mizan")
|
||||||
|
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
|
|
||||||
|
# Register your @client-decorated functions
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import register
|
||||||
|
from .my_functions import echo
|
||||||
|
register(echo, "echo")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .executor import (
|
||||||
|
ErrorCode,
|
||||||
|
MizanError,
|
||||||
|
NotFound,
|
||||||
|
BadRequest,
|
||||||
|
ValidationFailed,
|
||||||
|
Unauthorized,
|
||||||
|
Forbidden,
|
||||||
|
NotImplementedYet,
|
||||||
|
InternalError,
|
||||||
|
compute_invalidation,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"router",
|
||||||
|
"mizan_exception_handler",
|
||||||
|
"mizan_validation_handler",
|
||||||
|
"execute_function",
|
||||||
|
"compute_invalidation",
|
||||||
|
"ErrorCode",
|
||||||
|
"MizanError",
|
||||||
|
"NotFound",
|
||||||
|
"BadRequest",
|
||||||
|
"ValidationFailed",
|
||||||
|
"Unauthorized",
|
||||||
|
"Forbidden",
|
||||||
|
"NotImplementedYet",
|
||||||
|
"InternalError",
|
||||||
|
]
|
||||||
263
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
263
backends/mizan-fastapi/src/mizan_fastapi/executor.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
RPC dispatch — looks up registered functions, validates input against the
|
||||||
|
function's Pydantic Input model, executes, and returns the serialized result.
|
||||||
|
|
||||||
|
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||||
|
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||||
|
let them propagate to your own handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(str, Enum):
|
||||||
|
NOT_FOUND = "NOT_FOUND"
|
||||||
|
BAD_REQUEST = "BAD_REQUEST"
|
||||||
|
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||||
|
UNAUTHORIZED = "UNAUTHORIZED"
|
||||||
|
FORBIDDEN = "FORBIDDEN"
|
||||||
|
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
||||||
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS = {
|
||||||
|
ErrorCode.NOT_FOUND: 404,
|
||||||
|
ErrorCode.BAD_REQUEST: 400,
|
||||||
|
ErrorCode.VALIDATION_ERROR: 422,
|
||||||
|
ErrorCode.UNAUTHORIZED: 401,
|
||||||
|
ErrorCode.FORBIDDEN: 403,
|
||||||
|
ErrorCode.NOT_IMPLEMENTED: 501,
|
||||||
|
ErrorCode.INTERNAL_ERROR: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MizanError(Exception):
|
||||||
|
"""Base for protocol-level dispatch errors."""
|
||||||
|
|
||||||
|
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_code(self) -> int:
|
||||||
|
return _STATUS[self.code]
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
|
||||||
|
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
|
||||||
|
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
||||||
|
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
|
||||||
|
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
|
||||||
|
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
||||||
|
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _user(request: Any) -> Any:
|
||||||
|
return getattr(getattr(request, "state", None), "user", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_authenticated(user: Any) -> bool:
|
||||||
|
return bool(user) and getattr(user, "is_authenticated", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_auth(request: Any, requirement: Any) -> None:
|
||||||
|
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
|
||||||
|
if requirement is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = _user(request)
|
||||||
|
|
||||||
|
match requirement:
|
||||||
|
case True | "required":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
case "staff":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
if not getattr(user, "is_staff", False):
|
||||||
|
raise Forbidden("Staff access required")
|
||||||
|
case "superuser":
|
||||||
|
if not _is_authenticated(user):
|
||||||
|
raise Unauthorized("Authentication required")
|
||||||
|
if not getattr(user, "is_superuser", False):
|
||||||
|
raise Forbidden("Superuser access required")
|
||||||
|
case f if callable(f):
|
||||||
|
if not f(request):
|
||||||
|
raise Forbidden("Permission denied")
|
||||||
|
case other:
|
||||||
|
raise InternalError(f"Unknown auth requirement: {other!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Input validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
||||||
|
"""Validate input_data against the function's Input model. Returns the instance or None."""
|
||||||
|
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
fields = input_cls.model_fields
|
||||||
|
required = [name for name, f in fields.items() if f.is_required()]
|
||||||
|
|
||||||
|
if not input_data:
|
||||||
|
if required:
|
||||||
|
raise ValidationFailed(
|
||||||
|
"Input validation failed",
|
||||||
|
details={"fields": {name: ["Field required"] for name in required}},
|
||||||
|
)
|
||||||
|
return input_cls()
|
||||||
|
|
||||||
|
if not isinstance(input_data, dict):
|
||||||
|
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return input_cls(**input_data)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ValidationFailed(
|
||||||
|
"Input validation failed",
|
||||||
|
details={"errors": e.errors()},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_function(fn_name: str) -> Any:
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
if view_class is None:
|
||||||
|
raise NotFound("Function not found")
|
||||||
|
if getattr(view_class, "_meta", {}).get("private"):
|
||||||
|
raise Forbidden("Function is not client-callable")
|
||||||
|
return view_class
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(result: Any) -> Any:
|
||||||
|
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
|
||||||
|
# (and nested shapes) come out wire-ready without a per-shape branch here.
|
||||||
|
return jsonable_encoder(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_function(
|
||||||
|
request: Any,
|
||||||
|
fn_name: str,
|
||||||
|
input_data: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
|
||||||
|
|
||||||
|
Awaits `view.acall` — async handlers run on the loop, sync handlers run
|
||||||
|
in the default threadpool, both via the same entrypoint.
|
||||||
|
"""
|
||||||
|
view_class = _resolve_function(fn_name)
|
||||||
|
_enforce_auth(request, view_class._meta.get("auth"))
|
||||||
|
|
||||||
|
view = view_class(request)
|
||||||
|
validated = _validate_input(view.Input, input_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await view.acall(validated)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||||
|
except MizanError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise InternalError(str(e)) from e
|
||||||
|
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
||||||
|
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||||
|
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
||||||
|
return [_invalidation_target(target, input_data or {}) for target in affects]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Build the `merge` list from @client(merge=...) metadata.
|
||||||
|
|
||||||
|
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||||
|
function inside the context bundle the value lands in. The slot is
|
||||||
|
resolved server-side via `types_match_for_merge` so the kernel does
|
||||||
|
no shape inference — the server has the schema, type-checked routing
|
||||||
|
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||||
|
with a warning; the consumer falls back to refetch via `affects`.
|
||||||
|
"""
|
||||||
|
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||||
|
if not targets:
|
||||||
|
return []
|
||||||
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for ctx_name in targets:
|
||||||
|
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
||||||
|
scoped = _scoped_params(ctx_name, input_data or {})
|
||||||
|
if scoped:
|
||||||
|
entry["params"] = scoped
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||||
|
"""Find the unique function-name slot whose return type matches the mutation's output.
|
||||||
|
|
||||||
|
Returns None on no match or ambiguous match (multiple candidates).
|
||||||
|
"""
|
||||||
|
if mutation_output is None:
|
||||||
|
return None
|
||||||
|
matches: list[str] = []
|
||||||
|
for fn_name in get_context_groups().get(context_name, []):
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
fn_output = getattr(fn_cls, "Output", None)
|
||||||
|
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||||
|
matches.append(fn_name)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Match input args against the context's declared Input field names."""
|
||||||
|
fn_names = get_context_groups().get(context_name, [])
|
||||||
|
declared: set[str] = set()
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
declared.update(input_cls.model_fields.keys())
|
||||||
|
return {k: v for k, v in input_data.items() if k in declared}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||||
|
match target.get("type"):
|
||||||
|
case "context":
|
||||||
|
name = target["name"]
|
||||||
|
scoped = _scoped_params(name, input_data)
|
||||||
|
return {"context": name, "params": scoped} if scoped else name
|
||||||
|
case "function":
|
||||||
|
return {"function": target["name"]}
|
||||||
|
case _:
|
||||||
|
return target
|
||||||
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
39
backends/mizan-fastapi/src/mizan_fastapi/ir.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Mizan IR (KDL) export CLI for FastAPI backends.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m mizan_fastapi.ir <module>
|
||||||
|
|
||||||
|
Imports the named module (whose import side effects must register every
|
||||||
|
@client function with `mizan_core.registry`), then writes the canonical
|
||||||
|
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mizan_core.ir import build_ir
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if len(args) != 1:
|
||||||
|
print("usage: python -m mizan_fastapi.ir <module>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
module_name = args[0]
|
||||||
|
try:
|
||||||
|
importlib.import_module(module_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(build_ir())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
109
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
109
backends/mizan-fastapi/src/mizan_fastapi/router.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
FastAPI router exposing Mizan's HTTP endpoints:
|
||||||
|
|
||||||
|
POST /call/ — RPC dispatch
|
||||||
|
GET /ctx/{context_name}/ — bundled context fetch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix="/api/mizan")
|
||||||
|
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
|
|
||||||
|
from .executor import (
|
||||||
|
ErrorCode,
|
||||||
|
MizanError,
|
||||||
|
NotFound,
|
||||||
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
||||||
|
return JSONResponse(payload, status_code=status_code, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session/")
|
||||||
|
async def session_init() -> JSONResponse:
|
||||||
|
"""Session-init probe. Parity with mizan-django's session endpoint.
|
||||||
|
|
||||||
|
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||||
|
null token so the response shape stays uniform across backends. The
|
||||||
|
wire-parity harness uses this endpoint as its readiness probe.
|
||||||
|
"""
|
||||||
|
return _no_store({"csrfToken": None})
|
||||||
|
|
||||||
|
|
||||||
|
class CallBody(BaseModel):
|
||||||
|
fn: str = Field(..., min_length=1)
|
||||||
|
args: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/call/")
|
||||||
|
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||||
|
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||||
|
fn_class = get_function(body.fn)
|
||||||
|
result = await execute_function(request, body.fn, body.args)
|
||||||
|
invalidate = compute_invalidation(fn_class, body.args)
|
||||||
|
merges = compute_merges(fn_class, body.args, result)
|
||||||
|
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||||
|
if merges:
|
||||||
|
payload["merge"] = merges
|
||||||
|
return _no_store(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ctx/{context_name}/")
|
||||||
|
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||||
|
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||||
|
fn_names = get_context_groups().get(context_name)
|
||||||
|
if not fn_names:
|
||||||
|
raise NotFound(f"Context '{context_name}' not found")
|
||||||
|
|
||||||
|
params = dict(request.query_params)
|
||||||
|
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||||
|
return _no_store(bundled)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def mizan_exception_handler(_request: Request, exc: MizanError) -> JSONResponse:
|
||||||
|
"""FastAPI exception handler — renders MizanError to the protocol's error envelope."""
|
||||||
|
body: dict[str, Any] = {"error": {"code": exc.code.value, "message": exc.message}}
|
||||||
|
if exc.details:
|
||||||
|
body["error"]["details"] = exc.details
|
||||||
|
return _no_store(body, status_code=exc.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def mizan_validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
"""Maps malformed request bodies (invalid JSON, missing top-level fields) to BAD_REQUEST."""
|
||||||
|
return _no_store(
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": ErrorCode.BAD_REQUEST.value,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
"details": {"errors": exc.errors()},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
262
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
262
backends/mizan-fastapi/tests/test_dispatch.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mizan_core.client.function import client
|
||||||
|
from mizan_core.registry import clear_registry, register
|
||||||
|
from mizan_fastapi import (
|
||||||
|
MizanError,
|
||||||
|
mizan_exception_handler,
|
||||||
|
mizan_validation_handler,
|
||||||
|
router as mizan_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SumOutput(BaseModel):
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserOutput(BaseModel):
|
||||||
|
email: str
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOutput(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
@client
|
||||||
|
def echo(request, text: str) -> EchoOutput:
|
||||||
|
return EchoOutput(message=f"echo: {text}")
|
||||||
|
|
||||||
|
@client
|
||||||
|
def add(request, a: int, b: int) -> SumOutput:
|
||||||
|
return SumOutput(total=a + b)
|
||||||
|
|
||||||
|
@client(context="user")
|
||||||
|
def current_user(request) -> UserOutput:
|
||||||
|
return UserOutput(email="anon@example.com", authenticated=False)
|
||||||
|
|
||||||
|
@client(context="user")
|
||||||
|
def user_count(request) -> SumOutput:
|
||||||
|
return SumOutput(total=42)
|
||||||
|
|
||||||
|
@client(affects="user")
|
||||||
|
def update_email(request, email: str) -> EchoOutput:
|
||||||
|
return EchoOutput(message=f"updated: {email}")
|
||||||
|
|
||||||
|
@client(auth=True)
|
||||||
|
def whoami(request) -> UserOutput:
|
||||||
|
return UserOutput(email="real@example.com", authenticated=True)
|
||||||
|
|
||||||
|
@client
|
||||||
|
def list_items(request) -> list[ItemOutput]:
|
||||||
|
return [ItemOutput(id=1, name="a"), ItemOutput(id=2, name="b")]
|
||||||
|
|
||||||
|
@client
|
||||||
|
def find_item(request, item_id: int) -> ItemOutput | None:
|
||||||
|
return ItemOutput(id=item_id, name="found") if item_id > 0 else None
|
||||||
|
|
||||||
|
@client(merge="items")
|
||||||
|
def set_item_name(request, id: int, name: str) -> ItemOutput:
|
||||||
|
return ItemOutput(id=id, name=name)
|
||||||
|
|
||||||
|
@client(context="items")
|
||||||
|
def items_list(request) -> list[ItemOutput]:
|
||||||
|
return [ItemOutput(id=1, name="orig")]
|
||||||
|
|
||||||
|
@client
|
||||||
|
async def async_echo(request, text: str) -> EchoOutput:
|
||||||
|
# await something on the loop to prove we're really running async
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return EchoOutput(message=f"async: {text}")
|
||||||
|
|
||||||
|
register(echo, "echo")
|
||||||
|
register(add, "add")
|
||||||
|
register(current_user, "current_user")
|
||||||
|
register(user_count, "user_count")
|
||||||
|
register(update_email, "update_email")
|
||||||
|
register(whoami, "whoami")
|
||||||
|
register(list_items, "list_items")
|
||||||
|
register(find_item, "find_item")
|
||||||
|
register(set_item_name, "set_item_name")
|
||||||
|
register(items_list, "items_list")
|
||||||
|
register(async_echo, "async_echo")
|
||||||
|
|
||||||
|
fastapi_app = FastAPI()
|
||||||
|
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
|
||||||
|
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||||
|
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
|
||||||
|
|
||||||
|
yield fastapi_app
|
||||||
|
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def http(app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── RPC dispatch ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionCallTests:
|
||||||
|
def test_simple_call_returns_result(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["result"]["message"] == "echo: hi"
|
||||||
|
assert body["invalidate"] == []
|
||||||
|
|
||||||
|
def test_call_with_typed_input(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["result"]["total"] == 5
|
||||||
|
|
||||||
|
def test_unknown_function_returns_not_found(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "ghost"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
def test_validation_error_returns_422(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
def test_missing_required_input_returns_validation_error(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
def test_missing_fn_field_returns_400(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["error"]["code"] == "BAD_REQUEST"
|
||||||
|
|
||||||
|
def test_invalid_json_returns_400(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_response_carries_no_store(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}})
|
||||||
|
assert r.headers.get("cache-control") == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Context bundling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ContextFetchTests:
|
||||||
|
def test_context_returns_bundled_results(self, http):
|
||||||
|
r = http.get("/api/mizan/ctx/user/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "current_user" in body
|
||||||
|
assert "user_count" in body
|
||||||
|
assert body["current_user"]["email"] == "anon@example.com"
|
||||||
|
assert body["user_count"]["total"] == 42
|
||||||
|
|
||||||
|
def test_unknown_context_returns_not_found(self, http):
|
||||||
|
r = http.get("/api/mizan/ctx/ghost/")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AuthTests:
|
||||||
|
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
|
||||||
|
|
||||||
|
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidationTests:
|
||||||
|
def test_mutation_emits_invalidate_list(self, http):
|
||||||
|
r = http.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json={"fn": "update_email", "args": {"email": "new@example.com"}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# affects='user' is a context-name string → invalidate list contains 'user'
|
||||||
|
assert "user" in body["invalidate"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Structured-output shapes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredOutputTests:
|
||||||
|
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
||||||
|
|
||||||
|
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["result"] == [
|
||||||
|
{"id": 1, "name": "a"},
|
||||||
|
{"id": 2, "name": "b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_optional_basemodel_returns_inner_or_none(self, http):
|
||||||
|
r_found = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 5}})
|
||||||
|
assert r_found.status_code == 200
|
||||||
|
assert r_found.json()["result"] == {"id": 5, "name": "found"}
|
||||||
|
|
||||||
|
r_missing = http.post("/api/mizan/call/", json={"fn": "find_item", "args": {"item_id": 0}})
|
||||||
|
assert r_missing.status_code == 200
|
||||||
|
assert r_missing.json()["result"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncHandlerTests:
|
||||||
|
"""`async def` handlers dispatch on the loop via view.acall."""
|
||||||
|
|
||||||
|
def test_async_handler_returns_awaited_result(self, http):
|
||||||
|
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["result"] == {"message": "async: hello"}
|
||||||
|
|
||||||
|
|
||||||
|
class MergeTests:
|
||||||
|
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
||||||
|
|
||||||
|
def test_merge_target_emits_merge_entry(self, http):
|
||||||
|
r = http.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json={"fn": "set_item_name", "args": {"id": 42, "name": "renamed"}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
||||||
|
assert body["merge"] == [
|
||||||
|
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||||
|
]
|
||||||
|
# invalidate stays empty when only merge is declared
|
||||||
|
assert body["invalidate"] == []
|
||||||
591
backends/mizan-rust-axum/Cargo.lock
generated
Normal file
591
backends/mizan-rust-axum/Cargo.lock
generated
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkme"
|
||||||
|
version = "0.3.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
|
||||||
|
dependencies = [
|
||||||
|
"linkme-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkme-impl"
|
||||||
|
version = "0.3.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mizan-axum"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"mizan-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mizan-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"linkme",
|
||||||
|
"mizan-macros",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mizan-macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.52.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
15
backends/mizan-rust-axum/Cargo.toml
Normal file
15
backends/mizan-rust-axum/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "mizan-axum"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "axum HTTP adapter for Mizan — typed RPC dispatch + context-bundle fetch on top of mizan-core's compile-time function registry."
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
27
backends/mizan-rust-axum/src/errors.rs
Normal file
27
backends/mizan-rust-axum/src/errors.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
||||||
|
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
||||||
|
//! with a Cache-Control: no-store header.
|
||||||
|
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use mizan_core::MizanError;
|
||||||
|
|
||||||
|
pub struct ApiError(pub MizanError);
|
||||||
|
|
||||||
|
impl From<MizanError> for ApiError {
|
||||||
|
fn from(e: MizanError) -> Self {
|
||||||
|
Self(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = StatusCode::from_u16(self.0.http_status())
|
||||||
|
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
let mut resp = (status, Json(self.0.to_json())).into_response();
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backends/mizan-rust-axum/src/handlers.rs
Normal file
162
backends/mizan-rust-axum/src/handlers.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use mizan_core::{
|
||||||
|
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||||
|
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
|
||||||
|
/// Type-erased application state threaded into every `dispatch()` call via
|
||||||
|
/// `RequestHandle`. User handlers downcast to their concrete state type.
|
||||||
|
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||||
|
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||||
|
|
||||||
|
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CallBody {
|
||||||
|
pub fn_: Option<String>,
|
||||||
|
#[serde(rename = "fn")]
|
||||||
|
pub function_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallBody {
|
||||||
|
fn resolved_name(&self) -> Option<&str> {
|
||||||
|
self.function_name
|
||||||
|
.as_deref()
|
||||||
|
.or(self.fn_.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CallResponse {
|
||||||
|
pub result: Value,
|
||||||
|
pub invalidate: Vec<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub merge: Option<Vec<Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_store(json: Value) -> Response {
|
||||||
|
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /call/ — RPC dispatch.
|
||||||
|
pub async fn function_call(
|
||||||
|
State(app_state): State<AppStateAny>,
|
||||||
|
Json(body): Json<CallBody>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let fn_name = body
|
||||||
|
.resolved_name()
|
||||||
|
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let fn_spec = lookup_function(&fn_name)
|
||||||
|
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||||
|
|
||||||
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
|
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||||
|
|
||||||
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||||
|
.iter()
|
||||||
|
.map(InvalidationTarget::to_json)
|
||||||
|
.collect();
|
||||||
|
let merges = compute_merges(fn_spec, &body.args, &result);
|
||||||
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = CallResponse {
|
||||||
|
result,
|
||||||
|
invalidate,
|
||||||
|
merge: merge_payload,
|
||||||
|
};
|
||||||
|
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||||
|
pub async fn context_fetch(
|
||||||
|
State(app_state): State<AppStateAny>,
|
||||||
|
Path(context_name): Path<String>,
|
||||||
|
Query(params): Query<BTreeMap<String, String>>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
if lookup_context(&context_name).is_none() {
|
||||||
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} not registered"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| f.context() == Some(&context_name))
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
return Err(ApiError(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} has no registered members"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||||
|
// params get parsed via the per-function input_params primitive table.
|
||||||
|
let mut bundled = Map::new();
|
||||||
|
for fn_spec in &members {
|
||||||
|
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||||
|
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||||
|
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||||
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(no_store(Value::Object(bundled)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coerce string-valued query params into typed JSON values using the
|
||||||
|
/// function's declared input_params. Strings that don't parse stay as
|
||||||
|
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
||||||
|
fn coerce_query_args(
|
||||||
|
fn_spec: &dyn FunctionSpec,
|
||||||
|
params: &BTreeMap<String, String>,
|
||||||
|
) -> Map<String, Value> {
|
||||||
|
let mut out = Map::new();
|
||||||
|
for ip in fn_spec.input_params() {
|
||||||
|
if let Some(raw) = params.get(ip.name) {
|
||||||
|
let parsed = match ip.primitive {
|
||||||
|
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||||
|
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||||
|
serde_json::Number::from_f64(v).map(Value::Number)
|
||||||
|
}),
|
||||||
|
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||||
|
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||||
|
};
|
||||||
|
if let Some(v) = parsed {
|
||||||
|
out.insert(ip.name.into(), v);
|
||||||
|
} else {
|
||||||
|
out.insert(ip.name.into(), Value::from(raw.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||||
|
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||||
|
/// readiness-probe consumers see a well-formed response.
|
||||||
|
pub async fn session_init() -> Response {
|
||||||
|
let body = serde_json::json!({ "csrfToken": null });
|
||||||
|
no_store(body)
|
||||||
|
}
|
||||||
58
backends/mizan-rust-axum/src/lib.rs
Normal file
58
backends/mizan-rust-axum/src/lib.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```ignore
|
||||||
|
//! use axum::Router;
|
||||||
|
//! use mizan_axum::router;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! let app = Router::new().nest("/api/mizan", router());
|
||||||
|
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||||
|
//! axum::serve(listener, app).await.unwrap();
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||||
|
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||||
|
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||||
|
//! * `GET /ctx/:name/` — bundled context fetch
|
||||||
|
|
||||||
|
mod errors;
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
pub use errors::ApiError;
|
||||||
|
pub use handlers::{
|
||||||
|
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Build the Mizan router with user-supplied app state. The state is
|
||||||
|
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
|
||||||
|
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
|
||||||
|
/// type.
|
||||||
|
///
|
||||||
|
/// Mount under a prefix:
|
||||||
|
/// `Router::new().nest("/api/mizan", router(my_state))`.
|
||||||
|
pub fn router<S>(state: S) -> Router
|
||||||
|
where
|
||||||
|
S: Any + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let state: AppStateAny = Arc::new(state);
|
||||||
|
Router::new()
|
||||||
|
.route("/session/", get(handlers::session_init))
|
||||||
|
.route("/call/", post(handlers::function_call))
|
||||||
|
.route("/ctx/:context_name/", get(handlers::context_fetch))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Router variant for callers that have no app state to thread — the
|
||||||
|
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
||||||
|
/// and other stateless test apps.
|
||||||
|
pub fn router_stateless() -> Router {
|
||||||
|
router(())
|
||||||
|
}
|
||||||
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
4621
backends/mizan-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
backends/mizan-tauri/Cargo.toml
Normal file
12
backends/mizan-tauri/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "mizan-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mizan-core = { path = "../../cores/mizan-rust" }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
290
backends/mizan-tauri/README.md
Normal file
290
backends/mizan-tauri/README.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# mizan-tauri
|
||||||
|
|
||||||
|
Tauri backend adapter for the Mizan protocol. One plugin call on the Rust
|
||||||
|
side. `#[mizan::client]` on async functions. Typed React client generated.
|
||||||
|
Invalidation automatic — same protocol surface as mizan-fastapi /
|
||||||
|
mizan-django / mizan-rust-axum, routed through Tauri's IPC instead of HTTP.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
mizan-tauri targets the **AFI-common subset** — RPC dispatch, context
|
||||||
|
bundling, server-driven invalidation/merge. The transport channel is
|
||||||
|
Tauri's `invoke()`; the dispatch table is the linkme-backed `FUNCTIONS`
|
||||||
|
slice from `mizan-core`. No HTTP server is involved — the Tauri runtime
|
||||||
|
handles message framing, the plugin handles dispatch.
|
||||||
|
|
||||||
|
Forms / SSR / Channels are out of scope (those are Django-side primitives).
|
||||||
|
Tauri apps using mizan-tauri get RPC + context bundling + invalidation,
|
||||||
|
nothing more.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# src-tauri/Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
tauri = "2"
|
||||||
|
mizan-core = { path = "../../mizan/cores/mizan-rust" }
|
||||||
|
mizan-tauri = { path = "../../mizan/backends/mizan-tauri" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@mizan/base": "file:../mizan/frontends/mizan-base",
|
||||||
|
"@mizan/tauri-transport": "file:../mizan/frontends/mizan-tauri-transport",
|
||||||
|
"@tauri-apps/api": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup — Rust
|
||||||
|
|
||||||
|
Install the plugin on the Tauri builder. The plugin registers a single
|
||||||
|
command (`plugin:mizan|mizan_invoke`) that routes call/fetch envelopes
|
||||||
|
through the function registry. No per-function `#[tauri::command]` is
|
||||||
|
needed; the macro-emitted FunctionSpec IS the dispatch table.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/lib.rs
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(mizan_tauri::init())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`commands` must be reachable from the binary's link graph — `mod
|
||||||
|
commands;` works (private mod stays linked because `lib.rs` references
|
||||||
|
it through file inclusion). If a separate binary (e.g. the IR-export
|
||||||
|
bin below) also needs to see the registrations, mark it `pub mod
|
||||||
|
commands;` so the integration-test / sibling-binary path can force-link.
|
||||||
|
|
||||||
|
## Define server functions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/commands.rs
|
||||||
|
use mizan_core::{self as mizan, MizanError, RequestHandle};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, mizan_core::Mizan)]
|
||||||
|
pub struct Greeting {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mizan::client]
|
||||||
|
pub async fn greet(_req: &RequestHandle<'_>, name: String) -> Greeting {
|
||||||
|
Greeting { message: format!("hello, {name}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result<T, MizanError> is supported when the function can fail; the
|
||||||
|
// dispatch wrapper `?`-unwraps it so server-side errors surface as the
|
||||||
|
// protocol's standard {code, message, details?} envelope.
|
||||||
|
#[mizan::client]
|
||||||
|
pub async fn read_file(
|
||||||
|
_req: &RequestHandle<'_>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Greeting, MizanError> {
|
||||||
|
let body = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| MizanError::NotFound(e.to_string()))?;
|
||||||
|
Ok(Greeting { message: body })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`#[mizan::client]` parameters mirror the other backends — `context = …`,
|
||||||
|
`affects = …`, `merge = …`, `private`. See `mizan-rust-axum`'s README for
|
||||||
|
the full set.
|
||||||
|
|
||||||
|
### App-state access
|
||||||
|
|
||||||
|
The first parameter is `req: &RequestHandle<'_>` — the same handle the
|
||||||
|
HTTP adapter threads through. Inside a Tauri-mounted plugin, the handle
|
||||||
|
wraps `tauri::AppHandle`, so user functions can downcast for access to
|
||||||
|
Tauri's managed-state container or event emission:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[mizan::client]
|
||||||
|
pub async fn store_value(req: &RequestHandle<'_>, key: String) -> Greeting {
|
||||||
|
let app = req.downcast::<tauri::AppHandle>()
|
||||||
|
.expect("Tauri AppHandle threaded by mizan-tauri");
|
||||||
|
// app.state::<MyState>(), app.emit(...), etc.
|
||||||
|
Greeting { message: format!("stored {key}") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stateless functions ignore the handle (`_req: &RequestHandle<'_>`).
|
||||||
|
|
||||||
|
## IR export binary
|
||||||
|
|
||||||
|
mizan-generate needs the consumer crate's IR. Add a small bin that
|
||||||
|
references each `#[mizan::client]` function (so linkme keeps the
|
||||||
|
distributed slice's entries) and prints `mizan_core::build_ir()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/bin/emit_mizan_ir.rs
|
||||||
|
//
|
||||||
|
// Cargo.toml adds:
|
||||||
|
// [[bin]]
|
||||||
|
// name = "emit-mizan-ir"
|
||||||
|
// path = "src/bin/emit_mizan_ir.rs"
|
||||||
|
//
|
||||||
|
// linkme only collects from translation units that survive
|
||||||
|
// dead-code elimination; this fn names one item per file carrying
|
||||||
|
// #[derive(Mizan)] / #[mizan::client] registrations so the linker
|
||||||
|
// keeps them in the final binary.
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _force_link() {
|
||||||
|
use my_app_lib::commands;
|
||||||
|
let _ = commands::greet;
|
||||||
|
let _ = commands::read_file;
|
||||||
|
// ... one per #[mizan::client] function
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
_force_link();
|
||||||
|
print!("{}", mizan_core::build_ir());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generate the frontend
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# mizan.toml at the project root
|
||||||
|
project_id = "my-tauri-app"
|
||||||
|
output = "src/api"
|
||||||
|
targets = ["react"]
|
||||||
|
|
||||||
|
[source.rust]
|
||||||
|
manifest_path = "src-tauri/Cargo.toml"
|
||||||
|
bin = "emit-mizan-ir"
|
||||||
|
|
||||||
|
# Optional — author the Rust types from Pydantic models via decoru.
|
||||||
|
# Omit this block for pure-Rust usage.
|
||||||
|
[source.rust.pydantic]
|
||||||
|
module = "my_app.schema"
|
||||||
|
output = "src-tauri/src/schema.rs"
|
||||||
|
command = ["uv", "run", "python"] # any python with `decoru` importable
|
||||||
|
header = """\
|
||||||
|
// AUTO-GENERATED by mizan-generate (source.rust.pydantic step).
|
||||||
|
// Source of truth: my_app/schema.py.
|
||||||
|
// DO NOT EDIT BY HAND. Regenerate with: `mizan-generate`
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mizan-generate --config mizan.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The Pydantic pre-step auto-discovers `BaseModel` subclasses AND `Enum`
|
||||||
|
subclasses declared in the named module; decoru emits the structs, and a
|
||||||
|
small inline emitter renders enums (PascalCase variants from Python
|
||||||
|
member names, `#[serde(rename_all = "snake_case")]`, `#[default]` on the
|
||||||
|
last variant so decoru's `impl Default` keeps compiling).
|
||||||
|
|
||||||
|
The Rust step then runs `cargo run --bin emit-mizan-ir`, parses the
|
||||||
|
emitted KDL, and dispatches the configured `targets` to their emitters
|
||||||
|
(`stage1` → typed `callXxx`/`fetchXxx`; `react` → `<MizanContext>` +
|
||||||
|
per-context providers + `use{Hook}()` hooks).
|
||||||
|
|
||||||
|
## Setup — TS
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/main.tsx
|
||||||
|
import { configure } from "@mizan/base";
|
||||||
|
import { tauriTransport } from "@mizan/tauri-transport";
|
||||||
|
|
||||||
|
// Route every mizanCall / mizanFetch through Tauri's IPC. Must run
|
||||||
|
// before any generated callXxx() executes — top-level at the module
|
||||||
|
// entry is the safe place.
|
||||||
|
configure({ transport: tauriTransport() });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// any component
|
||||||
|
import { callGreet } from "@/api";
|
||||||
|
|
||||||
|
const greeting = await callGreet({ name: "world" });
|
||||||
|
console.log(greeting.message);
|
||||||
|
```
|
||||||
|
|
||||||
|
For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the
|
||||||
|
imperative `callGreet` with `isPending`/`error` state), wrap your tree
|
||||||
|
with `<MizanContext>` at the root — same as the HTTP-transport setup. The
|
||||||
|
generated provider is transport-agnostic; it reads from `config.transport`
|
||||||
|
the kernel is using.
|
||||||
|
|
||||||
|
### tsconfig / vite preserve symlinks
|
||||||
|
|
||||||
|
The `@mizan/*` packages are typically linked via `file:` in package.json.
|
||||||
|
Without `preserveSymlinks`, both TypeScript and Vite/Rollup follow the
|
||||||
|
symlinks to their real location and fail to resolve the linked packages'
|
||||||
|
peer dependencies (`@tauri-apps/api`, `@mizan/base`) from there.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"preserveSymlinks": true,
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
preserveSymlinks: true,
|
||||||
|
},
|
||||||
|
// …
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wire protocol
|
||||||
|
|
||||||
|
Same envelope as the HTTP adapter, wrapped in a Tauri invoke payload:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// call
|
||||||
|
invoke('plugin:mizan|mizan_invoke', {
|
||||||
|
envelope: { op: 'call', fn: 'greet', args: { name: 'world' } }
|
||||||
|
})
|
||||||
|
// → { result: { message: "hello, world" }, invalidate: [], merge?: [...] }
|
||||||
|
|
||||||
|
// fetch (context bundling)
|
||||||
|
invoke('plugin:mizan|mizan_invoke', {
|
||||||
|
envelope: { op: 'fetch', context: 'user', params: { user_id: 42 } }
|
||||||
|
})
|
||||||
|
// → { user_profile: {...}, user_orders: [...] } (flat bundle)
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport`
|
||||||
|
re-wraps them into the same `MizanError` shape the HTTP transport
|
||||||
|
produces, so consumer code is identical regardless of transport.
|
||||||
|
|
||||||
|
## Reference application
|
||||||
|
|
||||||
|
`claude-manage` is the production reference — Tauri + React + Pydantic
|
||||||
|
schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and
|
||||||
|
`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both
|
||||||
|
adapters dispatch through the same compile-time `FUNCTIONS` registry,
|
||||||
|
same `compute_invalidation` / `compute_merges` logic, same KDL IR
|
||||||
|
emitted by `build_ir()`. The only difference is the wire surface — axum
|
||||||
|
takes POST `/call/` and GET `/ctx/:name/`, mizan-tauri takes a single
|
||||||
|
`mizan_invoke` command with an op-tagged envelope.
|
||||||
220
backends/mizan-tauri/src/lib.rs
Normal file
220
backends/mizan-tauri/src/lib.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||||
|
//!
|
||||||
|
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! tauri::Builder::default()
|
||||||
|
//! .plugin(mizan_tauri::init())
|
||||||
|
//! .run(tauri::generate_context!())
|
||||||
|
//! .expect("error while running tauri application");
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||||
|
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||||
|
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||||
|
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||||
|
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||||
|
//! consumes. There is no per-function tauri::command; the registry IS
|
||||||
|
//! the dispatch table.
|
||||||
|
//!
|
||||||
|
//! Wire envelope:
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! { "op": "call", "fn": "list_sessions", "args": {} }
|
||||||
|
//! { "op": "fetch", "context": "session", "params": {} }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||||
|
//! mizan-rust-axum:
|
||||||
|
//!
|
||||||
|
//! * `call` → `{ result, invalidate, merge? }`
|
||||||
|
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||||
|
//!
|
||||||
|
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||||
|
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||||
|
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||||
|
//! see one error surface regardless of transport.
|
||||||
|
|
||||||
|
use mizan_core::{
|
||||||
|
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||||
|
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
|
||||||
|
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
|
||||||
|
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::<R>::new("mizan")
|
||||||
|
.invoke_handler(tauri::generate_handler![mizan_invoke])
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Wire envelope ===
|
||||||
|
|
||||||
|
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
|
||||||
|
/// Tauri's serde deserializer pulls this struct out of the `envelope`
|
||||||
|
/// field of the invoke payload.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "op")]
|
||||||
|
pub enum Envelope {
|
||||||
|
#[serde(rename = "call")]
|
||||||
|
Call {
|
||||||
|
/// Wire-level function name — registered name on the Rust side.
|
||||||
|
#[serde(rename = "fn")]
|
||||||
|
function_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Map<String, Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "fetch")]
|
||||||
|
Fetch {
|
||||||
|
context: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Map<String, Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||||
|
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||||
|
/// this and constructs a `MizanError`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorPayload {
|
||||||
|
pub code: &'static str,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MizanError> for ErrorPayload {
|
||||||
|
fn from(e: MizanError) -> Self {
|
||||||
|
let details = if let MizanError::ValidationFailed { details, .. } = &e {
|
||||||
|
Some(details.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
code: e.code(),
|
||||||
|
message: e.message().to_string(),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Dispatch ===
|
||||||
|
|
||||||
|
/// The single Mizan dispatch command. Registered on the plugin's invoke
|
||||||
|
/// handler — the consumer never wires it directly.
|
||||||
|
///
|
||||||
|
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
|
||||||
|
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||||
|
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||||
|
/// emission. Stateless functions ignore the handle.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn mizan_invoke<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
envelope: Envelope,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
match envelope {
|
||||||
|
Envelope::Call {
|
||||||
|
function_name,
|
||||||
|
args,
|
||||||
|
} => handle_call(&app, &function_name, args).await,
|
||||||
|
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_call<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
fn_name: &str,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||||
|
ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"function {fn_name:?} not registered"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
let result = fn_spec
|
||||||
|
.dispatch(req, Value::Object(args.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(ErrorPayload::from)?;
|
||||||
|
|
||||||
|
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||||
|
.iter()
|
||||||
|
.map(InvalidationTarget::to_json)
|
||||||
|
.collect();
|
||||||
|
let merges = compute_merges(fn_spec, &args, &result);
|
||||||
|
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"result": result,
|
||||||
|
"invalidate": invalidate,
|
||||||
|
});
|
||||||
|
if let Some(merge) = merge_payload {
|
||||||
|
payload
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("payload is a JSON object")
|
||||||
|
.insert("merge".into(), Value::Array(merge));
|
||||||
|
}
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_fetch<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
context_name: &str,
|
||||||
|
params: Map<String, Value>,
|
||||||
|
) -> Result<Value, ErrorPayload> {
|
||||||
|
if lookup_context(context_name).is_none() {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} not registered"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| f.context() == Some(context_name))
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||||
|
"context {context_name:?} has no registered members"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bundled = Map::new();
|
||||||
|
for fn_spec in &members {
|
||||||
|
let args = filter_args(*fn_spec, ¶ms);
|
||||||
|
let req = RequestHandle::new(app);
|
||||||
|
let result = fn_spec
|
||||||
|
.dispatch(req, Value::Object(args))
|
||||||
|
.await
|
||||||
|
.map_err(ErrorPayload::from)?;
|
||||||
|
bundled.insert(fn_spec.name().to_string(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(bundled))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter the envelope's params down to keys this function declares as
|
||||||
|
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||||
|
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||||
|
/// carries typed JSON, so the filter is sufficient on its own.
|
||||||
|
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||||
|
let mut out = Map::new();
|
||||||
|
for ip in fn_spec.input_params() {
|
||||||
|
if let Some(v) = params.get(ip.name) {
|
||||||
|
out.insert(ip.name.into(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
19
backends/mizan-ts/bun.lock
Normal file
19
backends/mizan-ts/bun.lock
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@mizan/ts",
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backends/mizan-ts/package.json
Normal file
14
backends/mizan-ts/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@mizan/ts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mizan TypeScript backend adapter — server functions, context bundling, invalidation protocol.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest"
|
||||||
|
},
|
||||||
|
"license": "Elastic-2.0"
|
||||||
|
}
|
||||||
44
backends/mizan-ts/src/cache/backend.ts
vendored
Normal file
44
backends/mizan-ts/src/cache/backend.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Cache backends — MemoryCache for testing.
|
||||||
|
*
|
||||||
|
* Simple key-value store. No reverse indexes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CacheBackend {
|
||||||
|
get(key: string): string | null
|
||||||
|
set(key: string, value: string): void
|
||||||
|
delete(key: string): boolean
|
||||||
|
deleteByPrefix(prefix: string): number
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryCache implements CacheBackend {
|
||||||
|
private _store = new Map<string, string>()
|
||||||
|
|
||||||
|
get(key: string): string | null {
|
||||||
|
return this._store.get(key) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
this._store.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): boolean {
|
||||||
|
return this._store.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByPrefix(prefix: string): number {
|
||||||
|
let count = 0
|
||||||
|
for (const key of [...this._store.keys()]) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this._store.delete(key)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this._store.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backends/mizan-ts/src/cache/index.ts
vendored
Normal file
72
backends/mizan-ts/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* mizan cache — TypeScript adapter.
|
||||||
|
*
|
||||||
|
* Same protocol as Python's mizan.cache. Cross-language conformance
|
||||||
|
* verified by pin tests. No reverse indexes — scoped purge recomputes
|
||||||
|
* the key directly, broad purge uses prefix scan.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { MemoryCache } from './backend'
|
||||||
|
export type { CacheBackend } from './backend'
|
||||||
|
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||||
|
|
||||||
|
import type { CacheBackend } from './backend'
|
||||||
|
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||||
|
|
||||||
|
let _cacheInstance: CacheBackend | null = null
|
||||||
|
|
||||||
|
export function getCache(): CacheBackend | null {
|
||||||
|
return _cacheInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCache(backend: CacheBackend | null): void {
|
||||||
|
_cacheInstance = backend
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCache(): void {
|
||||||
|
_cacheInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheGet(
|
||||||
|
secret: string,
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: string,
|
||||||
|
params: Record<string, any>,
|
||||||
|
userId?: string,
|
||||||
|
rev: number = 0,
|
||||||
|
): string | null {
|
||||||
|
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||||
|
return backend.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachePut(
|
||||||
|
secret: string,
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: string,
|
||||||
|
params: Record<string, any>,
|
||||||
|
value: string,
|
||||||
|
userId?: string,
|
||||||
|
rev: number = 0,
|
||||||
|
): void {
|
||||||
|
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||||
|
backend.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachePurge(
|
||||||
|
backend: CacheBackend,
|
||||||
|
context: string,
|
||||||
|
params?: Record<string, any> | null,
|
||||||
|
secret?: string | null,
|
||||||
|
userId?: string,
|
||||||
|
rev: number = 0,
|
||||||
|
): number {
|
||||||
|
if (params && secret) {
|
||||||
|
// Scoped purge — recompute key and delete directly
|
||||||
|
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||||
|
return backend.delete(key) ? 1 : 0
|
||||||
|
} else {
|
||||||
|
// Broad purge — prefix scan
|
||||||
|
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||||
|
return backend.deleteByPrefix(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backends/mizan-ts/src/cache/keys.ts
vendored
Normal file
57
backends/mizan-ts/src/cache/keys.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||||
|
*
|
||||||
|
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
||||||
|
* Cross-language conformance verified by pin tests.
|
||||||
|
*
|
||||||
|
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
|
||||||
|
const CONTEXT_KEY_PREFIX = 'ctx:'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||||
|
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
||||||
|
*/
|
||||||
|
function stableStringify(obj: any): string {
|
||||||
|
if (obj === null || obj === undefined) return 'null'
|
||||||
|
if (typeof obj === 'string') return JSON.stringify(obj)
|
||||||
|
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return '[' + obj.map(stableStringify).join(',') + ']'
|
||||||
|
}
|
||||||
|
const keys = Object.keys(obj).sort()
|
||||||
|
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
|
||||||
|
return '{' + pairs.join(',') + '}'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a deterministic HMAC-SHA256 cache key.
|
||||||
|
*
|
||||||
|
* Returns "ctx:{context}:{hmac_hex}".
|
||||||
|
*/
|
||||||
|
export function deriveCacheKey(
|
||||||
|
secret: string,
|
||||||
|
context: string,
|
||||||
|
params: Record<string, any>,
|
||||||
|
userId?: string,
|
||||||
|
rev: number = 0,
|
||||||
|
): string {
|
||||||
|
const sortedParams: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
||||||
|
sortedParams[k] = String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
|
||||||
|
if (userId !== undefined) {
|
||||||
|
keyData.u = String(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = stableStringify(keyData)
|
||||||
|
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
|
||||||
|
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CONTEXT_KEY_PREFIX }
|
||||||
140
backends/mizan-ts/src/decorator.ts
Normal file
140
backends/mizan-ts/src/decorator.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from './types'
|
||||||
|
import { register } from './registry'
|
||||||
|
|
||||||
|
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||||
|
if (ctx instanceof ReactContext) return ctx.name
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAffects(
|
||||||
|
affects: ClientOptions['affects'],
|
||||||
|
): RegistryEntry['affects'] | undefined {
|
||||||
|
if (!affects) return undefined
|
||||||
|
|
||||||
|
const items = Array.isArray(affects) ? affects : [affects]
|
||||||
|
return items.map(item => {
|
||||||
|
if (item instanceof ReactContext) {
|
||||||
|
return { type: 'context' as const, name: item.name }
|
||||||
|
}
|
||||||
|
return { type: 'context' as const, name: item }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParams(fn: Function): ParamDef[] {
|
||||||
|
// Extract parameter names from function.toString()
|
||||||
|
const source = fn.toString()
|
||||||
|
const match = source.match(/\(([^)]*)\)/)
|
||||||
|
if (!match || !match[1].trim()) return []
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
.split(',')
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p && !p.startsWith('...'))
|
||||||
|
.map(p => {
|
||||||
|
// Handle destructured defaults: name = default, name: type
|
||||||
|
const name = p.split(/[=:]/)[0].trim()
|
||||||
|
return { name, type: 'any', required: !p.includes('=') }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function wrapper — registers a standalone function.
|
||||||
|
*
|
||||||
|
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
||||||
|
*/
|
||||||
|
export function client<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
options: ClientOptions,
|
||||||
|
fn: T,
|
||||||
|
): T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class method decorator.
|
||||||
|
*
|
||||||
|
* class Handlers {
|
||||||
|
* @client({ context: UserCtx })
|
||||||
|
* async userProfile(userId: number) { ... }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function client(options: ClientOptions): MethodDecorator
|
||||||
|
|
||||||
|
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
|
||||||
|
// Function wrapper form: client(options, fn)
|
||||||
|
if (fn && typeof fn === 'function') {
|
||||||
|
const options = optionsOrFn as ClientOptions
|
||||||
|
const context = resolveContext(options.context)
|
||||||
|
const affects = normalizeAffects(options.affects)
|
||||||
|
|
||||||
|
if (context && affects) {
|
||||||
|
throw new Error('context and affects are mutually exclusive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = fn.name || 'anonymous'
|
||||||
|
const params = extractParams(fn)
|
||||||
|
const isView = false // Determined at call time for function wrappers
|
||||||
|
|
||||||
|
const entry: RegistryEntry = {
|
||||||
|
name,
|
||||||
|
fn: fn as any,
|
||||||
|
context,
|
||||||
|
affects,
|
||||||
|
params,
|
||||||
|
private: options.private ?? false,
|
||||||
|
viewPath: isView,
|
||||||
|
route: options.route,
|
||||||
|
methods: options.methods,
|
||||||
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
register(entry)
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorator form: @client(options)
|
||||||
|
const options = optionsOrFn as ClientOptions
|
||||||
|
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
209
backends/mizan-ts/src/dispatch.ts
Normal file
209
backends/mizan-ts/src/dispatch.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Request dispatch — context GET and mutation POST handlers.
|
||||||
|
*
|
||||||
|
* Framework-agnostic. Returns plain objects. The router adapter
|
||||||
|
* (Express, Hono, etc.) converts to framework-specific responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getFunction, getContextGroups } from './registry'
|
||||||
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
|
|
||||||
|
let _cacheSecret: string | null = null
|
||||||
|
|
||||||
|
/** Set the cache secret for origin-side caching. */
|
||||||
|
export function setCacheSecret(secret: string | null): void {
|
||||||
|
_cacheSecret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MizanResponse {
|
||||||
|
status: number
|
||||||
|
body: any
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/mizan/ctx/:contextName/
|
||||||
|
*
|
||||||
|
* Bundles all functions in a named context into one response.
|
||||||
|
*/
|
||||||
|
export async function handleContextFetch(
|
||||||
|
contextName: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
): Promise<MizanResponse> {
|
||||||
|
const groups = getContextGroups()
|
||||||
|
const fnNames = groups[contextName]
|
||||||
|
|
||||||
|
if (!fnNames) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: { error: true, code: 'NOT_FOUND', message: `Context '${contextName}' not found` },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||||
|
let effectiveRev = 0
|
||||||
|
for (const fnName of fnNames) {
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin-side cache lookup
|
||||||
|
const cacheBackend = getCache()
|
||||||
|
const cacheSecret = _cacheSecret
|
||||||
|
if (cacheBackend && cacheSecret) {
|
||||||
|
try {
|
||||||
|
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
|
||||||
|
if (cached !== null) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: JSON.parse(cached),
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* cache miss on error */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const fnName of fnNames) {
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
if (!entry) continue
|
||||||
|
|
||||||
|
// Filter params to only those this function declares
|
||||||
|
const fnParams: Record<string, any> = {}
|
||||||
|
for (const p of entry.params) {
|
||||||
|
if (p.name in params) fnParams[p.name] = params[p.name]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const argValues = entry.params.map(p => fnParams[p.name])
|
||||||
|
const result = await entry.fn(...argValues)
|
||||||
|
|
||||||
|
// View path — skip (context GET is for RPC data)
|
||||||
|
if (result instanceof Response) continue
|
||||||
|
|
||||||
|
results[fnName] = result
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve effective cache policy for origin-side cache decision
|
||||||
|
let effectiveCache: number | boolean = true
|
||||||
|
for (const fnName of fnNames) {
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
if (!entry) continue
|
||||||
|
if (entry.cache === false) { effectiveCache = false; break }
|
||||||
|
if (typeof entry.cache === 'number') {
|
||||||
|
effectiveCache = effectiveCache === true
|
||||||
|
? entry.cache
|
||||||
|
: Math.min(effectiveCache as number, entry.cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in origin-side cache (skip if cache=False)
|
||||||
|
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||||
|
try {
|
||||||
|
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||||
|
} catch { /* cache store failure is non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: results,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle POST /api/mizan/call/
|
||||||
|
*
|
||||||
|
* Dispatches to a named function. Returns result + invalidation.
|
||||||
|
*/
|
||||||
|
export async function handleMutationCall(
|
||||||
|
fnName: string,
|
||||||
|
args: Record<string, any>,
|
||||||
|
): Promise<MizanResponse> {
|
||||||
|
const entry = getFunction(fnName)
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: { error: true, code: 'NOT_FOUND', message: `Function '${fnName}' not found` },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject private functions from RPC dispatch
|
||||||
|
if (entry.private) {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { error: true, code: 'FORBIDDEN', message: 'Function is not client-callable' },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const argValues = entry.params.map(p => args[p.name])
|
||||||
|
const result = await entry.fn(...argValues)
|
||||||
|
|
||||||
|
// View path — return Response directly with invalidation header
|
||||||
|
if (result instanceof Response) {
|
||||||
|
const invalidate = resolveInvalidation(entry, args)
|
||||||
|
if (invalidate) {
|
||||||
|
result.headers.set('X-Mizan-Invalidate', formatInvalidateHeader(invalidate))
|
||||||
|
}
|
||||||
|
result.headers.set('Cache-Control', 'no-store')
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
body: result,
|
||||||
|
headers: Object.fromEntries(result.headers.entries()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC path — JSON response with invalidation
|
||||||
|
const invalidate = resolveInvalidation(entry, args)
|
||||||
|
const responseData: Record<string, any> = { result }
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidate) {
|
||||||
|
responseData.invalidate = invalidate
|
||||||
|
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
|
||||||
|
|
||||||
|
// Purge origin-side cache
|
||||||
|
const cb = getCache()
|
||||||
|
if (cb) {
|
||||||
|
try {
|
||||||
|
for (const entry of invalidate) {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
cachePurge(cb, entry)
|
||||||
|
} else {
|
||||||
|
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* purge failure is non-fatal */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, body: responseData, headers }
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||||
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backends/mizan-ts/src/index.ts
Normal file
17
backends/mizan-ts/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { ReactContext } from './types'
|
||||||
|
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
|
||||||
|
|
||||||
|
export { client } from './decorator'
|
||||||
|
|
||||||
|
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
|
||||||
|
|
||||||
|
export { handleContextFetch, handleMutationCall } from './dispatch'
|
||||||
|
export type { MizanResponse } from './dispatch'
|
||||||
|
|
||||||
|
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
|
|
||||||
|
export { generateManifest } from './manifest'
|
||||||
|
|
||||||
|
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
|
||||||
|
export type { CacheBackend } from './cache'
|
||||||
|
export { setCacheSecret } from './dispatch'
|
||||||
102
backends/mizan-ts/src/invalidation.ts
Normal file
102
backends/mizan-ts/src/invalidation.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Invalidation protocol — header formatting, auto-scoping.
|
||||||
|
*
|
||||||
|
* Matches Django's implementation exactly. Same format. Same rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RegistryEntry } from './types'
|
||||||
|
import { getContextGroups, getContextParamNames, getFunction } from './registry'
|
||||||
|
|
||||||
|
type InvalidateEntry = string | { context: string; params: Record<string, any> }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve invalidation targets with three-tier auto-scoping.
|
||||||
|
*
|
||||||
|
* Tier 1: Argument name matching
|
||||||
|
* Tier 2: Auth inference (Edge-side, not handled here)
|
||||||
|
* Tier 3: Broad fallback
|
||||||
|
*/
|
||||||
|
export function resolveInvalidation(
|
||||||
|
entry: RegistryEntry,
|
||||||
|
callArgs: Record<string, any> | null,
|
||||||
|
): InvalidateEntry[] | null {
|
||||||
|
if (!entry.affects) return null
|
||||||
|
|
||||||
|
const result: InvalidateEntry[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const target of entry.affects) {
|
||||||
|
const targetName = target.name
|
||||||
|
if (seen.has(targetName)) continue
|
||||||
|
seen.add(targetName)
|
||||||
|
|
||||||
|
// Resolve which context the target belongs to (for param lookup)
|
||||||
|
const resolved = resolveAffectsTarget(targetName)
|
||||||
|
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
|
||||||
|
|
||||||
|
// Tier 1: argument name matching
|
||||||
|
if (callArgs && ctxForParams) {
|
||||||
|
const contextParams = getContextParamNames(ctxForParams)
|
||||||
|
const matched: Record<string, any> = {}
|
||||||
|
for (const [k, v] of Object.entries(callArgs)) {
|
||||||
|
if (contextParams.has(k)) matched[k] = v
|
||||||
|
}
|
||||||
|
if (Object.keys(matched).length > 0) {
|
||||||
|
result.push({ context: targetName, params: matched })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: broad fallback
|
||||||
|
result.push(targetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.length > 0 ? result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an affects target is a context name or function name.
|
||||||
|
*/
|
||||||
|
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
|
||||||
|
const groups = getContextGroups()
|
||||||
|
|
||||||
|
if (name in groups) {
|
||||||
|
return { type: 'context', name }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
||||||
|
if (fnNames.includes(name)) {
|
||||||
|
return { type: 'function', name, context: ctxName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'context', name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format invalidation targets as X-Mizan-Invalidate header value.
|
||||||
|
*
|
||||||
|
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
|
||||||
|
*/
|
||||||
|
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
for (const entry of invalidate) {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
parts.push(entry)
|
||||||
|
} else {
|
||||||
|
const { context, params } = entry
|
||||||
|
if (params && Object.keys(params).length > 0) {
|
||||||
|
const paramStr = Object.entries(params)
|
||||||
|
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join(';')
|
||||||
|
parts.push(`${context};${paramStr}`)
|
||||||
|
} else {
|
||||||
|
parts.push(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ')
|
||||||
|
}
|
||||||
93
backends/mizan-ts/src/manifest.ts
Normal file
93
backends/mizan-ts/src/manifest.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Edge Manifest Generator
|
||||||
|
*
|
||||||
|
* Produces the same JSON format as mizan-django. One Edge Worker.
|
||||||
|
* Two backend languages. Same manifest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EdgeManifest } from './types'
|
||||||
|
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
|
||||||
|
|
||||||
|
// Both camelCase and snake_case forms included for cross-language matching.
|
||||||
|
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
|
||||||
|
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
|
||||||
|
|
||||||
|
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||||
|
const groups = getContextGroups()
|
||||||
|
const allFunctions = getAllFunctions()
|
||||||
|
const manifest: EdgeManifest = { version: 1, contexts: {}, mutations: {} }
|
||||||
|
|
||||||
|
// Contexts
|
||||||
|
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
||||||
|
const paramNames = new Set<string>()
|
||||||
|
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
|
||||||
|
const pageRoutes: string[] = []
|
||||||
|
|
||||||
|
for (const fnName of fnNames) {
|
||||||
|
const entry = allFunctions.get(fnName)
|
||||||
|
if (!entry) continue
|
||||||
|
|
||||||
|
for (const p of entry.params) paramNames.add(p.name)
|
||||||
|
|
||||||
|
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||||
|
if (entry.route) {
|
||||||
|
fnEntry.route = entry.route
|
||||||
|
fnEntry.methods = entry.methods || ['GET']
|
||||||
|
pageRoutes.push(entry.route)
|
||||||
|
}
|
||||||
|
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
||||||
|
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
|
||||||
|
functions.push(fnEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedParams = [...paramNames].sort()
|
||||||
|
const userScoped = [...paramNames].some(p => USER_SCOPED_PARAMS.has(p))
|
||||||
|
|
||||||
|
const ctxEntry: EdgeManifest['contexts'][string] = {
|
||||||
|
functions,
|
||||||
|
endpoints: [`${baseUrl}/ctx/${ctxName}/`],
|
||||||
|
params: sortedParams,
|
||||||
|
user_scoped: userScoped,
|
||||||
|
render_strategy: userScoped ? 'dynamic_cached' : 'psr',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageRoutes.length > 0) {
|
||||||
|
ctxEntry.page_routes = pageRoutes
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.contexts[ctxName] = ctxEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
for (const [fnName, entry] of allFunctions) {
|
||||||
|
if (!entry.affects) continue
|
||||||
|
|
||||||
|
const affectedContexts = [...new Set(entry.affects.map(a => a.name))]
|
||||||
|
|
||||||
|
// Auto-scoped params
|
||||||
|
const fnParamNames = new Set(entry.params.map(p => p.name))
|
||||||
|
const autoScoped: string[] = []
|
||||||
|
for (const ctxName of affectedContexts) {
|
||||||
|
const ctxParams = getContextParamNames(ctxName)
|
||||||
|
for (const p of fnParamNames) {
|
||||||
|
if (ctxParams.has(p) && !autoScoped.includes(p)) {
|
||||||
|
autoScoped.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation: EdgeManifest['mutations'][string] = {
|
||||||
|
affects: affectedContexts,
|
||||||
|
}
|
||||||
|
if (autoScoped.length > 0) mutation.auto_scoped_params = autoScoped.sort()
|
||||||
|
if (entry.private) mutation.private = true
|
||||||
|
if (entry.route) {
|
||||||
|
mutation.route = entry.route
|
||||||
|
mutation.methods = entry.methods || ['POST']
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.mutations[fnName] = mutation
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
49
backends/mizan-ts/src/registry.ts
Normal file
49
backends/mizan-ts/src/registry.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Mizan Registry — Central registration for server functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RegistryEntry } from './types'
|
||||||
|
|
||||||
|
const _functions: Map<string, RegistryEntry> = new Map()
|
||||||
|
|
||||||
|
export function register(entry: RegistryEntry): void {
|
||||||
|
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
|
||||||
|
throw new Error(`Function '${entry.name}' already registered`)
|
||||||
|
}
|
||||||
|
_functions.set(entry.name, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunction(name: string): RegistryEntry | undefined {
|
||||||
|
return _functions.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFunctions(): Map<string, RegistryEntry> {
|
||||||
|
return new Map(_functions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContextGroups(): Record<string, string[]> {
|
||||||
|
const groups: Record<string, string[]> = {}
|
||||||
|
for (const [name, entry] of _functions) {
|
||||||
|
if (entry.context) {
|
||||||
|
if (!groups[entry.context]) groups[entry.context] = []
|
||||||
|
groups[entry.context].push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContextParamNames(contextName: string): Set<string> {
|
||||||
|
const params = new Set<string>()
|
||||||
|
for (const [, entry] of _functions) {
|
||||||
|
if (entry.context === contextName) {
|
||||||
|
for (const p of entry.params) {
|
||||||
|
params.add(p.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRegistry(): void {
|
||||||
|
_functions.clear()
|
||||||
|
}
|
||||||
66
backends/mizan-ts/src/types.ts
Normal file
66
backends/mizan-ts/src/types.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Mizan TypeScript Adapter — Shared Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ReactContext {
|
||||||
|
constructor(public readonly name: string) {
|
||||||
|
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AffectsTarget = ReactContext | string
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
context?: ReactContext | string
|
||||||
|
affects?: AffectsTarget | AffectsTarget[]
|
||||||
|
private?: boolean
|
||||||
|
route?: string
|
||||||
|
methods?: string[]
|
||||||
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamDef {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistryEntry {
|
||||||
|
name: string
|
||||||
|
fn: (...args: any[]) => Promise<any>
|
||||||
|
context?: string
|
||||||
|
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
|
||||||
|
params: ParamDef[]
|
||||||
|
private: boolean
|
||||||
|
viewPath: boolean
|
||||||
|
route?: string
|
||||||
|
methods?: string[]
|
||||||
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestContext {
|
||||||
|
functions: Array<{ name: string; path: 'rpc' | 'view' }>
|
||||||
|
endpoints: string[]
|
||||||
|
params: string[]
|
||||||
|
user_scoped: boolean
|
||||||
|
render_strategy: 'psr' | 'dynamic_cached'
|
||||||
|
page_routes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestMutation {
|
||||||
|
affects: string[]
|
||||||
|
auto_scoped_params?: string[]
|
||||||
|
private?: boolean
|
||||||
|
route?: string
|
||||||
|
methods?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeManifest {
|
||||||
|
version: number
|
||||||
|
contexts: Record<string, ManifestContext>
|
||||||
|
mutations: Record<string, ManifestMutation>
|
||||||
|
}
|
||||||
425
backends/mizan-ts/tests/edge-compat.test.ts
Normal file
425
backends/mizan-ts/tests/edge-compat.test.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
|
||||||
|
*
|
||||||
|
* These prove that a Cloudflare Worker (Edge) can sit in front of a
|
||||||
|
* TypeScript backend and behave identically to sitting in front of Django.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
||||||
|
|
||||||
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
|
function setupUserContext() {
|
||||||
|
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||||
|
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||||
|
return { count: userId * 10 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||||
|
return { name, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
|
||||||
|
client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
|
||||||
|
return { name, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Edge Compatibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRegistry()
|
||||||
|
setupUserContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Deterministic JSON ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('deterministic JSON output', async () => {
|
||||||
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
const r2 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('different params produce different responses', async () => {
|
||||||
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
const r2 = await handleContextFetch('user', { userId: '6' })
|
||||||
|
expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
|
||||||
|
expect(r1.body.userProfile.name).toBe('user_5')
|
||||||
|
expect(r2.body.userProfile.name).toBe('user_6')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cache-Control correctness ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('context GET emits no-store', async () => {
|
||||||
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mutation POST not cacheable', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('error response not cacheable', async () => {
|
||||||
|
const r = await handleContextFetch('nonexistent', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── X-Mizan-Invalidate header ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('mutation response includes invalidation header', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auto-scoped invalidation in header', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalidation header matches JSON body', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
const body = r.body
|
||||||
|
expect(body.invalidate[0].context).toBe('user')
|
||||||
|
expect(body.invalidate[0].params.userId).toBe(5)
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('function-level invalidation in header', async () => {
|
||||||
|
const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no invalidation header on context GET', async () => {
|
||||||
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Header format edge cases ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('special characters in param values are URL-encoded', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'search', params: { q: 'a;b' } },
|
||||||
|
])
|
||||||
|
expect(header).not.toContain(';b')
|
||||||
|
expect(header).toContain('a%3Bb')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spaces in param values are URL-encoded', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'search', params: { q: 'hello world' } },
|
||||||
|
])
|
||||||
|
expect(header).not.toContain(' ')
|
||||||
|
expect(header).toContain('hello%20world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('header round-trip with special chars', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Parse (what Edge does)
|
||||||
|
const segments = header.split(';')
|
||||||
|
const ctx = segments[0]
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
for (const seg of segments.slice(1)) {
|
||||||
|
const [k, v] = seg.split('=', 2)
|
||||||
|
params[decodeURIComponent(k)] = decodeURIComponent(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ctx).toBe('data')
|
||||||
|
expect(params.name).toBe("O'Brien")
|
||||||
|
expect(params.tag).toBe('a;b;c')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Empty invalidation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('no affects = no header, no body key', async () => {
|
||||||
|
client({ context: new ReactContext('plain') }, async function plainFn() {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// A context function called via mutation dispatch (shouldn't have invalidation)
|
||||||
|
// Actually test a function without affects
|
||||||
|
clearRegistry()
|
||||||
|
client({}, async function noAffects() { return { ok: true } })
|
||||||
|
const r = await handleMutationCall('noAffects', {})
|
||||||
|
expect(r.body.invalidate).toBeUndefined()
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Private functions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('private functions rejected from RPC', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
client({ affects: 'subscription', private: true }, async function webhook() {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
const r = await handleMutationCall('webhook', {})
|
||||||
|
expect(r.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Unknown function ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('unknown function returns 404', async () => {
|
||||||
|
const r = await handleMutationCall('doesNotExist', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown context returns 404', async () => {
|
||||||
|
const r = await handleContextFetch('doesNotExist', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Manifest', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRegistry()
|
||||||
|
setupUserContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manifest matches expected structure', () => {
|
||||||
|
const m = generateManifest()
|
||||||
|
|
||||||
|
expect(m.version).toBe(1)
|
||||||
|
expect(m.contexts.user).toBeDefined()
|
||||||
|
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
|
||||||
|
expect(m.contexts.user.params).toContain('userId')
|
||||||
|
expect(m.contexts.user.user_scoped).toBe(true)
|
||||||
|
expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mutations section includes auto-scoped params', () => {
|
||||||
|
const m = generateManifest()
|
||||||
|
|
||||||
|
expect(m.mutations.updateProfile).toBeDefined()
|
||||||
|
expect(m.mutations.updateProfile.affects).toEqual(['user'])
|
||||||
|
expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PSR strategy for non-user-scoped context', () => {
|
||||||
|
clearRegistry()
|
||||||
|
const ProductCtx = new ReactContext('products')
|
||||||
|
client({ context: ProductCtx }, async function productDetail(productId: number) {
|
||||||
|
return { id: productId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
expect(m.contexts.products.user_scoped).toBe(false)
|
||||||
|
expect(m.contexts.products.render_strategy).toBe('psr')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('private mutation in manifest', () => {
|
||||||
|
clearRegistry()
|
||||||
|
client(
|
||||||
|
{ affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
|
||||||
|
async function stripeWebhook() { return new Response('ok') },
|
||||||
|
)
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
expect(m.mutations.stripeWebhook).toBeDefined()
|
||||||
|
expect(m.mutations.stripeWebhook.private).toBe(true)
|
||||||
|
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
||||||
|
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rev appears in manifest', () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('data')
|
||||||
|
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
|
||||||
|
return { value: itemId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
const fn = m.contexts.data.functions[0]
|
||||||
|
expect(fn.rev).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cache TTL appears in manifest', () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('trending')
|
||||||
|
client({ context: Ctx, cache: 60 }, async function trendingFn() {
|
||||||
|
return { items: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
const fn = m.contexts.trending.functions[0]
|
||||||
|
expect(fn.cache).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cache=60 still emits no-store on HTTP', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('live')
|
||||||
|
client({ context: Ctx, cache: 60 }, async function liveFn() {
|
||||||
|
return { score: 42 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const r = await handleContextFetch('live', {})
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cache=false sets no-store', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('random')
|
||||||
|
client({ context: Ctx, cache: false }, async function randomFn() {
|
||||||
|
return { value: Math.random() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const r = await handleContextFetch('random', {})
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cache Conformance Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cache Conformance', () => {
|
||||||
|
const SECRET = 'test-pin-secret-that-is-32bytes!'
|
||||||
|
|
||||||
|
test('deriveCacheKey determinism', () => {
|
||||||
|
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||||
|
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||||
|
expect(k1).toBe(k2)
|
||||||
|
expect(k1).toStartWith('ctx:user:')
|
||||||
|
expect(k1).toHaveLength('ctx:user:'.length + 64)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deriveCacheKey param order irrelevant', () => {
|
||||||
|
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
|
||||||
|
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
|
||||||
|
expect(k1).toBe(k2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
||||||
|
// These exact values are pinned from Python's derive_cache_key output.
|
||||||
|
// If this test fails, cross-language cache key compatibility is broken.
|
||||||
|
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||||
|
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||||
|
|
||||||
|
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
|
||||||
|
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('MemoryCache get/set/clear', () => {
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
expect(cache.get('k1')).toBeNull()
|
||||||
|
|
||||||
|
cache.set('k1', '{"data":true}')
|
||||||
|
expect(cache.get('k1')).toBe('{"data":true}')
|
||||||
|
|
||||||
|
cache.clear()
|
||||||
|
expect(cache.get('k1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scoped purge recomputes key directly', () => {
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
|
||||||
|
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
|
||||||
|
|
||||||
|
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
|
||||||
|
expect(count).toBe(1)
|
||||||
|
|
||||||
|
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
|
||||||
|
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('broad purge removes all entries', () => {
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
|
||||||
|
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
|
||||||
|
|
||||||
|
const count = cachePurge(cache, 'user')
|
||||||
|
expect(count).toBe(2)
|
||||||
|
|
||||||
|
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
|
||||||
|
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handleContextFetch caches response', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('cached')
|
||||||
|
client({ context: Ctx }, async function cachedFn(itemId: number) {
|
||||||
|
return { value: itemId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
setCache(cache)
|
||||||
|
setCacheSecret(SECRET)
|
||||||
|
|
||||||
|
const r1 = await handleContextFetch('cached', { itemId: '1' })
|
||||||
|
expect(r1.status).toBe(200)
|
||||||
|
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
||||||
|
|
||||||
|
const r2 = await handleContextFetch('cached', { itemId: '1' })
|
||||||
|
expect(r2.status).toBe(200)
|
||||||
|
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
|
||||||
|
expect(r2.body).toEqual(r1.body)
|
||||||
|
|
||||||
|
resetCache()
|
||||||
|
setCacheSecret(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handleMutationCall purges cache', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('product')
|
||||||
|
client({ context: Ctx }, async function getProduct(productId: number) {
|
||||||
|
return { id: productId }
|
||||||
|
})
|
||||||
|
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
setCache(cache)
|
||||||
|
setCacheSecret(SECRET)
|
||||||
|
|
||||||
|
// Prime cache
|
||||||
|
await handleContextFetch('product', { productId: '1' })
|
||||||
|
|
||||||
|
// Mutate
|
||||||
|
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
|
||||||
|
|
||||||
|
// Cache should be purged — next fetch is MISS
|
||||||
|
const r = await handleContextFetch('product', { productId: '1' })
|
||||||
|
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
|
||||||
|
|
||||||
|
resetCache()
|
||||||
|
setCacheSecret(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scoped invalidation preserves other entries', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
const Ctx = new ReactContext('user')
|
||||||
|
client({ context: Ctx }, async function userProfile(userId: number) {
|
||||||
|
return { name: `user_${userId}` }
|
||||||
|
})
|
||||||
|
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cache = new MemoryCache()
|
||||||
|
setCache(cache)
|
||||||
|
setCacheSecret(SECRET)
|
||||||
|
|
||||||
|
// Prime both users
|
||||||
|
await handleContextFetch('user', { userId: '5' })
|
||||||
|
await handleContextFetch('user', { userId: '6' })
|
||||||
|
|
||||||
|
// Mutate only user 5
|
||||||
|
await handleMutationCall('editUser', { userId: 5, name: 'New' })
|
||||||
|
|
||||||
|
// User 6 should still be cached
|
||||||
|
const r6 = await handleContextFetch('user', { userId: '6' })
|
||||||
|
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
|
||||||
|
|
||||||
|
// User 5 should be a miss
|
||||||
|
const r5 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
|
||||||
|
|
||||||
|
resetCache()
|
||||||
|
setCacheSecret(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
backends/mizan-ts/tsconfig.json
Normal file
16
backends/mizan-ts/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"]
|
||||||
|
}
|
||||||
27
cores/mizan-python/pyproject.toml
Normal file
27
cores/mizan-python/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "mizan-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"PyJWT>=2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mizan_core"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
115
cores/mizan-python/src/mizan_core/cache/backend.py
vendored
Normal file
115
cores/mizan-python/src/mizan_core/cache/backend.py
vendored
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Cache backends — MemoryCache (testing) and RedisCache (production).
|
||||||
|
|
||||||
|
Simple key-value stores. No reverse indexes. Cache keys are derived
|
||||||
|
from HMAC, so scoped purge just recomputes the key and deletes it.
|
||||||
|
Broad purge uses key-prefix scan (rare operation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(Protocol):
|
||||||
|
"""Interface that all Mizan cache backends implement."""
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None: ...
|
||||||
|
def set(self, key: str, value: bytes) -> None: ...
|
||||||
|
def delete(self, key: str) -> bool: ...
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int: ...
|
||||||
|
def clear(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""
|
||||||
|
In-memory cache backend for testing.
|
||||||
|
|
||||||
|
Uses a Python dict. No persistence, no cross-process sharing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None:
|
||||||
|
return self._store.get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: bytes) -> None:
|
||||||
|
self._store[key] = value
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
if key in self._store:
|
||||||
|
del self._store[key]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int:
|
||||||
|
to_delete = [k for k in self._store if k.startswith(prefix)]
|
||||||
|
for k in to_delete:
|
||||||
|
del self._store[k]
|
||||||
|
return len(to_delete)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._store.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
"""
|
||||||
|
Redis-backed cache backend for production.
|
||||||
|
|
||||||
|
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
|
||||||
|
the HMAC key and deletes directly. Broad purge uses SCAN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_TTL = 86400 # 24h safety-net
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_url: str,
|
||||||
|
prefix: str = "mizan:",
|
||||||
|
ttl: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
import redis as redis_lib
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"Redis is required for Mizan's cache backend. "
|
||||||
|
"Install it with: pip install mizan[cache]"
|
||||||
|
)
|
||||||
|
self._client = redis_lib.from_url(
|
||||||
|
redis_url,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=5,
|
||||||
|
health_check_interval=30,
|
||||||
|
retry_on_timeout=True,
|
||||||
|
max_connections=50,
|
||||||
|
)
|
||||||
|
self._prefix = prefix
|
||||||
|
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
|
||||||
|
|
||||||
|
def _key(self, key: str) -> str:
|
||||||
|
return f"{self._prefix}{key}"
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes | None:
|
||||||
|
return self._client.get(self._key(key))
|
||||||
|
|
||||||
|
def set(self, key: str, value: bytes) -> None:
|
||||||
|
self._client.set(self._key(key), value, ex=self._ttl)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
return self._client.unlink(self._key(key)) > 0
|
||||||
|
|
||||||
|
def delete_by_prefix(self, prefix: str) -> int:
|
||||||
|
pattern = f"{self._prefix}{prefix}*"
|
||||||
|
count = 0
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = self._client.scan(cursor, match=pattern, count=1000)
|
||||||
|
if keys:
|
||||||
|
count += self._client.unlink(*keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
return count
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.delete_by_prefix("")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user