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/ .vscode/
# Build artifacts # Build artifacts
desktop/frontend/dist/ examples/django-react-desktop-app/frontend/dist/
e2e/harness/src/api/generated.* examples/django-react-site/harness/src/api/generated.*
e2e/harness/test-results/ examples/django-react-site/harness/test-results/
# Env # Env
.env .env

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 .PHONY: install test test-django test-react test-integration docker-up docker-down clean
DJANGO = packages/mizan-django
REACT = packages/mizan-react
# ─── Setup ─────────────────────────────────────────────────────────────────── # ─── Setup ───────────────────────────────────────────────────────────────────
install: install:
cd django && pip install -e ".[dev,channels]" cd $(DJANGO) && uv pip install -e ".[dev,channels]"
cd react && npm install cd $(REACT) && npm install
# ─── Unit Tests ────────────────────────────────────────────────────────────── # ─── Unit Tests ──────────────────────────────────────────────────────────────
test: test-django test-react test: test-django test-react
test-django: test-django:
cd django && pytest cd $(DJANGO) && uv run pytest
test-react: test-react:
cd react && npm test cd $(REACT) && npm test
# ─── Integration Tests ────────────────────────────────────────────────────── # ─── Integration Tests ──────────────────────────────────────────────────────
test-integration: docker-up test-integration: docker-up
@echo "Waiting for backend..." @echo "Waiting for backend..."
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done' @timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
cd react && npm run test:integration cd $(REACT) && npm run test:integration
@$(MAKE) docker-down @$(MAKE) docker-down
# ─── Docker ────────────────────────────────────────────────────────────────── # ─── Docker ──────────────────────────────────────────────────────────────────
docker-up: docker-up:
docker compose -f docker-compose.test.yml up -d --build docker compose -f examples/django-react-site/docker-compose.test.yml up -d --build
@echo "Backend starting at http://localhost:8000" @echo "Backend starting at http://localhost:8000"
docker-down: docker-down:
docker compose -f docker-compose.test.yml down docker compose -f examples/django-react-site/docker-compose.test.yml down
# ─── All ───────────────────────────────────────────────────────────────────── # ─── All ─────────────────────────────────────────────────────────────────────
@@ -40,7 +43,7 @@ test-all: test test-integration
# ─── Cleanup ───────────────────────────────────────────────────────────────── # ─── Cleanup ─────────────────────────────────────────────────────────────────
clean: clean:
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
rm -rf django/src/djarea.egg-info django/dist django/build rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
rm -rf react/dist react/node_modules rm -rf $(REACT)/dist $(REACT)/node_modules
rm -f example/db.sqlite3 rm -f examples/django-react-site/backend/db.sqlite3

342
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. You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
No API routing, no serializers, no REST/CRUD bullshit.
```python ```python
@client # Django
def current_user(request) -> UserShape: @client(context='global')
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0] def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
``` ```
```tsx ```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 ## Quick Start
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
### 1. Django setup ### 1. Django setup
```python ```python
# settings.py # settings.py
INSTALLED_APPS = [ INSTALLED_APPS = [
"djarea", "mizan",
"myapp", "myapp",
] ]
# urls.py # urls.py
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
] ]
# asgi.py (for WebSocket support) # asgi.py (for WebSocket support)
from djarea import wrap_asgi from mizan import wrap_asgi
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
``` ```
### 2. Define your client functions ### 2. Define server functions
```python ```python
# myapp/clients.py # myapp/mizan_clients.py
from djarea.client import client from django.http import HttpRequest
from djarea.shapes import Shape from mizan.client import client
from mizan.setup.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
message: str message: str
@client @client
def echo(request, text: str) -> EchoOutput: def echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text) 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 ### 4. Generate TypeScript
// django.config.mjs
```bash
# django.config.mjs
export default { export default {
source: { source: {
django: { 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 ```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 ```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 }) { export default function Layout({ children }) {
return <DjangoContext>{children}</DjangoContext> return <DjangoContext>{children}</DjangoContext>
} }
```
```tsx
// page.tsx // page.tsx
import { useEcho, useCurrentUser, DjangoError } from '@/api'
function MyComponent() { function MyComponent() {
const user = useCurrentUser() const user = useCurrentUser()
const echo = useEcho() const echo = useEcho()
@@ -127,80 +121,91 @@ function MyComponent() {
console.log(result.message) // typed console.log(result.message) // typed
} catch (e) { } catch (e) {
if (e instanceof DjangoError) { if (e instanceof DjangoError) {
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc. 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 ## Architecture
# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
# Dropdown menu — two columns, no joins ```
class FlatAuthorShape(Shape[Author]): React app
id: int | None = None └─ <DjangoContext> ← generated provider (includes ChannelProvider)
name: str ├─ 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 The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()
# Dropdown: SELECT id, name. That's it. ## Code Generation
authors = FlatAuthorShape.query()
`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: Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
```python
@client
def update_articles(request, articles: list[ArticleShape]) -> dict:
for article, diff in ArticleShape.diff_many(articles):
if diff.is_new:
create_article(article)
elif diff.changed:
update_fields(article, diff.changed)
for tag in diff.tags.created:
add_tag(article, tag)
for tag_id in diff.tags.deleted:
remove_tag(article, tag_id)
return {"ok": True}
```
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
## The `@client` decorator
The decorator controls transport, caching, auth, and SSR behavior:
| Decorator | React hook | What it does |
|-----------|-----------|--------------|
| `@client` | `useEcho()` | HTTP call, returns typed result |
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
| `@client(auth=True)` | — | Requires authentication |
| `@client(auth='staff')` | — | Requires staff status |
| `@client(auth=my_check)` | — | Custom auth callable |
## Forms ## Forms
Django forms become typed React hooks with client-side Zod validation: Django forms get typed React hooks with client-side Zod validation:
```python ```python
class ContactForm(DjareaFormMixin, forms.Form): # Django
djarea = DjareaFormMeta( class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
``` ```
```tsx ```tsx
// React (generated)
const form = useContactForm() const form = useContactForm()
form.schema // field metadata, title, submit label form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
form.data // { name: '', email: '', message: '' } form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server) form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } } 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 ## Channels
WebSocket channels with typed messages: WebSocket channels with typed messages:
```python ```python
# Django
class ChatChannel(ReactChannel): class ChatChannel(ReactChannel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
``` ```
```tsx ```tsx
// React (generated)
const chat = useChatChannel({ room: 'general' }) const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected' chat.status // 'connecting' | 'connected' | 'disconnected'
@@ -259,111 +265,33 @@ chat.messages // ChatDjangoMessage[]
chat.send({ text: 'hello' }) 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 ## Testing
```bash ```bash
# Django # Django unit tests
cd django && uv run pytest cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
# React # React unit tests
cd react && npm test cd packages/mizan-react && npm test
# E2E (Playwright, real browser + real backend) # E2E integration tests (real browser, real backend)
docker compose -f docker-compose.test.yml up -d docker compose -f examples/django-react-site/docker-compose.test.yml up -d
cd e2e/harness && npx djarea-generate && npx playwright test 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 make test-all
``` ```
## Project structure ## Project Structure
``` ```
djarea/ mizan/
django/ Python package packages/
react/ TypeScript package mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
example/ Integration test backend mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
e2e/ Playwright E2E tests mizan-react/ React adapter (thin wrapper around runtime)
Makefile Test orchestration 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 #!/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. 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 import os
@@ -63,7 +63,7 @@ def main():
base_url = f"http://{host}:{port}" 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) print("ERROR: Django server failed to start", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -83,7 +83,7 @@ def main():
import webview import webview
window = webview.create_window( window = webview.create_window(
title="Djarea Desktop", title="mizan Desktop",
url=base_url, url=base_url,
width=1024, width=1024,
height=768, height=768,

View File

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

View File

@@ -1,7 +1,7 @@
""" """
Desktop RPC server functions. Desktop RPC server functions.
Tests Djarea's appropriateness for desktop apps: Tests mizan's appropriateness for desktop apps:
- Local file system access - Local file system access
- SQLite CRUD - SQLite CRUD
- System introspection - System introspection
@@ -20,10 +20,10 @@ from pathlib import Path
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import client from mizan.client import client
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.channels import register as register_channel from mizan.channels import register as register_channel
# ============================================================================= # =============================================================================
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
home_dir: str home_dir: str
cwd: str cwd: str
cpu_count: int cpu_count: int
djarea_version: str mizan_version: str
@client(websocket=True) @client(websocket=True)
def system_info(request: HttpRequest) -> SystemInfoOutput: def system_info(request: HttpRequest) -> SystemInfoOutput:
import djarea import mizan
return SystemInfoOutput( return SystemInfoOutput(
os_name=platform.system(), os_name=platform.system(),
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
home_dir=str(Path.home()), home_dir=str(Path.home()),
cwd=os.getcwd(), cwd=os.getcwd(),
cpu_count=os.cpu_count() or 1, 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 = [] entries = []
try: 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: try:
stat = entry.stat() stat = entry.stat()
entries.append(FileEntry( entries.append(
name=entry.name, FileEntry(
path=str(entry), name=entry.name,
is_dir=entry.is_dir(), path=str(entry),
size=stat.st_size if not entry.is_dir() else 0, is_dir=entry.is_dir(),
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), size=stat.st_size if not entry.is_dir() else 0,
)) modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
)
)
except (PermissionError, OSError): except (PermissionError, OSError):
continue continue
except PermissionError: except PermissionError:
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
@client(websocket=True) @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 from backend.models import Note
note = Note.objects.create(title=title, content=content, pinned=pinned) 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 from django.conf import settings
return AppInfoOutput( return AppInfoOutput(
app_name="Djarea Desktop", app_name="mizan Desktop",
uptime_seconds=round(time.time() - _start_time, 2), uptime_seconds=round(time.time() - _start_time, 2),
db_path=str(settings.DATABASES["default"]["NAME"]), db_path=str(settings.DATABASES["default"]["NAME"]),
pid=os.getpid(), 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, Runs entirely local: SQLite database, in-memory channel layer,
no external services required. no external services required.

View File

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

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Djarea Desktop</title> <title>mizan Desktop</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; } 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, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -7,7 +7,7 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@rythazhur/djarea": "file:../../react", "@rythazhur/mizan": "file:../../react",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
def ready(self): 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() django.setup()
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from djarea import wrap_asgi from mizan import wrap_asgi
# Register server functions and channels before building the ASGI app # 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()) application = wrap_asgi(get_asgi_application())

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
/** /**
* Djarea E2E Integration Tests * mizan E2E Integration Tests
* *
* Real Chromium Real React app (generated hooks) Real Django backend * 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' import { test, expect } from '@playwright/test'
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
expect(result.fields.password).toBeDefined() 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') await fixture(page, 'form-contact-schema')
const result = await getResult(page) const result = await getResult(page)
expect(result.title).toBe('Contact Us') expect(result.title).toBe('Contact Us')

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<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> <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html> </html>

View File

@@ -1,5 +1,5 @@
{ {
"name": "djarea-e2e-harness", "name": "mizan-e2e-harness",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -7,7 +7,7 @@
"dev": "vite --port 5174" "dev": "vite --port 5174"
}, },
"dependencies": { "dependencies": {
"@rythazhur/djarea": "file:../../react", "@rythazhur/mizan": "file:../../react",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zod": "^4.3.6" "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: * 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 // Regenerate with: npm run schemas
// ============================================================================= // =============================================================================
// Djarea Provider & Hooks // mizan Provider & Hooks
// ============================================================================= // =============================================================================
export { export {
@@ -55,9 +55,9 @@ export {
useJwtObtain, useJwtObtain,
useJwtRefresh, useJwtRefresh,
// Re-exports from djarea library // Re-exports from mizan library
useDjarea, usemizan,
useDjareaStatus, usemizanStatus,
usePush, usePush,
DjangoError, DjangoError,
type ConnectionStatus, type ConnectionStatus,

View File

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

View File

@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
function App() { function App() {
return ( return (
<DjangoContext baseUrl="/api/djarea"> <DjangoContext baseUrl="/api/mizan">
<Fixtures /> <Fixtures />
</DjangoContext> </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", "version": "1.0.0",
"description": "Django + React server functions framework.", "description": "Django + React server functions framework.",
"main": "index.js", "main": "index.js",

View File

@@ -1,7 +1,7 @@
import { defineConfig } from '@playwright/test' import { defineConfig } from '@playwright/test'
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: '.',
timeout: 15000, timeout: 15000,
retries: 0, retries: 0,
reporter: 'list', 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. Django server functions framework. See the [monorepo root](../README.md) for full documentation.
## Install ## Install
```bash ```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 ## Setup
```python ```python
# settings.py # settings.py
INSTALLED_APPS = ["djarea", ...] INSTALLED_APPS = ["mizan", ...]
# urls.py # urls.py
path("api/djarea/", include("djarea.urls")) path("api/mizan/", include("mizan.urls"))
# asgi.py (optional, for WebSocket) # asgi.py (optional, for WebSocket)
from djarea import wrap_asgi from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
``` ```
## Define Functions ## Define Functions
```python ```python
from djarea.client import client from mizan.client import client
from djarea.setup.registry import register from mizan.setup.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class Output(BaseModel): class Output(BaseModel):
@@ -43,7 +43,7 @@ Register in `apps.py`:
```python ```python
def ready(self): def ready(self):
import myapp.djarea_clients import myapp.mizan_clients
``` ```
## Auth ## Auth
@@ -65,10 +65,10 @@ def ready(self):
## Forms ## Forms
```python ```python
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="contact", title="Contact Us") mizan = mizanFormMeta(name="contact", title="Contact Us")
name = forms.CharField() name = forms.CharField()
email = forms.EmailField() email = forms.EmailField()
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
## Channels ## Channels
```python ```python
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
class ChatChannel(ReactChannel): class ChatChannel(ReactChannel):
class Params(BaseModel): class Params(BaseModel):

View File

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

View File

@@ -16,7 +16,7 @@ export async function generateChannelsTypes(schema) {
const typesCode = astToString(ast) const typesCode = astToString(ast)
const lines = [ const lines = [
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
'// ============================================================================', '// ============================================================================',
@@ -27,8 +27,8 @@ export async function generateChannelsTypes(schema) {
'', '',
] ]
// Extract channel metadata from x-djarea-channels extension // Extract channel metadata from x-mizan-channels extension
const channels = schema['x-djarea-channels'] || [] const channels = schema['x-mizan-channels'] || []
if (channels.length > 0) { if (channels.length > 0) {
lines.push('// ============================================================================') lines.push('// ============================================================================')
@@ -86,7 +86,7 @@ export async function generateChannelsTypes(schema) {
* Generate channel hooks from metadata. * Generate channel hooks from metadata.
*/ */
export function generateChannelsHooks(schema) { export function generateChannelsHooks(schema) {
const channels = schema['x-djarea-channels'] || [] const channels = schema['x-mizan-channels'] || []
if (channels.length === 0) { if (channels.length === 0) {
return null return null
@@ -95,10 +95,10 @@ export function generateChannelsHooks(schema) {
const lines = [ const lines = [
"'use client'", "'use client'",
'', '',
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// 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 * 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' 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) { 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. * from the generated files for clean imports.
*/ */
/** function pascalCase(str) {
* Extract context hooks from djarea schema. return str.charAt(0).toUpperCase() + str.slice(1)
* 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)
return contexts.map(ctx => { function toPascalCase(str) {
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1) return str
return `use${pascal}` .split(/[.\-_]/)
}).sort() .map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
} }
/** /**
* Generate the consolidated index.ts file. * 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 = [ const lines = [
'/**', '/**',
' * Djarea API - Consolidated Exports', ' * mizan API - Consolidated Exports',
' *', ' *',
' * Import everything from here:', ' * Import everything from here:',
' *', ' *',
' * @example', ' * @example',
' * ```tsx', ' * ```tsx',
' * import {', ' * import {',
' * DjangoContext,', ' * MizanContext,',
' * useUser,', ' * useCurrentUser,',
' * useEcho,', ' * useEcho,',
' * useChatChannel,', ' * useChatChannel,',
' * DjangoError,',
' * } from \'@/api\'', ' * } from \'@/api\'',
' * ```', ' * ```',
' */', ' */',
'', '',
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
] ]
// ========================================================================== const functions = mizanSchema?.['x-mizan-functions'] || []
// Djarea Provider & Hooks (from generated.django.tsx) const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
// ========================================================================== const hasMizan = functions.length > 0
const functions = djareaSchema?.['x-djarea-functions'] || [] if (hasMizan) {
const hasDjarea = functions.length > 0 const globalContexts = functions.filter(fn => fn.isContext === 'global')
if (hasDjarea) {
const contextHooks = extractContextHooks(djareaSchema)
const contexts = functions.filter(fn => fn.isContext)
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('// Djarea Provider & Hooks') lines.push('// mizan Provider & Hooks')
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('') lines.push('')
// Server exports (getDjangoHydration runs in server components) // Server exports
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push('export {') lines.push('export {')
lines.push(' getMizanHydration,')
lines.push(' getDjangoHydration,') lines.push(' getDjangoHydration,')
lines.push(' type MizanHydrationData,')
lines.push(' type DjangoHydration,') lines.push(' type DjangoHydration,')
lines.push("} from './generated.django.server'") lines.push("} from './generated.server'")
lines.push('') lines.push('')
} }
// Client exports // Client exports
lines.push('export {') lines.push('export {')
lines.push(' // Provider') lines.push(' // Provider')
lines.push(' MizanContext,')
lines.push(' type MizanContextProps,')
lines.push(' DjangoContext,') lines.push(' DjangoContext,')
lines.push(' type DjangoContextProps,') lines.push(' type DjangoContextProps,')
if (contexts.length > 0) { // Global context hooks
if (globalContexts.length > 0) {
lines.push('') lines.push('')
lines.push(' // Context hooks') lines.push(' // Global context hooks')
for (const hookName of contextHooks) { for (const ctx of globalContexts) {
lines.push(` ${hookName},`) const hookPascal = pascalCase(ctx.camelName)
lines.push(` use${hookPascal},`)
} }
lines.push('') lines.push('')
lines.push(' // Refresh hooks') lines.push(' // Refresh hooks')
lines.push(' useMizanRefresh,')
lines.push(' useDjangoRefresh,') 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) { if (regularFunctions.length > 0) {
lines.push('') lines.push('')
lines.push(' // Function hooks') lines.push(' // Function hooks')
for (const fn of regularFunctions) { 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(` use${pascal},`)
} }
} }
lines.push('') lines.push('')
lines.push(' // Re-exports from djarea library') lines.push(' // Re-exports from mizan library')
lines.push(' useDjarea,') lines.push(' useMizan,')
lines.push(' useDjareaStatus,') lines.push(' useMizanStatus,')
lines.push(' usePush,') lines.push(' usePush,')
lines.push(' DjangoError,') lines.push(' DjangoError,')
lines.push(' type ConnectionStatus,') lines.push(' type ConnectionStatus,')
lines.push(' type PushMessage,') lines.push(' type PushMessage,')
lines.push(' type PushListener,') lines.push(' type PushListener,')
lines.push("} from './generated.django'") lines.push("} from './generated.provider'")
lines.push('') 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) { if (channels.length > 0) {
lines.push('// =============================================================================') lines.push('// =============================================================================')
@@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, djareaSchema }) {
lines.push("} from './generated.channels.hooks'") lines.push("} from './generated.channels.hooks'")
lines.push('') lines.push('')
// Channel types
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('// Channel Types') lines.push('// Channel Types')
lines.push('// =============================================================================') 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. * Uses openapi-typescript for robust type generation.
* *
* Output structure: * Output structure:
* - generated.djarea.ts - Types only (from OpenAPI) * - generated.mizan.ts - Types only (from OpenAPI)
* - generated.provider.tsx - Typed provider wrapping DjareaProvider + hooks * - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks
* - generated.forms.ts - Typed form hooks with Zod schemas * - generated.forms.ts - Typed form hooks with Zod schemas
*/ */
@@ -74,14 +74,14 @@ function buildSchemaExports(schemaNames) {
/** /**
* Generate the types file using openapi-typescript. * Generate the types file using openapi-typescript.
*/ */
export async function generateDjareaTypes(schema) { export async function generateMizanTypes(schema) {
// Generate types using openapi-typescript // Generate types using openapi-typescript
const ast = await openapiTS(schema) const ast = await openapiTS(schema)
const schemaNames = getSchemaNamesFromAst(ast) const schemaNames = getSchemaNamesFromAst(ast)
const typesCode = astToString(ast) const typesCode = astToString(ast)
const lines = [ const lines = [
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
'// ============================================================================', '// ============================================================================',
@@ -104,11 +104,11 @@ export async function generateDjareaTypes(schema) {
'', '',
] ]
// Extract function metadata from x-djarea-functions extension // Extract function metadata from x-mizan-functions extension
const functions = schema['x-djarea-functions'] || [] const functions = schema['x-mizan-functions'] || []
if (functions.length > 0) { if (functions.length > 0) {
lines.push('export const DJANGO_FUNCTIONS = {') lines.push('export const MIZAN_FUNCTIONS = {')
for (const fn of functions) { for (const fn of functions) {
lines.push(` ${fn.camelName}: {`) lines.push(` ${fn.camelName}: {`)
lines.push(` name: '${fn.name}',`) lines.push(` name: '${fn.name}',`)
@@ -119,7 +119,7 @@ export async function generateDjareaTypes(schema) {
} }
lines.push('} as const') lines.push('} as const')
} else { } else {
lines.push('export const DJANGO_FUNCTIONS = {} as const') lines.push('export const MIZAN_FUNCTIONS = {} as const')
} }
lines.push('') 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: * The generated provider:
* - Wraps DjareaProvider (from djarea library) * - MizanContext: Root provider with global context bundled fetch
* - Passes context names for auto-fetch * - Named context providers: <UserContext user_id={...}>
* - Provides typed hooks for contexts and functions * - Mutation hooks with auto-invalidation
* - Plain function hooks
*/ */
export function generateDjareaProvider(schema, options = {}) { export function generateMizanProvider(schema, options = {}) {
const { hasChannels = false } = 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) { if (functions.length === 0) {
return null return null
} }
// Separate contexts from regular functions // Partition functions
const contexts = functions.filter(fn => fn.isContext) const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) 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 // Collect type imports
const typeImports = [] const typeImports = []
@@ -162,36 +194,36 @@ export function generateDjareaProvider(schema, options = {}) {
const lines = [ const lines = [
"'use client'", "'use client'",
'', '',
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
'// This file provides typed wrappers around the djarea library.', '// This file provides typed wrappers around the mizan library.',
'// - DjangoContext: Typed provider wrapping DjareaProvider', '// - MizanContext: Root provider with global context',
'// - Typed hooks: useAuthStatus(), useUser(), etc.', '// - 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 {", "import {",
" DjareaProvider,", " MizanProvider,",
" useDjarea,", " useMizan,",
" useDjareaContext,", " useMizanContext,",
" useDjareaCall,", " useMizanCall,",
" type DjareaHydration,", " type MizanHydration,",
" type Transport,", " type Transport,",
"} from 'djarea'", "} from 'mizan'",
...(hasChannels ? [ ...(hasChannels ? [
"import { ChannelProvider, ChannelConnection } from 'djarea/channels'", "import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
"import { useRef } from 'react'",
] : []), ] : []),
'', '',
] ]
if (uniqueTypeImports.length > 0) { if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`) lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('') lines.push('')
} }
// ============================================================================ // ============================================================================
// Hydration types // Hydration types (global contexts only)
// ============================================================================ // ============================================================================
lines.push('// ============================================================================') lines.push('// ============================================================================')
@@ -199,20 +231,19 @@ export function generateDjareaProvider(schema, options = {}) {
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push('/** Typed hydration data for SSR */') lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface DjangoHydration {') lines.push('export interface MizanHydrationData {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
} }
lines.push('}') lines.push('}')
lines.push('') lines.push('')
lines.push('/** Convert typed hydration to djarea format */') lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
lines.push('function toDjareaHydration(hydration?: DjangoHydration): DjareaHydration | undefined {')
lines.push(' if (!hydration) return undefined') lines.push(' if (!hydration) return undefined')
lines.push(' const result: DjareaHydration = {}') lines.push(' const result: MizanHydration = {}')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`) lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
} }
lines.push(' return result') 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('// ============================================================================')
lines.push('// Provider') lines.push('// Root Provider')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('export interface DjangoContextProps {') lines.push('export interface MizanContextProps {')
lines.push(' children: ReactNode') lines.push(' children: ReactNode')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' /** SSR hydration data */') lines.push(' /** SSR hydration data (global contexts only) */')
lines.push(' hydration?: DjangoHydration') lines.push(' hydration?: MizanHydrationData')
} }
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */') lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
lines.push(' wsUrl?: string') 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(' baseUrl?: string')
lines.push('}') lines.push('}')
lines.push('') lines.push('')
// Context names array for DjareaProvider
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
lines.push('/**') lines.push('/**')
lines.push(' * Typed Django context provider.') lines.push(' * Root mizan provider. Mount at your app root.')
lines.push(' *')
lines.push(' * Wraps DjareaProvider with:')
lines.push(' * - Typed hydration')
lines.push(' * - Auto-fetch for registered contexts')
lines.push(' *') lines.push(' *')
lines.push(' * Usage:') lines.push(' * Usage:')
lines.push(' * <DjangoContext hydration={hydration}>') lines.push(' * <MizanContext hydration={hydration}>')
lines.push(' * <App />') lines.push(' * <App />')
lines.push(' * </DjangoContext>') lines.push(' * </MizanContext>')
lines.push(' */') lines.push(' */')
lines.push('export function DjangoContext({') lines.push('export function MizanContext({')
lines.push(' children,') lines.push(' children,')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' hydration,') lines.push(' hydration,')
} }
lines.push(' wsUrl,') lines.push(' wsUrl,')
lines.push(' baseUrl,') lines.push(' baseUrl,')
lines.push('}: DjangoContextProps) {') lines.push('}: MizanContextProps) {')
if (hasChannels) { if (hasChannels) {
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)') lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
@@ -274,11 +336,11 @@ export function generateDjareaProvider(schema, options = {}) {
lines.push('') lines.push('')
} }
// Build the JSX tree
lines.push(' return (') lines.push(' return (')
lines.push(' <DjareaProvider') lines.push(' <MizanProvider')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' hydration={toDjareaHydration(hydration)}') lines.push(' hydration={toMizanHydration(hydration)}')
lines.push(` contexts={[${contextNames}]}`)
} }
lines.push(' wsUrl={wsUrl}') lines.push(' wsUrl={wsUrl}')
lines.push(' baseUrl={baseUrl}') lines.push(' baseUrl={baseUrl}')
@@ -287,93 +349,221 @@ export function generateDjareaProvider(schema, options = {}) {
} }
lines.push(' >') lines.push(' >')
if (hasChannels) { // Inner content: GlobalContextLoader wraps children if needed
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>') let innerContent = '{children}'
lines.push(' {children}') if (globalContexts.length > 0) {
lines.push(' </ChannelProvider>') innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
} else {
lines.push(' {children}')
} }
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('}') 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('// ============================================================================')
lines.push('// Context Hooks (typed wrappers)') lines.push('// Global Context Hooks')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
for (const ctx of contexts) { for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName) const pascal = pascalCase(ctx.camelName)
lines.push(`/**`) lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
lines.push(` * Get ${ctx.name} context data.`)
lines.push(` * @throws if context not loaded yet`)
lines.push(` */`)
lines.push(`export function use${pascal}(): ${ctx.outputType} {`) lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
lines.push(` const data = useDjareaContext<${ctx.outputType}>('${ctx.name}')`) lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) {`) lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` }`)
lines.push(` return data`) lines.push(` return data`)
lines.push(`}`) lines.push(`}`)
lines.push('') lines.push('')
} }
// Refresh hooks lines.push('/** Refresh functions for global contexts. */')
lines.push('/**') lines.push('export function useMizanRefresh() {')
lines.push(' * Get context refresh functions without subscribing to data changes.') lines.push(' const { invalidateContext } = useMizan()')
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(' return {') lines.push(' return {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName) 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('}') 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('// ============================================================================')
lines.push('// Function Hooks (typed wrappers)') lines.push('// Named Context Providers')
lines.push('// ============================================================================') lines.push('// ============================================================================')
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) const pascal = pascalCase(fn.camelName)
// Transport is known at generation time - pass it directly
const transport = fn.transport || 'http' const transport = fn.transport || 'http'
if (fn.hasInput) { if (fn.hasInput) {
lines.push(`/**`) lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`) 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(`}`) lines.push(`}`)
} else { } else {
lines.push(`/**`) lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`) 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(`}`)
} }
lines.push('') lines.push('')
@@ -385,11 +575,11 @@ export function generateDjareaProvider(schema, options = {}) {
// ============================================================================ // ============================================================================
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('// Re-exports from djarea library') lines.push('// Re-exports from mizan library')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push("export { useDjarea, useDjareaStatus, usePush, DjangoError } from 'djarea'") lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'")
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'djarea'") lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'")
lines.push('') lines.push('')
return lines.join('\n') return lines.join('\n')
@@ -399,20 +589,20 @@ export function generateDjareaProvider(schema, options = {}) {
* Generate server-side hydration helper (runs in Next.js server components). * 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. * This is separate from the client file because it needs to run on the server.
*/ */
export function generateDjareaServer(schema) { export function generateMizanServer(schema) {
const functions = schema['x-djarea-functions'] || [] const functions = schema['x-mizan-functions'] || []
const contexts = functions.filter(fn => fn.isContext) const globalContexts = functions.filter(fn => fn.isContext === 'global')
if (contexts.length === 0) { if (globalContexts.length === 0) {
return null return null
} }
// Collect type imports for contexts // Collect type imports for global contexts
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean) const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
const uniqueTypeImports = [...new Set(typeImports)].sort() const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [ const lines = [
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'//', '//',
'// Server-side functions for SSR hydration.', '// Server-side functions for SSR hydration.',
@@ -421,7 +611,7 @@ export function generateDjareaServer(schema) {
] ]
if (uniqueTypeImports.length > 0) { if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`) lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('') lines.push('')
} }
@@ -430,67 +620,66 @@ export function generateDjareaServer(schema) {
lines.push('// Hydration Types') lines.push('// Hydration Types')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('/** Typed hydration data for SSR */') lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface DjangoHydration {') lines.push('export interface MizanHydrationData {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
} }
lines.push('}') lines.push('}')
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('// ============================================================================')
lines.push('// SSR Hydration Helper') lines.push('// SSR Hydration Helper')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') 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(' *')
lines.push(' * Call this in your server component:') lines.push(' * Call this in your server component:')
lines.push(' * const hydration = await getDjangoHydration(client)') lines.push(' * const hydration = await getMizanHydration(client)')
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>') lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
lines.push(' */') 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(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
lines.push('): Promise<DjangoHydration> {') lines.push('): Promise<MizanHydrationData> {')
lines.push(' const hydration: DjangoHydration = {}') lines.push(' const hydration: MizanHydrationData = {}')
lines.push('') lines.push('')
lines.push(' const results = await Promise.allSettled([') lines.push(' try {')
for (const ctx of contexts) { lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
lines.push(` client.request('POST', '/api/djarea/call/', { fn: '${ctx.name}', args: {} }),`) 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(' } else {')
lines.push('') lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
lines.push(' }')
contexts.forEach((ctx, i) => { lines.push(' } catch (e) {')
lines.push(` if (results[${i}].status === 'fulfilled') {`) lines.push(" console.error('[getMizanHydration] Request failed:', e)")
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`) lines.push(' }')
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(' }')
})
lines.push('') lines.push('')
lines.push(' return hydration') lines.push(' return hydration')
lines.push('}') lines.push('}')
lines.push('') lines.push('')
lines.push('/** @deprecated Use getMizanHydration instead */')
lines.push('export const getDjangoHydration = getMizanHydration')
lines.push('')
return lines.join('\n') return lines.join('\n')
} }
/** /**
* Generate all djarea files. * Generate all mizan files.
*/ */
export async function generateDjareaFiles(schema, options = {}) { export async function generateMizanFiles(schema, options = {}) {
const types = await generateDjareaTypes(schema) const types = await generateMizanTypes(schema)
const provider = generateDjareaProvider(schema, options) const provider = generateMizanProvider(schema, options)
const server = generateDjareaServer(schema) const server = generateMizanServer(schema)
const forms = generateDjareaForms(schema) const forms = generateMizanForms(schema)
return { types, provider, server, forms } return { types, provider, server, forms }
} }
@@ -498,8 +687,8 @@ export async function generateDjareaFiles(schema, options = {}) {
/** /**
* Generate typed form hooks with Zod schemas. * Generate typed form hooks with Zod schemas.
*/ */
export function generateDjareaForms(schema) { export function generateMizanForms(schema) {
const functions = schema['x-djarea-functions'] || [] const functions = schema['x-mizan-functions'] || []
// Group form functions by form name // Group form functions by form name
const formFunctions = functions.filter(fn => fn.isForm) const formFunctions = functions.filter(fn => fn.isForm)
@@ -535,7 +724,7 @@ export function generateDjareaForms(schema) {
const lines = [ const lines = [
"'use client'", "'use client'",
'', '',
'// AUTO-GENERATED by djarea - do not edit manually', '// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
'// Typed form hooks with Zod validation.', '// Typed form hooks with Zod validation.',
@@ -549,7 +738,7 @@ export function generateDjareaForms(schema) {
" type DjangoFormState,", " type DjangoFormState,",
" type DjangoFormsetState,", " type DjangoFormsetState,",
" type FormOptions,", " type FormOptions,",
"} from 'djarea'", "} from 'mizan'",
'', '',
'// ============================================================================', '// ============================================================================',
'// Zod Schemas', '// Zod Schemas',
@@ -658,7 +847,7 @@ export function generateDjareaForms(schema) {
lines.push('// Form Registry') lines.push('// Form Registry')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('export const DJANGO_FORMS = {') lines.push('export const MIZAN_FORMS = {')
for (const [formName, group] of formGroups) { for (const [formName, group] of formGroups) {
if (!group.schema) continue if (!group.schema) continue
const pascalName = toPascalCase(formName) const pascalName = toPascalCase(formName)

View File

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

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

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

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. Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await self._try_jwt_auth() await self._try_jwt_auth()
await self.accept() await self.accept()
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}") logger.debug(
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
)
async def _try_jwt_auth(self): async def _try_jwt_auth(self):
""" """
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate JWT and create JWTUser (no DB query) # Validate JWT and create JWTUser (no DB query)
try: try:
from djarea.client.jwt import decode_token from mizan.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
payload = await sync_to_async(decode_token)(token, expected_type="access") payload = await sync_to_async(decode_token)(token, expected_type="access")
if payload is None: if payload is None:
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
elif action == "rpc": elif action == "rpc":
await self._handle_rpc(content) await self._handle_rpc(content)
else: else:
await self.send_json({ await self.send_json(
"error": f"Unknown action: {action}", {
}) "error": f"Unknown action: {action}",
}
)
async def _handle_subscribe(self, content: dict): async def _handle_subscribe(self, content: dict):
"""Handle subscription request.""" """Handle subscription request."""
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Get channel class # Get channel class
channel_class = get_channel(channel_name) channel_class = get_channel(channel_name)
if not channel_class: if not channel_class:
await self.send_json({ await self.send_json(
"error": f"Unknown channel: {channel_name}", {
}) "error": f"Unknown channel: {channel_name}",
}
)
return return
# Create subscription key # Create subscription key
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Check if already subscribed # Check if already subscribed
if sub_key in self._subscriptions: if sub_key in self._subscriptions:
await self.send_json({ await self.send_json(
"error": f"Already subscribed to {channel_name}", {
"channel": channel_name, "error": f"Already subscribed to {channel_name}",
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
return return
# Create channel instance # Create channel instance
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
try: try:
params_obj = channel_class.Params(**params_dict) params_obj = channel_class.Params(**params_dict)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid params: {e}", {
"channel": channel_name, "error": f"Invalid params: {e}",
}) "channel": channel_name,
}
)
return return
# Check authorization # Check authorization
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
authorized = instance.authorize() authorized = instance.authorize()
except Exception as e: except Exception as e:
logger.error(f"Authorization error for {channel_name}: {e}") logger.error(f"Authorization error for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": "Authorization failed", {
"channel": channel_name, "error": "Authorization failed",
}) "channel": channel_name,
}
)
return return
if not authorized: if not authorized:
await self.send_json({ await self.send_json(
"error": "Not authorized", {
"channel": channel_name, "error": "Not authorized",
}) "channel": channel_name,
}
)
return return
# Get group and join # Get group and join
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await instance._join_group(group_name) await instance._join_group(group_name)
except Exception as e: except Exception as e:
logger.error(f"Failed to join group for {channel_name}: {e}") logger.error(f"Failed to join group for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Failed to subscribe: {e}", {
"channel": channel_name, "error": f"Failed to subscribe: {e}",
}) "channel": channel_name,
}
)
return return
# Store subscription # Store subscription
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.error(f"on_connect error for {channel_name}: {e}") logger.error(f"on_connect error for {channel_name}: {e}")
# Confirm subscription # Confirm subscription
await self.send_json({ await self.send_json(
"subscribed": True, {
"channel": channel_name, "subscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Subscribed to {channel_name} with params {params_dict}") logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as e: except Exception as e:
logger.error(f"Error during unsubscribe: {e}") logger.error(f"Error during unsubscribe: {e}")
await self.send_json({ await self.send_json(
"unsubscribed": True, {
"channel": channel_name, "unsubscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Unsubscribed from {channel_name}") logger.debug(f"Unsubscribed from {channel_name}")
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
instance = self._subscriptions.get(sub_key) instance = self._subscriptions.get(sub_key)
if not instance: if not instance:
await self.send_json({ await self.send_json(
"error": f"Not subscribed to {channel_name}", {
"channel": channel_name, "error": f"Not subscribed to {channel_name}",
}) "channel": channel_name,
}
)
return return
channel_class = instance.__class__ channel_class = instance.__class__
# Check if channel accepts messages # Check if channel accepts messages
if not channel_class.ReactMessage: if not channel_class.ReactMessage:
await self.send_json({ await self.send_json(
"error": f"Channel {channel_name} does not accept messages", {
"channel": channel_name, "error": f"Channel {channel_name} does not accept messages",
}) "channel": channel_name,
}
)
return return
# Parse message # Parse message
try: try:
msg = channel_class.ReactMessage(**data) msg = channel_class.ReactMessage(**data)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid message: {e}", {
"channel": channel_name, "error": f"Invalid message: {e}",
}) "channel": channel_name,
}
)
return return
# Parse params # Parse params
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as e: except Exception as e:
logger.error(f"Error handling message for {channel_name}: {e}") logger.error(f"Error handling message for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Message handling failed: {e}", {
"channel": channel_name, "error": f"Message handling failed: {e}",
}) "channel": channel_name,
}
)
async def _handle_rpc(self, content: dict): async def _handle_rpc(self, content: dict):
""" """
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
- Function must be explicitly registered (no arbitrary code execution) - Function must be explicitly registered (no arbitrary code execution)
- User context from WebSocket session is passed to function - User context from WebSocket session is passed to function
""" """
from djarea.client.executor import execute_function, FunctionError from mizan.client.executor import execute_function, FunctionError
from djarea.setup.registry import get_function from mizan.setup.registry import get_function
request_id = content.get("id") request_id = content.get("id")
fn_name = content.get("fn") fn_name = content.get("fn")
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate request structure # Validate request structure
if not request_id: if not request_id:
await self.send_json({ await self.send_json(
"error": "RPC request missing 'id' field", {
}) "error": "RPC request missing 'id' field",
}
)
return return
if not fn_name: if not fn_name:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "BAD_REQUEST", "error": {
"message": "Missing 'fn' field", "code": "BAD_REQUEST",
}, "message": "Missing 'fn' field",
}) },
}
)
return return
# Check if function exists and has websocket=True # Check if function exists and has websocket=True
fn_class = get_function(fn_name) fn_class = get_function(fn_name)
if fn_class is None: if fn_class is None:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "NOT_FOUND", "error": {
"message": f"Function '{fn_name}' not found", "code": "NOT_FOUND",
}, "message": f"Function '{fn_name}' not found",
}) },
}
)
return return
# Only allow functions explicitly marked with websocket=True # Only allow functions explicitly marked with websocket=True
fn_meta = getattr(fn_class, "_meta", {}) fn_meta = getattr(fn_class, "_meta", {})
if not fn_meta.get("websocket"): if not fn_meta.get("websocket"):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "FORBIDDEN", "error": {
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.", "code": "FORBIDDEN",
}, "message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
}) },
}
)
return return
# Create request adapter from WebSocket scope # Create request adapter from WebSocket scope
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None)) ws_request = WebSocketRequest(
self.scope, channel_name=getattr(self, "channel_name", None)
)
# Execute function (Pydantic validation happens inside execute_function) # Execute function (Pydantic validation happens inside execute_function)
# This is sync, so we need to run it in a thread pool # This is sync, so we need to run it in a thread pool
@@ -435,21 +473,25 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Send response # Send response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": result.code.value, "error": {
"message": result.message, "code": result.code.value,
**({"details": result.details} if result.details else {}), "message": result.message,
}, **({"details": result.details} if result.details else {}),
}) },
}
)
else: else:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": True, "id": request_id,
"data": result.data, "ok": True,
}) "data": result.data,
}
)
async def channel_message(self, event: dict): async def channel_message(self, event: dict):
""" """
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Called when channel_layer.group_send() is used. Called when channel_layer.group_send() is used.
Includes channel name and params so the client can route the message. Includes channel name and params so the client can route the message.
""" """
await self.send_json({ await self.send_json(
"channel": event.get("channel"), {
"params": event.get("params", {}), "channel": event.get("channel"),
"type": event.get("message_type", "message"), "params": event.get("params", {}),
"data": event.get("data", {}), "type": event.get("message_type", "message"),
}) "data": event.get("data", {}),
}
)
async def push_message(self, event: dict): async def push_message(self, event: dict):
""" """
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Protocol: Protocol:
Server sends: {"type": "push", "topic": "room:42", "data": {...}} Server sends: {"type": "push", "topic": "room:42", "data": {...}}
""" """
await self.send_json({ await self.send_json(
"type": "push", {
"topic": event.get("topic"), "type": "push",
"data": event.get("data", {}), "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. Simple API for pushing data to subscribed WebSocket connections.
Usage: Usage:
# In a server function - push to all subscribers # In a server function - push to all subscribers
from djarea.push import push from mizan.push import push
push("room:42", {"type": "new_message", "data": {...}}) push("room:42", {"type": "new_message", "data": {...}})
# Subscribe a connection to a topic (call during context fetch) # Subscribe a connection to a topic (call during context fetch)
from djarea.push import subscribe from mizan.push import subscribe
subscribe(request, "room:42") subscribe(request, "room:42")
""" """
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
"""Get channel layer, returning None if channels is not installed.""" """Get channel layer, returning None if channels is not installed."""
try: try:
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
return get_channel_layer() return get_channel_layer()
except ImportError: except ImportError:
return None return None
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
def _async_to_sync(coro): def _async_to_sync(coro):
"""Wrapper for async_to_sync that handles missing channels.""" """Wrapper for async_to_sync that handles missing channels."""
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
return async_to_sync(coro) return async_to_sync(coro)
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
channel_layer = _get_channel_layer() channel_layer = _get_channel_layer()
if not channel_layer: if not channel_layer:
import logging import logging
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"No channel layer configured, cannot push to topic '%s'", topic "No channel layer configured, cannot push to topic '%s'", topic
) )
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
"type": "push.message", # Maps to push_message handler in consumer "type": "push.message", # Maps to push_message handler in consumer
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
"type": "push.message", "type": "push.message",
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )

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

View File

@@ -1,5 +1,5 @@
""" """
Djarea Function Executor mizan Function Executor
Handles execution of server functions. Handles execution of server functions.
This is the core of the "Server Functions" feature - callable from React 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 django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError 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: if TYPE_CHECKING:
pass pass
@@ -134,23 +134,23 @@ def _check_auth_requirement(
) )
# Check authentication (required for all string-based auth) # Check authentication (required for all string-based auth)
if not getattr(user, 'is_authenticated', False): if not getattr(user, "is_authenticated", False):
return FunctionError( return FunctionError(
code=ErrorCode.UNAUTHORIZED, code=ErrorCode.UNAUTHORIZED,
message="Authentication required", message="Authentication required",
) )
# Check staff requirement # Check staff requirement
if auth_requirement == 'staff': if auth_requirement == "staff":
if not getattr(user, 'is_staff', False): if not getattr(user, "is_staff", False):
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message="Staff access required", message="Staff access required",
) )
# Check superuser requirement # Check superuser requirement
elif auth_requirement == 'superuser': elif auth_requirement == "superuser":
if not getattr(user, 'is_superuser', False): if not getattr(user, "is_superuser", False):
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message="Superuser access required", message="Superuser access required",
@@ -159,6 +159,151 @@ def _check_auth_requirement(
return None 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( def execute_function(
request: HttpRequest, request: HttpRequest,
fn_name: str, fn_name: str,
@@ -190,8 +335,15 @@ def execute_function(
message=message, message=message,
) )
# Check auth requirement BEFORE executing # Reject private functions from RPC dispatch
meta = getattr(view_class, "_meta", {}) 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_requirement = meta.get("auth")
auth_error = _check_auth_requirement(request, auth_requirement) auth_error = _check_auth_requirement(request, auth_requirement)
if auth_error is not None: if auth_error is not None:
@@ -224,7 +376,8 @@ def execute_function(
if not isinstance(input_data, dict): if not isinstance(input_data, dict):
return FunctionError( return FunctionError(
code=ErrorCode.BAD_REQUEST, 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) validated_input = input_cls(**input_data)
elif has_input: elif has_input:
@@ -280,10 +433,23 @@ def execute_function(
code=ErrorCode.INTERNAL_ERROR, code=ErrorCode.INTERNAL_ERROR,
message="An internal error occurred", message="An internal error occurred",
# Don't expose internal details in production # 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: if output is None:
return FunctionResult(data=None) return FunctionResult(data=None)
return FunctionResult(data=output.model_dump()) return FunctionResult(data=output.model_dump())
@@ -313,8 +479,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
return False return False
try: try:
from djarea.client.jwt import decode_token from mizan.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
payload = decode_token(token, expected_type="access") payload = decode_token(token, expected_type="access")
if payload is None: if payload is None:
@@ -322,7 +488,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
# Create JWTUser from token claims - NO DATABASE QUERY # Create JWTUser from token claims - NO DATABASE QUERY
request.user = JWTUser(payload) request.user = JWTUser(payload)
request._djarea_jwt_authenticated = True request._mizan_jwt_authenticated = True
return True return True
except Exception: except Exception:
return False return False
@@ -379,7 +545,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed) - JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
- Session: Cookie-based with X-CSRFToken header (CSRF required) - Session: Cookie-based with X-CSRFToken header (CSRF required)
Endpoint: POST /api/djarea/call/ Endpoint: POST /api/mizan/call/
Request body (JSON): 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"} 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 # Attach parsed form data and files to request for form functions
request._djarea_form_data = input_data request._mizan_form_data = input_data
request._djarea_form_files = request.FILES request._mizan_form_files = request.FILES
else: else:
# JSON body - standard RPC # JSON body - standard RPC
@@ -462,6 +628,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
# Execute the function # Execute the function
result = execute_function(request, fn_name, input_data) result = execute_function(request, fn_name, input_data)
# View path — function returned an HttpResponse directly
from django.http import HttpResponseBase
if isinstance(result, HttpResponseBase):
return result
# Return appropriate response # Return appropriate response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
status = { status = {
@@ -475,4 +646,144 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
}.get(result.code, 400) }.get(result.code, 400)
return result.to_response(status=status) return result.to_response(status=status)
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. Server functions are the core primitive. Everything else builds on them.
@@ -20,15 +20,65 @@ Two styles supported:
from __future__ import annotations from __future__ import annotations
import inspect import inspect
import warnings
from abc import ABC, abstractmethod 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 django.http import HttpRequest
from pydantic import BaseModel 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) TInput = TypeVar("TInput", bound=BaseModel)
@@ -137,6 +187,11 @@ class _FunctionWrapper(ServerFunction):
else: else:
result = self._wrapped_fn(self.request) 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 # Wrap primitive returns in the generated output model
if self._is_primitive_output: if self._is_primitive_output:
return self._output_cls(result=result) return self._output_cls(result=result)
@@ -167,78 +222,107 @@ class _FunctionWrapper(ServerFunction):
# Valid string values for auth parameter # 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( def client(
fn: Callable = None, fn: Callable = None,
*, *,
context: ContextMode = False, context: ContextMode = False,
affects: AffectsMode = None,
private: bool = False,
route: str | None = None,
methods: list[str] | None = None,
websocket: bool = False, websocket: bool = False,
auth: bool | str | Callable[[Any], bool] | None = None, auth: bool | str | Callable[[Any], bool] | None = None,
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]: ) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
""" """
Register a function as a server function. Register a function as a server function.
Type annotations define the schema - just like Django Ninja/FastAPI.
Function parameters become input fields automatically.
Args: Args:
context: Context mode for React state management. context: Named context for React state management.
- False (default): Not a context, just a callable function - False (default): Not a context, just a callable function.
- 'global': Embedded in root DjangoContext, no params, singleton - ReactContext instance: groups functions into a named context.
- 'local': Standalone provider, supports params via flat props - 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). 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. 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: Usage:
# Basic HTTP-only function (not a context) UserContext = ReactContext('user')
@client
def echo(request, message: str) -> EchoOutput:
return EchoOutput(message=message)
# Global context - embedded in DjangoContext, no params @client(context=UserContext)
@client(context='global') def user_profile(request, user_id: int) -> ProfileOutput: ...
def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
# Local context - standalone provider, supports params @client(affects=UserContext)
@client(context='local') def update_profile(request, user_id: int, name: str) -> dict: ...
def user_profile(request, user_id: int) -> ProfileOutput:
return ProfileOutput(...)
# WebSocket-enabled for real-time # View with route — Mizan owns the URL
@client(websocket=True) @client(context=UserContext, route='/profile/<user_id>/')
def send_message(request, room_id: int, text: str) -> MessageOutput: def profile_page(request, user_id: int) -> HttpResponse: ...
return MessageOutput(...)
# Local context with WebSocket (live data) # Private webhook — not client-callable, emits invalidation
@client(context='local', websocket=True) @client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
def live_user_status(request, user_id: int) -> StatusOutput: def stripe_webhook(request) -> HttpResponse: ...
return StatusOutput(...)
Returns: Returns:
A ServerFunction class that wraps the function A ServerFunction class that wraps the function
""" """
# Validate context parameter # Resolve context to name string
if context not in (False, 'global', 'local'): resolved_context = _resolve_context(context)
raise ValueError(
f"Invalid context value '{context}'. " # Validate affects parameter
f"Must be False, 'global', or 'local'." if affects is not None:
) if resolved_context is not False:
raise ValueError(
"context= and affects= are mutually exclusive. "
"A function cannot be both a context reader and a mutation."
)
# Validate auth parameter # Validate auth parameter
if auth is not None: if auth is not None:
@@ -249,18 +333,58 @@ def client(
) )
def decorator(fn: Callable) -> type[ServerFunction]: 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(...) # Support both @client and @client(...)
if fn is not None: 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 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( def _create_server_function(
fn: Callable, 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, websocket: bool = False,
auth: bool | str | None = None, auth: bool | str | None = None,
) -> type[ServerFunction]: ) -> type[ServerFunction]:
@@ -301,35 +425,45 @@ def _create_server_function(
# Get output type from return annotation # Get output type from return annotation
output_type = hints.get("return") output_type = hints.get("return")
if output_type is None: if output_type is None:
raise TypeError( raise TypeError(f"Server function '{name}' must have a return type annotation")
f"Server function '{name}' must have a return type annotation"
)
# Support primitive return types by wrapping in a model with 'result' field # Detect view path: function returns HttpResponse (or has no return annotation
# Also handle Optional[X] / X | None by extracting the non-None type # that maps to a model — view functions often just have -> HttpResponse)
import types from django.http import HttpResponseBase
is_view_path = (
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
)
def is_basemodel_type(t: Any) -> bool: if is_view_path:
"""Check if type is a BaseModel subclass, handling Optional/Union.""" # View path — no Pydantic output wrapping needed
if isinstance(t, type) and issubclass(t, BaseModel): output_cls = BaseModel # placeholder, never used for serialization
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):
return True
return False
if is_basemodel_type(output_type):
output_cls = output_type
is_primitive_output = False is_primitive_output = False
else: else:
# Create model wrapper for primitive types (int, str, list, etc.) # RPC path — resolve output type
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...)) import types
is_primitive_output = True
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
origin = get_origin(t)
if origin is Union or isinstance(t, types.UnionType):
args = get_args(t)
for arg in args:
if (
arg is not type(None)
and isinstance(arg, type)
and issubclass(arg, BaseModel)
):
return True
return False
if is_basemodel_type(output_type):
output_cls = output_type
is_primitive_output = False
else:
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
is_primitive_output = True
# Store param names for unpacking validated input # Store param names for unpacking validated input
param_names = [p[0] for p in input_params] param_names = [p[0] for p in input_params]
@@ -354,10 +488,28 @@ def _create_server_function(
# Build metadata # Build metadata
meta = {} 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: if context:
meta["context"] = context meta["context"] = context
# Affects: mutation invalidation targets
normalized_affects = _normalize_affects(affects)
if normalized_affects:
meta["affects"] = normalized_affects
# WebSocket: enable WebSocket transport # WebSocket: enable WebSocket transport
if websocket: if websocket:
meta["websocket"] = True meta["websocket"] = True
@@ -365,7 +517,7 @@ def _create_server_function(
# Auth requirement # Auth requirement
if auth is not None: if auth is not None:
if auth is True: if auth is True:
meta["auth"] = 'required' meta["auth"] = "required"
elif callable(auth): elif callable(auth):
meta["auth"] = auth meta["auth"] = auth
else: else:
@@ -374,7 +526,7 @@ def _create_server_function(
if meta: if meta:
FunctionWrapper._meta = {**FunctionWrapper._meta, **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. # This allows the decorator to be used without import-time side effects.
return FunctionWrapper return FunctionWrapper
@@ -434,7 +586,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
return [item] return [item]
elif isinstance(item, ComposedContext): elif isinstance(item, ComposedContext):
return item._leaves.copy() return item._leaves.copy()
elif hasattr(item, '_leaves'): elif hasattr(item, "_leaves"):
# Duck typing for composed contexts # Duck typing for composed contexts
return item._leaves.copy() return item._leaves.copy()
else: else:
@@ -443,11 +595,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
def _is_context_enabled(item) -> bool: def _is_context_enabled(item) -> bool:
"""Check if an item is a context-enabled function or composition.""" """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 return True
if isinstance(item, type) and issubclass(item, ServerFunction): if isinstance(item, type) and issubclass(item, ServerFunction):
meta = getattr(item, '_meta', {}) meta = getattr(item, "_meta", {})
return meta.get('context') in ('global', 'local') return bool(meta.get("context"))
return False return False
@@ -460,7 +612,7 @@ def compose(
Compose multiple contexts into a single provider. Compose multiple contexts into a single provider.
Args: 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. or other @compose functions. All must be unique after flattening.
on_server: Bundle all calls into a single server request (default: False). on_server: Bundle all calls into a single server request (default: False).
@@ -498,18 +650,21 @@ def compose(
Returns: Returns:
A ComposedContext that can be used in other compositions. A ComposedContext that can be used in other compositions.
""" """
def decorator(fn: Callable) -> ComposedContext: def decorator(fn: Callable) -> ComposedContext:
from djarea.setup.registry import register_compose from mizan.setup.registry import register_compose
name = fn.__name__ name = fn.__name__
# Validate: all children must be context-enabled # Validate: all children must be context-enabled
for i, child in enumerate(children): for i, child in enumerate(children):
if not _is_context_enabled(child): 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( raise ValueError(
f"@compose argument {i} ({child_name}) is not context-enabled. " 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 # Flatten to collect all leaves
@@ -529,12 +684,16 @@ def compose(
# Validate transport consistency when on_server=True # Validate transport consistency when on_server=True
if on_server: 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: if websocket:
# All must have websocket=True # All must have websocket=True
if not all(has_websocket): 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( raise ValueError(
f"@compose({name}, on_server=True, websocket=True) requires all children " f"@compose({name}, on_server=True, websocket=True) requires all children "
f"to have websocket=True. These are HTTP-only: {non_ws}" f"to have websocket=True. These are HTTP-only: {non_ws}"
@@ -542,7 +701,9 @@ def compose(
else: else:
# All must be HTTP-only # All must be HTTP-only
if any(has_websocket): 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( raise ValueError(
f"@compose({name}, on_server=True, websocket=False) requires all children " f"@compose({name}, on_server=True, websocket=False) requires all children "
f"to be HTTP-only. These have websocket=True: {ws_enabled}" f"to be HTTP-only. These have websocket=True: {ws_enabled}"
@@ -628,7 +789,7 @@ def create_form_functions(
Or use the helper: Or use the helper:
register_form(ContactForm, 'contact', submit_handler=...) 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 # Schema function - returns field definitions
class FormSchema(ServerFunction): class FormSchema(ServerFunction):
@@ -644,7 +805,9 @@ def create_form_functions(
required=field.required, required=field.required,
label=field.label or field.name, label=field.label or field.name,
help_text=field.help_text or None, 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, initial=field.initial,
) )
for field in schema.fields 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: Provides:
- Server functions for obtaining/refreshing JWT tokens - Server functions for obtaining/refreshing JWT tokens
@@ -9,12 +9,12 @@ Server Functions:
- jwt_obtain: Convert authenticated session to JWT tokens - jwt_obtain: Convert authenticated session to JWT tokens
- jwt_refresh: Refresh tokens using a refresh token - jwt_refresh: Refresh tokens using a refresh token
Note: This module is purpose-built for Djarea server functions. Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use djarea.jwt.security directly. For Django Ninja API authentication, use mizan.jwt.security directly.
""" """
# Token utilities (re-exports from django_jwt_session) # Token utilities (re-exports from django_jwt_session)
from djarea.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
) )
# Settings # Settings
from djarea.jwt.settings import get_settings, JWTSettings from mizan.jwt.settings import get_settings, JWTSettings
__all__ = [ __all__ = [
# Token utilities # Token utilities

View File

@@ -1,5 +1,5 @@
""" """
Djarea OpenAPI Schema Generator mizan OpenAPI Schema Generator
Generates OpenAPI 3.0 compatible schema from registered server functions. Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion. 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. HTTP endpoint has been removed to prevent function enumeration.
Usage: Usage:
python manage.py export_djarea_schema python manage.py export_mizan_schema
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,15 +21,21 @@ import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
# Lazy imports to avoid Django settings access at module load time # 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: if TYPE_CHECKING:
from django import forms from django import forms
from ninja import NinjaAPI 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]]: def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
@@ -167,21 +173,26 @@ def _register_schema_endpoint(
and exec() security concerns. and exec() security concerns.
""" """
if input_cls is not None: if input_cls is not None:
def endpoint(request, data): def endpoint(request, data):
pass pass
# Set annotations directly to the actual type objects (not strings) # Set annotations directly to the actual type objects (not strings)
endpoint.__annotations__ = {"data": input_cls} endpoint.__annotations__ = {"data": input_cls}
else: else:
def endpoint(request): def endpoint(request):
pass pass
# Register with Ninja # 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]: 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 Uses Django Ninja's schema generation internally to ensure proper
PydanticOpenAPI conversion (handling $refs, nested types, etc.). 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 # This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
# battle-tested Pydantic→OpenAPI conversion # battle-tested Pydantic→OpenAPI conversion
schema_api = NinjaAPI( schema_api = NinjaAPI(
title="Djarea Server Functions", title="mizan Server Functions",
version="1.0.0", 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 docs_url=None, # No docs endpoint
openapi_url=None, # No openapi 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 # Store them in schema_classes so they persist beyond loop scope
# Uses create_model to avoid metaclass conflicts with custom base classes # Uses create_model to avoid metaclass conflicts with custom base classes
if has_input: if has_input:
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls) schema_classes[input_type_name] = create_model(
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls) 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 endpoint using helper to avoid closure capture issues
_register_schema_endpoint( _register_schema_endpoint(
api=schema_api, api=schema_api,
path=f"/djarea/{name}", path=f"/mizan/{name}",
operation_id=camel_name, operation_id=camel_name,
summary=fn_class.__doc__ or f"Call {name}", summary=fn_class.__doc__ or f"Call {name}",
input_cls=schema_classes.get(input_type_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" "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 # For form schema functions, extract field definitions for Zod generation
if meta.get("form") and meta.get("form_role") == "schema": if meta.get("form") and meta.get("form_role") == "schema":
form_class = meta.get("form_class") 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="") schema = schema_api.get_openapi_schema(path_prefix="")
# Add custom extension with function metadata for provider generation # 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: for fn_meta in function_metadata:
path = f"/djarea/{fn_meta['name']}" path = f"/mizan/{fn_meta['name']}"
if path in schema.get("paths", {}): if path in schema.get("paths", {}):
schema["paths"][path]["post"]["x-djarea"] = { schema["paths"][path]["post"]["x-mizan"] = {
"transport": fn_meta["transport"], "transport": fn_meta["transport"],
"isContext": fn_meta["isContext"], "isContext": fn_meta["isContext"],
} }
@@ -297,3 +356,154 @@ def generate_openapi_json(indent: int = 2) -> str:
"""Generate OpenAPI schema as formatted JSON string.""" """Generate OpenAPI schema as formatted JSON string."""
schema = generate_openapi_schema() schema = generate_openapi_schema()
return json.dumps(schema, indent=indent) 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.) preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
while exposing them through the unified server function API. while exposing them through the unified server function API.
Usage: Usage:
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -98,7 +98,7 @@ def _create_form_input_schema(
form = form_class() form = form_class()
except TypeError: except TypeError:
# Form requires extra args (like request) - use form_class.base_fields instead # Form requires extra args (like request) - use form_class.base_fields instead
fields_dict = getattr(form_class, 'base_fields', {}) fields_dict = getattr(form_class, "base_fields", {})
else: else:
fields_dict = form.fields fields_dict = form.fields
@@ -125,9 +125,9 @@ def _create_form_input_schema(
return model return model
class DjareaFormMeta(BaseModel): class mizanFormMeta(BaseModel):
""" """
Configuration for a Djarea form. Configuration for a mizan form.
This Pydantic model provides type-safe configuration with full LSP support, This Pydantic model provides type-safe configuration with full LSP support,
and serializes to JSON for the frontend schema. and serializes to JSON for the frontend schema.
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
enable_formset: bool = False enable_formset: bool = False
class DjareaFormMixin: class mizanFormMixin:
""" """
Mixin that exposes a Django Form as Djarea server functions. Mixin that exposes a Django Form as mizan server functions.
Add this mixin to any Django Form class along with a `djarea` configuration: Add this mixin to any Django Form class along with a `mizan` configuration:
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
) )
@@ -197,10 +197,10 @@ class DjareaFormMixin:
""" """
# Configuration - subclasses must define this # Configuration - subclasses must define this
djarea: ClassVar[DjareaFormMeta] mizan: ClassVar[mizanFormMeta]
# Track registered forms to avoid duplicate registration # Track registered forms to avoid duplicate registration
_djarea_registered: ClassVar[bool] = False _mizan_registered: ClassVar[bool] = False
@classmethod @classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]: def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
@@ -236,9 +236,7 @@ class DjareaFormMixin:
return result return result
return None return None
def on_submit_failure( def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
self, request: HttpRequest, errors: "FormValidation"
) -> None:
""" """
Called after form validation fails. Called after form validation fails.
@@ -250,23 +248,23 @@ class DjareaFormMixin:
"""Auto-register when a concrete form class is defined.""" """Auto-register when a concrete form class is defined."""
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
# Only register concrete forms with djarea config defined # Only register concrete forms with mizan config defined
if _is_concrete_djarea_form(cls): if _is_concrete_mizan_form(cls):
_register_form_as_server_functions(cls) _register_form_as_server_functions(cls)
def _is_concrete_djarea_form(cls: type) -> bool: def _is_concrete_mizan_form(cls: type) -> bool:
""" """
Check if a class is a concrete Djarea form ready for registration. Check if a class is a concrete mizan form ready for registration.
A form is concrete if: A form is concrete if:
1. It has a `djarea` attribute that is a DjareaFormMeta instance 1. It has a `mizan` attribute that is a mizanFormMeta instance
2. It inherits from Django's BaseForm 2. It inherits from Django's BaseForm
3. It hasn't been registered yet (for this class definition) 3. It hasn't been registered yet (for this class definition)
""" """
# Must have djarea config (check cls.__dict__ to avoid inheriting) # Must have mizan config (check cls.__dict__ to avoid inheriting)
djarea_config = cls.__dict__.get("djarea") mizan_config = cls.__dict__.get("mizan")
if not isinstance(djarea_config, DjareaFormMeta): if not isinstance(mizan_config, mizanFormMeta):
return False return False
# Must be a Django form # Must be a Django form
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
return False return False
# Check if already registered (handle re-imports gracefully) # Check if already registered (handle re-imports gracefully)
if cls.__dict__.get("_djarea_registered", False): if cls.__dict__.get("_mizan_registered", False):
return False return False
return True return True
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
def _register_form_as_server_functions(form_class: type) -> None: def _register_form_as_server_functions(form_class: type) -> None:
""" """
Register a Django Form class as Djarea server functions. Register a Django Form class as mizan server functions.
Creates and registers: Creates and registers:
- {name}.schema - Returns form field definitions - {name}.schema - Returns form field definitions
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
from .schema_utils import build_form_schema from .schema_utils import build_form_schema
from .validation_utils import validate_form_instance from .validation_utils import validate_form_instance
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
config: DjareaFormMeta = form_class.djarea config: mizanFormMeta = form_class.mizan
form_name = config.name form_name = config.name
# Mark as registered # Mark as registered
form_class._djarea_registered = True form_class._mizan_registered = True
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact") # Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_')) pascal_name = "".join(
word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_")
)
# NOTE: We cannot create FormDataSchema here because form fields aren't # NOTE: We cannot create FormDataSchema here because form fields aren't
# populated yet during __init_subclass__. We use lazy creation instead. # populated yet during __init_subclass__. We use lazy creation instead.
@@ -346,7 +347,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
data=input.data if input else {}, data=input.data if input else {},
**init_kwargs, **init_kwargs,
) )
# Override with DjareaFormMeta values # Override with mizanFormMeta values
if config.title is not None: if config.title is not None:
schema.title = config.title schema.title = config.title
if config.subtitle is not None: if config.subtitle is not None:
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
request = self.request request = self.request
# Check if we have multipart data from executor # Check if we have multipart data from executor
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
data = request._djarea_form_data data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input is not None: elif input is not None:
# JSON input - already a dict # JSON input - already a dict
data = input if isinstance(input, dict) else input.model_dump() data = input if isinstance(input, dict) else input.model_dump()
@@ -474,17 +475,25 @@ def _register_formset_functions(
"""Register formset server functions for a form.""" """Register formset server functions for a form."""
from django.forms import formset_factory from django.forms import formset_factory
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation from .schemas import (
FormsetSchema,
FormsetSubmitFail,
FormsetSubmitPass,
FormsetValidation,
)
from .schema_utils import build_form_schema from .schema_utils import build_form_schema
from .validation_utils import build_formset_validation from .validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data from .formset_utils import forms_to_formset_post_data
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
formset_class = formset_factory(form_class) formset_class = formset_factory(form_class)
# Generate PascalCase name for schemas # Generate PascalCase name for schemas
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_')) pascal_name = "".join(
word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_")
)
# NOTE: We cannot create typed schemas here because form fields aren't # NOTE: We cannot create typed schemas here because form fields aren't
# populated yet during __init_subclass__. We use generic dict inputs. # populated yet during __init_subclass__. We use generic dict inputs.
@@ -506,7 +515,7 @@ def _register_formset_functions(
"form": True, "form": True,
"form_name": form_name, "form_name": form_name,
"form_role": "formset_schema", "form_role": "formset_schema",
} }
def call(self, input) -> FormsetSchema: def call(self, input) -> FormsetSchema:
init_kwargs = form_class.get_init_kwargs(self.request) init_kwargs = form_class.get_init_kwargs(self.request)
@@ -590,10 +599,10 @@ def _register_formset_functions(
init_kwargs = form_class.get_init_kwargs(request) init_kwargs = form_class.get_init_kwargs(request)
# Handle multipart vs JSON # Handle multipart vs JSON
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
post_data = request._djarea_form_data post_data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input and hasattr(input, 'forms'): elif input and hasattr(input, "forms"):
# Input.forms is already a list of dicts # Input.forms is already a list of dicts
forms_data = input.forms forms_data = input.forms
post_data = forms_to_formset_post_data(forms_data) post_data = forms_to_formset_post_data(forms_data)

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

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

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
from djarea.channels import get_channels_openapi_schema from mizan.channels import get_channels_openapi_schema
schema = get_channels_openapi_schema() schema = get_channels_openapi_schema()

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

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 - Registry for server functions and channels
- Auto-discovery for apps - Auto-discovery for apps
- Configuration settings - Configuration settings
Usage: Usage:
from djarea.setup import djarea_clients, register, get_function from mizan.setup import mizan_clients, register, get_function
""" """
from .registry import ( from .registry import (
@@ -25,17 +25,18 @@ from .registry import (
get_registry, get_registry,
get_schema, get_schema,
get_contexts, get_contexts,
get_context_groups,
get_forms, get_forms,
clear_registry, clear_registry,
) )
from .discovery import ( from .discovery import (
djarea_clients, mizan_clients,
djarea_module, mizan_module,
) )
from .settings import ( from .settings import (
DjareaSettings, mizanSettings,
get_settings, get_settings,
clear_settings_cache, clear_settings_cache,
) )
@@ -57,13 +58,14 @@ __all__ = [
"get_registry", "get_registry",
"get_schema", "get_schema",
"get_contexts", "get_contexts",
"get_context_groups",
"get_forms", "get_forms",
"clear_registry", "clear_registry",
# Discovery # Discovery
"djarea_clients", "mizan_clients",
"djarea_module", "mizan_module",
# Settings # Settings
"DjareaSettings", "mizanSettings",
"get_settings", "get_settings",
"clear_settings_cache", "clear_settings_cache",
] ]

View File

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

View File

@@ -1,5 +1,5 @@
""" """
Djarea Registry mizan Registry
Central registration for server functions, channels, and compositions. Central registration for server functions, channels, and compositions.
All items are identified by name. All items are identified by name.
@@ -10,8 +10,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING: if TYPE_CHECKING:
from djarea.client.function import ServerFunction, ComposedContext from mizan.client.function import ServerFunction, ComposedContext
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
# Global registries - all use name as key # Global registries - all use name as key
@@ -34,8 +34,8 @@ def register(
Returns: Returns:
The view class (allows use as part of decorator chain) The view class (allows use as part of decorator chain)
""" """
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
view_class.name = name view_class.name = name
@@ -98,7 +98,7 @@ def register_form(
Usage: Usage:
register_form(ContactForm, 'contact', submit_handler=handle_contact) 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( schema_fn, validate_fn, submit_fn = create_form_functions(
form_class, name, submit_handler form_class, name, submit_handler
@@ -130,9 +130,7 @@ def register_compose(
# Same composition being re-registered (reload scenario) # Same composition being re-registered (reload scenario)
_compositions[name] = composed _compositions[name] = composed
return composed return composed
raise ValueError( raise ValueError(f"Composition '{name}' already registered by {existing.name}")
f"Composition '{name}' already registered by {existing.name}"
)
_compositions[name] = composed _compositions[name] = composed
return composed return composed
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
} }
# Extract Params schema (only if defined) # 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() channel_schema["params"] = channel_class.Params.model_json_schema()
# Extract ReactMessage schema (only if defined - indicates bidirectional) # Extract ReactMessage schema (only if defined - indicates bidirectional)
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage: if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema() channel_schema[
"react_message"
] = channel_class.ReactMessage.model_json_schema()
channel_schema["bidirectional"] = True channel_schema["bidirectional"] = True
# Extract DjangoMessage schema (only if defined) # Extract DjangoMessage schema (only if defined)
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage: if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema() channel_schema[
"django_message"
] = channel_class.DjangoMessage.model_json_schema()
channels_schema[name] = channel_schema channels_schema[name] = channel_schema
@@ -288,6 +290,21 @@ def get_contexts() -> dict[str, type["ServerFunction"]]:
return contexts 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"]]]: def get_forms() -> dict[str, list[type["ServerFunction"]]]:
""" """
Get all server functions that are form-related, grouped by form name. 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. Configuration is read from Django settings with sensible defaults.
""" """
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
@dataclass @dataclass
class DjareaSettings: class mizanSettings:
"""Djarea configuration.""" """mizan configuration."""
# Whether to expose function names in DEBUG mode errors # Whether to expose function names in DEBUG mode errors
debug_expose_names: bool debug_expose_names: bool
@lru_cache @lru_cache
def get_settings() -> DjareaSettings: def get_settings() -> mizanSettings:
""" """
Load Djarea settings from Django settings. Load mizan settings from Django settings.
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( return mizanSettings(
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True), 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