Compare commits

...

20 Commits

Author SHA1 Message Date
97237ed1a4 Add mizan-ts: TypeScript backend adapter proving AFI is language-agnostic
The TypeScript adapter produces the same manifest, the same
X-Mizan-Invalidate headers, the same JSON invalidation protocol,
and the same CDN-ready response headers as mizan-django.

One Edge Worker. Two backend languages. Same protocol.

Features:
- @client decorator (function wrapper + class method decorator)
- ReactContext class (same API as Django adapter)
- Registry with context groups and param tracking
- Context bundled GET: /api/mizan/ctx/<name>/
- Mutation POST: /api/mizan/call/ with server-driven invalidation
- Three-tier auto-scoping (argument name matching → broad fallback)
- Function-level affects targeting
- private=True (rejected from RPC, in manifest for Edge)
- X-Mizan-Invalidate header with URL-encoded params
- Edge manifest generation (identical format to Django's)
- render_strategy + user_scoped derivation

22 edge compatibility tests pass (Bun, 21ms):
- Deterministic JSON, sorted keys
- Cache-Control: public on GETs, no-store on mutations/errors
- Vary: Authorization, Cookie
- Header round-trip with special characters
- Auto-scoped invalidation matches body and header
- Function-level invalidation
- Private function rejection
- Manifest structure with PSR/dynamic_cached strategies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
d228c7ab1b Add private=True, route=, methods= to @client decorator
private=True: server-internal functions (webhooks, cron) that emit
invalidation but are not client-callable. Rejected from POST /call/
with 403. No codegen. Appears in manifest for Edge.

  @client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
  def stripe_webhook(request) -> HttpResponse: ...

route=: Mizan-owned URL pattern for view-path functions. Registered
during autodiscovery. Populates page_routes in the manifest for
Edge/PSR to resolve during invalidation.

methods=: HTTP methods for the route. Defaults to ['GET'] for context
functions, ['POST'] for mutations.

Extended Edge manifest with:
- mutations section: affects, auto_scoped_params, private, route
- render_strategy: "psr" (no user params) or "dynamic_cached" (user-scoped)
- user_scoped: derived from param names matching common identity params
- page_routes: from route= on view-path functions + external view_urls

323 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
28e517e6ee Edge manifest: static JSON for CDN cache invalidation routing
generate_edge_manifest() compiles the decorator registry into a
static JSON artifact that Edge reads at deploy time:

{
  "contexts": {
    "user": {
      "functions": [
        {"name": "user_profile", "path": "rpc"},
        {"name": "profile_page", "path": "view"}
      ],
      "endpoints": ["/api/mizan/ctx/user/"],
      "params": ["user_id"],
      "views": ["/profile/:user_id/"]
    }
  }
}

When Edge receives X-Mizan-Invalidate: user;user_id=5, it:
1. Looks up "user" in the manifest
2. Resolves URL patterns: /profile/:user_id/ → /profile/5/
3. Purges /profile/5/ and /api/mizan/ctx/user/?user_id=5

Features:
- Distinguishes view-path vs RPC-path functions
- Accepts optional view_urls mapping from developer
- Custom base URL support
- Deterministic JSON output (sorted keys)
- Management command: python manage.py export_edge_manifest

314 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
b4c7e783bd Return-type branching: one decorator, two paths
@client now handles both RPC and view functions based on return type:

  @client(affects=UserContext)
  def update_name(request, user_id: int, name: str) -> dict:
      ...  # RPC path: JSON response with invalidate key

  @client(affects=UserContext)
  def update_profile(request, user_id: int) -> HttpResponse:
      ...  # View path: HttpResponse with X-Mizan-Invalidate header

Detection: isinstance(result, HttpResponseBase) after execution.

RPC path (data return):
  - Serialized via Pydantic model_dump()
  - Wrapped in {"result": ..., "invalidate": [...]}
  - Invalidation in JSON body + X-Mizan-Invalidate header

View path (HttpResponse return):
  - Response passed through directly (redirect, HTML, etc.)
  - X-Mizan-Invalidate header added automatically
  - Cache-Control: no-store added
  - No codegen (view_path=True in _meta)
  - Registered in invalidation graph (for Edge manifest)

Auto-scoping works on both paths: if mutation args overlap
with context params, invalidation is scoped automatically.

308 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
89196a02c6 Edge compatibility tests + URL-encode header param values
19 tests that prove Edge caching is possible before Edge exists:

- Deterministic JSON (byte-identical responses for same input)
- Sorted JSON keys for consistent cache keys
- Cache-Control: public on context GETs, no-store on mutations/errors
- Vary: Authorization, Cookie differentiates by auth state
- Auth-dependent responses: same URL, different user → different body
- X-Mizan-Invalidate header round-trip: format → parse → verify
- Header matches JSON body invalidation targets
- Special characters in param values: semicolons, spaces, quotes
  are URL-encoded to prevent delimiter collisions
- Large invalidation sets (20 contexts) serialize and parse correctly
- Concurrent mutations produce independent, correct headers
- Empty invalidation: no affects → no header, no body key
- Param order irrelevant for response determinism

Design decision: param values in X-Mizan-Invalidate are URL-encoded
(percent-encoded). This prevents semicolon collision when values
contain the delimiter character.

301 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
1a4da68f8d Add HTTP integration tests through full Django stack
9 tests that use Django's test Client instead of RequestFactory.
These go through URL routing, middleware (sessions, CSRF, auth),
and real request parsing — proving the protocol works end-to-end:

- Mutation with auto-scoped invalidation (JSON body + header)
- Context fetch with bundled response + CDN headers
- String-to-int query param coercion
- Broad invalidation fallback (no matching args)
- Function-level affects targeting
- 404 for unknown functions and contexts
- Method enforcement (GET-only on /ctx/, POST-only on /call/)

282 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
a91ce78c3a Replace affects_params with three-tier auto-scoping
Remove affects_params lambda. Scoping is now automatic:

Tier 1 - Argument name matching:
  If the mutation's args overlap with the context's params by name,
  the invalidation is auto-scoped. No developer annotation needed.

  @client(context=UserContext)
  def user_profile(request, user_id: int) -> UserShape: ...

  @client(affects=UserContext)
  def update_profile(request, user_id: int, name: str) -> dict: ...
  # user_id matches → invalidate: [{context: "user", params: {user_id: 5}}]

Tier 2 - Auth inference (Edge-side, not implemented in framework)
Tier 3 - Broad fallback when no param names match

Also adds function-level affects targeting:
  @client(affects='user_profile')  # only user_profile, not user_orders
  def update_name(request, user_id: int, name: str) -> dict: ...

Function names resolve to their parent context for param lookup.
v1 runtime refetches the whole context regardless, but the protocol
carries the function-level signal for Edge and future optimization.

273 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
37f3f3d3eb Add affects_params for scoped invalidation
affects_params is a callable that extracts which specific params were
affected by a mutation. The server uses it to produce scoped
invalidation in both transports:

  @client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
  def update_avatar(request, url: str) -> dict: ...

JSON body: {"result": ..., "invalidate": [{"context": "user", "params": {"user_id": 42}}]}
Header:    X-Mizan-Invalidate: user;user_id=42

Edge reads the scoped params to purge only /profile/42/ instead of
all user profiles. The runtime refetches only the UserContext mounted
with user_id=42, not all UserContext instances.

Requires affects= to be set. Falls back to broad invalidation if
the callable fails.

272 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
8aa20111b4 Add X-Mizan-Invalidate header (second invalidation transport)
Mutation responses now carry invalidation via two transports:

1. JSON body: {"result": ..., "invalidate": ["user"]}
2. HTTP header: X-Mizan-Invalidate: user, notifications

Both are set on every mutation response. The JSON body is consumed
by the client runtime (mizanCall). The header is consumed by Edge
for CDN cache purging and by XHR responses for htmx-style apps.

Header format: comma-separated contexts, semicolon-separated params.
  X-Mizan-Invalidate: user;user_id=5, notifications

Also: _resolve_invalidation and _format_invalidate_header extracted
as reusable helpers for when return-type branching adds HttpResponse
support (view-path mutations will only use the header transport).

Updated ROADMAP.md with full v1 plan including both transports,
return-type branching, affects_params, and Edge manifest.

270 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
f4d7c64e3c Add CDN-ready headers, ROADMAP, fold runtime into mizan-react
CDN headers on context GETs (Edge-ready):
- Cache-Control: public, max-age=0, stale-while-revalidate=300
- Vary: Authorization, Cookie
- Deterministic JSON (sorted keys) for consistent cache keys
- Error responses: Cache-Control: no-store
- Mutation POSTs: Cache-Control: no-store

ROADMAP.md documents v1 deliverables and Mizan Cloud (Edge, Render,
Deploy) as closed-source products built on the open-source protocol.

mizan-runtime folded into mizan-react/src/runtime/ — framework-agnostic
split deferred until a second frontend adapter exists.

268 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
3f737132a2 Server-driven invalidation + raw context response format
Mutation responses now include invalidation directives from the server:

  POST /api/mizan/call/
  → {"result": {...}, "invalidate": ["user"]}

The client never hardcodes invalidation targets. The server resolves
affects= metadata and returns what to invalidate. mizan-runtime reads
the invalidate key and triggers refetches automatically.

Context fetch returns raw bundled data (not wrapped):

  GET /api/mizan/ctx/user/?user_id=5
  → {"user_profile": {...}, "user_orders": [...]}

Also fixed QueryDict handling (use .dict() not dict() to avoid
list-wrapped values).

267 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
787f90fd12 Flatten to three packages + extract mizan-runtime
packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
b28ee72c67 Restructure repo into five-package AFI architecture
Mizan is an Application Framework Interface (AFI) with five
independent packages:

  packages/
    mizan-ast/       Language layer (source → KDL schema)
    mizan-schema/    IR layer (KDL schema definition)
    mizan-rpc/       Protocol layer (client gen + server adapters)
      adapters/django/   ← was django/
      generator/         ← was react/src/generator/
    mizan-csr/       State layer (client state engine)
      adapters/react/    ← was react/
    mizan-ssr/       Rendering layer (server-side rendering)

Each package is independent. The adapter directories contain the
framework-specific implementations. Stub packages (ast, schema, ssr)
establish the structure for future work.

264 Django tests + 33 React tests pass from new locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
01d33173a4 Add ReactContext class for type-safe context and affects declarations
ReactContext('user') creates a reusable context marker that provides
proper linting, find-references, and autocomplete:

  UserContext = ReactContext('user')

  @client(context=UserContext)
  def user_profile(request, user_id: int) -> ProfileShape: ...

  @client(affects=UserContext)
  def edit_profile(request, name: str) -> dict: ...

  @client(affects=[UserContext, OrderContext])
  def change_plan(request) -> dict: ...

- ReactContext class with name validation
- GlobalContext built-in instance for context='global'
- affects= accepts ReactContext, lists, strings, or function refs
- Backwards compat: raw strings still work for context= and affects=
- Exported from mizan and mizan.client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
af7e22ffc1 Rewrite codegen for named contexts, mutation hooks, and Mizan naming
Generator (react/src/generator/lib/mizan.mjs):
- Rename djarea.mjs → mizan.mjs
- MizanContext replaces DjangoContext (legacy alias kept)
- Global contexts fetched via bundled GET /ctx/global/ through
  inner GlobalContextLoader component
- Named context providers generated per context group with param
  elevation (required/optional props from x-mizan-contexts)
- Mutation hooks auto-invalidate affected contexts on success
- SSR hydration uses single GET /ctx/global/ instead of N POSTs
- Output files: generated.provider.tsx, generated.server.ts

Runtime (react/src/context.tsx):
- Add setContextData() for bundle splitting without refetch
- Add request() for auth-transparent HTTP from generated code

Index generator updated for new export names and named contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
3523f2e3fe Add named contexts, bundled fetch endpoint, and affects invalidation
Phase 1 (Named Contexts):
- @client(context=) accepts any string, not just 'global'/'local'
- context='local' emits deprecation warning
- Registry groups functions by context name (get_context_groups)
- GET /api/mizan/ctx/<name>/ bundles all context functions in one response
- Schema export includes x-mizan-contexts with param elevation metadata

Phase 2 (Affects):
- @client(affects=) declares mutation invalidation targets
- Accepts context name strings, function refs, or lists
- Mutually exclusive with context=
- Exported in x-mizan-functions schema for codegen

React runtime:
- MizanContextValue gains invalidateContext, invalidateFunctions,
  registerContextProvider, and baseUrl
- Named context providers register for invalidation on mount

259 Django tests pass, 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
f3c225ef49 Move Playwright, Docker, and package.json into examples/django-react-site
Root directory now contains only the two core packages (django/, react/),
examples/, and top-level docs. All e2e/integration test infrastructure
lives in examples/django-react-site/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
eee352d908 Move desktop and e2e into examples/ directory
- desktop/ → examples/django-react-desktop-app/
- e2e/ → examples/django-react-site/
- example/ → examples/django-react-site/backend/
- Update Dockerfile.test, Makefile, playwright config, and
  django.config.mjs path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
c866142770 Rename djarea to mizan and fix React casing conventions
Rename the package from djarea to mizan across the entire codebase —
Python package, React library, generators, tests, and examples. Fix
JSX/hook casing (MizanProvider, useMizan, etc.) that broke when the
original PascalCase names were lowercased during the rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00
bf837e598b Add Mizan architecture plan
Named contexts, param elevation, affects-based invalidation,
ReactContext classes for read/write coupling. This is the evolution
roadmap from Djarea to Mizan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:13 -04:00
208 changed files with 13459 additions and 2736 deletions

6
.gitignore vendored
View File

@@ -21,9 +21,9 @@ package-lock.json
.vscode/
# Build artifacts
desktop/frontend/dist/
e2e/harness/src/api/generated.*
e2e/harness/test-results/
examples/django-react-desktop-app/frontend/dist/
examples/django-react-site/harness/src/api/generated.*
examples/django-react-site/harness/test-results/
# Env
.env

464
MIZAN.md Normal file
View File

@@ -0,0 +1,464 @@
# MIZAN — Named Contexts & Mutation Architecture
## For Claude Code
This plan was written by Ryth's Claude.ai session after an extended design conversation
reviewing the full codebase, the original @compose discussion from January 2025, and
several rounds of architectural refinement. Treat this as the spec.
The framework formerly called mizan is now called **MIZAN**. Package names, imports,
and references should be updated accordingly. The internal codegen engine is called
**Maison** — it lives inside Mizan and does not need its own public surface.
---
## 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.
---
## 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.

View File

@@ -1,37 +1,40 @@
.PHONY: install test test-django test-react test-integration docker-up docker-down clean
DJANGO = packages/mizan-django
REACT = packages/mizan-react
# ─── Setup ───────────────────────────────────────────────────────────────────
install:
cd django && pip install -e ".[dev,channels]"
cd react && npm install
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
cd $(REACT) && npm install
# ─── Unit Tests ──────────────────────────────────────────────────────────────
test: test-django test-react
test-django:
cd django && pytest
cd $(DJANGO) && uv run pytest
test-react:
cd react && npm test
cd $(REACT) && npm test
# ─── Integration Tests ──────────────────────────────────────────────────────
test-integration: docker-up
@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'
cd react && npm run test:integration
@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
@$(MAKE) docker-down
# ─── Docker ──────────────────────────────────────────────────────────────────
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"
docker-down:
docker compose -f docker-compose.test.yml down
docker compose -f examples/django-react-site/docker-compose.test.yml down
# ─── All ─────────────────────────────────────────────────────────────────────
@@ -40,7 +43,7 @@ test-all: test test-integration
# ─── Cleanup ─────────────────────────────────────────────────────────────────
clean:
docker compose -f 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 react/dist react/node_modules
rm -f example/db.sqlite3
docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
rm -rf $(REACT)/dist $(REACT)/node_modules
rm -f examples/django-react-site/backend/db.sqlite3

340
README.md
View File

@@ -1,94 +1,84 @@
# DJAREA
# mizan
A modern Django + React Framework for perfectionists with deadlines.
Django + React server functions framework. RPC, not REST.
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
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.
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
```python
@client
def current_user(request) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
# Django
@client(context='global')
def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
```
```tsx
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
// React (generated)
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
```
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
## Packages
Starts with session auth and upgrades to JWT on login. **It just works**.
| Package | Path | Install |
|---------|------|---------|
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
## What Djarea does
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.
```python
class ArticleShape(Shape[Article]):
id: int | None = None
title: str
author: FlatAuthorShape
tags: list[TagShape] = []
```
One Djarea **Shape** does three things simultaneously:
- Defines the Pydantic model for validation and serialization
- Generates a django-readers spec for a lean, field-scoped SQL query
- Produces the TypeScript interface on the React side
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
## Quick start
## Quick Start
### 1. Django setup
```python
# settings.py
INSTALLED_APPS = [
"djarea",
"mizan",
"myapp",
]
# urls.py
from django.urls import include, path
urlpatterns = [
path("api/djarea/", include("djarea.urls")),
path("api/mizan/", include("mizan.urls")),
]
# asgi.py (for WebSocket support)
from djarea import wrap_asgi
from mizan import wrap_asgi
from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application())
```
### 2. Define your client functions
### 2. Define server functions
```python
# myapp/clients.py
from djarea.client import client
from djarea.shapes import Shape
# myapp/mizan_clients.py
from django.http import HttpRequest
from mizan.client import client
from mizan.setup.registry import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request, text: str) -> EchoOutput:
def echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
### 3. Register in apps.py
### 3. Generate TypeScript
```python
class MyAppConfig(AppConfig):
name = "myapp"
To get your generated React client, set this up in your frontend root:
def ready(self):
import myapp.mizan_clients # noqa: F401
```
```javascript
// django.config.mjs
### 4. Generate TypeScript
```bash
# django.config.mjs
export default {
source: {
django: {
@@ -100,23 +90,27 @@ export default {
}
```
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
```bash
npx djarea-generate
npx mizan-generate
```
### 4. Use in React
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
### 5. Use in React
```tsx
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
// layout.tsx
import { DjangoContext } from '@/api'
// layout.tsx — one provider, handles everything
export default function Layout({ children }) {
return <DjangoContext>{children}</DjangoContext>
}
```
```tsx
// page.tsx
import { useEcho, useCurrentUser, DjangoError } from '@/api'
function MyComponent() {
const user = useCurrentUser()
const echo = useEcho()
@@ -128,79 +122,90 @@ function MyComponent() {
} catch (e) {
if (e instanceof DjangoError) {
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
e.getFieldErrors('email') // field-level errors
}
}
}
}
```
## Shapes
## Features
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.
| Backend | Frontend (generated) | Transport |
|---------|---------------------|-----------|
| `@client` | `useXxx()` | HTTP |
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
| `@client(context='local')` | `useXxx()` with params | HTTP |
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket |
| `@compose(...)` | Combined providers | varies |
```python
# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
## Architecture
# Dropdown menu — two columns, no joins
class FlatAuthorShape(Shape[Author]):
id: int | None = None
name: str
```
React app
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
├─ useEcho() ← generated function hook
├─ useContactForm() ← generated form hook (Zod + server validation)
└─ useChatChannel() ← generated channel hook (WebSocket)
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
Django executor
├─ Pydantic input validation
├─ Auth check (session, JWT, or custom)
├─ Function execution
└─ Pydantic output serialization
```
```python
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
# Dropdown: SELECT id, name. That's it.
authors = FlatAuthorShape.query()
## Code Generation
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
| File | Contents |
|------|----------|
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
| `generated.channels.ts` | Channel message types |
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
| `index.ts` | Consolidated re-exports |
## Error Handling
All errors from server functions are thrown as `DjangoError`:
```tsx
try {
await echo({ text: 'hello' })
} catch (e) {
if (e instanceof DjangoError) {
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
e.message // Human-readable message
e.details // Field-level validation errors, etc.
e.isAuthError()
e.isValidationError()
e.getFieldErrors('email')
}
}
```
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 |
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
## Forms
Django forms become typed React hooks with client-side Zod validation:
Django forms get typed React hooks with client-side Zod validation:
```python
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
# Django
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
submit_label="Send",
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
```
```tsx
// React (generated)
const form = useContactForm()
form.schema // field metadata, title, submit label
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } }
```
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
# Django
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
```
```tsx
// React (generated)
const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected'
@@ -259,111 +265,33 @@ 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
# Django unit tests
cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
# React
cd react && npm test
# React unit tests
cd packages/mizan-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
# E2E integration tests (real browser, real backend)
docker compose -f examples/django-react-site/docker-compose.test.yml up -d
cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 &
npx playwright test
# Everything
# All at once
make test-all
```
## Project structure
## Project Structure
```
djarea/
django/ Python package
react/ TypeScript package
example/ Integration test backend
e2e/ Playwright E2E tests
Makefile Test orchestration
mizan/
packages/
mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
mizan-react/ React adapter (thin wrapper around runtime)
examples/
django-react-site/ E2E tests + Django backend
django-react-desktop-app/ PyWebView desktop app
```
## 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

192
ROADMAP.md Normal file
View File

@@ -0,0 +1,192 @@
# Mizan Roadmap
## v1 — Django + React
### Done
- **@client decorator** — `context=`, `affects=`, `auth=`, `websocket=`
- **ReactContext class** — type-safe context/affects references with linting
- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]`
- **Param elevation** — shared params become required provider props, non-shared become optional
- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
- **JWT + session auth** — auto-detected, CSRF handled
- **Shapes** — Pydantic + django-readers for typed query projections
- **WebSocket channels** — real-time bidirectional communication
- **Codegen** — generates typed React providers, hooks, mutations from schema
- **CDN-ready headers** — `Cache-Control`, `Vary`, deterministic JSON on context GETs, `no-store` on mutations
### Next: X-Mizan-Invalidate Header
Second invalidation transport. For view responses (redirects, HTML), invalidation goes in an HTTP header instead of the JSON body. Both transports are first-class AFI spec.
- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications`
- Comma-separated contexts, semicolon-separated params per context
- Decorator auto-adds header to any HttpResponse with `affects=`
- Edge reads this header to purge cached pages
- Runtime also reads it on XHR/fetch responses (htmx path)
### Next: Return-Type Branching
`@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior:
- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body.
- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header.
Same decorator. Same `affects=`. Same invalidation graph. Two paths.
### Next: affects_params
Scoped invalidation with a lambda that extracts which params were affected:
```python
@client(affects='user', affects_params=lambda req: {'user_id': req.user.pk})
def update_name(request, name: str) -> dict:
...
```
Produces `invalidate: [{context: "user", params: {user_id: 5}}]` in JSON body or `X-Mizan-Invalidate: user;user_id=5` in header.
### Next: Edge Manifest
`mizan-generate --manifest` compiles the decorator registry + Django URL conf into static JSON for Edge:
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```
Edge reads the manifest at deploy time. When it receives `X-Mizan-Invalidate: user;user_id=5`, it resolves URL patterns with params and purges `/profile/5/` and `/api/mizan/ctx/user/?user_id=5`.
Generated alongside React code. Covers both RPC and view-path functions.
### Next: Codegen Rewrite
Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response.
### Next: SSR Bridge
Django renders React components server-side via a persistent Bun subprocess.
- Bun worker: stdin/stdout JSON-RPC, `renderToString`, component registry
- Django bridge: subprocess management, IPC, request synthesis
- Template tag: `{% mizan_render "ProfilePage" user_profile=profile %}`
- Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers
- Generated contexts check SSR data before first fetch
---
## 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
---
## Protocol Spec (AFI)
The protocol is the product. Two invalidation transports. Every endpoint CDN-ready.
### Context fetch
```
GET /api/mizan/ctx/<name>/?param=value
200 OK
Cache-Control: public, max-age=0, stale-while-revalidate=300
Vary: Authorization, Cookie
{
"function_a": { ... },
"function_b": [ ... ]
}
```
### Mutation call (RPC path — JSON body transport)
```
POST /api/mizan/call/
Cache-Control: no-store
{
"result": { ... },
"invalidate": ["context_name"]
}
```
### Mutation call (View path — header transport)
```
POST /profile/update/
302 Found
Location: /profile/5/
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Scoped invalidation (JSON)
```json
{
"result": { ... },
"invalidate": [
"notifications",
{ "context": "user", "params": { "user_id": 5 } }
]
}
```
### Scoped invalidation (Header)
```
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Edge manifest
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```

View File

@@ -1,3 +0,0 @@
from djarea.shapes.core import Diff, NestedDiff, Shape
__all__ = ["Diff", "NestedDiff", "Shape"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const reactPkg = path.resolve(__dirname, '../../react/src')
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'djarea/channels': path.join(reactPkg, 'channels/index.ts'),
'djarea/client/react': path.join(reactPkg, 'client/react.ts'),
'djarea/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
'djarea/client': path.join(reactPkg, 'client/index.ts'),
'djarea/jwt': path.join(reactPkg, 'jwt/index.ts'),
'djarea/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
'djarea/allauth': path.join(reactPkg, 'allauth/index.ts'),
'djarea': path.join(reactPkg, 'index.ts'),
'@rythazhur/djarea/channels': path.join(reactPkg, 'channels/index.ts'),
'@rythazhur/djarea/jwt': path.join(reactPkg, 'jwt/index.ts'),
'@rythazhur/djarea': path.join(reactPkg, 'index.ts'),
},
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
})

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python
"""
Djarea Desktop PyWebView + Django local RPC.
mizan Desktop PyWebView + Django local RPC.
Starts a local Django ASGI server and opens a native desktop window.
All communication between the UI and backend uses Djarea server functions.
All communication between the UI and backend uses mizan server functions.
"""
import os
@@ -63,7 +63,7 @@ def main():
base_url = f"http://{host}:{port}"
if not wait_for_server(f"{base_url}/api/djarea/session/"):
if not wait_for_server(f"{base_url}/api/mizan/session/"):
print("ERROR: Django server failed to start", file=sys.stderr)
sys.exit(1)
@@ -83,7 +83,7 @@ def main():
import webview
window = webview.create_window(
title="Djarea Desktop",
title="mizan Desktop",
url=base_url,
width=1024,
height=768,

View File

@@ -6,8 +6,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
django.setup()
from django.core.asgi import get_asgi_application
from djarea import wrap_asgi
from mizan import wrap_asgi
import backend.djarea_clients # noqa: F401
import backend.mizan_clients # noqa: F401
application = wrap_asgi(get_asgi_application())

View File

@@ -1,7 +1,7 @@
"""
Desktop RPC server functions.
Tests Djarea's appropriateness for desktop apps:
Tests mizan's appropriateness for desktop apps:
- Local file system access
- SQLite CRUD
- System introspection
@@ -20,10 +20,10 @@ from pathlib import Path
from django.http import HttpRequest
from pydantic import BaseModel
from djarea.client import client
from djarea.channels import ReactChannel
from djarea.setup.registry import register
from djarea.channels import register as register_channel
from mizan.client import client
from mizan.channels import ReactChannel
from mizan.setup.registry import register
from mizan.channels import register as register_channel
# =============================================================================
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
home_dir: str
cwd: str
cpu_count: int
djarea_version: str
mizan_version: str
@client(websocket=True)
def system_info(request: HttpRequest) -> SystemInfoOutput:
import djarea
import mizan
return SystemInfoOutput(
os_name=platform.system(),
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
home_dir=str(Path.home()),
cwd=os.getcwd(),
cpu_count=os.cpu_count() or 1,
djarea_version=getattr(djarea, "__version__", "dev"),
mizan_version=getattr(mizan, "__version__", "dev"),
)
@@ -114,16 +114,20 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
entries = []
try:
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
for entry in sorted(
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
):
try:
stat = entry.stat()
entries.append(FileEntry(
entries.append(
FileEntry(
name=entry.name,
path=str(entry),
is_dir=entry.is_dir(),
size=stat.st_size if not entry.is_dir() else 0,
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
))
)
)
except (PermissionError, OSError):
continue
except PermissionError:
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
@client(websocket=True)
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
def create_note(
request: HttpRequest, title: str, content: str = "", pinned: bool = False
) -> NoteOutput:
from backend.models import Note
note = Note.objects.create(title=title, content=content, pinned=pinned)
@@ -403,7 +409,7 @@ def app_info(request: HttpRequest) -> AppInfoOutput:
from django.conf import settings
return AppInfoOutput(
app_name="Djarea Desktop",
app_name="mizan Desktop",
uptime_seconds=round(time.time() - _start_time, 2),
db_path=str(settings.DATABASES["default"]["NAME"]),
pid=os.getpid(),

View File

@@ -1,5 +1,5 @@
"""
Django settings for the Djarea desktop integration test app.
Django settings for the mizan desktop integration test app.
Runs entirely local: SQLite database, in-memory channel layer,
no external services required.

View File

@@ -27,7 +27,7 @@ def serve_dist(request, path="index.html"):
urlpatterns = [
path("api/djarea/", include("djarea.urls")),
path("api/mizan/", include("mizan.urls")),
re_path(r"^(?P<path>assets/.+)$", serve_dist),
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
path("", serve_dist),

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Djarea Desktop</title>
<title>mizan Desktop</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }

View File

@@ -1,5 +1,5 @@
{
"name": "djarea-desktop-frontend",
"name": "mizan-desktop-frontend",
"private": true,
"type": "module",
"scripts": {
@@ -7,7 +7,7 @@
"build": "vite build"
},
"dependencies": {
"@rythazhur/djarea": "file:../../react",
"@rythazhur/mizan": "file:../../react",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},

View File

@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea'
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
// ─── System Info ────────────────────────────────────────────────────────────
function SystemInfo() {
const { call } = useDjarea()
const { call } = useMizan()
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => {
@@ -33,7 +33,7 @@ function SystemInfo() {
// ─── Connection Status ──────────────────────────────────────────────────────
function StatusBar() {
const status = useDjareaStatus()
const status = useMizanStatus()
return (
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
{status}
@@ -46,7 +46,7 @@ function StatusBar() {
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
function Notes() {
const { call } = useDjarea()
const { call } = useMizan()
const [notes, setNotes] = useState<Note[]>([])
const [selected, setSelected] = useState<Note | null>(null)
const [title, setTitle] = useState('')
@@ -140,7 +140,7 @@ function Notes() {
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
function FileBrowser() {
const { call } = useDjarea()
const { call } = useMizan()
const [dir, setDir] = useState('~')
const [entries, setEntries] = useState<FileEntry[]>([])
const [parent, setParent] = useState<string | null>(null)
@@ -184,17 +184,17 @@ function FileBrowser() {
export function App() {
return (
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}>
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 24, color: '#fff' }}>Djarea Desktop</h1>
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
<StatusBar />
</div>
<SystemInfo />
<Notes />
<FileBrowser />
</div>
</DjareaProvider>
</MizanProvider>
)
}

View File

@@ -1,16 +1,16 @@
[project]
name = "djarea-desktop"
name = "mizan-desktop"
version = "0.1.0"
description = "Desktop integration test app for Djarea"
description = "Desktop integration test app for mizan"
requires-python = ">=3.10"
dependencies = [
"djarea[channels]",
"mizan[channels]",
"uvicorn[standard]>=0.30",
"pywebview[qt]>=5.0",
]
[tool.uv.sources]
djarea = { path = "../django", editable = true }
mizan = { path = "../django", editable = true }
[project.optional-dependencies]
dev = [

View File

@@ -1,7 +1,8 @@
import django
from django.conf import settings
# Ensure migrations run before tests
def pytest_configure():
# Import djarea_clients to trigger function registration
import backend.djarea_clients # noqa: F401
# Import mizan_clients to trigger function registration
import backend.mizan_clients # noqa: F401

View File

@@ -1,5 +1,5 @@
"""
REAL integration tests for the Djarea RPC framework layer.
REAL integration tests for the mizan RPC framework layer.
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
Every test makes a real HTTP request no mocks, no RequestFactory.
@@ -14,7 +14,7 @@ from django.test import LiveServerTestCase
class RealHTTPMixin:
def _session_init(self):
url = f"{self.live_server_url}/api/djarea/session/"
url = f"{self.live_server_url}/api/mizan/session/"
resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or []
for cookie in cookies:
@@ -26,7 +26,7 @@ class RealHTTPMixin:
self._cookies = ""
def _call(self, fn: str, args: dict | None = None):
url = f"{self.live_server_url}/api/djarea/call/"
url = f"{self.live_server_url}/api/mizan/call/"
body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
@@ -37,7 +37,13 @@ class RealHTTPMixin:
resp = urlopen(req)
return json.loads(resp.read())
def _raw_post(self, path: str, body: bytes | str, content_type: str = "application/json", include_csrf: bool = False):
def _raw_post(
self,
path: str,
body: bytes | str,
content_type: str = "application/json",
include_csrf: bool = False,
):
"""Raw POST without the call() envelope — for testing malformed requests."""
url = f"{self.live_server_url}{path}"
if isinstance(body, str):
@@ -55,7 +61,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
def test_session_endpoint_sets_csrf_cookie(self):
"""GET /session/ must return a Set-Cookie with csrftoken."""
url = f"{self.live_server_url}/api/djarea/session/"
url = f"{self.live_server_url}/api/mizan/session/"
resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or []
@@ -64,7 +70,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
def test_call_without_csrf_is_rejected(self):
"""POST /call/ without CSRF token must fail."""
url = f"{self.live_server_url}/api/djarea/call/"
url = f"{self.live_server_url}/api/mizan/call/"
body = json.dumps({"fn": "system_info", "args": {}}).encode()
req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
@@ -134,7 +140,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
def test_get_method_rejected(self):
"""GET to /call/ should be rejected."""
url = f"{self.live_server_url}/api/djarea/call/"
url = f"{self.live_server_url}/api/mizan/call/"
try:
resp = urlopen(Request(url))
data = json.loads(resp.read())
@@ -147,7 +153,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
self._session_init()
try:
resp = self._raw_post(
"/api/djarea/call/",
"/api/mizan/call/",
body="not valid json{{{",
include_csrf=True,
)
@@ -162,7 +168,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
self._session_init()
try:
resp = self._raw_post(
"/api/djarea/call/",
"/api/mizan/call/",
body=json.dumps({"not_fn": "hello"}),
include_csrf=True,
)

View File

@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
class RealHTTPMixin:
def _session_init(self):
url = f"{self.live_server_url}/api/djarea/session/"
url = f"{self.live_server_url}/api/mizan/session/"
resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or []
for cookie in cookies:
@@ -24,7 +24,7 @@ class RealHTTPMixin:
self._cookies = ""
def _call(self, fn: str, args: dict | None = None):
url = f"{self.live_server_url}/api/djarea/call/"
url = f"{self.live_server_url}/api/mizan/call/"
body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
@@ -105,6 +105,7 @@ class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
# Verify it's gone
from urllib.error import HTTPError
try:
get_data = self._call("get_note", {"id": note_id})
self.assertTrue(get_data["error"])

View File

@@ -18,8 +18,8 @@ class RealHTTPMixin:
"""Makes real HTTP requests to the live server."""
def _session_init(self):
"""Hit /session/ to get CSRF cookie, like DjareaProvider does."""
url = f"{self.live_server_url}/api/djarea/session/"
"""Hit /session/ to get CSRF cookie, like mizanProvider does."""
url = f"{self.live_server_url}/api/mizan/session/"
req = Request(url)
resp = urlopen(req)
# Extract csrftoken from Set-Cookie header
@@ -33,8 +33,8 @@ class RealHTTPMixin:
self._cookies = ""
def _call(self, fn: str, args: dict | None = None):
"""Make a real POST to /api/djarea/call/ with CSRF token."""
url = f"{self.live_server_url}/api/djarea/call/"
"""Make a real POST to /api/mizan/call/ with CSRF token."""
url = f"{self.live_server_url}/api/mizan/call/"
body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
@@ -80,7 +80,7 @@ class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
data = self._call("app_info")
self.assertFalse(data["error"])
self.assertEqual(data["data"]["app_name"], "Djarea Desktop")
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
self.assertGreater(data["data"]["uptime_seconds"], 0)
@@ -89,11 +89,12 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
self.test_dir = Path.home() / ".djarea-test"
self.test_dir = Path.home() / ".mizan-test"
self.test_dir.mkdir(exist_ok=True)
def tearDown(self):
import shutil
if self.test_dir.exists():
shutil.rmtree(self.test_dir)
@@ -116,7 +117,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
test_content = "Hello from a REAL HTTP integration test!"
# Write
write_data = self._call("write_file", {"path": test_path, "content": test_content})
write_data = self._call(
"write_file", {"path": test_path, "content": test_content}
)
self.assertFalse(write_data["error"])
self.assertEqual(write_data["data"]["path"], test_path)
@@ -130,7 +133,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
from urllib.error import HTTPError
try:
data = self._call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
data = self._call(
"write_file", {"path": "/tmp/escape.txt", "content": "nope"}
)
# If we get here, check the response has an error
self.assertTrue(data["error"])
self.assertEqual(data["code"], "FORBIDDEN")

View File

@@ -7,12 +7,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install djarea from local source with channels support
COPY django/ /app/django/
# Install mizan from local source with channels support
COPY packages/mizan-django/ /app/django/
RUN pip install --no-cache-dir /app/django[channels] daphne
# Copy example app
COPY example/ /app/example/
COPY examples/django-react-site/backend/ /app/example/
WORKDIR /app/example

View File

@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
import testapp.djarea_clients # noqa: F401
import testapp.mizan_clients # noqa: F401

View File

@@ -6,9 +6,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
django.setup()
from django.core.asgi import get_asgi_application
from djarea import wrap_asgi
from mizan import wrap_asgi
# Register server functions and channels before building the ASGI app
import testapp.djarea_clients # noqa: F401
import testapp.mizan_clients # noqa: F401
application = wrap_asgi(get_asgi_application())

View File

@@ -11,12 +11,12 @@ from django import forms
from django.http import HttpRequest
from pydantic import BaseModel
from djarea.client import ServerFunction, client
from djarea.channels import ReactChannel
from djarea.setup.registry import register, register_form, register_as
from djarea.channels import register as register_channel
from djarea.forms import DjareaFormMixin, DjareaFormMeta
from djarea.jwt import jwt_obtain, jwt_refresh
from mizan.client import ServerFunction, client
from mizan.channels import ReactChannel
from mizan.setup.registry import register, register_form, register_as
from mizan.channels import register as register_channel
from mizan.forms import mizanFormMixin, mizanFormMeta
from mizan.jwt import jwt_obtain, jwt_refresh
# =============================================================================
@@ -57,9 +57,9 @@ class WhoamiOutput(BaseModel):
@client(auth=True)
def whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput(
user_id=getattr(request.user, 'id', None),
email=getattr(request.user, 'email', ''),
is_staff=getattr(request.user, 'is_staff', False),
user_id=getattr(request.user, "id", None),
email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, "is_staff", False),
)
@@ -197,18 +197,20 @@ register_channel(PresenceChannel, "presence")
# --- Staff-only ---
@client(auth='staff')
@client(auth="staff")
def staff_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"staff:{request.user.email}")
register(staff_only, "staff_only")
# --- Superuser-only ---
@client(auth='superuser')
@client(auth="superuser")
def superuser_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"superuser:{request.user.email}")
register(superuser_only, "superuser_only")
@@ -216,12 +218,14 @@ register(superuser_only, "superuser_only")
def check_verified_email(request):
if not request.user.is_authenticated:
return False
return getattr(request.user, 'email', '').endswith('@verified.com')
return getattr(request.user, "email", "").endswith("@verified.com")
@client(auth=check_verified_email)
def verified_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message="verified")
register(verified_only, "verified_only")
@@ -235,7 +239,8 @@ class CurrentUserOutput(BaseModel):
email: str
is_staff: bool
@client(context='global')
@client(context="global")
def current_user(request: HttpRequest) -> CurrentUserOutput:
if request.user.is_authenticated:
return CurrentUserOutput(
@@ -245,16 +250,19 @@ def current_user(request: HttpRequest) -> CurrentUserOutput:
)
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
register(current_user, "current_user")
class GreetOutput(BaseModel):
greeting: str
@client(context='local')
@client(context="local")
def greet(request: HttpRequest, name: str) -> GreetOutput:
return GreetOutput(greeting=f"Hello, {name}!")
register(greet, "greet")
@@ -267,9 +275,11 @@ class MultiplyInput(BaseModel):
x: int
y: int
class MultiplyOutput(BaseModel):
product: int
@register_as("multiply")
class Multiply(ServerFunction):
Input = MultiplyInput
@@ -288,6 +298,7 @@ class Multiply(ServerFunction):
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
raise NotImplementedError("This feature is not yet implemented")
register(not_implemented_fn, "not_implemented_fn")
@@ -295,6 +306,7 @@ register(not_implemented_fn, "not_implemented_fn")
def buggy_fn(request: HttpRequest) -> EchoOutput:
raise RuntimeError("Unexpected internal failure")
register(buggy_fn, "buggy_fn")
@@ -304,6 +316,7 @@ def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
raise PermissionError("Wrong secret")
return EchoOutput(message="access granted")
register(permission_check_fn, "permission_check_fn")
@@ -315,21 +328,22 @@ register(permission_check_fn, "permission_check_fn")
@client(websocket=True, auth=True)
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput(
user_id=getattr(request.user, 'id', None),
email=getattr(request.user, 'email', ''),
is_staff=getattr(request.user, 'is_staff', False),
user_id=getattr(request.user, "id", None),
email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, "is_staff", False),
)
register(ws_whoami, "ws_whoami")
# =============================================================================
# DjareaFormMixin Forms
# mizanFormMixin Forms
# =============================================================================
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
subtitle="We'd love to hear from you",
@@ -351,8 +365,8 @@ class ContactForm(DjareaFormMixin, forms.Form):
# =============================================================================
class ItemForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
class ItemForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="item",
title="Items",
submit_label="Save Items",
@@ -363,7 +377,10 @@ class ItemForm(DjareaFormMixin, forms.Form):
quantity = forms.IntegerField(min_value=1, label="Quantity")
def on_submit_success(self, request):
return {"label": self.cleaned_data["label"], "qty": self.cleaned_data["quantity"]}
return {
"label": self.cleaned_data["label"],
"qty": self.cleaned_data["quantity"],
}
# =============================================================================
@@ -376,11 +393,12 @@ class PrivateChannel(ReactChannel):
text: str
def authorize(self, params=None):
return getattr(self.user, 'is_authenticated', False)
return getattr(self.user, "is_authenticated", False)
def group(self, params=None):
return "private_global"
register_channel(PrivateChannel, "private")

View File

@@ -20,7 +20,7 @@ INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"djarea",
"mizan",
"testapp",
]

View File

@@ -1,5 +1,5 @@
from django.urls import include, path
urlpatterns = [
path("api/djarea/", include("djarea.urls")),
path("api/mizan/", include("mizan.urls")),
]

View File

@@ -1,9 +1,9 @@
/**
* Djarea E2E Integration Tests
* mizan E2E Integration Tests
*
* Real Chromium Real React app (generated hooks) Real Django backend
*
* Every test uses the generated Djarea API, not raw call() or fetch().
* Every test uses the generated mizan API, not raw call() or fetch().
*/
import { test, expect } from '@playwright/test'
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
expect(result.fields.password).toBeDefined()
})
test('useContactForm loads schema with DjareaFormMeta', async ({ page }) => {
test('useContactForm loads schema with mizanFormMeta', async ({ page }) => {
await fixture(page, 'form-contact-schema')
const result = await getResult(page)
expect(result.title).toBe('Contact Us')

View File

@@ -6,8 +6,8 @@ services:
django:
build:
context: .
dockerfile: Dockerfile.test
context: ../..
dockerfile: examples/django-react-site/Dockerfile.test
ports:
- "8000:8000"
depends_on:

View File

@@ -2,17 +2,17 @@ import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '../..')
const root = path.resolve(__dirname, '../../..')
export default {
projectId: 'e2e-harness',
source: {
django: {
managePath: path.join(root, 'example/manage.py'),
command: [path.join(root, 'django/.venv/bin/python')],
managePath: path.join(root, 'examples/django-react-site/backend/manage.py'),
command: [path.join(root, 'packages/mizan-django/.venv/bin/python')],
env: {
PYTHONPATH: `${path.join(root, 'django/src')}:${path.join(root, 'example')}`,
PYTHONPATH: `${path.join(root, 'packages/mizan-django/src')}:${path.join(root, 'examples/django-react-site/backend')}`,
DJANGO_SETTINGS_MODULE: 'testapp.settings',
},
},

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /><title>Djarea E2E Harness</title></head>
<head><meta charset="UTF-8" /><title>mizan E2E Harness</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

View File

@@ -1,5 +1,5 @@
{
"name": "djarea-e2e-harness",
"name": "mizan-e2e-harness",
"private": true,
"type": "module",
"scripts": {
@@ -7,7 +7,7 @@
"dev": "vite --port 5174"
},
"dependencies": {
"@rythazhur/djarea": "file:../../react",
"@rythazhur/mizan": "file:../../react",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^4.3.6"

View File

@@ -0,0 +1,40 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
import { useChannel, type ChannelSubscription } from 'mizan/channels'
import type { ChatParams, ChatReactMessage, ChatDjangoMessage, NotificationsDjangoMessage, PresenceDjangoMessage, PrivateDjangoMessage } from './generated.channels'
// ============================================================================
// Channel Hooks
// ============================================================================
/**
* Hook for the chat channel.
*/
export function useChatChannel(params: ChatParams): ChannelSubscription<ChatParams, ChatDjangoMessage, ChatReactMessage> {
return useChannel('chat', params)
}
/**
* Hook for the notifications channel.
*/
export function useNotificationsChannel(): ChannelSubscription<Record<string, never>, NotificationsDjangoMessage, never> {
return useChannel('notifications', {})
}
/**
* Hook for the presence channel.
*/
export function usePresenceChannel(): ChannelSubscription<Record<string, never>, PresenceDjangoMessage, never> {
return useChannel('presence', {})
}
/**
* Hook for the private channel.
*/
export function usePrivateChannel(): ChannelSubscription<Record<string, never>, PrivateDjangoMessage, never> {
return useChannel('private', {})
}

View File

@@ -0,0 +1,268 @@
{
"openapi": "3.1.0",
"info": {
"title": "mizan Channels",
"version": "1.0.0",
"description": "Auto-generated schema for mizan channels"
},
"paths": {
"/channels/chat/params": {
"post": {
"operationId": "chatParams",
"summary": "Chat channel params",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseModel"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatParams"
}
}
},
"required": true
}
}
},
"/channels/chat/react": {
"post": {
"operationId": "chatReactMessage",
"summary": "Chat React→Django message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseModel"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatReactMessage"
}
}
},
"required": true
}
}
},
"/channels/chat/django": {
"post": {
"operationId": "chatDjangoMessage",
"summary": "Chat Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatDjangoMessage"
}
}
}
}
}
}
},
"/channels/notifications/django": {
"post": {
"operationId": "notificationsDjangoMessage",
"summary": "Notifications Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationsDjangoMessage"
}
}
}
}
}
}
},
"/channels/presence/django": {
"post": {
"operationId": "presenceDjangoMessage",
"summary": "Presence Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresenceDjangoMessage"
}
}
}
}
}
}
},
"/channels/private/django": {
"post": {
"operationId": "privateDjangoMessage",
"summary": "Private Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PrivateDjangoMessage"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"BaseModel": {
"properties": {},
"title": "BaseModel",
"type": "object"
},
"ChatParams": {
"properties": {
"room": {
"title": "Room",
"type": "string"
}
},
"required": [
"room"
],
"title": "ChatParams",
"type": "object"
},
"ChatReactMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "ChatReactMessage",
"type": "object"
},
"ChatDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "ChatDjangoMessage",
"type": "object"
},
"NotificationsDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "NotificationsDjangoMessage",
"type": "object"
},
"PresenceDjangoMessage": {
"properties": {
"value": {
"title": "Value",
"type": "integer"
}
},
"required": [
"value"
],
"title": "PresenceDjangoMessage",
"type": "object"
},
"PrivateDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "PrivateDjangoMessage",
"type": "object"
}
}
},
"servers": [],
"x-mizan-channels": [
{
"name": "chat",
"pascalName": "Chat",
"hasParams": true,
"hasReactMessage": true,
"hasDjangoMessage": true,
"paramsType": "ChatParams",
"reactMessageType": "ChatReactMessage",
"djangoMessageType": "ChatDjangoMessage"
},
{
"name": "notifications",
"pascalName": "Notifications",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "NotificationsDjangoMessage"
},
{
"name": "presence",
"pascalName": "Presence",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "PresenceDjangoMessage"
},
{
"name": "private",
"pascalName": "Private",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "PrivateDjangoMessage"
}
]
}

View File

@@ -0,0 +1,337 @@
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// ============================================================================
// OpenAPI Types (generated by openapi-typescript)
// ============================================================================
export interface paths {
"/channels/chat/params": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat channel params */
post: operations["chatParams"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/chat/react": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat React→Django message */
post: operations["chatReactMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/chat/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat Django→React message */
post: operations["chatDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/notifications/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Notifications Django→React message */
post: operations["notificationsDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/presence/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Presence Django→React message */
post: operations["presenceDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/private/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Private Django→React message */
post: operations["privateDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** BaseModel */
BaseModel: Record<string, never>;
/** ChatParams */
ChatParams: {
/** Room */
room: string;
};
/** ChatReactMessage */
ChatReactMessage: {
/** Text */
text: string;
};
/** ChatDjangoMessage */
ChatDjangoMessage: {
/** Text */
text: string;
};
/** NotificationsDjangoMessage */
NotificationsDjangoMessage: {
/** Text */
text: string;
};
/** PresenceDjangoMessage */
PresenceDjangoMessage: {
/** Value */
value: number;
};
/** PrivateDjangoMessage */
PrivateDjangoMessage: {
/** Text */
text: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
chatParams: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChatParams"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BaseModel"];
};
};
};
};
chatReactMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChatReactMessage"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BaseModel"];
};
};
};
};
chatDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ChatDjangoMessage"];
};
};
};
};
notificationsDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NotificationsDjangoMessage"];
};
};
};
};
presenceDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PresenceDjangoMessage"];
};
};
};
};
privateDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PrivateDjangoMessage"];
};
};
};
};
}
// ============================================================================
// Convenience Type Exports
// ============================================================================
export type ChatParams = components["schemas"]["ChatParams"]
export type ChatReactMessage = components["schemas"]["ChatReactMessage"]
export type ChatDjangoMessage = components["schemas"]["ChatDjangoMessage"]
export type NotificationsDjangoMessage = components["schemas"]["NotificationsDjangoMessage"]
export type PresenceDjangoMessage = components["schemas"]["PresenceDjangoMessage"]
export type PrivateDjangoMessage = components["schemas"]["PrivateDjangoMessage"]
// ============================================================================
// Channel Registry
// ============================================================================
export const CHANNELS = {
chat: {
name: 'chat',
pascalName: 'Chat',
hasParams: true,
hasReactMessage: true,
hasDjangoMessage: true,
paramsType: 'ChatParams',
reactMessageType: 'ChatReactMessage',
djangoMessageType: 'ChatDjangoMessage',
},
notifications: {
name: 'notifications',
pascalName: 'Notifications',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'NotificationsDjangoMessage',
},
presence: {
name: 'presence',
pascalName: 'Presence',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'PresenceDjangoMessage',
},
private: {
name: 'private',
pascalName: 'Private',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'PrivateDjangoMessage',
},
} as const

View File

@@ -0,0 +1,62 @@
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
//
// Server-side functions for SSR hydration.
// These run in Next.js server components/layouts.
import type { currentUserOutput, greetOutput } from './generated.mizan'
// ============================================================================
// Hydration Types
// ============================================================================
/** Typed hydration data for SSR */
export interface DjangoHydration {
currentUser?: currentUserOutput
greet?: greetOutput
}
// ============================================================================
// SSR Hydration Helper
// ============================================================================
/**
* Fetch hydration data for SSR.
*
* Call this in your server component:
* const hydration = await getDjangoHydration(client)
* return <DjangoContext hydration={hydration}>...</DjangoContext>
*/
export async function getDjangoHydration(
client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }
): Promise<DjangoHydration> {
const hydration: DjangoHydration = {}
const results = await Promise.allSettled([
client.request('POST', '/api/mizan/call/', { fn: 'current_user', args: {} }),
client.request('POST', '/api/mizan/call/', { fn: 'greet', args: {} }),
])
if (results[0].status === 'fulfilled') {
const data = await (results[0] as PromiseFulfilledResult<Response>).value.json()
if (data.error) {
console.error('[getDjangoHydration] current_user failed:', data.code, data.message)
} else {
hydration.currentUser = data.data
}
} else {
console.error('[getDjangoHydration] current_user request failed:', (results[0] as PromiseRejectedResult).reason)
}
if (results[1].status === 'fulfilled') {
const data = await (results[1] as PromiseFulfilledResult<Response>).value.json()
if (data.error) {
console.error('[getDjangoHydration] greet failed:', data.code, data.message)
} else {
hydration.greet = data.data
}
} else {
console.error('[getDjangoHydration] greet request failed:', (results[1] as PromiseRejectedResult).reason)
}
return hydration
}

View File

@@ -0,0 +1,257 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// This file provides typed wrappers around the mizan library.
// - DjangoContext: Typed provider wrapping mizanProvider
// - Typed hooks: useAuthStatus(), useUser(), etc.
import { type ReactNode, useCallback } from 'react'
import {
mizanProvider,
usemizan,
usemizanContext,
usemizanCall,
type mizanHydration,
type Transport,
} from 'mizan'
import { ChannelProvider, ChannelConnection } from 'mizan/channels'
import { useRef } from 'react'
import type { addEmailSchemaOutput, addEmailValidateInput, addEmailValidateOutput, addInput, addOutput, buggyFnOutput, contactSchemaInput, contactSchemaOutput, contactSubmitOutput, contactValidateInput, contactValidateOutput, currentUserOutput, echoInput, echoOutput, greetInput, greetOutput, httpOnlyEchoInput, httpOnlyEchoOutput, itemFormsetSchemaInput, itemFormsetSchemaOutput, itemFormsetSubmitInput, itemFormsetSubmitOutput, itemFormsetValidateInput, itemFormsetValidateOutput, itemSchemaInput, itemSchemaOutput, itemSubmitOutput, itemValidateInput, itemValidateOutput, jwtObtainOutput, jwtRefreshInput, jwtRefreshOutput, loginSchemaOutput, loginSubmitInput, loginSubmitOutput, loginValidateInput, loginValidateOutput, multiplyInput, multiplyOutput, notImplementedFnOutput, permissionCheckFnInput, permissionCheckFnOutput, signupSchemaOutput, signupSubmitInput, signupSubmitOutput, signupValidateInput, signupValidateOutput, staffOnlyOutput, superuserOnlyOutput, verifiedOnlyOutput, whoamiOutput, wsWhoamiOutput } from './generated.mizan'
// ============================================================================
// Hydration Types
// ============================================================================
/** Typed hydration data for SSR */
export interface DjangoHydration {
currentUser?: currentUserOutput
greet?: greetOutput
}
/** Convert typed hydration to mizan format */
function tomizanHydration(hydration?: DjangoHydration): mizanHydration | undefined {
if (!hydration) return undefined
const result: mizanHydration = {}
if (hydration.currentUser !== undefined) result['current_user'] = hydration.currentUser
if (hydration.greet !== undefined) result['greet'] = hydration.greet
return result
}
// ============================================================================
// Provider
// ============================================================================
export interface DjangoContextProps {
children: ReactNode
/** SSR hydration data */
hydration?: DjangoHydration
/** WebSocket URL for RPC calls (default: /ws/) */
wsUrl?: string
/** Base URL for HTTP fallback (default: /api/mizan) */
baseUrl?: string
}
/**
* Typed Django context provider.
*
* Wraps mizanProvider with:
* - Typed hydration
* - Auto-fetch for registered contexts
*
* Usage:
* <DjangoContext hydration={hydration}>
* <App />
* </DjangoContext>
*/
export function DjangoContext({
children,
hydration,
wsUrl,
baseUrl,
}: DjangoContextProps) {
const connectionRef = useRef<ChannelConnection | null>(null)
if (!connectionRef.current) {
connectionRef.current = new ChannelConnection({ url: wsUrl || '/ws/' })
}
return (
<mizanProvider
hydration={tomizanHydration(hydration)}
contexts={['current_user', 'greet']}
wsUrl={wsUrl}
baseUrl={baseUrl}
connection={connectionRef.current}
>
<ChannelProvider connection={connectionRef.current} autoConnect={true}>
{children}
</ChannelProvider>
</mizanProvider>
)
}
// ============================================================================
// Context Hooks (typed wrappers)
// ============================================================================
/**
* Get current_user context data.
* @throws if context not loaded yet
*/
export function useCurrentUser(): currentUserOutput {
const data = usemizanContext<currentUserOutput>('current_user')
if (data === undefined) {
throw new Error('useCurrentUser: context not loaded yet')
}
return data
}
/**
* Get greet context data.
* @throws if context not loaded yet
*/
export function useGreet(): greetOutput {
const data = usemizanContext<greetOutput>('greet')
if (data === undefined) {
throw new Error('useGreet: context not loaded yet')
}
return data
}
/**
* Get context refresh functions without subscribing to data changes.
* Use this in components that only need to trigger refreshes.
*/
export function useDjangoRefresh() {
const { refreshContext, refreshAllContexts } = usemizan()
return {
refreshCurrentUser: () => refreshContext('current_user'),
refreshGreet: () => refreshContext('greet'),
refreshAll: refreshAllContexts,
}
}
// ============================================================================
// Function Hooks (typed wrappers)
// ============================================================================
/**
* Call echo server function.
* Transport: websocket
*/
export function useEcho() {
return usemizanCall<echoInput, echoOutput>('echo', 'websocket')
}
/**
* Call add server function.
* Transport: websocket
*/
export function useAdd() {
return usemizanCall<addInput, addOutput>('add', 'websocket')
}
/**
* Call whoami server function.
* Transport: http
*/
export function useWhoami() {
return usemizanCall<void, whoamiOutput>('whoami', 'http')
}
/**
* Call http_only_echo server function.
* Transport: http
*/
export function useHttpOnlyEcho() {
return usemizanCall<httpOnlyEchoInput, httpOnlyEchoOutput>('http_only_echo', 'http')
}
/**
* Call staff_only server function.
* Transport: http
*/
export function useStaffOnly() {
return usemizanCall<void, staffOnlyOutput>('staff_only', 'http')
}
/**
* Call superuser_only server function.
* Transport: http
*/
export function useSuperuserOnly() {
return usemizanCall<void, superuserOnlyOutput>('superuser_only', 'http')
}
/**
* Call verified_only server function.
* Transport: http
*/
export function useVerifiedOnly() {
return usemizanCall<void, verifiedOnlyOutput>('verified_only', 'http')
}
/**
* Call multiply server function.
* Transport: http
*/
export function useMultiply() {
return usemizanCall<multiplyInput, multiplyOutput>('multiply', 'http')
}
/**
* Call not_implemented_fn server function.
* Transport: http
*/
export function useNotImplementedFn() {
return usemizanCall<void, notImplementedFnOutput>('not_implemented_fn', 'http')
}
/**
* Call buggy_fn server function.
* Transport: http
*/
export function useBuggyFn() {
return usemizanCall<void, buggyFnOutput>('buggy_fn', 'http')
}
/**
* Call permission_check_fn server function.
* Transport: http
*/
export function usePermissionCheckFn() {
return usemizanCall<permissionCheckFnInput, permissionCheckFnOutput>('permission_check_fn', 'http')
}
/**
* Call ws_whoami server function.
* Transport: websocket
*/
export function useWsWhoami() {
return usemizanCall<void, wsWhoamiOutput>('ws_whoami', 'websocket')
}
/**
* Call jwt_obtain server function.
* Transport: http
*/
export function useJwtObtain() {
return usemizanCall<void, jwtObtainOutput>('jwt_obtain', 'http')
}
/**
* Call jwt_refresh server function.
* Transport: http
*/
export function useJwtRefresh() {
return usemizanCall<jwtRefreshInput, jwtRefreshOutput>('jwt_refresh', 'http')
}
// ============================================================================
// Re-exports from mizan library
// ============================================================================
export { usemizan, usemizanStatus, usePush, DjangoError } from 'mizan'
export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// Typed form hooks with Zod validation.
// Zod schemas are generated from Django form field definitions.
// Client-side validation matches Django constraints (required, max_length, email, etc.)
import { z } from 'zod'
import {
useDjangoFormCore,
useDjangoFormsetCore,
type DjangoFormState,
type DjangoFormsetState,
type FormOptions,
} from 'mizan'
// ============================================================================
// Zod Schemas
// ============================================================================
/**
* Zod schema for login form
* Generated from Django form field definitions
*/
export const LoginSchema = z.object({
})
/**
* Zod schema for signup form
* Generated from Django form field definitions
*/
export const SignupSchema = z.object({
})
/**
* Zod schema for add_email form
* Generated from Django form field definitions
*/
export const AddEmailSchema = z.object({
})
/**
* Zod schema for contact form
* Generated from Django form field definitions
*/
export const ContactSchema = z.object({
name: z.string().max(100),
email: z.string().email('Invalid email address').max(320),
message: z.string(),
})
/**
* Zod schema for item form
* Generated from Django form field definitions
*/
export const ItemSchema = z.object({
label: z.string().max(50),
quantity: z.number().int().min(1),
})
// ============================================================================
// Form Data Types (inferred from Zod schemas)
// ============================================================================
/** Form data type for login, inferred from Zod schema */
export type LoginFormData = z.infer<typeof LoginSchema>
/** Form data type for signup, inferred from Zod schema */
export type SignupFormData = z.infer<typeof SignupSchema>
/** Form data type for add_email, inferred from Zod schema */
export type AddEmailFormData = z.infer<typeof AddEmailSchema>
/** Form data type for contact, inferred from Zod schema */
export type ContactFormData = z.infer<typeof ContactSchema>
/** Form data type for item, inferred from Zod schema */
export type ItemFormData = z.infer<typeof ItemSchema>
// ============================================================================
// Form Hooks
// ============================================================================
/**
* Typed form hook for login
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useLoginForm(
options?: FormOptions
): DjangoFormState<LoginFormData> {
return useDjangoFormCore<LoginFormData>({
name: 'login',
zodSchema: LoginSchema,
options,
})
}
/**
* Typed form hook for signup
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useSignupForm(
options?: FormOptions
): DjangoFormState<SignupFormData> {
return useDjangoFormCore<SignupFormData>({
name: 'signup',
zodSchema: SignupSchema,
options,
})
}
/**
* Typed form hook for add_email
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useAddEmailForm(
options?: FormOptions
): DjangoFormState<AddEmailFormData> {
return useDjangoFormCore<AddEmailFormData>({
name: 'add_email',
zodSchema: AddEmailSchema,
options,
})
}
/**
* Typed form hook for contact
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useContactForm(
options?: FormOptions
): DjangoFormState<ContactFormData> {
return useDjangoFormCore<ContactFormData>({
name: 'contact',
zodSchema: ContactSchema,
options,
})
}
/**
* Typed form hook for item
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useItemForm(
options?: FormOptions
): DjangoFormState<ItemFormData> {
return useDjangoFormCore<ItemFormData>({
name: 'item',
zodSchema: ItemSchema,
options,
})
}
/**
* Typed formset hook for item
*/
export function useItemFormset(
initialCount?: number,
liveValidation?: boolean
): DjangoFormsetState<ItemFormData> {
return useDjangoFormsetCore<ItemFormData>({
name: 'item',
zodSchema: ItemSchema,
initialCount,
liveValidation,
})
}
// ============================================================================
// Form Registry
// ============================================================================
export const DJANGO_FORMS = {
login: {
name: 'login',
schema: LoginSchema,
hook: 'useLoginForm',
hasFormset: false,
},
signup: {
name: 'signup',
schema: SignupSchema,
hook: 'useSignupForm',
hasFormset: false,
},
addEmail: {
name: 'add_email',
schema: AddEmailSchema,
hook: 'useAddEmailForm',
hasFormset: false,
},
contact: {
name: 'contact',
schema: ContactSchema,
hook: 'useContactForm',
hasFormset: false,
},
item: {
name: 'item',
schema: ItemSchema,
hook: 'useItemForm',
hasFormset: true,
},
} as const

View File

@@ -1,5 +1,5 @@
/**
* Djarea API - Consolidated Exports
* mizan API - Consolidated Exports
*
* Import everything from here:
*
@@ -15,11 +15,11 @@
* ```
*/
// AUTO-GENERATED by djarea - do not edit manually
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// =============================================================================
// Djarea Provider & Hooks
// mizan Provider & Hooks
// =============================================================================
export {
@@ -55,9 +55,9 @@ export {
useJwtObtain,
useJwtRefresh,
// Re-exports from djarea library
useDjarea,
useDjareaStatus,
// Re-exports from mizan library
usemizan,
usemizanStatus,
usePush,
DjangoError,
type ConnectionStatus,

View File

@@ -1,7 +1,7 @@
/**
* E2E Test Fixtures
*
* Each fixture uses GENERATED Djarea hooks (not raw call()).
* Each fixture uses GENERATED mizan hooks (not raw call()).
* Playwright reads the DOM to verify behavior.
*
* URL hash selects the fixture: #echo, #add, #multiply, etc.
@@ -9,7 +9,7 @@
import { useState, useEffect, useRef } from 'react'
// Generated typed hooks — the actual Djarea API
// Generated typed hooks — the actual mizan API
import {
DjangoContext,
useEcho,
@@ -24,7 +24,7 @@ import {
usePermissionCheckFn,
useCurrentUser,
DjangoError,
useDjarea,
useMizan,
} from './api/generated.django'
import { useContactForm, useLoginForm } from './api/generated.forms'
import { useChatChannel } from './api/generated.channels.hooks'
@@ -121,7 +121,7 @@ function Multiply() {
function NotFound() {
// Deliberately call a non-existent function via the raw primitive
const { call } = useDjarea()
const { call } = useMizan()
const [error, setError] = useState<unknown>()
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
return <Result error={error} />

View File

@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
function App() {
return (
<DjangoContext baseUrl="/api/djarea">
<DjangoContext baseUrl="/api/mizan">
<Fixtures />
</DjangoContext>
)

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const reactPkg = path.resolve(__dirname, '../../react/src')
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'mizan/client/react': path.join(reactPkg, 'client/react.ts'),
'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
'mizan/client': path.join(reactPkg, 'client/index.ts'),
'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'),
'mizan': path.join(reactPkg, 'index.ts'),
'@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'@rythazhur/mizan': path.join(reactPkg, 'index.ts'),
},
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
})

View File

@@ -1,5 +1,5 @@
{
"name": "djarea",
"name": "mizan",
"version": "1.0.0",
"description": "Django + React server functions framework.",
"main": "index.js",

View File

@@ -1,7 +1,7 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
testDir: '.',
timeout: 15000,
retries: 0,
reporter: 'list',

View File

@@ -1,32 +1,32 @@
# djarea (Python)
# mizan (Python)
Django server functions framework. See the [monorepo root](../README.md) for full documentation.
## Install
```bash
uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
uv add "mizan[channels,allauth] @ git+https://git.impactsoundworks.com/isw/mizan.git#subdirectory=django"
```
## Setup
```python
# settings.py
INSTALLED_APPS = ["djarea", ...]
INSTALLED_APPS = ["mizan", ...]
# urls.py
path("api/djarea/", include("djarea.urls"))
path("api/mizan/", include("mizan.urls"))
# asgi.py (optional, for WebSocket)
from djarea import wrap_asgi
from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application())
```
## Define Functions
```python
from djarea.client import client
from djarea.setup.registry import register
from mizan.client import client
from mizan.setup.registry import register
from pydantic import BaseModel
class Output(BaseModel):
@@ -43,7 +43,7 @@ Register in `apps.py`:
```python
def ready(self):
import myapp.djarea_clients
import myapp.mizan_clients
```
## Auth
@@ -65,10 +65,10 @@ def ready(self):
## Forms
```python
from djarea.forms import DjareaFormMixin, DjareaFormMeta
from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(name="contact", title="Contact Us")
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(name="contact", title="Contact Us")
name = forms.CharField()
email = forms.EmailField()
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
## Channels
```python
from djarea.channels import ReactChannel
from mizan.channels import ReactChannel
class ChatChannel(ReactChannel):
class Params(BaseModel):

View File

@@ -1,22 +1,22 @@
#!/usr/bin/env node
/**
* Djarea Code Generator CLI
* mizan Code Generator CLI
*
* Generate TypeScript types, React provider, and hooks from Django schemas.
*
* Usage:
* npx djarea-generate # Run once
* npx djarea-generate --watch # Watch mode
* npx mizan-generate # Run once
* npx mizan-generate --watch # Watch mode
*/
import { promises as fs } from 'fs'
import path from 'path'
import { fetchChannelsSchema, fetchDjareaSchema } from './lib/fetch.mjs'
import { generateDjareaFiles } from './lib/djarea.mjs'
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
import { generateMizanFiles } from './lib/mizan.mjs'
import { generateChannelsFiles } from './lib/channels.mjs'
import { generateIndex } from './lib/index.mjs'
// Use cwd — the script runs via `npx djarea-generate` from the frontend root
// Use cwd — the script runs via `npx mizan-generate` from the frontend root
const frontendDir = process.cwd()
/**
@@ -70,21 +70,21 @@ async function writeOutput(filePath, content) {
async function generate(config, options = {}) {
const { output } = options
console.log('[djarea] Starting schema generation...')
console.log('[mizan] Starting schema generation...')
const outputPath = output || config.output || 'src/api/generated.ts'
let channelsSchema = null
let djareaSchema = null
let mizanSchema = null
// Fetch and generate channels if available
try {
console.log('[djarea] Fetching channels schema...')
console.log('[mizan] Fetching channels schema...')
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
const channelCount = channelsSchema['x-djarea-channels']?.length || 0
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
if (channelCount > 0) {
console.log(`[djarea] Found ${channelCount} channels`)
console.log(`[mizan] Found ${channelCount} channels`)
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
@@ -95,85 +95,85 @@ async function generate(config, options = {}) {
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
console.log(`[djarea] Generating -> ${channelsTypesPath}`)
console.log(`[mizan] Generating -> ${channelsTypesPath}`)
await writeOutput(fullChannelsTypesPath, channelsTypes)
if (channelsHooks) {
console.log(`[djarea] Generating -> ${channelsHooksPath}`)
console.log(`[mizan] Generating -> ${channelsHooksPath}`)
await writeOutput(fullChannelsHooksPath, channelsHooks)
}
console.log(`[djarea] Generating -> ${channelsSchemaPath}`)
console.log(`[mizan] Generating -> ${channelsSchemaPath}`)
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
} else {
console.log('[djarea] No channels registered, skipping channels generation')
console.log('[mizan] No channels registered, skipping channels generation')
}
} catch (err) {
console.log(`[djarea] Channels schema not available: ${err.message}`)
console.log(`[mizan] Channels schema not available: ${err.message}`)
}
// Fetch and generate djarea files
// Fetch and generate mizan files
try {
console.log('[djarea] Fetching djarea schema...')
djareaSchema = await fetchDjareaSchema(config.source, frontendDir)
console.log('[mizan] Fetching mizan schema...')
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
const functionCount = djareaSchema['x-djarea-functions']?.length || 0
const functionCount = mizanSchema['x-mizan-functions']?.length || 0
if (functionCount > 0) {
console.log(`[djarea] Found ${functionCount} djarea functions`)
console.log(`[mizan] Found ${functionCount} mizan functions`)
const djareaTypesPath = outputPath.replace(/\.ts$/, '.djarea.ts')
const fullDjareaTypesPath = path.resolve(frontendDir, djareaTypesPath)
const djareaProviderPath = outputPath.replace(/\.ts$/, '.django.tsx')
const fullDjareaProviderPath = path.resolve(frontendDir, djareaProviderPath)
const djareaServerPath = outputPath.replace(/\.ts$/, '.django.server.ts')
const fullDjareaServerPath = path.resolve(frontendDir, djareaServerPath)
const djareaFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
const fullDjareaFormsPath = path.resolve(frontendDir, djareaFormsPath)
const djareaSchemaPath = outputPath.replace(/\.ts$/, '.djarea.schema.json')
const fullDjareaSchemaPath = path.resolve(frontendDir, djareaSchemaPath)
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts')
const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath)
const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath)
const mizanSchemaPath = outputPath.replace(/\.ts$/, '.mizan.schema.json')
const fullMizanSchemaPath = path.resolve(frontendDir, mizanSchemaPath)
const hasChannels = (channelsSchema?.['x-djarea-channels']?.length || 0) > 0
const { types: djareaTypes, provider: djareaProvider, server: djareaServer, forms: djareaForms } = await generateDjareaFiles(djareaSchema, { hasChannels })
const hasChannels = (channelsSchema?.['x-mizan-channels']?.length || 0) > 0
const { types: mizanTypes, provider: mizanProvider, server: mizanServer, forms: mizanForms } = await generateMizanFiles(mizanSchema, { hasChannels })
console.log(`[djarea] Generating -> ${djareaTypesPath}`)
await writeOutput(fullDjareaTypesPath, djareaTypes)
console.log(`[mizan] Generating -> ${mizanTypesPath}`)
await writeOutput(fullMizanTypesPath, mizanTypes)
if (djareaProvider) {
console.log(`[djarea] Generating -> ${djareaProviderPath}`)
await writeOutput(fullDjareaProviderPath, djareaProvider)
if (mizanProvider) {
console.log(`[mizan] Generating -> ${mizanProviderPath}`)
await writeOutput(fullMizanProviderPath, mizanProvider)
}
if (djareaServer) {
console.log(`[djarea] Generating -> ${djareaServerPath}`)
await writeOutput(fullDjareaServerPath, djareaServer)
if (mizanServer) {
console.log(`[mizan] Generating -> ${mizanServerPath}`)
await writeOutput(fullMizanServerPath, mizanServer)
}
if (djareaForms) {
console.log(`[djarea] Generating -> ${djareaFormsPath}`)
await writeOutput(fullDjareaFormsPath, djareaForms)
if (mizanForms) {
console.log(`[mizan] Generating -> ${mizanFormsPath}`)
await writeOutput(fullMizanFormsPath, mizanForms)
}
console.log(`[djarea] Generating -> ${djareaSchemaPath}`)
await writeOutput(fullDjareaSchemaPath, JSON.stringify(djareaSchema, null, 2))
console.log(`[mizan] Generating -> ${mizanSchemaPath}`)
await writeOutput(fullMizanSchemaPath, JSON.stringify(mizanSchema, null, 2))
} else {
console.log('[djarea] No djarea functions registered, skipping djarea generation')
console.log('[mizan] No mizan functions registered, skipping mizan generation')
}
} catch (err) {
console.log(`[djarea] Djarea schema not available: ${err.message}`)
console.log(`[mizan] mizan schema not available: ${err.message}`)
}
// Generate consolidated index.ts
const indexPath = path.dirname(outputPath) + '/index.ts'
const fullIndexPath = path.resolve(frontendDir, indexPath)
console.log(`[djarea] Generating -> ${indexPath}`)
console.log(`[mizan] Generating -> ${indexPath}`)
const indexContent = generateIndex({
channelsSchema,
djareaSchema,
mizanSchema,
})
await writeOutput(fullIndexPath, indexContent)
console.log('[djarea] Generation complete!')
console.log('[mizan] Generation complete!')
}
/**
@@ -194,7 +194,7 @@ async function watch(config, options) {
try {
await generate(config, options)
} catch (err) {
console.error('[djarea] Generation failed:', err.message)
console.error('[mizan] Generation failed:', err.message)
} finally {
running = false
}
@@ -202,7 +202,7 @@ async function watch(config, options) {
await runGenerate()
console.log('[djarea] Watching for changes (press Ctrl+C to stop)...')
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
if (config.source.django) {
const { watch: chokidarWatch } = await import('chokidar')
@@ -221,14 +221,14 @@ async function watch(config, options) {
})
watcher.on('change', (filePath) => {
console.log(`[djarea] Detected change: ${path.relative(djangoDir, filePath)}`)
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(runGenerate, debounce)
})
}
process.on('SIGINT', () => {
console.log('\n[djarea] Stopping watch mode...')
console.log('\n[mizan] Stopping watch mode...')
process.exit(0)
})
}
@@ -252,10 +252,10 @@ async function main() {
output = args[++i]
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Djarea Code Generator - Generate TypeScript from Django schemas
mizan Code Generator - Generate TypeScript from Django schemas
Usage:
npx djarea-generate [options]
npx mizan-generate [options]
Options:
-c, --config <path> Config file path (default: django.config.mjs)
@@ -278,6 +278,6 @@ Options:
}
main().catch(err => {
console.error('[djarea] Error:', err.message)
console.error('[mizan] Error:', err.message)
process.exit(1)
})

View File

@@ -16,7 +16,7 @@ export async function generateChannelsTypes(schema) {
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
@@ -27,8 +27,8 @@ export async function generateChannelsTypes(schema) {
'',
]
// Extract channel metadata from x-djarea-channels extension
const channels = schema['x-djarea-channels'] || []
// Extract channel metadata from x-mizan-channels extension
const channels = schema['x-mizan-channels'] || []
if (channels.length > 0) {
lines.push('// ============================================================================')
@@ -86,7 +86,7 @@ export async function generateChannelsTypes(schema) {
* Generate channel hooks from metadata.
*/
export function generateChannelsHooks(schema) {
const channels = schema['x-djarea-channels'] || []
const channels = schema['x-mizan-channels'] || []
if (channels.length === 0) {
return null
@@ -95,10 +95,10 @@ export function generateChannelsHooks(schema) {
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
"import { useChannel, type ChannelSubscription } from 'djarea/channels'",
"import { useChannel, type ChannelSubscription } from 'mizan/channels'",
'',
]

View File

@@ -1,7 +1,7 @@
/**
* Schema Fetching
*
* Fetches djarea and channels schemas from Django management commands.
* Fetches mizan and channels schemas from Django management commands.
*/
import { spawn } from 'child_process'
@@ -78,11 +78,11 @@ export async function fetchChannelsSchema(source, cwd) {
}
/**
* Fetch djarea schema from Django.
* Fetch mizan schema from Django.
*/
export async function fetchDjareaSchema(source, cwd) {
export async function fetchMizanSchema(source, cwd) {
if (!source.django) {
throw new Error('Djarea schema export requires django source configuration')
throw new Error('mizan schema export requires django source configuration')
}
return runDjangoCommand(source, cwd, 'export_djarea_schema')
return runDjangoCommand(source, cwd, 'export_mizan_schema')
}

View File

@@ -5,122 +5,134 @@
* from the generated files for clean imports.
*/
/**
* Extract context hooks from djarea schema.
* Returns hook names in PascalCase (e.g., useAuthStatus, useUser).
*/
function extractContextHooks(djareaSchema) {
const functions = djareaSchema?.['x-djarea-functions'] || []
const contexts = functions.filter(fn => fn.isContext)
function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
return contexts.map(ctx => {
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1)
return `use${pascal}`
}).sort()
function toPascalCase(str) {
return str
.split(/[.\-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* Generate the consolidated index.ts file.
*
* @param {Object} options - Generation options
* @param {Object} options.channelsSchema - Channels schema (optional)
* @param {Object} options.djareaSchema - Djarea schema (optional)
* @returns {string} Generated index.ts content
*/
export function generateIndex({ channelsSchema, djareaSchema }) {
export function generateIndex({ channelsSchema, mizanSchema }) {
const lines = [
'/**',
' * Djarea API - Consolidated Exports',
' * mizan API - Consolidated Exports',
' *',
' * Import everything from here:',
' *',
' * @example',
' * ```tsx',
' * import {',
' * DjangoContext,',
' * useUser,',
' * MizanContext,',
' * useCurrentUser,',
' * useEcho,',
' * useChatChannel,',
' * DjangoError,',
' * } from \'@/api\'',
' * ```',
' */',
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
]
// ==========================================================================
// Djarea Provider & Hooks (from generated.django.tsx)
// ==========================================================================
const functions = mizanSchema?.['x-mizan-functions'] || []
const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
const hasMizan = functions.length > 0
const functions = djareaSchema?.['x-djarea-functions'] || []
const hasDjarea = functions.length > 0
if (hasDjarea) {
const contextHooks = extractContextHooks(djareaSchema)
const contexts = functions.filter(fn => fn.isContext)
if (hasMizan) {
const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
lines.push('// =============================================================================')
lines.push('// Djarea Provider & Hooks')
lines.push('// mizan Provider & Hooks')
lines.push('// =============================================================================')
lines.push('')
// Server exports (getDjangoHydration runs in server components)
if (contexts.length > 0) {
// Server exports
if (globalContexts.length > 0) {
lines.push('export {')
lines.push(' getMizanHydration,')
lines.push(' getDjangoHydration,')
lines.push(' type MizanHydrationData,')
lines.push(' type DjangoHydration,')
lines.push("} from './generated.django.server'")
lines.push("} from './generated.server'")
lines.push('')
}
// Client exports
lines.push('export {')
lines.push(' // Provider')
lines.push(' MizanContext,')
lines.push(' type MizanContextProps,')
lines.push(' DjangoContext,')
lines.push(' type DjangoContextProps,')
if (contexts.length > 0) {
// Global context hooks
if (globalContexts.length > 0) {
lines.push('')
lines.push(' // Context hooks')
for (const hookName of contextHooks) {
lines.push(` ${hookName},`)
lines.push(' // Global context hooks')
for (const ctx of globalContexts) {
const hookPascal = pascalCase(ctx.camelName)
lines.push(` use${hookPascal},`)
}
lines.push('')
lines.push(' // Refresh hooks')
lines.push(' useMizanRefresh,')
lines.push(' useDjangoRefresh,')
}
// Named context providers and hooks
if (namedContextEntries.length > 0) {
lines.push('')
lines.push(' // Named context providers')
for (const [ctxName, ctxMeta] of namedContextEntries) {
const ctxPascal = toPascalCase(ctxName)
lines.push(` ${ctxPascal}Context,`)
// Hooks for this context's functions
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(` use${hookPascal},`)
}
}
}
// Function hooks (mutations + plain)
if (regularFunctions.length > 0) {
lines.push('')
lines.push(' // Function hooks')
for (const fn of regularFunctions) {
const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1)
const pascal = pascalCase(fn.camelName)
lines.push(` use${pascal},`)
}
}
lines.push('')
lines.push(' // Re-exports from djarea library')
lines.push(' useDjarea,')
lines.push(' useDjareaStatus,')
lines.push(' // Re-exports from mizan library')
lines.push(' useMizan,')
lines.push(' useMizanStatus,')
lines.push(' usePush,')
lines.push(' DjangoError,')
lines.push(' type ConnectionStatus,')
lines.push(' type PushMessage,')
lines.push(' type PushListener,')
lines.push("} from './generated.django'")
lines.push("} from './generated.provider'")
lines.push('')
}
// ==========================================================================
// Channel Hooks (from generated.channels.hooks.tsx)
// Channel Hooks
// ==========================================================================
const channels = channelsSchema?.['x-djarea-channels'] || []
const channels = channelsSchema?.['x-mizan-channels'] || []
if (channels.length > 0) {
lines.push('// =============================================================================')
@@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, djareaSchema }) {
lines.push("} from './generated.channels.hooks'")
lines.push('')
// Channel types
lines.push('// =============================================================================')
lines.push('// Channel Types')
lines.push('// =============================================================================')

View File

@@ -1,12 +1,12 @@
/**
* Djarea Code Generator
* mizan Code Generator
*
* Generates TypeScript types and React provider from Djarea OpenAPI schema.
* Generates TypeScript types and React provider from mizan OpenAPI schema.
* Uses openapi-typescript for robust type generation.
*
* Output structure:
* - generated.djarea.ts - Types only (from OpenAPI)
* - generated.provider.tsx - Typed provider wrapping DjareaProvider + hooks
* - generated.mizan.ts - Types only (from OpenAPI)
* - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks
* - generated.forms.ts - Typed form hooks with Zod schemas
*/
@@ -74,14 +74,14 @@ function buildSchemaExports(schemaNames) {
/**
* Generate the types file using openapi-typescript.
*/
export async function generateDjareaTypes(schema) {
export async function generateMizanTypes(schema) {
// Generate types using openapi-typescript
const ast = await openapiTS(schema)
const schemaNames = getSchemaNamesFromAst(ast)
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
@@ -104,11 +104,11 @@ export async function generateDjareaTypes(schema) {
'',
]
// Extract function metadata from x-djarea-functions extension
const functions = schema['x-djarea-functions'] || []
// Extract function metadata from x-mizan-functions extension
const functions = schema['x-mizan-functions'] || []
if (functions.length > 0) {
lines.push('export const DJANGO_FUNCTIONS = {')
lines.push('export const MIZAN_FUNCTIONS = {')
for (const fn of functions) {
lines.push(` ${fn.camelName}: {`)
lines.push(` name: '${fn.name}',`)
@@ -119,7 +119,7 @@ export async function generateDjareaTypes(schema) {
}
lines.push('} as const')
} else {
lines.push('export const DJANGO_FUNCTIONS = {} as const')
lines.push('export const MIZAN_FUNCTIONS = {} as const')
}
lines.push('')
@@ -128,24 +128,56 @@ export async function generateDjareaTypes(schema) {
}
/**
* Generate the React provider that wraps DjareaProvider with typed hooks.
* Extract unique context names from an affects array.
* Both context-level and function-level affects resolve to context names.
*/
function getAffectedContexts(affects) {
const contexts = new Set()
for (const target of affects) {
if (target.type === 'context') {
contexts.add(target.name)
} else if (target.type === 'function' && target.context) {
contexts.add(target.context)
}
}
return [...contexts]
}
/**
* Map JSON schema type string to TypeScript type.
*/
function jsonTypeToTS(type) {
if (type === 'integer' || type === 'number') return 'number'
if (type === 'boolean') return 'boolean'
return 'string'
}
/**
* Generate the React provider that wraps MizanProvider with typed hooks.
*
* The generated provider:
* - Wraps DjareaProvider (from djarea library)
* - Passes context names for auto-fetch
* - Provides typed hooks for contexts and functions
* - MizanContext: Root provider with global context bundled fetch
* - Named context providers: <UserContext user_id={...}>
* - Mutation hooks with auto-invalidation
* - Plain function hooks
*/
export function generateDjareaProvider(schema, options = {}) {
export function generateMizanProvider(schema, options = {}) {
const { hasChannels = false } = options
const functions = schema['x-djarea-functions'] || []
const functions = schema['x-mizan-functions'] || []
const contextGroups = schema['x-mizan-contexts'] || {}
if (functions.length === 0) {
return null
}
// Separate contexts from regular functions
const contexts = functions.filter(fn => fn.isContext)
// Partition functions
const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const mutationFunctions = regularFunctions.filter(fn => fn.affects)
const plainFunctions = regularFunctions.filter(fn => !fn.affects)
// Named context groups (everything except 'global')
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
// Collect type imports
const typeImports = []
@@ -162,36 +194,36 @@ export function generateDjareaProvider(schema, options = {}) {
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// This file provides typed wrappers around the djarea library.',
'// - DjangoContext: Typed provider wrapping DjareaProvider',
'// - Typed hooks: useAuthStatus(), useUser(), etc.',
'// This file provides typed wrappers around the mizan library.',
'// - MizanContext: Root provider with global context',
'// - Named context providers: <UserContext user_id={...}>',
'// - Typed hooks with auto-invalidation',
'',
"import { type ReactNode, useCallback } from 'react'",
"import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'",
"import {",
" DjareaProvider,",
" useDjarea,",
" useDjareaContext,",
" useDjareaCall,",
" type DjareaHydration,",
" MizanProvider,",
" useMizan,",
" useMizanContext,",
" useMizanCall,",
" type MizanHydration,",
" type Transport,",
"} from 'djarea'",
"} from 'mizan'",
...(hasChannels ? [
"import { ChannelProvider, ChannelConnection } from 'djarea/channels'",
"import { useRef } from 'react'",
"import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
] : []),
'',
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('')
}
// ============================================================================
// Hydration types
// Hydration types (global contexts only)
// ============================================================================
lines.push('// ============================================================================')
@@ -199,20 +231,19 @@ export function generateDjareaProvider(schema, options = {}) {
lines.push('// ============================================================================')
lines.push('')
if (contexts.length > 0) {
lines.push('/** Typed hydration data for SSR */')
lines.push('export interface DjangoHydration {')
for (const ctx of contexts) {
if (globalContexts.length > 0) {
lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface MizanHydrationData {')
for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
lines.push('/** Convert typed hydration to djarea format */')
lines.push('function toDjareaHydration(hydration?: DjangoHydration): DjareaHydration | undefined {')
lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
lines.push(' if (!hydration) return undefined')
lines.push(' const result: DjareaHydration = {}')
for (const ctx of contexts) {
lines.push(' const result: MizanHydration = {}')
for (const ctx of globalContexts) {
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
}
lines.push(' return result')
@@ -221,50 +252,81 @@ export function generateDjareaProvider(schema, options = {}) {
}
// ============================================================================
// Provider
// Global Context Loader (inner component, fetches GET /ctx/global/)
// ============================================================================
if (globalContexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Global Context Loader')
lines.push('// ============================================================================')
lines.push('')
lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {')
lines.push(' const mizan = useMizan()')
lines.push(' const loaded = useRef(false)')
lines.push('')
lines.push(' useEffect(() => {')
lines.push(' if (loaded.current) return')
lines.push(' loaded.current = true')
lines.push('')
lines.push(' ;(async () => {')
lines.push(' await mizan.whenReady')
lines.push(' try {')
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
lines.push(' mizan.setContextData(name, data)')
lines.push(' }')
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
lines.push(' }')
lines.push(' })()')
lines.push(' }, [mizan])')
lines.push('')
lines.push(' return <>{children}</>')
lines.push('}')
lines.push('')
}
// ============================================================================
// Root Provider (MizanContext)
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Provider')
lines.push('// Root Provider')
lines.push('// ============================================================================')
lines.push('')
lines.push('export interface DjangoContextProps {')
lines.push('export interface MizanContextProps {')
lines.push(' children: ReactNode')
if (contexts.length > 0) {
lines.push(' /** SSR hydration data */')
lines.push(' hydration?: DjangoHydration')
if (globalContexts.length > 0) {
lines.push(' /** SSR hydration data (global contexts only) */')
lines.push(' hydration?: MizanHydrationData')
}
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
lines.push(' wsUrl?: string')
lines.push(' /** Base URL for HTTP fallback (default: /api/djarea) */')
lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */')
lines.push(' baseUrl?: string')
lines.push('}')
lines.push('')
// Context names array for DjareaProvider
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
lines.push('/**')
lines.push(' * Typed Django context provider.')
lines.push(' *')
lines.push(' * Wraps DjareaProvider with:')
lines.push(' * - Typed hydration')
lines.push(' * - Auto-fetch for registered contexts')
lines.push(' * Root mizan provider. Mount at your app root.')
lines.push(' *')
lines.push(' * Usage:')
lines.push(' * <DjangoContext hydration={hydration}>')
lines.push(' * <MizanContext hydration={hydration}>')
lines.push(' * <App />')
lines.push(' * </DjangoContext>')
lines.push(' * </MizanContext>')
lines.push(' */')
lines.push('export function DjangoContext({')
lines.push('export function MizanContext({')
lines.push(' children,')
if (contexts.length > 0) {
if (globalContexts.length > 0) {
lines.push(' hydration,')
}
lines.push(' wsUrl,')
lines.push(' baseUrl,')
lines.push('}: DjangoContextProps) {')
lines.push('}: MizanContextProps) {')
if (hasChannels) {
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
@@ -274,11 +336,11 @@ export function generateDjareaProvider(schema, options = {}) {
lines.push('')
}
// Build the JSX tree
lines.push(' return (')
lines.push(' <DjareaProvider')
if (contexts.length > 0) {
lines.push(' hydration={toDjareaHydration(hydration)}')
lines.push(` contexts={[${contextNames}]}`)
lines.push(' <MizanProvider')
if (globalContexts.length > 0) {
lines.push(' hydration={toMizanHydration(hydration)}')
}
lines.push(' wsUrl={wsUrl}')
lines.push(' baseUrl={baseUrl}')
@@ -287,93 +349,221 @@ export function generateDjareaProvider(schema, options = {}) {
}
lines.push(' >')
if (hasChannels) {
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>')
lines.push(' {children}')
lines.push(' </ChannelProvider>')
} else {
lines.push(' {children}')
// Inner content: GlobalContextLoader wraps children if needed
let innerContent = '{children}'
if (globalContexts.length > 0) {
innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
}
lines.push(' </DjareaProvider>')
if (hasChannels) {
lines.push(` <ChannelProvider connection={connectionRef.current} autoConnect={true}>`)
lines.push(` ${innerContent}`)
lines.push(` </ChannelProvider>`)
} else {
lines.push(` ${innerContent}`)
}
lines.push(' </MizanProvider>')
lines.push(' )')
lines.push('}')
lines.push('')
// Legacy alias
lines.push('/** @deprecated Use MizanContext instead */')
lines.push('export const DjangoContext = MizanContext')
lines.push('/** @deprecated Use MizanContextProps instead */')
lines.push('export type DjangoContextProps = MizanContextProps')
if (globalContexts.length > 0) {
lines.push('/** @deprecated Use MizanHydrationData instead */')
lines.push('export type DjangoHydration = MizanHydrationData')
}
lines.push('')
// ============================================================================
// Context Hooks
// Global Context Hooks
// ============================================================================
if (contexts.length > 0) {
if (globalContexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Context Hooks (typed wrappers)')
lines.push('// Global Context Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const ctx of contexts) {
for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(`/**`)
lines.push(` * Get ${ctx.name} context data.`)
lines.push(` * @throws if context not loaded yet`)
lines.push(` */`)
lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
lines.push(` const data = useDjareaContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) {`)
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` }`)
lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` return data`)
lines.push(`}`)
lines.push('')
}
// Refresh hooks
lines.push('/**')
lines.push(' * Get context refresh functions without subscribing to data changes.')
lines.push(' * Use this in components that only need to trigger refreshes.')
lines.push(' */')
lines.push('export function useDjangoRefresh() {')
lines.push(' const { refreshContext, refreshAllContexts } = useDjarea()')
lines.push('/** Refresh functions for global contexts. */')
lines.push('export function useMizanRefresh() {')
lines.push(' const { invalidateContext } = useMizan()')
lines.push(' return {')
for (const ctx of contexts) {
for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`)
lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`)
}
lines.push(' refreshAll: refreshAllContexts,')
lines.push(' }')
lines.push('}')
lines.push('')
// Legacy alias
lines.push('/** @deprecated Use useMizanRefresh instead */')
lines.push('export const useDjangoRefresh = useMizanRefresh')
lines.push('')
}
// ============================================================================
// Function Hooks
// Named Context Providers
// ============================================================================
if (regularFunctions.length > 0) {
if (namedContextEntries.length > 0) {
lines.push('// ============================================================================')
lines.push('// Function Hooks (typed wrappers)')
lines.push('// Named Context Providers')
lines.push('// ============================================================================')
lines.push('')
for (const fn of regularFunctions) {
for (const [ctxName, ctxMeta] of namedContextEntries) {
const ctxPascal = toPascalCase(ctxName)
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
const params = ctxMeta.params || {}
const paramEntries = Object.entries(params)
// Internal React context type
lines.push(`const ${ctxPascal}ContextInternal = createContext<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(`} | null>(null)`)
lines.push('')
// Props interface
lines.push(`export interface ${ctxPascal}ContextProps {`)
lines.push(` children: ReactNode`)
for (const [pName, pMeta] of paramEntries) {
const tsType = jsonTypeToTS(pMeta.type)
const optional = pMeta.required ? '' : '?'
lines.push(` ${pName}${optional}: ${tsType}`)
}
lines.push(`}`)
lines.push('')
// Provider component
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
lines.push(` const mizan = useMizan()`)
lines.push(` const [data, setData] = useState<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(` } | null>(null)`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` await mizan.whenReady`)
lines.push(` const qs = new URLSearchParams()`)
for (const [pName] of paramEntries) {
lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`)
}
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
lines.push(` const result = await resp.json()`)
lines.push(` if (!result.error) setData(result.data)`)
// Dependency array: mizan + each param
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
lines.push(` }, [${deps.join(', ')}])`)
lines.push('')
lines.push(` useEffect(() => { refetch() }, [refetch])`)
lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`)
lines.push('')
lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}</${ctxPascal}ContextInternal>`)
lines.push(`}`)
lines.push('')
// Individual data hooks
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`)
lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`)
lines.push(` return ctx.${fn.name}`)
lines.push(`}`)
lines.push('')
}
}
}
// ============================================================================
// Mutation Hooks (with auto-invalidation)
// ============================================================================
if (mutationFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Mutation Hooks (auto-invalidate on success)')
lines.push('// ============================================================================')
lines.push('')
for (const fn of mutationFunctions) {
const pascal = pascalCase(fn.camelName)
const transport = fn.transport || 'http'
const affectedContexts = getAffectedContexts(fn.affects)
lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` const mizan = useMizan()`)
if (fn.hasInput) {
lines.push(` return useCallback(async (input: ${fn.inputType}) => {`)
lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`)
} else {
lines.push(` return useCallback(async () => {`)
lines.push(` const result = await mizan.call<void, ${fn.outputType}>('${fn.name}', undefined, '${transport}')`)
}
// Invalidation
if (affectedContexts.length === 1) {
lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`)
} else if (affectedContexts.length > 1) {
lines.push(` await Promise.all([`)
for (const ctx of affectedContexts) {
lines.push(` mizan.invalidateContext('${ctx}'),`)
}
lines.push(` ])`)
}
lines.push(` return result`)
lines.push(` }, [mizan])`)
lines.push(`}`)
lines.push('')
}
}
// ============================================================================
// Plain Function Hooks
// ============================================================================
if (plainFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Function Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const fn of plainFunctions) {
const pascal = pascalCase(fn.camelName)
// Transport is known at generation time - pass it directly
const transport = fn.transport || 'http'
if (fn.hasInput) {
lines.push(`/**`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useDjareaCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
} else {
lines.push(`/**`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useDjareaCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
}
lines.push('')
@@ -385,11 +575,11 @@ export function generateDjareaProvider(schema, options = {}) {
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Re-exports from djarea library')
lines.push('// Re-exports from mizan library')
lines.push('// ============================================================================')
lines.push('')
lines.push("export { useDjarea, useDjareaStatus, usePush, DjangoError } from 'djarea'")
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'djarea'")
lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'")
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'")
lines.push('')
return lines.join('\n')
@@ -399,20 +589,20 @@ export function generateDjareaProvider(schema, options = {}) {
* Generate server-side hydration helper (runs in Next.js server components).
* This is separate from the client file because it needs to run on the server.
*/
export function generateDjareaServer(schema) {
const functions = schema['x-djarea-functions'] || []
const contexts = functions.filter(fn => fn.isContext)
export function generateMizanServer(schema) {
const functions = schema['x-mizan-functions'] || []
const globalContexts = functions.filter(fn => fn.isContext === 'global')
if (contexts.length === 0) {
if (globalContexts.length === 0) {
return null
}
// Collect type imports for contexts
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean)
// Collect type imports for global contexts
const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'//',
'// Server-side functions for SSR hydration.',
@@ -421,7 +611,7 @@ export function generateDjareaServer(schema) {
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('')
}
@@ -430,67 +620,66 @@ export function generateDjareaServer(schema) {
lines.push('// Hydration Types')
lines.push('// ============================================================================')
lines.push('')
lines.push('/** Typed hydration data for SSR */')
lines.push('export interface DjangoHydration {')
for (const ctx of contexts) {
lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface MizanHydrationData {')
for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
lines.push('/** @deprecated Use MizanHydrationData instead */')
lines.push('export type DjangoHydration = MizanHydrationData')
lines.push('')
// SSR Hydration Helper
// SSR Hydration Helper — single bundled GET
lines.push('// ============================================================================')
lines.push('// SSR Hydration Helper')
lines.push('// ============================================================================')
lines.push('')
lines.push('/**')
lines.push(' * Fetch hydration data for SSR.')
lines.push(' * Fetch hydration data for SSR via bundled context endpoint.')
lines.push(' *')
lines.push(' * Call this in your server component:')
lines.push(' * const hydration = await getDjangoHydration(client)')
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>')
lines.push(' * const hydration = await getMizanHydration(client)')
lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
lines.push(' */')
lines.push('export async function getDjangoHydration(')
lines.push('export async function getMizanHydration(')
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
lines.push('): Promise<DjangoHydration> {')
lines.push(' const hydration: DjangoHydration = {}')
lines.push('): Promise<MizanHydrationData> {')
lines.push(' const hydration: MizanHydrationData = {}')
lines.push('')
lines.push(' const results = await Promise.allSettled([')
for (const ctx of contexts) {
lines.push(` client.request('POST', '/api/djarea/call/', { fn: '${ctx.name}', args: {} }),`)
lines.push(' try {')
lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
for (const ctx of globalContexts) {
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
}
lines.push(' ])')
lines.push('')
contexts.forEach((ctx, i) => {
lines.push(` if (results[${i}].status === 'fulfilled') {`)
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`)
lines.push(` if (data.error) {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`)
lines.push(` } else {`)
lines.push(` hydration.${ctx.camelName} = data.data`)
lines.push(` }`)
lines.push(` } else {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`)
lines.push(' } else {')
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[getMizanHydration] Request failed:', e)")
lines.push(' }')
})
lines.push('')
lines.push(' return hydration')
lines.push('}')
lines.push('')
lines.push('/** @deprecated Use getMizanHydration instead */')
lines.push('export const getDjangoHydration = getMizanHydration')
lines.push('')
return lines.join('\n')
}
/**
* Generate all djarea files.
* Generate all mizan files.
*/
export async function generateDjareaFiles(schema, options = {}) {
const types = await generateDjareaTypes(schema)
const provider = generateDjareaProvider(schema, options)
const server = generateDjareaServer(schema)
const forms = generateDjareaForms(schema)
export async function generateMizanFiles(schema, options = {}) {
const types = await generateMizanTypes(schema)
const provider = generateMizanProvider(schema, options)
const server = generateMizanServer(schema)
const forms = generateMizanForms(schema)
return { types, provider, server, forms }
}
@@ -498,8 +687,8 @@ export async function generateDjareaFiles(schema, options = {}) {
/**
* Generate typed form hooks with Zod schemas.
*/
export function generateDjareaForms(schema) {
const functions = schema['x-djarea-functions'] || []
export function generateMizanForms(schema) {
const functions = schema['x-mizan-functions'] || []
// Group form functions by form name
const formFunctions = functions.filter(fn => fn.isForm)
@@ -535,7 +724,7 @@ export function generateDjareaForms(schema) {
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// Typed form hooks with Zod validation.',
@@ -549,7 +738,7 @@ export function generateDjareaForms(schema) {
" type DjangoFormState,",
" type DjangoFormsetState,",
" type FormOptions,",
"} from 'djarea'",
"} from 'mizan'",
'',
'// ============================================================================',
'// Zod Schemas',
@@ -658,7 +847,7 @@ export function generateDjareaForms(schema) {
lines.push('// Form Registry')
lines.push('// ============================================================================')
lines.push('')
lines.push('export const DJANGO_FORMS = {')
lines.push('export const MIZAN_FORMS = {')
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)

View File

@@ -1,5 +1,5 @@
[project]
name = "djarea"
name = "mizan"
version = "1.0.1"
description = "Django + React server functions framework"
readme = "README.md"
@@ -36,11 +36,11 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/djarea"]
packages = ["src/mizan"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
pythonpath = ["src", "."]
testpaths = ["src/djarea/tests"]
testpaths = ["src/mizan/tests"]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -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.
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
### 1. urls.py - HTTP endpoint
```python
from djarea import urls as djarea_urls
from mizan import urls as mizan_urls
urlpatterns = [
path('api/djarea/', include(djarea_urls)),
path('api/mizan/', include(mizan_urls)),
]
```
### 2. asgi.py - WebSocket support (optional)
```python
from djarea import wrap_asgi
from mizan import wrap_asgi
from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application())
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
### 3. Define server functions
```python
# apps/myapp/clients.py
from djarea import client
from mizan import client
from pydantic import BaseModel
class EchoOutput(BaseModel):
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
```python
class MyAppConfig(AppConfig):
def ready(self):
from djarea.setup import djarea_clients
djarea_clients('apps')
from mizan.setup import mizan_clients
mizan_clients('apps')
```
### 5. Frontend - generate types and use
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
| `@compose(...)` | `<XxxProvider>` combined | varies |
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP |
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket |
"""
@@ -88,12 +88,13 @@ from . import forms
from . import setup
from .channels import ReactChannel
from .channels import register as register_channel
from .client import ComposedContext, ServerFunction, client, compose
# Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate()
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()
from .setup import (
djarea_clients,
djarea_module,
mizan_clients,
mizan_module,
get_channel,
get_function,
register,
@@ -104,9 +105,9 @@ from .setup import (
def __getattr__(name):
"""Lazy loading for modules that can't be imported at app load time."""
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":
from .shapes import Shape
@@ -116,11 +117,11 @@ def __getattr__(name):
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:
from django.core.asgi import get_asgi_application
from djarea import wrap_asgi
from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application())
@@ -156,14 +157,16 @@ def wrap_asgi(http_application):
__all__ = [
# Decorators
# Decorators & Contexts
"client",
"compose",
"ReactContext",
"GlobalContext",
"ServerFunction",
"ComposedContext",
# Setup
"djarea_clients",
"djarea_module",
"mizan_clients",
"mizan_module",
"register",
"register_as",
"get_function",

View File

@@ -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.
Hooks are auto-generated with full TypeScript types.
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
```python
# channels.py
from pydantic import BaseModel
from djarea import channels
from mizan import channels
class ChatChannel(channels.ReactChannel):
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
```python
# asgi.py
from djarea import channels
from mizan import channels
application = ProtocolTypeRouter({
"http": get_asgi_application(),
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
# Base Classes
# =============================================================================
class ReactChannel:
"""
Base class for WebSocket channels.
@@ -140,9 +141,7 @@ class ReactChannel:
Messages returned from receive() are broadcast to this group.
"""
raise NotImplementedError(
f"{self.__class__.__name__} must implement group()"
)
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
"""
@@ -191,9 +190,9 @@ class ReactChannel:
"type": "channel.message",
"channel": self._registered_name,
"params": self._params_dict,
"data": message.model_dump(mode='json'),
"data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__,
}
},
)
# -------------------------------------------------------------------------
@@ -215,7 +214,9 @@ class ReactChannel:
channel_layer = get_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
# Build params model if defined
@@ -234,9 +235,9 @@ class ReactChannel:
"type": "channel.message",
"channel": cls._registered_name,
"params": params,
"data": message.model_dump(mode='json'),
"data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__,
}
},
)
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
channel_class._registered_name = name
# 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()")
if not hasattr(channel_class, 'group'):
if not hasattr(channel_class, "group"):
raise ValueError(f"{channel_class.__name__} must implement group()")
_registry[name] = channel_class
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
# WebSocket Consumer
# =============================================================================
def get_websocket_application():
"""
Get the WebSocket application for ASGI.
Usage in asgi.py:
from djarea import channels
from mizan import channels
application = ProtocolTypeRouter({
"http": get_asgi_application(),
@@ -309,9 +311,11 @@ def get_websocket_application():
from .connection import DjangoReactConsumer
return AuthMiddlewareStack(
URLRouter([
URLRouter(
[
path("ws/", DjangoReactConsumer.as_asgi()),
])
]
)
)
@@ -319,15 +323,14 @@ def get_websocket_application():
# Schema Export (for TypeScript generation)
# =============================================================================
def get_channels_schema() -> dict:
"""
Get schema for all registered channels (for TypeScript generation).
Returns a dict suitable for the frontend code generator.
"""
schema = {
"channels": {}
}
schema = {"channels": {}}
for name, channel_class in _registry.items():
channel_schema = {
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
}
# 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()
# Extract ReactMessage schema
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema()
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema[
"reactMessage"
] = channel_class.ReactMessage.model_json_schema()
# Extract DjangoMessage schema
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema()
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema[
"djangoMessage"
] = channel_class.DjangoMessage.model_json_schema()
schema["channels"][name] = channel_schema
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
) -> None:
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
if input_cls is not None:
def endpoint(request, data):
pass
endpoint.__annotations__ = {"data": input_cls}
else:
def endpoint(request):
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:
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
# Create temporary Ninja API for schema generation only
schema_api = NinjaAPI(
title="Djarea Channels",
title="mizan Channels",
version="1.0.0",
description="Auto-generated schema for djarea channels",
description="Auto-generated schema for mizan channels",
docs_url=None,
openapi_url=None,
)
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
}
# 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"
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
channel_meta["hasParams"] = True
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
)
# 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"
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["reactMessageType"] = react_name
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
)
# 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"
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["djangoMessageType"] = django_name
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
schema = schema_api.get_openapi_schema(path_prefix="")
# Add channel metadata extension
schema["x-djarea-channels"] = channel_metadata
schema["x-mizan-channels"] = channel_metadata
return schema

View File

@@ -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.
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await self._try_jwt_auth()
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):
"""
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate JWT and create JWTUser (no DB query)
try:
from djarea.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser
from mizan.client.jwt import decode_token
from mizan.jwt.tokens import JWTUser
payload = await sync_to_async(decode_token)(token, expected_type="access")
if payload is None:
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
elif action == "rpc":
await self._handle_rpc(content)
else:
await self.send_json({
await self.send_json(
{
"error": f"Unknown action: {action}",
})
}
)
async def _handle_subscribe(self, content: dict):
"""Handle subscription request."""
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Get channel class
channel_class = get_channel(channel_name)
if not channel_class:
await self.send_json({
await self.send_json(
{
"error": f"Unknown channel: {channel_name}",
})
}
)
return
# Create subscription key
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Check if already subscribed
if sub_key in self._subscriptions:
await self.send_json({
await self.send_json(
{
"error": f"Already subscribed to {channel_name}",
"channel": channel_name,
"params": params_dict,
})
}
)
return
# Create channel instance
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
try:
params_obj = channel_class.Params(**params_dict)
except Exception as e:
await self.send_json({
await self.send_json(
{
"error": f"Invalid params: {e}",
"channel": channel_name,
})
}
)
return
# Check authorization
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
authorized = instance.authorize()
except Exception as e:
logger.error(f"Authorization error for {channel_name}: {e}")
await self.send_json({
await self.send_json(
{
"error": "Authorization failed",
"channel": channel_name,
})
}
)
return
if not authorized:
await self.send_json({
await self.send_json(
{
"error": "Not authorized",
"channel": channel_name,
})
}
)
return
# Get group and join
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await instance._join_group(group_name)
except Exception as 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}",
"channel": channel_name,
})
}
)
return
# Store subscription
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.error(f"on_connect error for {channel_name}: {e}")
# Confirm subscription
await self.send_json({
await self.send_json(
{
"subscribed": True,
"channel": channel_name,
"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:
logger.error(f"Error during unsubscribe: {e}")
await self.send_json({
await self.send_json(
{
"unsubscribed": True,
"channel": channel_name,
"params": params_dict,
})
}
)
logger.debug(f"Unsubscribed from {channel_name}")
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
instance = self._subscriptions.get(sub_key)
if not instance:
await self.send_json({
await self.send_json(
{
"error": f"Not subscribed to {channel_name}",
"channel": channel_name,
})
}
)
return
channel_class = instance.__class__
# Check if channel accepts messages
if not channel_class.ReactMessage:
await self.send_json({
await self.send_json(
{
"error": f"Channel {channel_name} does not accept messages",
"channel": channel_name,
})
}
)
return
# Parse message
try:
msg = channel_class.ReactMessage(**data)
except Exception as e:
await self.send_json({
await self.send_json(
{
"error": f"Invalid message: {e}",
"channel": channel_name,
})
}
)
return
# Parse params
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as 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}",
"channel": channel_name,
})
}
)
async def _handle_rpc(self, content: dict):
"""
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
- Function must be explicitly registered (no arbitrary code execution)
- User context from WebSocket session is passed to function
"""
from djarea.client.executor import execute_function, FunctionError
from djarea.setup.registry import get_function
from mizan.client.executor import execute_function, FunctionError
from mizan.setup.registry import get_function
request_id = content.get("id")
fn_name = content.get("fn")
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate request structure
if not request_id:
await self.send_json({
await self.send_json(
{
"error": "RPC request missing 'id' field",
})
}
)
return
if not fn_name:
await self.send_json({
await self.send_json(
{
"id": request_id,
"ok": False,
"error": {
"code": "BAD_REQUEST",
"message": "Missing 'fn' field",
},
})
}
)
return
# Check if function exists and has websocket=True
fn_class = get_function(fn_name)
if fn_class is None:
await self.send_json({
await self.send_json(
{
"id": request_id,
"ok": False,
"error": {
"code": "NOT_FOUND",
"message": f"Function '{fn_name}' not found",
},
})
}
)
return
# Only allow functions explicitly marked with websocket=True
fn_meta = getattr(fn_class, "_meta", {})
if not fn_meta.get("websocket"):
await self.send_json({
await self.send_json(
{
"id": request_id,
"ok": False,
"error": {
"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
# 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)
# This is sync, so we need to run it in a thread pool
@@ -435,7 +473,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Send response
if isinstance(result, FunctionError):
await self.send_json({
await self.send_json(
{
"id": request_id,
"ok": False,
"error": {
@@ -443,13 +482,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
"message": result.message,
**({"details": result.details} if result.details else {}),
},
})
}
)
else:
await self.send_json({
await self.send_json(
{
"id": request_id,
"ok": True,
"data": result.data,
})
}
)
async def channel_message(self, event: dict):
"""
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Called when channel_layer.group_send() is used.
Includes channel name and params so the client can route the message.
"""
await self.send_json({
await self.send_json(
{
"channel": event.get("channel"),
"params": event.get("params", {}),
"type": event.get("message_type", "message"),
"data": event.get("data", {}),
})
}
)
async def push_message(self, event: dict):
"""
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Protocol:
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
"""
await self.send_json({
await self.send_json(
{
"type": "push",
"topic": event.get("topic"),
"data": event.get("data", {}),
})
}
)

View File

@@ -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.
Usage:
# 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": {...}})
# Subscribe a connection to a topic (call during context fetch)
from djarea.push import subscribe
from mizan.push import subscribe
subscribe(request, "room:42")
"""
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
"""Get channel layer, returning None if channels is not installed."""
try:
from channels.layers import get_channel_layer
return get_channel_layer()
except ImportError:
return None
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
def _async_to_sync(coro):
"""Wrapper for async_to_sync that handles missing channels."""
from asgiref.sync import async_to_sync
return async_to_sync(coro)
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
channel_layer = _get_channel_layer()
if not channel_layer:
import logging
logging.getLogger(__name__).warning(
"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
"topic": topic,
"data": data,
}
},
)
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
"type": "push.message",
"topic": topic,
"data": data,
}
},
)

View File

@@ -1,5 +1,5 @@
"""
djarea.client - Server function implementation.
mizan.client - Server function implementation.
This subpackage contains everything needed to make server functions work:
- The @client decorator
@@ -8,12 +8,15 @@ This subpackage contains everything needed to make server functions work:
- JWT authentication (integral to server functions)
Usage:
from djarea.client import client, ServerFunction, compose
from mizan.client import client, ServerFunction, compose
"""
from .function import (
# Decorator
client,
# Context markers
ReactContext,
GlobalContext,
# Base classes
ServerFunction,
ComposedContext,
@@ -39,6 +42,9 @@ from .executor import (
__all__ = [
# Decorator
"client",
# Context markers
"ReactContext",
"GlobalContext",
# Base classes
"ServerFunction",
"ComposedContext",

View File

@@ -1,5 +1,5 @@
"""
Djarea Function Executor
mizan Function Executor
Handles execution of server functions.
This is the core of the "Server Functions" feature - callable from React
@@ -27,7 +27,7 @@ from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError
from djarea.setup.registry import get_function
from mizan.setup.registry import get_function, get_context_groups
if TYPE_CHECKING:
pass
@@ -134,23 +134,23 @@ def _check_auth_requirement(
)
# Check authentication (required for all string-based auth)
if not getattr(user, 'is_authenticated', False):
if not getattr(user, "is_authenticated", False):
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Authentication required",
)
# Check staff requirement
if auth_requirement == 'staff':
if not getattr(user, 'is_staff', False):
if auth_requirement == "staff":
if not getattr(user, "is_staff", False):
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Staff access required",
)
# Check superuser requirement
elif auth_requirement == 'superuser':
if not getattr(user, 'is_superuser', False):
elif auth_requirement == "superuser":
if not getattr(user, "is_superuser", False):
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Superuser access required",
@@ -159,6 +159,151 @@ def _check_auth_requirement(
return None
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
"""
Determine whether an affects target is a context name or function name.
Returns:
("context", "user", None) full context invalidation
("function", "user_profile", "user") function within context
"""
groups = get_context_groups()
# Check if it's a context name directly
if target_name in groups:
return ("context", target_name, None)
# Check if it's a function name within a context
for ctx_name, fn_names in groups.items():
if target_name in fn_names:
return ("function", target_name, ctx_name)
# Not a context or context function — treat as context name anyway
# (it might be a non-context function or an as-yet-unregistered context)
return ("context", target_name, None)
def _get_context_param_names(context_name: str) -> set[str]:
"""
Get the set of parameter names used by functions in a context.
Returns the union of all Input field names across context functions.
"""
groups = get_context_groups()
fn_names = groups.get(context_name, [])
param_names: set[str] = set()
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
param_names.update(input_cls.model_fields.keys())
return param_names
def _resolve_invalidation(
view_class: type | None,
input_data: dict[str, Any] | None = None,
) -> list[str | dict[str, Any]] | None:
"""
Resolve invalidation targets with three-tier auto-scoping.
Tier 1: Argument name matching if the mutation's input args overlap
with the context's params by name, auto-scope.
Tier 2: Auth inference Edge-side concern, not handled here.
Tier 3: Broad fallback invalidate all instances.
Also handles function-level targeting: affects='user_profile' resolves
to the function name (v1: runtime refetches the whole context anyway).
Returns a list suitable for both JSON body and header serialization.
Returns None if no invalidation needed.
"""
if view_class is None:
return None
meta = getattr(view_class, "_meta", {})
affects = meta.get("affects")
if not affects:
return None
result = []
seen = set()
for target in affects:
if target["type"] == "context":
target_name = target["name"]
elif target["type"] == "function" and target.get("context"):
# Function-level: use the function name as the invalidation key
target_name = target["name"]
else:
continue
if target_name in seen:
continue
seen.add(target_name)
# Resolve the context this target belongs to (for param lookup)
resolved = _resolve_affects_target(target_name)
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
# Tier 1: argument name matching
if input_data and ctx_for_params:
context_params = _get_context_param_names(ctx_for_params)
matched = {
k: v for k, v in input_data.items()
if k in context_params
}
if matched:
result.append({"context": target_name, "params": matched})
continue
# Tier 3: broad fallback
result.append(target_name)
return result if result else None
def _format_invalidate_header(
invalidate: list[str | dict[str, Any]],
) -> str:
"""
Format invalidation targets as X-Mizan-Invalidate header value.
Format: comma-separated contexts. Semicolon-separated params per context.
Param values are URL-encoded to prevent delimiter collisions.
Examples:
["user"] "user"
["user", "notifications"] "user, notifications"
[{"context": "user", "params": {"user_id": 5}}]
"user;user_id=5"
[{"context": "search", "params": {"q": "hello world"}}]
"search;q=hello%20world"
"""
from urllib.parse import quote
parts = []
for entry in invalidate:
if isinstance(entry, str):
parts.append(entry)
elif isinstance(entry, dict):
ctx = entry["context"]
params = entry.get("params", {})
if params:
param_str = ";".join(
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
for k, v in sorted(params.items())
)
parts.append(f"{ctx};{param_str}")
else:
parts.append(ctx)
return ", ".join(parts)
def execute_function(
request: HttpRequest,
fn_name: str,
@@ -190,8 +335,15 @@ def execute_function(
message=message,
)
# Check auth requirement BEFORE executing
# Reject private functions from RPC dispatch
meta = getattr(view_class, "_meta", {})
if meta.get("private"):
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Function is not client-callable",
)
# Check auth requirement BEFORE executing
auth_requirement = meta.get("auth")
auth_error = _check_auth_requirement(request, auth_requirement)
if auth_error is not None:
@@ -224,7 +376,8 @@ def execute_function(
if not isinstance(input_data, dict):
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message="Input must be an object, not " + type(input_data).__name__,
message="Input must be an object, not "
+ type(input_data).__name__,
)
validated_input = input_cls(**input_data)
elif has_input:
@@ -280,10 +433,23 @@ def execute_function(
code=ErrorCode.INTERNAL_ERROR,
message="An internal error occurred",
# Don't expose internal details in production
details={"type": type(e).__name__} if logger.isEnabledFor(logging.DEBUG) else None,
details={"type": type(e).__name__}
if logger.isEnabledFor(logging.DEBUG)
else None,
)
# Serialize output (handle None for Optional return types)
# Return-type branching: HttpResponse (view path) vs data (RPC path)
from django.http import HttpResponseBase
if isinstance(output, HttpResponseBase):
# View path — add invalidation header, pass through the response
invalidate = _resolve_invalidation(view_class, input_data)
if invalidate:
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
output["Cache-Control"] = "no-store"
return output
# RPC path — serialize output
if output is None:
return FunctionResult(data=None)
return FunctionResult(data=output.model_dump())
@@ -313,8 +479,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
return False
try:
from djarea.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser
from mizan.client.jwt import decode_token
from mizan.jwt.tokens import JWTUser
payload = decode_token(token, expected_type="access")
if payload is None:
@@ -322,7 +488,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
# Create JWTUser from token claims - NO DATABASE QUERY
request.user = JWTUser(payload)
request._djarea_jwt_authenticated = True
request._mizan_jwt_authenticated = True
return True
except Exception:
return False
@@ -379,7 +545,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
- Session: Cookie-based with X-CSRFToken header (CSRF required)
Endpoint: POST /api/djarea/call/
Endpoint: POST /api/mizan/call/
Request body (JSON):
{
@@ -430,8 +596,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
# Attach parsed form data and files to request for form functions
request._djarea_form_data = input_data
request._djarea_form_files = request.FILES
request._mizan_form_data = input_data
request._mizan_form_files = request.FILES
else:
# JSON body - standard RPC
@@ -462,6 +628,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
# Execute the function
result = execute_function(request, fn_name, input_data)
# View path — function returned an HttpResponse directly
from django.http import HttpResponseBase
if isinstance(result, HttpResponseBase):
return result
# Return appropriate response
if isinstance(result, FunctionError):
status = {
@@ -475,4 +646,144 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
}.get(result.code, 400)
return result.to_response(status=status)
return result.to_response()
# RPC path — build response with server-driven invalidation
view_class = get_function(fn_name)
response_data = {"result": result.data}
invalidate_contexts = _resolve_invalidation(view_class, input_data)
if invalidate_contexts:
response_data["invalidate"] = invalidate_contexts
response = JsonResponse(response_data)
response["Cache-Control"] = "no-store"
# Always set the header transport too (Edge reads this)
if invalidate_contexts:
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
return response
def execute_context(
request: HttpRequest,
context_name: str,
params: dict[str, str],
) -> FunctionResult | FunctionError:
"""
Execute all functions in a named context with merged params.
Each function receives only the params it declares in its Input schema.
If any function fails (auth, validation, execution), the entire request fails.
Args:
request: The Django HttpRequest
context_name: Name of the context (e.g., 'user', 'global')
params: Query parameters (strings Pydantic coerces types)
Returns:
FunctionResult with bundled data, or FunctionError
"""
groups = get_context_groups()
fn_names = groups.get(context_name)
if not fn_names:
return FunctionError(
code=ErrorCode.NOT_FOUND,
message=f"Context '{context_name}' not found",
)
results = {}
for fn_name in fn_names:
view_class = get_function(fn_name)
if view_class is None:
continue
# Filter params to only those in this function's Input schema
input_cls = getattr(view_class, "Input", None)
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
fn_params = {
k: v for k, v in params.items()
if k in input_cls.model_fields
}
else:
fn_params = None
result = execute_function(request, fn_name, fn_params)
if isinstance(result, FunctionError):
return result
results[fn_name] = result.data
return FunctionResult(data=results)
def _jwt_auth_only(view_func):
"""
Decorator that handles JWT auth for GET endpoints (no CSRF needed for GET).
"""
@wraps(view_func)
def wrapper(request: HttpRequest, *args, **kwargs):
has_jwt = _has_jwt_header(request)
if has_jwt:
if _try_jwt_auth(request):
return view_func(request, *args, **kwargs)
else:
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired JWT token",
).to_response(status=401)
# No JWT — session auth (no CSRF needed for GET)
return view_func(request, *args, **kwargs)
return wrapper
@_jwt_auth_only
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
"""
Fetch all functions in a named context in a single bundled GET request.
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2
Response: raw bundled data, CDN-cacheable.
{
"user_profile": { ... },
"user_orders": [ ... ]
}
Headers:
Cache-Control: public, max-age=0, stale-while-revalidate=300
Vary: Authorization, Cookie
"""
if request.method != "GET":
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message="Only GET method allowed",
).to_response(status=405)
params = request.GET.dict()
result = execute_context(request, context_name, params)
if isinstance(result, FunctionError):
status = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.INTERNAL_ERROR: 500,
ErrorCode.NOT_IMPLEMENTED: 501,
}.get(result.code, 400)
error_response = result.to_response(status=status)
error_response["Cache-Control"] = "no-store"
return error_response
# Deterministic JSON (sorted keys) for consistent cache keys
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
# CDN-ready headers
# max-age=0: browser always revalidates (mutations may have invalidated)
# stale-while-revalidate: edge can serve stale while fetching fresh
# Vary: different auth = different cache entry
response["Cache-Control"] = "public, max-age=0, stale-while-revalidate=300"
response["Vary"] = "Authorization, Cookie"
return response

View File

@@ -1,5 +1,5 @@
"""
Djarea Server Functions - Core Primitive
mizan Server Functions - Core Primitive
Server functions are the core primitive. Everything else builds on them.
@@ -20,15 +20,65 @@ Two styles supported:
from __future__ import annotations
import inspect
import warnings
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, Generic, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
from typing import (
Any,
Callable,
ClassVar,
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
get_type_hints,
)
from django.http import HttpRequest
from pydantic import BaseModel
# Valid context modes: 'global', 'local', or False (not a context)
ContextMode = Literal['global', 'local', False]
# =============================================================================
# REACT CONTEXT - Named context marker
# =============================================================================
class ReactContext:
"""
A named context that groups server functions into one provider and one fetch.
Usage:
UserContext = ReactContext('user')
@client(context=UserContext)
def user_profile(request, user_id: int) -> ProfileShape: ...
@client(context=UserContext)
def user_orders(request, user_id: int) -> list[OrderShape]: ...
@client(affects=UserContext)
def edit_profile(request, name: str) -> dict: ...
@client(affects=[UserContext, OrderContext])
def change_plan(request) -> dict: ...
"""
def __init__(self, name: str):
if not name or not isinstance(name, str):
raise ValueError("ReactContext name must be a non-empty string")
self.name = name
def __repr__(self) -> str:
return f"ReactContext({self.name!r})"
# Built-in global context (auto-mounted at root, SSR-hydrated)
GlobalContext = ReactContext("global")
# Context parameter type: a ReactContext instance, a raw string, or False
ContextMode = ReactContext | str | Literal[False]
TInput = TypeVar("TInput", bound=BaseModel)
@@ -137,6 +187,11 @@ class _FunctionWrapper(ServerFunction):
else:
result = self._wrapped_fn(self.request)
# View path — return HttpResponse directly (no serialization)
from django.http import HttpResponseBase
if isinstance(result, HttpResponseBase):
return result
# Wrap primitive returns in the generated output model
if self._is_primitive_output:
return self._output_cls(result=result)
@@ -167,77 +222,106 @@ class _FunctionWrapper(ServerFunction):
# Valid string values for auth parameter
_VALID_AUTH_STRINGS = frozenset({'required', 'staff', 'superuser'})
_VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
def _resolve_context(context: ContextMode) -> str | Literal[False]:
"""Resolve a context parameter to its name string."""
if context is False:
return False
if isinstance(context, ReactContext):
return context.name
if isinstance(context, str):
if not context.strip():
raise ValueError("context must be a non-empty string, ReactContext, or False.")
if context == "local":
warnings.warn(
"context='local' is deprecated. Use ReactContext('name') instead.",
DeprecationWarning,
stacklevel=3,
)
return context
raise ValueError(
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
)
# Affects parameter type
AffectsTarget = ReactContext | str | type["ServerFunction"]
AffectsMode = AffectsTarget | list[AffectsTarget] | None
def client(
fn: Callable = None,
*,
context: ContextMode = False,
affects: AffectsMode = None,
private: bool = False,
route: str | None = None,
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | Callable[[Any], bool] | None = None,
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
"""
Register a function as a server function.
Type annotations define the schema - just like Django Ninja/FastAPI.
Function parameters become input fields automatically.
Args:
context: Context mode for React state management.
- False (default): Not a context, just a callable function
- 'global': Embedded in root DjangoContext, no params, singleton
- 'local': Standalone provider, supports params via flat props
context: Named context for React state management.
- False (default): Not a context, just a callable function.
- ReactContext instance: groups functions into a named context.
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
affects: Declare which contexts or functions this mutation invalidates.
Mutually exclusive with context=.
Scoping is automatic via argument name matching.
private: If True, the function is not client-callable.
- Not exposed as an RPC endpoint
- No generated TypeScript
- Still participates in the invalidation graph
- Use for webhooks, cron jobs, internal mutations
route: URL route pattern for view-path functions.
Mizan registers this route during autodiscovery.
Example: '/profile/<user_id>/', '/webhooks/stripe/'
methods: HTTP methods allowed for the route.
Default: ['GET'] for context functions, ['POST'] for mutations.
Example: ['POST'], ['GET', 'POST']
websocket: Enable WebSocket RPC transport (default: False).
By default, functions use HTTP-only transport. Enable this for
real-time features (chat, gaming, live updates) that benefit
from lower latency.
Note: Forms (DjareaFormMixin) always use HTTP because auth
flows require full HTTP request semantics.
auth: Authentication requirement.
- None (default): No auth required (AnonymousUser allowed)
- True or 'required': Must be authenticated
- 'staff': Must have is_staff=True
- 'superuser': Must have is_superuser=True
- callable(request) -> bool: Custom check function
Usage:
# Basic HTTP-only function (not a context)
@client
def echo(request, message: str) -> EchoOutput:
return EchoOutput(message=message)
UserContext = ReactContext('user')
# Global context - embedded in DjangoContext, no params
@client(context='global')
def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
@client(context=UserContext)
def user_profile(request, user_id: int) -> ProfileOutput: ...
# Local context - standalone provider, supports params
@client(context='local')
def user_profile(request, user_id: int) -> ProfileOutput:
return ProfileOutput(...)
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict: ...
# WebSocket-enabled for real-time
@client(websocket=True)
def send_message(request, room_id: int, text: str) -> MessageOutput:
return MessageOutput(...)
# View with route — Mizan owns the URL
@client(context=UserContext, route='/profile/<user_id>/')
def profile_page(request, user_id: int) -> HttpResponse: ...
# Local context with WebSocket (live data)
@client(context='local', websocket=True)
def live_user_status(request, user_id: int) -> StatusOutput:
return StatusOutput(...)
# Private webhook — not client-callable, emits invalidation
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
def stripe_webhook(request) -> HttpResponse: ...
Returns:
A ServerFunction class that wraps the function
"""
# Validate context parameter
if context not in (False, 'global', 'local'):
# Resolve context to name string
resolved_context = _resolve_context(context)
# Validate affects parameter
if affects is not None:
if resolved_context is not False:
raise ValueError(
f"Invalid context value '{context}'. "
f"Must be False, 'global', or 'local'."
"context= and affects= are mutually exclusive. "
"A function cannot be both a context reader and a mutation."
)
# Validate auth parameter
@@ -249,18 +333,58 @@ def client(
)
def decorator(fn: Callable) -> type[ServerFunction]:
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
)
# Support both @client and @client(...)
if fn is not None:
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
)
return decorator
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
"""Normalize the affects parameter into a list of target descriptors."""
if affects is None:
return None
items = affects if isinstance(affects, list) else [affects]
result = []
for item in items:
if isinstance(item, ReactContext):
result.append({"type": "context", "name": item.name})
elif isinstance(item, str):
result.append({"type": "context", "name": item})
elif isinstance(item, type) and issubclass(item, ServerFunction):
fn_meta = getattr(item, "_meta", {})
fn_ctx = fn_meta.get("context")
result.append({
"type": "function",
"name": getattr(item, "__name__", str(item)),
"context": fn_ctx or None,
})
else:
raise ValueError(
f"affects items must be ReactContext instances, context name strings, "
f"or @client function references. Got {type(item)}"
)
return result
def _create_server_function(
fn: Callable,
*,
context: ContextMode = False,
context: str | Literal[False] = False,
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
private: bool = False,
route: str | None = None,
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | None = None,
) -> type[ServerFunction]:
@@ -301,25 +425,36 @@ def _create_server_function(
# Get output type from return annotation
output_type = hints.get("return")
if output_type is None:
raise TypeError(
f"Server function '{name}' must have a return type annotation"
raise TypeError(f"Server function '{name}' must have a return type annotation")
# Detect view path: function returns HttpResponse (or has no return annotation
# that maps to a model — view functions often just have -> HttpResponse)
from django.http import HttpResponseBase
is_view_path = (
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
)
# Support primitive return types by wrapping in a model with 'result' field
# Also handle Optional[X] / X | None by extracting the non-None type
if is_view_path:
# View path — no Pydantic output wrapping needed
output_cls = BaseModel # placeholder, never used for serialization
is_primitive_output = False
else:
# RPC path — resolve output type
import types
def is_basemodel_type(t: Any) -> bool:
"""Check if type is a BaseModel subclass, handling Optional/Union."""
if isinstance(t, type) and issubclass(t, BaseModel):
return True
# Handle Union types: typing.Union (Optional[X]) and types.UnionType (X | None)
origin = get_origin(t)
if origin is Union or isinstance(t, types.UnionType):
args = get_args(t)
# Check if any non-None arg is a BaseModel
for arg in args:
if arg is not type(None) and isinstance(arg, type) and issubclass(arg, BaseModel):
if (
arg is not type(None)
and isinstance(arg, type)
and issubclass(arg, BaseModel)
):
return True
return False
@@ -327,7 +462,6 @@ def _create_server_function(
output_cls = output_type
is_primitive_output = False
else:
# Create model wrapper for primitive types (int, str, list, etc.)
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
is_primitive_output = True
@@ -354,10 +488,28 @@ def _create_server_function(
# Build metadata
meta = {}
# Context mode: 'global' or 'local' (False means not a context)
# View path flag (function returns HttpResponse, no codegen)
if is_view_path:
meta["view_path"] = True
# Private flag (not client-callable, no codegen, no RPC endpoint)
if private:
meta["private"] = True
# Route (Mizan-owned URL pattern for view-path functions)
if route:
meta["route"] = route
meta["methods"] = methods or (["GET"] if context else ["POST"])
# Context name (any non-empty string)
if context:
meta["context"] = context
# Affects: mutation invalidation targets
normalized_affects = _normalize_affects(affects)
if normalized_affects:
meta["affects"] = normalized_affects
# WebSocket: enable WebSocket transport
if websocket:
meta["websocket"] = True
@@ -365,7 +517,7 @@ def _create_server_function(
# Auth requirement
if auth is not None:
if auth is True:
meta["auth"] = 'required'
meta["auth"] = "required"
elif callable(auth):
meta["auth"] = auth
else:
@@ -374,7 +526,7 @@ def _create_server_function(
if meta:
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
# Note: Registration happens via discovery (djarea_clients), not here.
# Note: Registration happens via discovery (mizan_clients), not here.
# This allows the decorator to be used without import-time side effects.
return FunctionWrapper
@@ -434,7 +586,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
return [item]
elif isinstance(item, ComposedContext):
return item._leaves.copy()
elif hasattr(item, '_leaves'):
elif hasattr(item, "_leaves"):
# Duck typing for composed contexts
return item._leaves.copy()
else:
@@ -443,11 +595,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
def _is_context_enabled(item) -> bool:
"""Check if an item is a context-enabled function or composition."""
if isinstance(item, ComposedContext) or hasattr(item, '_leaves'):
if isinstance(item, ComposedContext) or hasattr(item, "_leaves"):
return True
if isinstance(item, type) and issubclass(item, ServerFunction):
meta = getattr(item, '_meta', {})
return meta.get('context') in ('global', 'local')
meta = getattr(item, "_meta", {})
return bool(meta.get("context"))
return False
@@ -460,7 +612,7 @@ def compose(
Compose multiple contexts into a single provider.
Args:
*children: Context functions (@client with context='global'|'local')
*children: Context functions (@client with a context name)
or other @compose functions. All must be unique after flattening.
on_server: Bundle all calls into a single server request (default: False).
@@ -498,18 +650,21 @@ def compose(
Returns:
A ComposedContext that can be used in other compositions.
"""
def decorator(fn: Callable) -> ComposedContext:
from djarea.setup.registry import register_compose
from mizan.setup.registry import register_compose
name = fn.__name__
# Validate: all children must be context-enabled
for i, child in enumerate(children):
if not _is_context_enabled(child):
child_name = getattr(child, 'name', getattr(child, '__name__', str(child)))
child_name = getattr(
child, "name", getattr(child, "__name__", str(child))
)
raise ValueError(
f"@compose argument {i} ({child_name}) is not context-enabled. "
f"All children must have @client(context='global'|'local') or be @compose."
f"All children must have @client(context=...) or be @compose."
)
# Flatten to collect all leaves
@@ -529,12 +684,16 @@ def compose(
# Validate transport consistency when on_server=True
if on_server:
has_websocket = [getattr(leaf, '_meta', {}).get('websocket', False) for leaf in leaves]
has_websocket = [
getattr(leaf, "_meta", {}).get("websocket", False) for leaf in leaves
]
if websocket:
# All must have websocket=True
if not all(has_websocket):
non_ws = [leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws]
non_ws = [
leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws
]
raise ValueError(
f"@compose({name}, on_server=True, websocket=True) requires all children "
f"to have websocket=True. These are HTTP-only: {non_ws}"
@@ -542,7 +701,9 @@ def compose(
else:
# All must be HTTP-only
if any(has_websocket):
ws_enabled = [leaf.name for leaf, ws in zip(leaves, has_websocket) if ws]
ws_enabled = [
leaf.name for leaf, ws in zip(leaves, has_websocket) if ws
]
raise ValueError(
f"@compose({name}, on_server=True, websocket=False) requires all children "
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
@@ -628,7 +789,7 @@ def create_form_functions(
Or use the helper:
register_form(ContactForm, 'contact', submit_handler=...)
"""
from djarea.forms.schema_utils import build_form_schema
from mizan.forms.schema_utils import build_form_schema
# Schema function - returns field definitions
class FormSchema(ServerFunction):
@@ -644,7 +805,9 @@ def create_form_functions(
required=field.required,
label=field.label or field.name,
help_text=field.help_text or None,
choices=[(c.value, c.label) for c in field.choices] if field.choices else None,
choices=[(c.value, c.label) for c in field.choices]
if field.choices
else None,
initial=field.initial,
)
for field in schema.fields

View File

@@ -1,5 +1,5 @@
"""
djarea.client.jwt - JWT authentication for server functions.
mizan.client.jwt - JWT authentication for server functions.
Provides:
- Server functions for obtaining/refreshing JWT tokens
@@ -9,12 +9,12 @@ Server Functions:
- jwt_obtain: Convert authenticated session to JWT tokens
- jwt_refresh: Refresh tokens using a refresh token
Note: This module is purpose-built for Djarea server functions.
For Django Ninja API authentication, use djarea.jwt.security directly.
Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use mizan.jwt.security directly.
"""
# Token utilities (re-exports from django_jwt_session)
from djarea.jwt.tokens import (
from mizan.jwt.tokens import (
create_token_pair,
create_access_token,
create_refresh_token,
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
)
# Settings
from djarea.jwt.settings import get_settings, JWTSettings
from mizan.jwt.settings import get_settings, JWTSettings
__all__ = [
# Token utilities

View File

@@ -1,5 +1,5 @@
"""
Djarea OpenAPI Schema Generator
mizan OpenAPI Schema Generator
Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
@@ -11,7 +11,7 @@ NOTE: Schema export is only available via management command for security.
HTTP endpoint has been removed to prevent function enumeration.
Usage:
python manage.py export_djarea_schema
python manage.py export_mizan_schema
"""
from __future__ import annotations
@@ -21,15 +21,21 @@ import re
from typing import TYPE_CHECKING, Any
# Lazy imports to avoid Django settings access at module load time
# (asgi.py imports djarea before Django is fully configured)
# (asgi.py imports mizan before Django is fully configured)
if TYPE_CHECKING:
from django import forms
from ninja import NinjaAPI
from djarea.setup.registry import get_registry, get_schema
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
__all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
__all__ = [
"get_schema",
"generate_openapi_schema",
"generate_openapi_json",
"generate_edge_manifest",
"generate_edge_manifest_json",
]
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
@@ -167,21 +173,26 @@ def _register_schema_endpoint(
and exec() security concerns.
"""
if input_cls is not None:
def endpoint(request, data):
pass
# Set annotations directly to the actual type objects (not strings)
endpoint.__annotations__ = {"data": input_cls}
else:
def endpoint(request):
pass
# Register with Ninja
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 generate_openapi_schema() -> dict[str, Any]:
"""
Generate OpenAPI 3.0 schema for all registered djarea functions.
Generate OpenAPI 3.0 schema for all registered mizan functions.
Uses Django Ninja's schema generation internally to ensure proper
PydanticOpenAPI conversion (handling $refs, nested types, etc.).
@@ -198,9 +209,9 @@ def generate_openapi_schema() -> dict[str, Any]:
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
# battle-tested Pydantic→OpenAPI conversion
schema_api = NinjaAPI(
title="Djarea Server Functions",
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for djarea server functions",
description="Auto-generated schema for mizan server functions",
docs_url=None, # No docs endpoint
openapi_url=None, # No openapi endpoint
)
@@ -234,13 +245,17 @@ def generate_openapi_schema() -> dict[str, Any]:
# Store them in schema_classes so they persist beyond loop scope
# Uses create_model to avoid metaclass conflicts with custom base classes
if has_input:
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls)
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls)
schema_classes[input_type_name] = create_model(
input_type_name, __base__=input_cls
)
schema_classes[output_type_name] = create_model(
output_type_name, __base__=output_cls
)
# Register endpoint using helper to avoid closure capture issues
_register_schema_endpoint(
api=schema_api,
path=f"/djarea/{name}",
path=f"/mizan/{name}",
operation_id=camel_name,
summary=fn_class.__doc__ or f"Call {name}",
input_cls=schema_classes.get(input_type_name),
@@ -262,6 +277,10 @@ def generate_openapi_schema() -> dict[str, Any]:
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
}
# Affects metadata (mutation invalidation)
if meta.get("affects"):
fn_meta_entry["affects"] = meta["affects"]
# For form schema functions, extract field definitions for Zod generation
if meta.get("form") and meta.get("form_role") == "schema":
form_class = meta.get("form_class")
@@ -279,13 +298,53 @@ def generate_openapi_schema() -> dict[str, Any]:
schema = schema_api.get_openapi_schema(path_prefix="")
# Add custom extension with function metadata for provider generation
schema["x-djarea-functions"] = function_metadata
schema["x-mizan-functions"] = function_metadata
# Add x-djarea metadata to each operation
# Add x-mizan-contexts: grouped context metadata with param elevation
context_groups = get_context_groups()
if context_groups:
contexts_meta: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
# Analyze params across all functions in the context
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
annotation = field_info.annotation
# Map Python types to JSON schema types
type_name = "string"
if annotation in (int,):
type_name = "integer"
elif annotation in (float,):
type_name = "number"
elif annotation in (bool,):
type_name = "boolean"
param_info[field_name] = {
"type": type_name,
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required if ALL functions in the context declare it
for p_name, p_meta in param_info.items():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
contexts_meta[ctx_name] = {
"functions": fn_names,
"params": param_info,
}
schema["x-mizan-contexts"] = contexts_meta
# Add x-mizan metadata to each operation
for fn_meta in function_metadata:
path = f"/djarea/{fn_meta['name']}"
path = f"/mizan/{fn_meta['name']}"
if path in schema.get("paths", {}):
schema["paths"][path]["post"]["x-djarea"] = {
schema["paths"][path]["post"]["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
@@ -297,3 +356,154 @@ def generate_openapi_json(indent: int = 2) -> str:
"""Generate OpenAPI schema as formatted JSON string."""
schema = generate_openapi_schema()
return json.dumps(schema, indent=indent)
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.
Example: {"user": ["/profile/:user_id/"]}
Returns:
Manifest dict suitable for JSON serialization.
"""
from pydantic import BaseModel as PydanticBaseModel
# Common user identity param names for user_scoped detection
_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] = {"contexts": {}, "mutations": {}}
for ctx_name, fn_names in groups.items():
# Collect params and routes from all functions in this context
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
meta = getattr(fn_cls, "_meta", {})
is_view = meta.get("view_path", False)
# Collect param names from Input schema
input_cls = getattr(fn_cls, "Input", None)
if (
input_cls
and input_cls is not PydanticBaseModel
and hasattr(input_cls, "model_fields")
):
param_names.update(input_cls.model_fields.keys())
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if is_view else "rpc",
}
# Collect routes from view-path functions
fn_route = meta.get("route")
if fn_route:
fn_entry["route"] = fn_route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(fn_route)
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
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",
}
# Add page routes from view-path functions with route=
if page_routes:
ctx_entry["page_routes"] = page_routes
# Add externally-declared view URLs
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
# Mutations section — all functions with affects=
for fn_name, fn_cls in all_functions.items():
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
continue
# Resolve context names from affects targets
affected_contexts = []
for target in affects:
if target["type"] == "context":
affected_contexts.append(target["name"])
elif target["type"] == "function" and target.get("context"):
affected_contexts.append(target["context"])
affected_contexts = list(dict.fromkeys(affected_contexts))
# Determine which params auto-scope
auto_scoped = []
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
fn_params = set(input_cls.model_fields.keys())
for ctx_name in affected_contexts:
ctx_params = set()
for ctx_fn_name in groups.get(ctx_name, []):
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls:
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
ctx_params.update(ctx_input.model_fields.keys())
auto_scoped.extend(sorted(fn_params & ctx_params))
auto_scoped = list(dict.fromkeys(auto_scoped))
mutation_entry: dict[str, Any] = {
"affects": affected_contexts,
}
if auto_scoped:
mutation_entry["auto_scoped_params"] = auto_scoped
if meta.get("private"):
mutation_entry["private"] = True
if meta.get("route"):
mutation_entry["route"] = meta["route"]
mutation_entry["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation_entry
return manifest
def generate_edge_manifest_json(
indent: int = 2,
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
) -> str:
"""Generate Edge manifest as formatted JSON string."""
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
return json.dumps(manifest, indent=indent, sort_keys=True)

View File

@@ -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.)
while exposing them through the unified server function API.
Usage:
from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta
from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
submit_label="Send",
@@ -98,7 +98,7 @@ def _create_form_input_schema(
form = form_class()
except TypeError:
# 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:
fields_dict = form.fields
@@ -125,9 +125,9 @@ def _create_form_input_schema(
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,
and serializes to JSON for the frontend schema.
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
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):
djarea = DjareaFormMeta(
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
)
@@ -197,10 +197,10 @@ class DjareaFormMixin:
"""
# Configuration - subclasses must define this
djarea: ClassVar[DjareaFormMeta]
mizan: ClassVar[mizanFormMeta]
# Track registered forms to avoid duplicate registration
_djarea_registered: ClassVar[bool] = False
_mizan_registered: ClassVar[bool] = False
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
@@ -236,9 +236,7 @@ class DjareaFormMixin:
return result
return None
def on_submit_failure(
self, request: HttpRequest, errors: "FormValidation"
) -> None:
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
"""
Called after form validation fails.
@@ -250,23 +248,23 @@ class DjareaFormMixin:
"""Auto-register when a concrete form class is defined."""
super().__init_subclass__(**kwargs)
# Only register concrete forms with djarea config defined
if _is_concrete_djarea_form(cls):
# Only register concrete forms with mizan config defined
if _is_concrete_mizan_form(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:
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
3. It hasn't been registered yet (for this class definition)
"""
# Must have djarea config (check cls.__dict__ to avoid inheriting)
djarea_config = cls.__dict__.get("djarea")
if not isinstance(djarea_config, DjareaFormMeta):
# Must have mizan config (check cls.__dict__ to avoid inheriting)
mizan_config = cls.__dict__.get("mizan")
if not isinstance(mizan_config, mizanFormMeta):
return False
# Must be a Django form
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
return False
# 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 True
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
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:
- {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 .schema_utils import build_form_schema
from .validation_utils import validate_form_instance
from djarea.setup.registry import register
from djarea.client.function import ServerFunction
from mizan.setup.registry import register
from mizan.client.function import ServerFunction
config: DjareaFormMeta = form_class.djarea
config: mizanFormMeta = form_class.mizan
form_name = config.name
# Mark as registered
form_class._djarea_registered = True
form_class._mizan_registered = True
# 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
# 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 {},
**init_kwargs,
)
# Override with DjareaFormMeta values
# Override with mizanFormMeta values
if config.title is not None:
schema.title = config.title
if config.subtitle is not None:
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
request = self.request
# Check if we have multipart data from executor
if hasattr(request, "_djarea_form_data"):
data = request._djarea_form_data
files = request._djarea_form_files
if hasattr(request, "_mizan_form_data"):
data = request._mizan_form_data
files = request._mizan_form_files
elif input is not None:
# JSON input - already a dict
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."""
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 .validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data
from djarea.setup.registry import register
from djarea.client.function import ServerFunction
from mizan.setup.registry import register
from mizan.client.function import ServerFunction
formset_class = formset_factory(form_class)
# 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
# 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)
# Handle multipart vs JSON
if hasattr(request, "_djarea_form_data"):
post_data = request._djarea_form_data
files = request._djarea_form_files
elif input and hasattr(input, 'forms'):
if hasattr(request, "_mizan_form_data"):
post_data = request._mizan_form_data
files = request._mizan_form_files
elif input and hasattr(input, "forms"):
# Input.forms is already a list of dicts
forms_data = input.forms
post_data = forms_to_formset_post_data(forms_data)

View File

@@ -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:
- Auth contexts (auth_status, user) - required by frontend allauth module
@@ -11,8 +11,8 @@ Usage:
# In your app's apps.py
class MyAppConfig(AppConfig):
def ready(self):
import djarea.allauth.forms # noqa - registers forms
import djarea.allauth.contexts # noqa - registers contexts
import mizan.allauth.forms # noqa - registers forms
import mizan.allauth.contexts # noqa - registers contexts
"""
from .contexts import auth_status, user, AuthStatusOutput, UserOutput

View File

@@ -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.
Separated into two concerns:
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
from django.http import HttpRequest
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):
"""Authentication status and permission guards."""
is_authenticated: bool
user_id: int | None = None
is_staff: bool = False
is_superuser: bool = False
@client(context='global')
@client(context="global")
def auth_status(request: HttpRequest) -> AuthStatusOutput:
"""
Auth status context - provides authentication state and guards.
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
class UserOutput(BaseModel):
"""Full user profile data."""
id: int
email: str
first_name: str = ""
last_name: str = ""
@client(context='global')
@client(context="global")
def user(request: HttpRequest) -> UserOutput | None:
"""
User profile context - provides full user data.
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
return None
# 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)
return UserOutput(
id=req_user.id,
email=req_user.email,
first_name=getattr(req_user, 'first_name', '') or '',
last_name=getattr(req_user, 'last_name', '') or '',
first_name=getattr(req_user, "first_name", "") or "",
last_name=getattr(req_user, "last_name", "") or "",
)
# JWTUser - need to fetch from DB
from django.contrib.auth import get_user_model
User = get_user_model()
try:
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
return UserOutput(
id=db_user.id,
email=db_user.email,
first_name=db_user.first_name or '',
last_name=db_user.last_name or '',
first_name=db_user.first_name or "",
last_name=db_user.last_name or "",
)
except User.DoesNotExist:
return None

View File

@@ -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.
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):
def ready(self):
import djarea.allauth.forms # noqa
import mizan.allauth.forms # noqa
"""
from __future__ import annotations
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
from django.http import HttpRequest
from djarea.forms import DjareaFormMixin, DjareaFormMeta
from mizan.forms import mizanFormMixin, mizanFormMeta
# Account forms
from allauth.account.forms import (
@@ -41,6 +41,7 @@ from allauth.account.forms import (
# Password reauthentication form - conditionally import
try:
from allauth.account.forms import ReauthenticateForm
HAS_REAUTH = True
except ImportError:
HAS_REAUTH = False
@@ -51,6 +52,7 @@ try:
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
HAS_MFA = True
except ImportError:
HAS_MFA = False
@@ -58,22 +60,24 @@ except ImportError:
# WebAuthn forms (if available)
try:
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
HAS_WEBAUTHN = True
except ImportError:
HAS_WEBAUTHN = False
if TYPE_CHECKING:
from djarea.forms.schemas import FormValidation
from mizan.forms.schemas import FormValidation
# =============================================================================
# Account Forms
# =============================================================================
class DjareaLoginForm(LoginForm, DjareaFormMixin):
class mizanLoginForm(LoginForm, mizanFormMixin):
"""Sign in with email and password."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="login",
title="Sign In",
subtitle="Welcome back. Enter your credentials to continue.",
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
return None
class DjareaSignupForm(SignupForm, DjareaFormMixin):
class mizanSignupForm(SignupForm, mizanFormMixin):
"""Create a new account."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="signup",
title="Create Account",
subtitle="Enter your details to get started.",
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
return None
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
"""Add another email address to your account."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="add_email",
title="Add Email Address",
subtitle="Add another email address to your account.",
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
return None
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
"""Change your account password."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="change_password",
title="Change Password",
subtitle="Update your password to keep your account secure.",
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
return None
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
"""Set a password for accounts created via social login."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="set_password",
title="Set Password",
subtitle="Create a password for your account.",
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
return None
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
"""Request a password reset email."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="reset_password",
title="Reset 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
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
"""Set a new password using a reset key."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="reset_password_from_key",
title="Set New Password",
subtitle="Enter your new password below.",
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
return None
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
"""Request a login code via email."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="request_login_code",
title="Sign In with Code",
subtitle="Enter your email address and we'll send you a login code.",
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
return None
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
"""Confirm a login code."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="confirm_login_code",
title="Enter Code",
subtitle="Enter the code we sent to your email.",
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
return None
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
"""Verify an email with a token."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="user_token",
title="Verify Email",
subtitle="Enter the verification code from your email.",
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
# Password reauthentication - conditionally define
if HAS_REAUTH:
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
"""Re-authenticate with password for sensitive actions."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="reauthenticate",
title="Confirm Your Identity",
subtitle="Please enter your password to continue.",
@@ -280,6 +285,7 @@ if HAS_REAUTH:
def on_submit_success(self, request: HttpRequest) -> dict | None:
from allauth.account.internal.flows import reauthentication
reauthentication.reauthenticate_by_password(request)
return None
@@ -289,10 +295,11 @@ if HAS_REAUTH:
# =============================================================================
if HAS_MFA:
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
"""Authenticate with MFA during login."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="mfa_authenticate",
title="Two-Factor Authentication",
subtitle="Enter your authentication code to continue.",
@@ -307,10 +314,10 @@ if HAS_MFA:
self.save()
return None
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin):
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
"""Re-authenticate with MFA for sensitive actions."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="mfa_reauthenticate",
title="Confirm Your Identity",
subtitle="Enter your authentication code to continue.",
@@ -325,10 +332,10 @@ if HAS_MFA:
self.save()
return None
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin):
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
"""Activate TOTP authenticator."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="activate_totp",
title="Set Up Authenticator",
subtitle="Enter the code from your authenticator app to complete setup.",
@@ -343,10 +350,10 @@ if HAS_MFA:
self.save()
return None
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin):
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
"""Deactivate TOTP authenticator."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="deactivate_totp",
title="Disable Authenticator",
subtitle="Enter your password to disable two-factor authentication.",
@@ -361,10 +368,10 @@ if HAS_MFA:
self.save()
return None
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin):
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
"""Generate new recovery codes."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="generate_recovery_codes",
title="Recovery Codes",
subtitle="Generate new recovery codes for your account.",
@@ -381,10 +388,11 @@ if HAS_MFA:
if HAS_WEBAUTHN:
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
"""Authenticate with WebAuthn security key."""
djarea = DjareaFormMeta(
mizan = mizanFormMeta(
name="webauthn_authenticate",
title="Security Key",
subtitle="Use your security key to authenticate.",

View File

@@ -1,5 +1,5 @@
"""
djarea.jwt - JWT authentication for server functions.
mizan.jwt - JWT authentication for server functions.
Provides:
- Server functions for obtaining/refreshing JWT tokens
@@ -10,10 +10,10 @@ Server Functions:
- jwt_refresh: Refresh tokens using a refresh token
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.
For Django Ninja API authentication, use djarea.jwt.security directly.
Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use mizan.jwt.security directly.
"""
# Server functions (import to register with @client decorator)
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
# Security (Ninja API auth) - lazy import to avoid triggering
# django-ninja's settings access at module load time.
# Use: from djarea.jwt.security import jwt_auth
# Use: from mizan.jwt.security import jwt_auth
def __getattr__(name):
if name in ("JWTAuth", "jwt_auth"):
from .security import JWTAuth, jwt_auth
globals()["JWTAuth"] = JWTAuth
globals()["jwt_auth"] = jwt_auth
return globals()[name]

View File

@@ -1,19 +1,20 @@
"""
JWT Server Functions
JWT token operations exposed as djarea server functions.
JWT token operations exposed as mizan server functions.
Works over WebSocket RPC (primary) or HTTP fallback.
"""
from django.http import HttpRequest
from pydantic import BaseModel
from djarea.client import client
from djarea.jwt.tokens import create_token_pair, refresh_tokens
from mizan.client import client
from mizan.jwt.tokens import create_token_pair, refresh_tokens
class TokenPairOutput(BaseModel):
"""JWT token pair response."""
access_token: str
refresh_token: str
expires_in: int
@@ -21,6 +22,7 @@ class TokenPairOutput(BaseModel):
class JWTError(BaseModel):
"""JWT operation error."""
error: str
@@ -45,10 +47,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
raise PermissionError("Authentication required")
# Get session key - for WebSocket, this comes from the scope
session = getattr(request, 'session', None)
session = getattr(request, "session", None)
if session is None:
# 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:
raise PermissionError("No session available")
else:
@@ -61,8 +65,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
tokens = create_token_pair(
user.pk,
session_key,
is_staff=getattr(user, 'is_staff', False),
is_superuser=getattr(user, 'is_superuser', False),
is_staff=getattr(user, "is_staff", False),
is_superuser=getattr(user, "is_superuser", False),
)
return TokenPairOutput(

View File

@@ -25,7 +25,7 @@ class Command(BaseCommand):
)
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()

View File

@@ -1,12 +1,12 @@
"""
Export Djarea Schema
Export mizan Schema
Management command to export the djarea OpenAPI schema for TypeScript code generation.
Management command to export the mizan OpenAPI schema for TypeScript code generation.
The schema is consumed by openapi-typescript for robust type generation.
Usage:
python manage.py export_djarea_schema # Output to stdout
python manage.py export_djarea_schema --output schema.json # Output to file
python manage.py export_mizan_schema # Output to stdout
python manage.py export_mizan_schema --output schema.json # Output to file
"""
import json
@@ -14,11 +14,11 @@ from pathlib import Path
from django.core.management.base import BaseCommand
from djarea.export import generate_openapi_schema
from mizan.export import generate_openapi_schema
class Command(BaseCommand):
help = "Export djarea OpenAPI schema for TypeScript code generation"
help = "Export mizan OpenAPI schema for TypeScript code generation"
def add_arguments(self, parser):
parser.add_argument(
@@ -44,8 +44,6 @@ class Command(BaseCommand):
output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output)
self.stdout.write(
self.style.SUCCESS(f"Schema written to {output_path}")
)
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
else:
self.stdout.write(json_output)

View File

@@ -0,0 +1,56 @@
"""
Export Edge Manifest
Generates the static JSON manifest that Mizan Edge reads at deploy time
to configure CDN cache rules and invalidation routing.
Usage:
python manage.py export_edge_manifest
python manage.py export_edge_manifest --output mizan-manifest.json
python manage.py export_edge_manifest --base-url /api/mizan
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from mizan.export import generate_edge_manifest
class Command(BaseCommand):
help = "Export Edge manifest for CDN cache invalidation"
def add_arguments(self, parser):
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path. If not specified, outputs to stdout.",
)
parser.add_argument(
"--indent",
type=int,
default=2,
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):
manifest = generate_edge_manifest(base_url=options["base_url"])
indent = options["indent"] if options["indent"] > 0 else None
json_output = json.dumps(manifest, indent=indent, sort_keys=True)
if options["output"]:
output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output)
self.stdout.write(self.style.SUCCESS(f"Manifest written to {output_path}"))
else:
self.stdout.write(json_output)

View File

@@ -1,13 +1,13 @@
"""
djarea.setup - Integration and registration utilities.
mizan.setup - Integration and registration utilities.
This subpackage contains everything developers need to integrate Djarea:
This subpackage contains everything developers need to integrate mizan:
- Registry for server functions and channels
- Auto-discovery for apps
- Configuration settings
Usage:
from djarea.setup import djarea_clients, register, get_function
from mizan.setup import mizan_clients, register, get_function
"""
from .registry import (
@@ -25,17 +25,18 @@ from .registry import (
get_registry,
get_schema,
get_contexts,
get_context_groups,
get_forms,
clear_registry,
)
from .discovery import (
djarea_clients,
djarea_module,
mizan_clients,
mizan_module,
)
from .settings import (
DjareaSettings,
mizanSettings,
get_settings,
clear_settings_cache,
)
@@ -57,13 +58,14 @@ __all__ = [
"get_registry",
"get_schema",
"get_contexts",
"get_context_groups",
"get_forms",
"clear_registry",
# Discovery
"djarea_clients",
"djarea_module",
"mizan_clients",
"mizan_module",
# Settings
"DjareaSettings",
"mizanSettings",
"get_settings",
"clear_settings_cache",
]

View File

@@ -1,25 +1,25 @@
"""
Djarea Auto-Discovery
mizan Auto-Discovery
Scans Django apps for server functions following the 'clients' layer convention:
- <app>/clients.py
- <app>/clients/**/*.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
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py
mizan_clients('apps') # Scans apps/*/clients.py
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
This replaces manual "import to register" patterns with explicit auto-discovery.
"""
from typing import Any
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
from .registry import register, get_function
from djarea.client.function import ServerFunction
from mizan.client.function import ServerFunction
class _RegisterServerFunctions:
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
isinstance(member, type)
and issubclass(member, ServerFunction)
and member is not ServerFunction
and hasattr(member, '__name__')
and hasattr(member, "__name__")
):
# Use the function name as registration name
fn_name = getattr(member, 'name', None) or member.__name__
fn_name = getattr(member, "name", None) or member.__name__
# Skip already registered (idempotent)
if get_function(fn_name) is member:
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
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.
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
Example:
# In urls.py
djarea_clients('apps') # Scans apps/*/clients.py
djarea_clients('apps', 'functions') # Scans apps/*/functions.py
mizan_clients('apps') # Scans apps/*/clients.py
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
"""
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions())
def djarea_module(module_path: str) -> None:
def mizan_module(module_path: str) -> None:
"""
Register server functions from a specific module.
Use this for library modules that don't follow the app convention.
Args:
module_path: Full module path (e.g., 'djarea.integrations.allauth')
module_path: Full module path (e.g., 'mizan.integrations.allauth')
Example:
djarea_module('djarea.integrations.allauth')
djarea_module('djarea.jwt.functions')
mizan_module('mizan.integrations.allauth')
mizan_module('mizan.jwt.functions')
"""
members = get_members(module_path)
handler = _RegisterServerFunctions()
handler.on_module('', [], members)
handler.on_module("", [], members)

View File

@@ -1,5 +1,5 @@
"""
Djarea Registry
mizan Registry
Central registration for server functions, channels, and compositions.
All items are identified by name.
@@ -10,8 +10,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from djarea.client.function import ServerFunction, ComposedContext
from djarea.channels import ReactChannel
from mizan.client.function import ServerFunction, ComposedContext
from mizan.channels import ReactChannel
# Global registries - all use name as key
@@ -34,8 +34,8 @@ def register(
Returns:
The view class (allows use as part of decorator chain)
"""
from djarea.client.function import ServerFunction
from djarea.channels import ReactChannel
from mizan.client.function import ServerFunction
from mizan.channels import ReactChannel
view_class.name = name
@@ -98,7 +98,7 @@ def register_form(
Usage:
register_form(ContactForm, 'contact', submit_handler=handle_contact)
"""
from djarea.client.function import create_form_functions
from mizan.client.function import create_form_functions
schema_fn, validate_fn, submit_fn = create_form_functions(
form_class, name, submit_handler
@@ -130,9 +130,7 @@ def register_compose(
# Same composition being re-registered (reload scenario)
_compositions[name] = composed
return composed
raise ValueError(
f"Composition '{name}' already registered by {existing.name}"
)
raise ValueError(f"Composition '{name}' already registered by {existing.name}")
_compositions[name] = composed
return composed
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
}
# Extract Params schema (only if defined)
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()
# Extract ReactMessage schema (only if defined - indicates bidirectional)
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema[
"react_message"
] = channel_class.ReactMessage.model_json_schema()
channel_schema["bidirectional"] = True
# Extract DjangoMessage schema (only if defined)
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema[
"django_message"
] = channel_class.DjangoMessage.model_json_schema()
channels_schema[name] = channel_schema
@@ -288,6 +290,21 @@ def get_contexts() -> dict[str, type["ServerFunction"]]:
return contexts
def get_context_groups() -> dict[str, list[str]]:
"""
Group function names by their context string.
Returns:
{"global": ["current_user"], "user": ["user_profile", "user_orders"]}
"""
groups: dict[str, list[str]] = {}
for name, cls in _functions.items():
ctx = getattr(cls, "_meta", {}).get("context")
if ctx:
groups.setdefault(ctx, []).append(name)
return groups
def get_forms() -> dict[str, list[type["ServerFunction"]]]:
"""
Get all server functions that are form-related, grouped by form name.

View File

@@ -1,5 +1,5 @@
"""
Djarea Settings
mizan Settings
Configuration is read from Django settings with sensible defaults.
"""
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
@dataclass
class DjareaSettings:
"""Djarea configuration."""
class mizanSettings:
"""mizan configuration."""
# Whether to expose function names in DEBUG mode errors
debug_expose_names: bool
@lru_cache
def get_settings() -> DjareaSettings:
def get_settings() -> mizanSettings:
"""
Load Djarea settings from Django settings.
Load mizan settings from Django settings.
Settings:
DJAREA_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
"""
return DjareaSettings(
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True),
return mizanSettings(
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
)

Some files were not shown because too many files have changed in this diff Show More