Compare commits
20 Commits
70c817c2be
...
97237ed1a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 97237ed1a4 | |||
| d228c7ab1b | |||
| 28e517e6ee | |||
| b4c7e783bd | |||
| 89196a02c6 | |||
| 1a4da68f8d | |||
| a91ce78c3a | |||
| 37f3f3d3eb | |||
| 8aa20111b4 | |||
| f4d7c64e3c | |||
| 3f737132a2 | |||
| 787f90fd12 | |||
| b28ee72c67 | |||
| 01d33173a4 | |||
| af7e22ffc1 | |||
| 3523f2e3fe | |||
| f3c225ef49 | |||
| eee352d908 | |||
| c866142770 | |||
| bf837e598b |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -21,9 +21,9 @@ package-lock.json
|
||||
.vscode/
|
||||
|
||||
# Build artifacts
|
||||
desktop/frontend/dist/
|
||||
e2e/harness/src/api/generated.*
|
||||
e2e/harness/test-results/
|
||||
examples/django-react-desktop-app/frontend/dist/
|
||||
examples/django-react-site/harness/src/api/generated.*
|
||||
examples/django-react-site/harness/test-results/
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
464
MIZAN.md
Normal file
464
MIZAN.md
Normal 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.
|
||||
27
Makefile
27
Makefile
@@ -1,37 +1,40 @@
|
||||
.PHONY: install test test-django test-react test-integration docker-up docker-down clean
|
||||
|
||||
DJANGO = packages/mizan-django
|
||||
REACT = packages/mizan-react
|
||||
|
||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
install:
|
||||
cd django && pip install -e ".[dev,channels]"
|
||||
cd react && npm install
|
||||
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||
cd $(REACT) && npm install
|
||||
|
||||
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test: test-django test-react
|
||||
|
||||
test-django:
|
||||
cd django && pytest
|
||||
cd $(DJANGO) && uv run pytest
|
||||
|
||||
test-react:
|
||||
cd react && npm test
|
||||
cd $(REACT) && npm test
|
||||
|
||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||
|
||||
test-integration: docker-up
|
||||
@echo "Waiting for backend..."
|
||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||
cd react && npm run test:integration
|
||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||
cd $(REACT) && npm run test:integration
|
||||
@$(MAKE) docker-down
|
||||
|
||||
# ─── Docker ──────────────────────────────────────────────────────────────────
|
||||
|
||||
docker-up:
|
||||
docker compose -f docker-compose.test.yml up -d --build
|
||||
docker compose -f examples/django-react-site/docker-compose.test.yml up -d --build
|
||||
@echo "Backend starting at http://localhost:8000"
|
||||
|
||||
docker-down:
|
||||
docker compose -f docker-compose.test.yml down
|
||||
docker compose -f examples/django-react-site/docker-compose.test.yml down
|
||||
|
||||
# ─── All ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +43,7 @@ test-all: test test-integration
|
||||
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
clean:
|
||||
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||
rm -rf django/src/djarea.egg-info django/dist django/build
|
||||
rm -rf react/dist react/node_modules
|
||||
rm -f example/db.sqlite3
|
||||
docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||
rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
|
||||
rm -rf $(REACT)/dist $(REACT)/node_modules
|
||||
rm -f examples/django-react-site/backend/db.sqlite3
|
||||
|
||||
342
README.md
342
README.md
@@ -1,94 +1,84 @@
|
||||
# DJAREA
|
||||
# mizan
|
||||
|
||||
A modern Django + React Framework for perfectionists with deadlines.
|
||||
Django + React server functions framework. RPC, not REST.
|
||||
|
||||
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
|
||||
|
||||
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
|
||||
|
||||
No API routing, no serializers, no REST/CRUD bullshit.
|
||||
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
|
||||
|
||||
```python
|
||||
@client
|
||||
def current_user(request) -> UserShape:
|
||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
||||
# Django
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
```
|
||||
|
||||
|
||||
```tsx
|
||||
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
|
||||
// React (generated)
|
||||
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
|
||||
```
|
||||
|
||||
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
|
||||
## Packages
|
||||
|
||||
Starts with session auth and upgrades to JWT on login. **It just works**.
|
||||
| Package | Path | Install |
|
||||
|---------|------|---------|
|
||||
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
|
||||
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
|
||||
|
||||
## What Djarea does
|
||||
|
||||
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection.
|
||||
|
||||
```python
|
||||
class ArticleShape(Shape[Article]):
|
||||
id: int | None = None
|
||||
title: str
|
||||
author: FlatAuthorShape
|
||||
tags: list[TagShape] = []
|
||||
```
|
||||
|
||||
One Djarea **Shape** does three things simultaneously:
|
||||
- Defines the Pydantic model for validation and serialization
|
||||
- Generates a django-readers spec for a lean, field-scoped SQL query
|
||||
- Produces the TypeScript interface on the React side
|
||||
|
||||
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
|
||||
|
||||
## Quick start
|
||||
## Quick Start
|
||||
|
||||
### 1. Django setup
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = [
|
||||
"djarea",
|
||||
"mizan",
|
||||
"myapp",
|
||||
]
|
||||
|
||||
# urls.py
|
||||
from django.urls import include, path
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
|
||||
# asgi.py (for WebSocket support)
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
### 2. Define your client functions
|
||||
### 2. Define server functions
|
||||
|
||||
```python
|
||||
# myapp/clients.py
|
||||
from djarea.client import client
|
||||
from djarea.shapes import Shape
|
||||
# myapp/mizan_clients.py
|
||||
from django.http import HttpRequest
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
register(echo, "echo")
|
||||
```
|
||||
|
||||
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
|
||||
### 3. Register in apps.py
|
||||
|
||||
### 3. Generate TypeScript
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
name = "myapp"
|
||||
|
||||
To get your generated React client, set this up in your frontend root:
|
||||
def ready(self):
|
||||
import myapp.mizan_clients # noqa: F401
|
||||
```
|
||||
|
||||
```javascript
|
||||
// django.config.mjs
|
||||
### 4. Generate TypeScript
|
||||
|
||||
```bash
|
||||
# django.config.mjs
|
||||
export default {
|
||||
source: {
|
||||
django: {
|
||||
@@ -100,23 +90,27 @@ export default {
|
||||
}
|
||||
```
|
||||
|
||||
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
|
||||
|
||||
```bash
|
||||
npx djarea-generate
|
||||
npx mizan-generate
|
||||
```
|
||||
|
||||
### 4. Use in React
|
||||
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
|
||||
|
||||
### 5. Use in React
|
||||
|
||||
```tsx
|
||||
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||
// layout.tsx
|
||||
import { DjangoContext } from '@/api'
|
||||
|
||||
// layout.tsx — one provider, handles everything
|
||||
export default function Layout({ children }) {
|
||||
return <DjangoContext>{children}</DjangoContext>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// page.tsx
|
||||
import { useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||
|
||||
function MyComponent() {
|
||||
const user = useCurrentUser()
|
||||
const echo = useEcho()
|
||||
@@ -127,80 +121,91 @@ function MyComponent() {
|
||||
console.log(result.message) // typed
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||
e.getFieldErrors('email') // field-level errors
|
||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shapes
|
||||
## Features
|
||||
|
||||
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
|
||||
| Backend | Frontend (generated) | Transport |
|
||||
|---------|---------------------|-----------|
|
||||
| `@client` | `useXxx()` | HTTP |
|
||||
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
|
||||
| `@client(context='local')` | `useXxx()` with params | HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
|
||||
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
| `@compose(...)` | Combined providers | varies |
|
||||
|
||||
```python
|
||||
# Full detail page — joins books with chapters
|
||||
class AuthorDetailShape(Shape[Author]):
|
||||
id: int | None = None
|
||||
name: str
|
||||
bio: str
|
||||
books: list[BookShape] = []
|
||||
## Architecture
|
||||
|
||||
# Dropdown menu — two columns, no joins
|
||||
class FlatAuthorShape(Shape[Author]):
|
||||
id: int | None = None
|
||||
name: str
|
||||
```
|
||||
React app
|
||||
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
|
||||
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
|
||||
├─ useEcho() ← generated function hook
|
||||
├─ useContactForm() ← generated form hook (Zod + server validation)
|
||||
└─ useChatChannel() ← generated channel hook (WebSocket)
|
||||
│
|
||||
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
|
||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||
│
|
||||
Django executor
|
||||
├─ Pydantic input validation
|
||||
├─ Auth check (session, JWT, or custom)
|
||||
├─ Function execution
|
||||
└─ Pydantic output serialization
|
||||
```
|
||||
|
||||
```python
|
||||
# Detail page: SELECT id, name, bio + prefetch books
|
||||
authors = AuthorDetailShape.query()
|
||||
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||
|
||||
# Dropdown: SELECT id, name. That's it.
|
||||
authors = FlatAuthorShape.query()
|
||||
## Code Generation
|
||||
|
||||
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
|
||||
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
|
||||
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
|
||||
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
|
||||
| `generated.channels.ts` | Channel message types |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
|
||||
| `index.ts` | Consolidated re-exports |
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors from server functions are thrown as `DjangoError`:
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await echo({ text: 'hello' })
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
|
||||
e.message // Human-readable message
|
||||
e.details // Field-level validation errors, etc.
|
||||
e.isAuthError()
|
||||
e.isValidationError()
|
||||
e.getFieldErrors('email')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
|
||||
|
||||
```python
|
||||
@client
|
||||
def update_articles(request, articles: list[ArticleShape]) -> dict:
|
||||
for article, diff in ArticleShape.diff_many(articles):
|
||||
if diff.is_new:
|
||||
create_article(article)
|
||||
elif diff.changed:
|
||||
update_fields(article, diff.changed)
|
||||
for tag in diff.tags.created:
|
||||
add_tag(article, tag)
|
||||
for tag_id in diff.tags.deleted:
|
||||
remove_tag(article, tag_id)
|
||||
return {"ok": True}
|
||||
```
|
||||
|
||||
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
|
||||
|
||||
## The `@client` decorator
|
||||
|
||||
The decorator controls transport, caching, auth, and SSR behavior:
|
||||
|
||||
| Decorator | React hook | What it does |
|
||||
|-----------|-----------|--------------|
|
||||
| `@client` | `useEcho()` | HTTP call, returns typed result |
|
||||
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
|
||||
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
|
||||
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
|
||||
| `@client(auth=True)` | — | Requires authentication |
|
||||
| `@client(auth='staff')` | — | Requires staff status |
|
||||
| `@client(auth=my_check)` | — | Custom auth callable |
|
||||
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
|
||||
|
||||
## Forms
|
||||
|
||||
Django forms become typed React hooks with client-side Zod validation:
|
||||
Django forms get typed React hooks with client-side Zod validation:
|
||||
|
||||
```python
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
# Django
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const form = useContactForm()
|
||||
|
||||
form.schema // field metadata, title, submit label
|
||||
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
|
||||
form.data // { name: '', email: '', message: '' }
|
||||
form.set('email', v) // typed setter
|
||||
form.errors // field-level errors (Zod + server)
|
||||
form.submit() // → { success: true, data: { sent: true } }
|
||||
```
|
||||
|
||||
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
|
||||
|
||||
## Channels
|
||||
|
||||
WebSocket channels with typed messages:
|
||||
|
||||
```python
|
||||
# Django
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const chat = useChatChannel({ room: 'general' })
|
||||
|
||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
@@ -259,111 +265,33 @@ chat.messages // ChatDjangoMessage[]
|
||||
chat.send({ text: 'hello' })
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
React app
|
||||
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
|
||||
├─ useCurrentUser() ← context hook (SSR-hydrated)
|
||||
├─ useEcho() ← function hook
|
||||
├─ useContactForm() ← form hook (Zod + server validation)
|
||||
└─ useChatChannel() ← channel hook (WebSocket)
|
||||
│
|
||||
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
|
||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||
│
|
||||
Django executor
|
||||
├─ Pydantic input validation
|
||||
├─ Auth check
|
||||
├─ Function execution
|
||||
└─ Pydantic output serialization
|
||||
```
|
||||
|
||||
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||
|
||||
## Code generation
|
||||
|
||||
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.djarea.ts` | Pydantic model types |
|
||||
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
|
||||
| `generated.django.server.ts` | SSR hydration helper |
|
||||
| `generated.forms.ts` | Form hooks with Zod schemas |
|
||||
| `generated.channels.ts` | Channel message types |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks |
|
||||
| `index.ts` | Re-exports |
|
||||
|
||||
## Error handling
|
||||
|
||||
All errors from server functions throw as `DjangoError`:
|
||||
|
||||
```tsx
|
||||
if (e instanceof DjangoError) {
|
||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
|
||||
e.message // human-readable
|
||||
e.details // field-level validation errors
|
||||
e.isAuthError()
|
||||
e.isValidationError()
|
||||
e.getFieldErrors('email')
|
||||
}
|
||||
```
|
||||
|
||||
## Why RPC instead of REST
|
||||
|
||||
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
|
||||
|
||||
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
|
||||
|
||||
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Install |
|
||||
|---------|---------|
|
||||
| `djarea` (Python) | `pip install djarea` |
|
||||
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
|
||||
|
||||
For WebSocket support: `pip install "djarea[channels]"`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Django
|
||||
cd django && uv run pytest
|
||||
# Django unit tests
|
||||
cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
|
||||
|
||||
# React
|
||||
cd react && npm test
|
||||
# React unit tests
|
||||
cd packages/mizan-react && npm test
|
||||
|
||||
# E2E (Playwright, real browser + real backend)
|
||||
docker compose -f docker-compose.test.yml up -d
|
||||
cd e2e/harness && npx djarea-generate && npx playwright test
|
||||
# E2E integration tests (real browser, real backend)
|
||||
docker compose -f examples/django-react-site/docker-compose.test.yml up -d
|
||||
cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 &
|
||||
npx playwright test
|
||||
|
||||
# Everything
|
||||
# All at once
|
||||
make test-all
|
||||
```
|
||||
|
||||
## Project structure
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
djarea/
|
||||
django/ Python package
|
||||
react/ TypeScript package
|
||||
example/ Integration test backend
|
||||
e2e/ Playwright E2E tests
|
||||
Makefile Test orchestration
|
||||
mizan/
|
||||
packages/
|
||||
mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
|
||||
mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
|
||||
mizan-react/ React adapter (thin wrapper around runtime)
|
||||
examples/
|
||||
django-react-site/ E2E tests + Django backend
|
||||
django-react-desktop-app/ PyWebView desktop app
|
||||
```
|
||||
|
||||
## Disclosure
|
||||
|
||||
Djarea was developed with the assistance of IDE AI Assistance and later with Claude Code.
|
||||
|
||||
The architecture, design decisions, developer experience standards and technical direction are mine. I've been programming for 16 years and have a lot of opinions!
|
||||
|
||||
DX ideas are inspired by the amazing work of these projects and the hardworking folks behind them:
|
||||
- Django Ninja
|
||||
- Django Readers
|
||||
- Django RAPID Architecture
|
||||
- React
|
||||
- Next.js
|
||||
192
ROADMAP.md
Normal file
192
ROADMAP.md
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Djarea Desktop — PyWebView + Django local RPC.
|
||||
mizan Desktop — PyWebView + Django local RPC.
|
||||
|
||||
Starts a local Django ASGI server and opens a native desktop window.
|
||||
All communication between the UI and backend uses Djarea server functions.
|
||||
All communication between the UI and backend uses mizan server functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -63,7 +63,7 @@ def main():
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
if not wait_for_server(f"{base_url}/api/djarea/session/"):
|
||||
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -83,7 +83,7 @@ def main():
|
||||
import webview
|
||||
|
||||
window = webview.create_window(
|
||||
title="Djarea Desktop",
|
||||
title="mizan Desktop",
|
||||
url=base_url,
|
||||
width=1024,
|
||||
height=768,
|
||||
@@ -6,8 +6,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
import backend.djarea_clients # noqa: F401
|
||||
import backend.mizan_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests Djarea's appropriateness for desktop apps:
|
||||
Tests mizan's appropriateness for desktop apps:
|
||||
- Local file system access
|
||||
- SQLite CRUD
|
||||
- System introspection
|
||||
@@ -20,10 +20,10 @@ from pathlib import Path
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register
|
||||
from djarea.channels import register as register_channel
|
||||
from mizan.client import client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
|
||||
home_dir: str
|
||||
cwd: str
|
||||
cpu_count: int
|
||||
djarea_version: str
|
||||
mizan_version: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
import djarea
|
||||
import mizan
|
||||
|
||||
return SystemInfoOutput(
|
||||
os_name=platform.system(),
|
||||
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
home_dir=str(Path.home()),
|
||||
cwd=os.getcwd(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
djarea_version=getattr(djarea, "__version__", "dev"),
|
||||
mizan_version=getattr(mizan, "__version__", "dev"),
|
||||
)
|
||||
|
||||
|
||||
@@ -114,16 +114,20 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
for entry in sorted(
|
||||
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
|
||||
):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append(FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
))
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except PermissionError:
|
||||
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
|
||||
def create_note(
|
||||
request: HttpRequest, title: str, content: str = "", pinned: bool = False
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||
@@ -403,7 +409,7 @@ def app_info(request: HttpRequest) -> AppInfoOutput:
|
||||
from django.conf import settings
|
||||
|
||||
return AppInfoOutput(
|
||||
app_name="Djarea Desktop",
|
||||
app_name="mizan Desktop",
|
||||
uptime_seconds=round(time.time() - _start_time, 2),
|
||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||
pid=os.getpid(),
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Django settings for the Djarea desktop integration test app.
|
||||
Django settings for the mizan desktop integration test app.
|
||||
|
||||
Runs entirely local: SQLite database, in-memory channel layer,
|
||||
no external services required.
|
||||
@@ -27,7 +27,7 @@ def serve_dist(request, path="index.html"):
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||
path("", serve_dist),
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Djarea Desktop</title>
|
||||
<title>mizan Desktop</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea-desktop-frontend",
|
||||
"name": "mizan-desktop-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/djarea": "file:../../react",
|
||||
"@rythazhur/mizan": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea'
|
||||
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
||||
|
||||
// ─── System Info ────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemInfo() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +33,7 @@ function SystemInfo() {
|
||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const status = useDjareaStatus()
|
||||
const status = useMizanStatus()
|
||||
return (
|
||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||
{status}
|
||||
@@ -46,7 +46,7 @@ function StatusBar() {
|
||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||
|
||||
function Notes() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [selected, setSelected] = useState<Note | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -140,7 +140,7 @@ function Notes() {
|
||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||
|
||||
function FileBrowser() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [dir, setDir] = useState('~')
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [parent, setParent] = useState<string | null>(null)
|
||||
@@ -184,17 +184,17 @@ function FileBrowser() {
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}>
|
||||
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>Djarea Desktop</h1>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
||||
<StatusBar />
|
||||
</div>
|
||||
<SystemInfo />
|
||||
<Notes />
|
||||
<FileBrowser />
|
||||
</div>
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
[project]
|
||||
name = "djarea-desktop"
|
||||
name = "mizan-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop integration test app for Djarea"
|
||||
description = "Desktop integration test app for mizan"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"djarea[channels]",
|
||||
"mizan[channels]",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"pywebview[qt]>=5.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
djarea = { path = "../django", editable = true }
|
||||
mizan = { path = "../django", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
@@ -1,7 +1,8 @@
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Ensure migrations run before tests
|
||||
def pytest_configure():
|
||||
# Import djarea_clients to trigger function registration
|
||||
import backend.djarea_clients # noqa: F401
|
||||
# Import mizan_clients to trigger function registration
|
||||
import backend.mizan_clients # noqa: F401
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
REAL integration tests for the Djarea RPC framework layer.
|
||||
REAL integration tests for the mizan RPC framework layer.
|
||||
|
||||
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
||||
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
||||
@@ -14,7 +14,7 @@ from django.test import LiveServerTestCase
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
@@ -26,7 +26,7 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -37,7 +37,13 @@ class RealHTTPMixin:
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
def _raw_post(self, path: str, body: bytes | str, content_type: str = "application/json", include_csrf: bool = False):
|
||||
def _raw_post(
|
||||
self,
|
||||
path: str,
|
||||
body: bytes | str,
|
||||
content_type: str = "application/json",
|
||||
include_csrf: bool = False,
|
||||
):
|
||||
"""Raw POST without the call() envelope — for testing malformed requests."""
|
||||
url = f"{self.live_server_url}{path}"
|
||||
if isinstance(body, str):
|
||||
@@ -55,7 +61,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_session_endpoint_sets_csrf_cookie(self):
|
||||
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
|
||||
@@ -64,7 +70,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_call_without_csrf_is_rejected(self):
|
||||
"""POST /call/ without CSRF token must fail."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -134,7 +140,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_get_method_rejected(self):
|
||||
"""GET to /call/ should be rejected."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
try:
|
||||
resp = urlopen(Request(url))
|
||||
data = json.loads(resp.read())
|
||||
@@ -147,7 +153,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
body="not valid json{{{",
|
||||
include_csrf=True,
|
||||
)
|
||||
@@ -162,7 +168,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
body=json.dumps({"not_fn": "hello"}),
|
||||
include_csrf=True,
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
@@ -24,7 +24,7 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -105,6 +105,7 @@ class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
# Verify it's gone
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
get_data = self._call("get_note", {"id": note_id})
|
||||
self.assertTrue(get_data["error"])
|
||||
@@ -18,8 +18,8 @@ class RealHTTPMixin:
|
||||
"""Makes real HTTP requests to the live server."""
|
||||
|
||||
def _session_init(self):
|
||||
"""Hit /session/ to get CSRF cookie, like DjareaProvider does."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
"""Hit /session/ to get CSRF cookie, like mizanProvider does."""
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
req = Request(url)
|
||||
resp = urlopen(req)
|
||||
# Extract csrftoken from Set-Cookie header
|
||||
@@ -33,8 +33,8 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
"""Make a real POST to /api/djarea/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
"""Make a real POST to /api/mizan/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -80,7 +80,7 @@ class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
|
||||
data = self._call("app_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["app_name"], "Djarea Desktop")
|
||||
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
|
||||
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
||||
|
||||
|
||||
@@ -89,11 +89,12 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
self.test_dir = Path.home() / ".djarea-test"
|
||||
self.test_dir = Path.home() / ".mizan-test"
|
||||
self.test_dir.mkdir(exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
if self.test_dir.exists():
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
@@ -116,7 +117,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
test_content = "Hello from a REAL HTTP integration test!"
|
||||
|
||||
# Write
|
||||
write_data = self._call("write_file", {"path": test_path, "content": test_content})
|
||||
write_data = self._call(
|
||||
"write_file", {"path": test_path, "content": test_content}
|
||||
)
|
||||
self.assertFalse(write_data["error"])
|
||||
self.assertEqual(write_data["data"]["path"], test_path)
|
||||
|
||||
@@ -130,7 +133,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
data = self._call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
|
||||
data = self._call(
|
||||
"write_file", {"path": "/tmp/escape.txt", "content": "nope"}
|
||||
)
|
||||
# If we get here, check the response has an error
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
@@ -7,12 +7,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install djarea from local source with channels support
|
||||
COPY django/ /app/django/
|
||||
# Install mizan from local source with channels support
|
||||
COPY packages/mizan-django/ /app/django/
|
||||
RUN pip install --no-cache-dir /app/django[channels] daphne
|
||||
|
||||
# Copy example app
|
||||
COPY example/ /app/example/
|
||||
COPY examples/django-react-site/backend/ /app/example/
|
||||
|
||||
WORKDIR /app/example
|
||||
|
||||
@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self):
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
import testapp.mizan_clients # noqa: F401
|
||||
@@ -6,9 +6,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
# Register server functions and channels before building the ASGI app
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
import testapp.mizan_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
@@ -11,12 +11,12 @@ from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import ServerFunction, client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register, register_form, register_as
|
||||
from djarea.channels import register as register_channel
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from djarea.jwt import jwt_obtain, jwt_refresh
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register, register_form, register_as
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
from mizan.jwt import jwt_obtain, jwt_refresh
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -57,9 +57,9 @@ class WhoamiOutput(BaseModel):
|
||||
@client(auth=True)
|
||||
def whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
user_id=getattr(request.user, "id", None),
|
||||
email=getattr(request.user, "email", ""),
|
||||
is_staff=getattr(request.user, "is_staff", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -197,18 +197,20 @@ register_channel(PresenceChannel, "presence")
|
||||
|
||||
|
||||
# --- Staff-only ---
|
||||
@client(auth='staff')
|
||||
@client(auth="staff")
|
||||
def staff_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"staff:{request.user.email}")
|
||||
|
||||
|
||||
register(staff_only, "staff_only")
|
||||
|
||||
|
||||
# --- Superuser-only ---
|
||||
@client(auth='superuser')
|
||||
@client(auth="superuser")
|
||||
def superuser_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"superuser:{request.user.email}")
|
||||
|
||||
|
||||
register(superuser_only, "superuser_only")
|
||||
|
||||
|
||||
@@ -216,12 +218,14 @@ register(superuser_only, "superuser_only")
|
||||
def check_verified_email(request):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
return getattr(request.user, 'email', '').endswith('@verified.com')
|
||||
return getattr(request.user, "email", "").endswith("@verified.com")
|
||||
|
||||
|
||||
@client(auth=check_verified_email)
|
||||
def verified_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message="verified")
|
||||
|
||||
|
||||
register(verified_only, "verified_only")
|
||||
|
||||
|
||||
@@ -235,7 +239,8 @@ class CurrentUserOutput(BaseModel):
|
||||
email: str
|
||||
is_staff: bool
|
||||
|
||||
@client(context='global')
|
||||
|
||||
@client(context="global")
|
||||
def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||
if request.user.is_authenticated:
|
||||
return CurrentUserOutput(
|
||||
@@ -245,16 +250,19 @@ def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||
)
|
||||
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
|
||||
|
||||
|
||||
register(current_user, "current_user")
|
||||
|
||||
|
||||
class GreetOutput(BaseModel):
|
||||
greeting: str
|
||||
|
||||
@client(context='local')
|
||||
|
||||
@client(context="local")
|
||||
def greet(request: HttpRequest, name: str) -> GreetOutput:
|
||||
return GreetOutput(greeting=f"Hello, {name}!")
|
||||
|
||||
|
||||
register(greet, "greet")
|
||||
|
||||
|
||||
@@ -267,9 +275,11 @@ class MultiplyInput(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
class MultiplyOutput(BaseModel):
|
||||
product: int
|
||||
|
||||
|
||||
@register_as("multiply")
|
||||
class Multiply(ServerFunction):
|
||||
Input = MultiplyInput
|
||||
@@ -288,6 +298,7 @@ class Multiply(ServerFunction):
|
||||
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise NotImplementedError("This feature is not yet implemented")
|
||||
|
||||
|
||||
register(not_implemented_fn, "not_implemented_fn")
|
||||
|
||||
|
||||
@@ -295,6 +306,7 @@ register(not_implemented_fn, "not_implemented_fn")
|
||||
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise RuntimeError("Unexpected internal failure")
|
||||
|
||||
|
||||
register(buggy_fn, "buggy_fn")
|
||||
|
||||
|
||||
@@ -304,6 +316,7 @@ def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
|
||||
raise PermissionError("Wrong secret")
|
||||
return EchoOutput(message="access granted")
|
||||
|
||||
|
||||
register(permission_check_fn, "permission_check_fn")
|
||||
|
||||
|
||||
@@ -315,21 +328,22 @@ register(permission_check_fn, "permission_check_fn")
|
||||
@client(websocket=True, auth=True)
|
||||
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
user_id=getattr(request.user, "id", None),
|
||||
email=getattr(request.user, "email", ""),
|
||||
is_staff=getattr(request.user, "is_staff", False),
|
||||
)
|
||||
|
||||
|
||||
register(ws_whoami, "ws_whoami")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DjareaFormMixin Forms
|
||||
# mizanFormMixin Forms
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
subtitle="We'd love to hear from you",
|
||||
@@ -351,8 +365,8 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ItemForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ItemForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="item",
|
||||
title="Items",
|
||||
submit_label="Save Items",
|
||||
@@ -363,7 +377,10 @@ class ItemForm(DjareaFormMixin, forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, label="Quantity")
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"label": self.cleaned_data["label"], "qty": self.cleaned_data["quantity"]}
|
||||
return {
|
||||
"label": self.cleaned_data["label"],
|
||||
"qty": self.cleaned_data["quantity"],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -376,11 +393,12 @@ class PrivateChannel(ReactChannel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return getattr(self.user, 'is_authenticated', False)
|
||||
return getattr(self.user, "is_authenticated", False)
|
||||
|
||||
def group(self, params=None):
|
||||
return "private_global"
|
||||
|
||||
|
||||
register_channel(PrivateChannel, "private")
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"djarea",
|
||||
"mizan",
|
||||
"testapp",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Djarea E2E Integration Tests
|
||||
* mizan E2E Integration Tests
|
||||
*
|
||||
* Real Chromium → Real React app (generated hooks) → Real Django backend
|
||||
*
|
||||
* Every test uses the generated Djarea API, not raw call() or fetch().
|
||||
* Every test uses the generated mizan API, not raw call() or fetch().
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
|
||||
expect(result.fields.password).toBeDefined()
|
||||
})
|
||||
|
||||
test('useContactForm loads schema with DjareaFormMeta', async ({ page }) => {
|
||||
test('useContactForm loads schema with mizanFormMeta', async ({ page }) => {
|
||||
await fixture(page, 'form-contact-schema')
|
||||
const result = await getResult(page)
|
||||
expect(result.title).toBe('Contact Us')
|
||||
@@ -6,8 +6,8 @@ services:
|
||||
|
||||
django:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test
|
||||
context: ../..
|
||||
dockerfile: examples/django-react-site/Dockerfile.test
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -2,17 +2,17 @@ import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
|
||||
export default {
|
||||
projectId: 'e2e-harness',
|
||||
|
||||
source: {
|
||||
django: {
|
||||
managePath: path.join(root, 'example/manage.py'),
|
||||
command: [path.join(root, 'django/.venv/bin/python')],
|
||||
managePath: path.join(root, 'examples/django-react-site/backend/manage.py'),
|
||||
command: [path.join(root, 'packages/mizan-django/.venv/bin/python')],
|
||||
env: {
|
||||
PYTHONPATH: `${path.join(root, 'django/src')}:${path.join(root, 'example')}`,
|
||||
PYTHONPATH: `${path.join(root, 'packages/mizan-django/src')}:${path.join(root, 'examples/django-react-site/backend')}`,
|
||||
DJANGO_SETTINGS_MODULE: 'testapp.settings',
|
||||
},
|
||||
},
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8" /><title>Djarea E2E Harness</title></head>
|
||||
<head><meta charset="UTF-8" /><title>mizan E2E Harness</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea-e2e-harness",
|
||||
"name": "mizan-e2e-harness",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite --port 5174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/djarea": "file:../../react",
|
||||
"@rythazhur/mizan": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^4.3.6"
|
||||
@@ -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', {})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
337
examples/django-react-site/harness/src/api/generated.channels.ts
Normal file
337
examples/django-react-site/harness/src/api/generated.channels.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
257
examples/django-react-site/harness/src/api/generated.django.tsx
Normal file
257
examples/django-react-site/harness/src/api/generated.django.tsx
Normal 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
2123
examples/django-react-site/harness/src/api/generated.djarea.ts
Normal file
2123
examples/django-react-site/harness/src/api/generated.djarea.ts
Normal file
File diff suppressed because it is too large
Load Diff
226
examples/django-react-site/harness/src/api/generated.forms.ts
Normal file
226
examples/django-react-site/harness/src/api/generated.forms.ts
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Djarea API - Consolidated Exports
|
||||
* mizan API - Consolidated Exports
|
||||
*
|
||||
* Import everything from here:
|
||||
*
|
||||
@@ -15,11 +15,11 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
// AUTO-GENERATED by djarea - do not edit manually
|
||||
// AUTO-GENERATED by mizan - do not edit manually
|
||||
// Regenerate with: npm run schemas
|
||||
|
||||
// =============================================================================
|
||||
// Djarea Provider & Hooks
|
||||
// mizan Provider & Hooks
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
@@ -55,9 +55,9 @@ export {
|
||||
useJwtObtain,
|
||||
useJwtRefresh,
|
||||
|
||||
// Re-exports from djarea library
|
||||
useDjarea,
|
||||
useDjareaStatus,
|
||||
// Re-exports from mizan library
|
||||
usemizan,
|
||||
usemizanStatus,
|
||||
usePush,
|
||||
DjangoError,
|
||||
type ConnectionStatus,
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* E2E Test Fixtures
|
||||
*
|
||||
* Each fixture uses GENERATED Djarea hooks (not raw call()).
|
||||
* Each fixture uses GENERATED mizan hooks (not raw call()).
|
||||
* Playwright reads the DOM to verify behavior.
|
||||
*
|
||||
* URL hash selects the fixture: #echo, #add, #multiply, etc.
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
// Generated typed hooks — the actual Djarea API
|
||||
// Generated typed hooks — the actual mizan API
|
||||
import {
|
||||
DjangoContext,
|
||||
useEcho,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
usePermissionCheckFn,
|
||||
useCurrentUser,
|
||||
DjangoError,
|
||||
useDjarea,
|
||||
useMizan,
|
||||
} from './api/generated.django'
|
||||
import { useContactForm, useLoginForm } from './api/generated.forms'
|
||||
import { useChatChannel } from './api/generated.channels.hooks'
|
||||
@@ -121,7 +121,7 @@ function Multiply() {
|
||||
|
||||
function NotFound() {
|
||||
// Deliberately call a non-existent function via the raw primitive
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [error, setError] = useState<unknown>()
|
||||
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
|
||||
return <Result error={error} />
|
||||
@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DjangoContext baseUrl="/api/djarea">
|
||||
<DjangoContext baseUrl="/api/mizan">
|
||||
<Fixtures />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
30
examples/django-react-site/harness/vite.config.ts
Normal file
30
examples/django-react-site/harness/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea",
|
||||
"name": "mizan",
|
||||
"version": "1.0.0",
|
||||
"description": "Django + React server functions framework.",
|
||||
"main": "index.js",
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testDir: '.',
|
||||
timeout: 15000,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
@@ -1,32 +1,32 @@
|
||||
# djarea (Python)
|
||||
# mizan (Python)
|
||||
|
||||
Django server functions framework. See the [monorepo root](../README.md) for full documentation.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
|
||||
uv add "mizan[channels,allauth] @ git+https://git.impactsoundworks.com/isw/mizan.git#subdirectory=django"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = ["djarea", ...]
|
||||
INSTALLED_APPS = ["mizan", ...]
|
||||
|
||||
# urls.py
|
||||
path("api/djarea/", include("djarea.urls"))
|
||||
path("api/mizan/", include("mizan.urls"))
|
||||
|
||||
# asgi.py (optional, for WebSocket)
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
## Define Functions
|
||||
|
||||
```python
|
||||
from djarea.client import client
|
||||
from djarea.setup.registry import register
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Output(BaseModel):
|
||||
@@ -43,7 +43,7 @@ Register in `apps.py`:
|
||||
|
||||
```python
|
||||
def ready(self):
|
||||
import myapp.djarea_clients
|
||||
import myapp.mizan_clients
|
||||
```
|
||||
|
||||
## Auth
|
||||
@@ -65,10 +65,10 @@ def ready(self):
|
||||
## Forms
|
||||
|
||||
```python
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="contact", title="Contact Us")
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="contact", title="Contact Us")
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
|
||||
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
|
||||
## Channels
|
||||
|
||||
```python
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
@@ -1,22 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Djarea Code Generator CLI
|
||||
* mizan Code Generator CLI
|
||||
*
|
||||
* Generate TypeScript types, React provider, and hooks from Django schemas.
|
||||
*
|
||||
* Usage:
|
||||
* npx djarea-generate # Run once
|
||||
* npx djarea-generate --watch # Watch mode
|
||||
* npx mizan-generate # Run once
|
||||
* npx mizan-generate --watch # Watch mode
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { fetchChannelsSchema, fetchDjareaSchema } from './lib/fetch.mjs'
|
||||
import { generateDjareaFiles } from './lib/djarea.mjs'
|
||||
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
|
||||
import { generateMizanFiles } from './lib/mizan.mjs'
|
||||
import { generateChannelsFiles } from './lib/channels.mjs'
|
||||
import { generateIndex } from './lib/index.mjs'
|
||||
|
||||
// Use cwd — the script runs via `npx djarea-generate` from the frontend root
|
||||
// Use cwd — the script runs via `npx mizan-generate` from the frontend root
|
||||
const frontendDir = process.cwd()
|
||||
|
||||
/**
|
||||
@@ -70,21 +70,21 @@ async function writeOutput(filePath, content) {
|
||||
async function generate(config, options = {}) {
|
||||
const { output } = options
|
||||
|
||||
console.log('[djarea] Starting schema generation...')
|
||||
console.log('[mizan] Starting schema generation...')
|
||||
|
||||
const outputPath = output || config.output || 'src/api/generated.ts'
|
||||
|
||||
let channelsSchema = null
|
||||
let djareaSchema = null
|
||||
let mizanSchema = null
|
||||
|
||||
// Fetch and generate channels if available
|
||||
try {
|
||||
console.log('[djarea] Fetching channels schema...')
|
||||
console.log('[mizan] Fetching channels schema...')
|
||||
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
|
||||
|
||||
const channelCount = channelsSchema['x-djarea-channels']?.length || 0
|
||||
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
|
||||
if (channelCount > 0) {
|
||||
console.log(`[djarea] Found ${channelCount} channels`)
|
||||
console.log(`[mizan] Found ${channelCount} channels`)
|
||||
|
||||
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
|
||||
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
|
||||
@@ -95,85 +95,85 @@ async function generate(config, options = {}) {
|
||||
|
||||
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
|
||||
|
||||
console.log(`[djarea] Generating -> ${channelsTypesPath}`)
|
||||
console.log(`[mizan] Generating -> ${channelsTypesPath}`)
|
||||
await writeOutput(fullChannelsTypesPath, channelsTypes)
|
||||
|
||||
if (channelsHooks) {
|
||||
console.log(`[djarea] Generating -> ${channelsHooksPath}`)
|
||||
console.log(`[mizan] Generating -> ${channelsHooksPath}`)
|
||||
await writeOutput(fullChannelsHooksPath, channelsHooks)
|
||||
}
|
||||
|
||||
console.log(`[djarea] Generating -> ${channelsSchemaPath}`)
|
||||
console.log(`[mizan] Generating -> ${channelsSchemaPath}`)
|
||||
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
|
||||
} else {
|
||||
console.log('[djarea] No channels registered, skipping channels generation')
|
||||
console.log('[mizan] No channels registered, skipping channels generation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[djarea] Channels schema not available: ${err.message}`)
|
||||
console.log(`[mizan] Channels schema not available: ${err.message}`)
|
||||
}
|
||||
|
||||
// Fetch and generate djarea files
|
||||
// Fetch and generate mizan files
|
||||
try {
|
||||
console.log('[djarea] Fetching djarea schema...')
|
||||
djareaSchema = await fetchDjareaSchema(config.source, frontendDir)
|
||||
console.log('[mizan] Fetching mizan schema...')
|
||||
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
|
||||
|
||||
const functionCount = djareaSchema['x-djarea-functions']?.length || 0
|
||||
const functionCount = mizanSchema['x-mizan-functions']?.length || 0
|
||||
if (functionCount > 0) {
|
||||
console.log(`[djarea] Found ${functionCount} djarea functions`)
|
||||
console.log(`[mizan] Found ${functionCount} mizan functions`)
|
||||
|
||||
const djareaTypesPath = outputPath.replace(/\.ts$/, '.djarea.ts')
|
||||
const fullDjareaTypesPath = path.resolve(frontendDir, djareaTypesPath)
|
||||
const djareaProviderPath = outputPath.replace(/\.ts$/, '.django.tsx')
|
||||
const fullDjareaProviderPath = path.resolve(frontendDir, djareaProviderPath)
|
||||
const djareaServerPath = outputPath.replace(/\.ts$/, '.django.server.ts')
|
||||
const fullDjareaServerPath = path.resolve(frontendDir, djareaServerPath)
|
||||
const djareaFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
|
||||
const fullDjareaFormsPath = path.resolve(frontendDir, djareaFormsPath)
|
||||
const djareaSchemaPath = outputPath.replace(/\.ts$/, '.djarea.schema.json')
|
||||
const fullDjareaSchemaPath = path.resolve(frontendDir, djareaSchemaPath)
|
||||
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
|
||||
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
|
||||
const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
|
||||
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
|
||||
const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts')
|
||||
const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath)
|
||||
const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
|
||||
const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath)
|
||||
const mizanSchemaPath = outputPath.replace(/\.ts$/, '.mizan.schema.json')
|
||||
const fullMizanSchemaPath = path.resolve(frontendDir, mizanSchemaPath)
|
||||
|
||||
const hasChannels = (channelsSchema?.['x-djarea-channels']?.length || 0) > 0
|
||||
const { types: djareaTypes, provider: djareaProvider, server: djareaServer, forms: djareaForms } = await generateDjareaFiles(djareaSchema, { hasChannels })
|
||||
const hasChannels = (channelsSchema?.['x-mizan-channels']?.length || 0) > 0
|
||||
const { types: mizanTypes, provider: mizanProvider, server: mizanServer, forms: mizanForms } = await generateMizanFiles(mizanSchema, { hasChannels })
|
||||
|
||||
console.log(`[djarea] Generating -> ${djareaTypesPath}`)
|
||||
await writeOutput(fullDjareaTypesPath, djareaTypes)
|
||||
console.log(`[mizan] Generating -> ${mizanTypesPath}`)
|
||||
await writeOutput(fullMizanTypesPath, mizanTypes)
|
||||
|
||||
if (djareaProvider) {
|
||||
console.log(`[djarea] Generating -> ${djareaProviderPath}`)
|
||||
await writeOutput(fullDjareaProviderPath, djareaProvider)
|
||||
if (mizanProvider) {
|
||||
console.log(`[mizan] Generating -> ${mizanProviderPath}`)
|
||||
await writeOutput(fullMizanProviderPath, mizanProvider)
|
||||
}
|
||||
|
||||
if (djareaServer) {
|
||||
console.log(`[djarea] Generating -> ${djareaServerPath}`)
|
||||
await writeOutput(fullDjareaServerPath, djareaServer)
|
||||
if (mizanServer) {
|
||||
console.log(`[mizan] Generating -> ${mizanServerPath}`)
|
||||
await writeOutput(fullMizanServerPath, mizanServer)
|
||||
}
|
||||
|
||||
if (djareaForms) {
|
||||
console.log(`[djarea] Generating -> ${djareaFormsPath}`)
|
||||
await writeOutput(fullDjareaFormsPath, djareaForms)
|
||||
if (mizanForms) {
|
||||
console.log(`[mizan] Generating -> ${mizanFormsPath}`)
|
||||
await writeOutput(fullMizanFormsPath, mizanForms)
|
||||
}
|
||||
|
||||
console.log(`[djarea] Generating -> ${djareaSchemaPath}`)
|
||||
await writeOutput(fullDjareaSchemaPath, JSON.stringify(djareaSchema, null, 2))
|
||||
console.log(`[mizan] Generating -> ${mizanSchemaPath}`)
|
||||
await writeOutput(fullMizanSchemaPath, JSON.stringify(mizanSchema, null, 2))
|
||||
} else {
|
||||
console.log('[djarea] No djarea functions registered, skipping djarea generation')
|
||||
console.log('[mizan] No mizan functions registered, skipping mizan generation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[djarea] Djarea schema not available: ${err.message}`)
|
||||
console.log(`[mizan] mizan schema not available: ${err.message}`)
|
||||
}
|
||||
|
||||
// Generate consolidated index.ts
|
||||
const indexPath = path.dirname(outputPath) + '/index.ts'
|
||||
const fullIndexPath = path.resolve(frontendDir, indexPath)
|
||||
|
||||
console.log(`[djarea] Generating -> ${indexPath}`)
|
||||
console.log(`[mizan] Generating -> ${indexPath}`)
|
||||
const indexContent = generateIndex({
|
||||
channelsSchema,
|
||||
djareaSchema,
|
||||
mizanSchema,
|
||||
})
|
||||
await writeOutput(fullIndexPath, indexContent)
|
||||
|
||||
console.log('[djarea] Generation complete!')
|
||||
console.log('[mizan] Generation complete!')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +194,7 @@ async function watch(config, options) {
|
||||
try {
|
||||
await generate(config, options)
|
||||
} catch (err) {
|
||||
console.error('[djarea] Generation failed:', err.message)
|
||||
console.error('[mizan] Generation failed:', err.message)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
@@ -202,7 +202,7 @@ async function watch(config, options) {
|
||||
|
||||
await runGenerate()
|
||||
|
||||
console.log('[djarea] Watching for changes (press Ctrl+C to stop)...')
|
||||
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
|
||||
|
||||
if (config.source.django) {
|
||||
const { watch: chokidarWatch } = await import('chokidar')
|
||||
@@ -221,14 +221,14 @@ async function watch(config, options) {
|
||||
})
|
||||
|
||||
watcher.on('change', (filePath) => {
|
||||
console.log(`[djarea] Detected change: ${path.relative(djangoDir, filePath)}`)
|
||||
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(runGenerate, debounce)
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n[djarea] Stopping watch mode...')
|
||||
console.log('\n[mizan] Stopping watch mode...')
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
@@ -252,10 +252,10 @@ async function main() {
|
||||
output = args[++i]
|
||||
} else if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(`
|
||||
Djarea Code Generator - Generate TypeScript from Django schemas
|
||||
mizan Code Generator - Generate TypeScript from Django schemas
|
||||
|
||||
Usage:
|
||||
npx djarea-generate [options]
|
||||
npx mizan-generate [options]
|
||||
|
||||
Options:
|
||||
-c, --config <path> Config file path (default: django.config.mjs)
|
||||
@@ -278,6 +278,6 @@ Options:
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[djarea] Error:', err.message)
|
||||
console.error('[mizan] Error:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -16,7 +16,7 @@ export async function generateChannelsTypes(schema) {
|
||||
const typesCode = astToString(ast)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
'// ============================================================================',
|
||||
@@ -27,8 +27,8 @@ export async function generateChannelsTypes(schema) {
|
||||
'',
|
||||
]
|
||||
|
||||
// Extract channel metadata from x-djarea-channels extension
|
||||
const channels = schema['x-djarea-channels'] || []
|
||||
// Extract channel metadata from x-mizan-channels extension
|
||||
const channels = schema['x-mizan-channels'] || []
|
||||
|
||||
if (channels.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
@@ -86,7 +86,7 @@ export async function generateChannelsTypes(schema) {
|
||||
* Generate channel hooks from metadata.
|
||||
*/
|
||||
export function generateChannelsHooks(schema) {
|
||||
const channels = schema['x-djarea-channels'] || []
|
||||
const channels = schema['x-mizan-channels'] || []
|
||||
|
||||
if (channels.length === 0) {
|
||||
return null
|
||||
@@ -95,10 +95,10 @@ export function generateChannelsHooks(schema) {
|
||||
const lines = [
|
||||
"'use client'",
|
||||
'',
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
"import { useChannel, type ChannelSubscription } from 'djarea/channels'",
|
||||
"import { useChannel, type ChannelSubscription } from 'mizan/channels'",
|
||||
'',
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Schema Fetching
|
||||
*
|
||||
* Fetches djarea and channels schemas from Django management commands.
|
||||
* Fetches mizan and channels schemas from Django management commands.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process'
|
||||
@@ -78,11 +78,11 @@ export async function fetchChannelsSchema(source, cwd) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch djarea schema from Django.
|
||||
* Fetch mizan schema from Django.
|
||||
*/
|
||||
export async function fetchDjareaSchema(source, cwd) {
|
||||
export async function fetchMizanSchema(source, cwd) {
|
||||
if (!source.django) {
|
||||
throw new Error('Djarea schema export requires django source configuration')
|
||||
throw new Error('mizan schema export requires django source configuration')
|
||||
}
|
||||
return runDjangoCommand(source, cwd, 'export_djarea_schema')
|
||||
return runDjangoCommand(source, cwd, 'export_mizan_schema')
|
||||
}
|
||||
@@ -5,122 +5,134 @@
|
||||
* from the generated files for clean imports.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract context hooks from djarea schema.
|
||||
* Returns hook names in PascalCase (e.g., useAuthStatus, useUser).
|
||||
*/
|
||||
function extractContextHooks(djareaSchema) {
|
||||
const functions = djareaSchema?.['x-djarea-functions'] || []
|
||||
const contexts = functions.filter(fn => fn.isContext)
|
||||
function pascalCase(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
return contexts.map(ctx => {
|
||||
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1)
|
||||
return `use${pascal}`
|
||||
}).sort()
|
||||
function toPascalCase(str) {
|
||||
return str
|
||||
.split(/[.\-_]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the consolidated index.ts file.
|
||||
*
|
||||
* @param {Object} options - Generation options
|
||||
* @param {Object} options.channelsSchema - Channels schema (optional)
|
||||
* @param {Object} options.djareaSchema - Djarea schema (optional)
|
||||
* @returns {string} Generated index.ts content
|
||||
*/
|
||||
export function generateIndex({ channelsSchema, djareaSchema }) {
|
||||
export function generateIndex({ channelsSchema, mizanSchema }) {
|
||||
const lines = [
|
||||
'/**',
|
||||
' * Djarea API - Consolidated Exports',
|
||||
' * mizan API - Consolidated Exports',
|
||||
' *',
|
||||
' * Import everything from here:',
|
||||
' *',
|
||||
' * @example',
|
||||
' * ```tsx',
|
||||
' * import {',
|
||||
' * DjangoContext,',
|
||||
' * useUser,',
|
||||
' * MizanContext,',
|
||||
' * useCurrentUser,',
|
||||
' * useEcho,',
|
||||
' * useChatChannel,',
|
||||
' * DjangoError,',
|
||||
' * } from \'@/api\'',
|
||||
' * ```',
|
||||
' */',
|
||||
'',
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// Djarea Provider & Hooks (from generated.django.tsx)
|
||||
// ==========================================================================
|
||||
const functions = mizanSchema?.['x-mizan-functions'] || []
|
||||
const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
|
||||
const hasMizan = functions.length > 0
|
||||
|
||||
const functions = djareaSchema?.['x-djarea-functions'] || []
|
||||
const hasDjarea = functions.length > 0
|
||||
|
||||
if (hasDjarea) {
|
||||
const contextHooks = extractContextHooks(djareaSchema)
|
||||
const contexts = functions.filter(fn => fn.isContext)
|
||||
if (hasMizan) {
|
||||
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
|
||||
|
||||
lines.push('// =============================================================================')
|
||||
lines.push('// Djarea Provider & Hooks')
|
||||
lines.push('// mizan Provider & Hooks')
|
||||
lines.push('// =============================================================================')
|
||||
lines.push('')
|
||||
|
||||
// Server exports (getDjangoHydration runs in server components)
|
||||
if (contexts.length > 0) {
|
||||
// Server exports
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('export {')
|
||||
lines.push(' getMizanHydration,')
|
||||
lines.push(' getDjangoHydration,')
|
||||
lines.push(' type MizanHydrationData,')
|
||||
lines.push(' type DjangoHydration,')
|
||||
lines.push("} from './generated.django.server'")
|
||||
lines.push("} from './generated.server'")
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Client exports
|
||||
lines.push('export {')
|
||||
lines.push(' // Provider')
|
||||
lines.push(' MizanContext,')
|
||||
lines.push(' type MizanContextProps,')
|
||||
lines.push(' DjangoContext,')
|
||||
lines.push(' type DjangoContextProps,')
|
||||
|
||||
if (contexts.length > 0) {
|
||||
// Global context hooks
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('')
|
||||
lines.push(' // Context hooks')
|
||||
for (const hookName of contextHooks) {
|
||||
lines.push(` ${hookName},`)
|
||||
lines.push(' // Global context hooks')
|
||||
for (const ctx of globalContexts) {
|
||||
const hookPascal = pascalCase(ctx.camelName)
|
||||
lines.push(` use${hookPascal},`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push(' // Refresh hooks')
|
||||
lines.push(' useMizanRefresh,')
|
||||
lines.push(' useDjangoRefresh,')
|
||||
}
|
||||
|
||||
// Named context providers and hooks
|
||||
if (namedContextEntries.length > 0) {
|
||||
lines.push('')
|
||||
lines.push(' // Named context providers')
|
||||
for (const [ctxName, ctxMeta] of namedContextEntries) {
|
||||
const ctxPascal = toPascalCase(ctxName)
|
||||
lines.push(` ${ctxPascal}Context,`)
|
||||
// Hooks for this context's functions
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
for (const fn of ctxFunctions) {
|
||||
const hookPascal = pascalCase(fn.camelName)
|
||||
lines.push(` use${hookPascal},`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function hooks (mutations + plain)
|
||||
if (regularFunctions.length > 0) {
|
||||
lines.push('')
|
||||
lines.push(' // Function hooks')
|
||||
for (const fn of regularFunctions) {
|
||||
const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1)
|
||||
const pascal = pascalCase(fn.camelName)
|
||||
lines.push(` use${pascal},`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push(' // Re-exports from djarea library')
|
||||
lines.push(' useDjarea,')
|
||||
lines.push(' useDjareaStatus,')
|
||||
lines.push(' // Re-exports from mizan library')
|
||||
lines.push(' useMizan,')
|
||||
lines.push(' useMizanStatus,')
|
||||
lines.push(' usePush,')
|
||||
lines.push(' DjangoError,')
|
||||
lines.push(' type ConnectionStatus,')
|
||||
lines.push(' type PushMessage,')
|
||||
lines.push(' type PushListener,')
|
||||
lines.push("} from './generated.django'")
|
||||
lines.push("} from './generated.provider'")
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Channel Hooks (from generated.channels.hooks.tsx)
|
||||
// Channel Hooks
|
||||
// ==========================================================================
|
||||
|
||||
const channels = channelsSchema?.['x-djarea-channels'] || []
|
||||
const channels = channelsSchema?.['x-mizan-channels'] || []
|
||||
|
||||
if (channels.length > 0) {
|
||||
lines.push('// =============================================================================')
|
||||
@@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, djareaSchema }) {
|
||||
lines.push("} from './generated.channels.hooks'")
|
||||
lines.push('')
|
||||
|
||||
// Channel types
|
||||
lines.push('// =============================================================================')
|
||||
lines.push('// Channel Types')
|
||||
lines.push('// =============================================================================')
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Djarea Code Generator
|
||||
* mizan Code Generator
|
||||
*
|
||||
* Generates TypeScript types and React provider from Djarea OpenAPI schema.
|
||||
* Generates TypeScript types and React provider from mizan OpenAPI schema.
|
||||
* Uses openapi-typescript for robust type generation.
|
||||
*
|
||||
* Output structure:
|
||||
* - generated.djarea.ts - Types only (from OpenAPI)
|
||||
* - generated.provider.tsx - Typed provider wrapping DjareaProvider + hooks
|
||||
* - generated.mizan.ts - Types only (from OpenAPI)
|
||||
* - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks
|
||||
* - generated.forms.ts - Typed form hooks with Zod schemas
|
||||
*/
|
||||
|
||||
@@ -74,14 +74,14 @@ function buildSchemaExports(schemaNames) {
|
||||
/**
|
||||
* Generate the types file using openapi-typescript.
|
||||
*/
|
||||
export async function generateDjareaTypes(schema) {
|
||||
export async function generateMizanTypes(schema) {
|
||||
// Generate types using openapi-typescript
|
||||
const ast = await openapiTS(schema)
|
||||
const schemaNames = getSchemaNamesFromAst(ast)
|
||||
const typesCode = astToString(ast)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
'// ============================================================================',
|
||||
@@ -104,11 +104,11 @@ export async function generateDjareaTypes(schema) {
|
||||
'',
|
||||
]
|
||||
|
||||
// Extract function metadata from x-djarea-functions extension
|
||||
const functions = schema['x-djarea-functions'] || []
|
||||
// Extract function metadata from x-mizan-functions extension
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
|
||||
if (functions.length > 0) {
|
||||
lines.push('export const DJANGO_FUNCTIONS = {')
|
||||
lines.push('export const MIZAN_FUNCTIONS = {')
|
||||
for (const fn of functions) {
|
||||
lines.push(` ${fn.camelName}: {`)
|
||||
lines.push(` name: '${fn.name}',`)
|
||||
@@ -119,7 +119,7 @@ export async function generateDjareaTypes(schema) {
|
||||
}
|
||||
lines.push('} as const')
|
||||
} else {
|
||||
lines.push('export const DJANGO_FUNCTIONS = {} as const')
|
||||
lines.push('export const MIZAN_FUNCTIONS = {} as const')
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
@@ -128,24 +128,56 @@ export async function generateDjareaTypes(schema) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the React provider that wraps DjareaProvider with typed hooks.
|
||||
* Extract unique context names from an affects array.
|
||||
* Both context-level and function-level affects resolve to context names.
|
||||
*/
|
||||
function getAffectedContexts(affects) {
|
||||
const contexts = new Set()
|
||||
for (const target of affects) {
|
||||
if (target.type === 'context') {
|
||||
contexts.add(target.name)
|
||||
} else if (target.type === 'function' && target.context) {
|
||||
contexts.add(target.context)
|
||||
}
|
||||
}
|
||||
return [...contexts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map JSON schema type string to TypeScript type.
|
||||
*/
|
||||
function jsonTypeToTS(type) {
|
||||
if (type === 'integer' || type === 'number') return 'number'
|
||||
if (type === 'boolean') return 'boolean'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the React provider that wraps MizanProvider with typed hooks.
|
||||
*
|
||||
* The generated provider:
|
||||
* - Wraps DjareaProvider (from djarea library)
|
||||
* - Passes context names for auto-fetch
|
||||
* - Provides typed hooks for contexts and functions
|
||||
* - MizanContext: Root provider with global context bundled fetch
|
||||
* - Named context providers: <UserContext user_id={...}>
|
||||
* - Mutation hooks with auto-invalidation
|
||||
* - Plain function hooks
|
||||
*/
|
||||
export function generateDjareaProvider(schema, options = {}) {
|
||||
export function generateMizanProvider(schema, options = {}) {
|
||||
const { hasChannels = false } = options
|
||||
const functions = schema['x-djarea-functions'] || []
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||
|
||||
if (functions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Separate contexts from regular functions
|
||||
const contexts = functions.filter(fn => fn.isContext)
|
||||
// Partition functions
|
||||
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||
const mutationFunctions = regularFunctions.filter(fn => fn.affects)
|
||||
const plainFunctions = regularFunctions.filter(fn => !fn.affects)
|
||||
|
||||
// Named context groups (everything except 'global')
|
||||
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
|
||||
|
||||
// Collect type imports
|
||||
const typeImports = []
|
||||
@@ -162,36 +194,36 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
const lines = [
|
||||
"'use client'",
|
||||
'',
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
'// This file provides typed wrappers around the djarea library.',
|
||||
'// - DjangoContext: Typed provider wrapping DjareaProvider',
|
||||
'// - Typed hooks: useAuthStatus(), useUser(), etc.',
|
||||
'// This file provides typed wrappers around the mizan library.',
|
||||
'// - MizanContext: Root provider with global context',
|
||||
'// - Named context providers: <UserContext user_id={...}>',
|
||||
'// - Typed hooks with auto-invalidation',
|
||||
'',
|
||||
"import { type ReactNode, useCallback } from 'react'",
|
||||
"import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'",
|
||||
"import {",
|
||||
" DjareaProvider,",
|
||||
" useDjarea,",
|
||||
" useDjareaContext,",
|
||||
" useDjareaCall,",
|
||||
" type DjareaHydration,",
|
||||
" MizanProvider,",
|
||||
" useMizan,",
|
||||
" useMizanContext,",
|
||||
" useMizanCall,",
|
||||
" type MizanHydration,",
|
||||
" type Transport,",
|
||||
"} from 'djarea'",
|
||||
"} from 'mizan'",
|
||||
...(hasChannels ? [
|
||||
"import { ChannelProvider, ChannelConnection } from 'djarea/channels'",
|
||||
"import { useRef } from 'react'",
|
||||
"import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
|
||||
] : []),
|
||||
'',
|
||||
]
|
||||
|
||||
if (uniqueTypeImports.length > 0) {
|
||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
|
||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hydration types
|
||||
// Hydration types (global contexts only)
|
||||
// ============================================================================
|
||||
|
||||
lines.push('// ============================================================================')
|
||||
@@ -199,20 +231,19 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
if (contexts.length > 0) {
|
||||
lines.push('/** Typed hydration data for SSR */')
|
||||
lines.push('export interface DjangoHydration {')
|
||||
for (const ctx of contexts) {
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('/** Typed hydration data for SSR (global contexts only) */')
|
||||
lines.push('export interface MizanHydrationData {')
|
||||
for (const ctx of globalContexts) {
|
||||
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
lines.push('/** Convert typed hydration to djarea format */')
|
||||
lines.push('function toDjareaHydration(hydration?: DjangoHydration): DjareaHydration | undefined {')
|
||||
lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
|
||||
lines.push(' if (!hydration) return undefined')
|
||||
lines.push(' const result: DjareaHydration = {}')
|
||||
for (const ctx of contexts) {
|
||||
lines.push(' const result: MizanHydration = {}')
|
||||
for (const ctx of globalContexts) {
|
||||
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
|
||||
}
|
||||
lines.push(' return result')
|
||||
@@ -221,50 +252,81 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider
|
||||
// Global Context Loader (inner component, fetches GET /ctx/global/)
|
||||
// ============================================================================
|
||||
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Global Context Loader')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {')
|
||||
lines.push(' const mizan = useMizan()')
|
||||
lines.push(' const loaded = useRef(false)')
|
||||
lines.push('')
|
||||
lines.push(' useEffect(() => {')
|
||||
lines.push(' if (loaded.current) return')
|
||||
lines.push(' loaded.current = true')
|
||||
lines.push('')
|
||||
lines.push(' ;(async () => {')
|
||||
lines.push(' await mizan.whenReady')
|
||||
lines.push(' try {')
|
||||
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
|
||||
lines.push(' const result = await response.json()')
|
||||
lines.push(' if (!result.error) {')
|
||||
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
|
||||
lines.push(' mizan.setContextData(name, data)')
|
||||
lines.push(' }')
|
||||
lines.push(' }')
|
||||
lines.push(' } catch (e) {')
|
||||
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
|
||||
lines.push(' }')
|
||||
lines.push(' })()')
|
||||
lines.push(' }, [mizan])')
|
||||
lines.push('')
|
||||
lines.push(' return <>{children}</>')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Root Provider (MizanContext)
|
||||
// ============================================================================
|
||||
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Provider')
|
||||
lines.push('// Root Provider')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
lines.push('export interface DjangoContextProps {')
|
||||
lines.push('export interface MizanContextProps {')
|
||||
lines.push(' children: ReactNode')
|
||||
if (contexts.length > 0) {
|
||||
lines.push(' /** SSR hydration data */')
|
||||
lines.push(' hydration?: DjangoHydration')
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push(' /** SSR hydration data (global contexts only) */')
|
||||
lines.push(' hydration?: MizanHydrationData')
|
||||
}
|
||||
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
|
||||
lines.push(' wsUrl?: string')
|
||||
lines.push(' /** Base URL for HTTP fallback (default: /api/djarea) */')
|
||||
lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */')
|
||||
lines.push(' baseUrl?: string')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// Context names array for DjareaProvider
|
||||
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
|
||||
|
||||
lines.push('/**')
|
||||
lines.push(' * Typed Django context provider.')
|
||||
lines.push(' *')
|
||||
lines.push(' * Wraps DjareaProvider with:')
|
||||
lines.push(' * - Typed hydration')
|
||||
lines.push(' * - Auto-fetch for registered contexts')
|
||||
lines.push(' * Root mizan provider. Mount at your app root.')
|
||||
lines.push(' *')
|
||||
lines.push(' * Usage:')
|
||||
lines.push(' * <DjangoContext hydration={hydration}>')
|
||||
lines.push(' * <MizanContext hydration={hydration}>')
|
||||
lines.push(' * <App />')
|
||||
lines.push(' * </DjangoContext>')
|
||||
lines.push(' * </MizanContext>')
|
||||
lines.push(' */')
|
||||
lines.push('export function DjangoContext({')
|
||||
lines.push('export function MizanContext({')
|
||||
lines.push(' children,')
|
||||
if (contexts.length > 0) {
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push(' hydration,')
|
||||
}
|
||||
lines.push(' wsUrl,')
|
||||
lines.push(' baseUrl,')
|
||||
lines.push('}: DjangoContextProps) {')
|
||||
lines.push('}: MizanContextProps) {')
|
||||
|
||||
if (hasChannels) {
|
||||
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
|
||||
@@ -274,11 +336,11 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Build the JSX tree
|
||||
lines.push(' return (')
|
||||
lines.push(' <DjareaProvider')
|
||||
if (contexts.length > 0) {
|
||||
lines.push(' hydration={toDjareaHydration(hydration)}')
|
||||
lines.push(` contexts={[${contextNames}]}`)
|
||||
lines.push(' <MizanProvider')
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push(' hydration={toMizanHydration(hydration)}')
|
||||
}
|
||||
lines.push(' wsUrl={wsUrl}')
|
||||
lines.push(' baseUrl={baseUrl}')
|
||||
@@ -287,93 +349,221 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
}
|
||||
lines.push(' >')
|
||||
|
||||
if (hasChannels) {
|
||||
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>')
|
||||
lines.push(' {children}')
|
||||
lines.push(' </ChannelProvider>')
|
||||
} else {
|
||||
lines.push(' {children}')
|
||||
// Inner content: GlobalContextLoader wraps children if needed
|
||||
let innerContent = '{children}'
|
||||
if (globalContexts.length > 0) {
|
||||
innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
|
||||
}
|
||||
|
||||
lines.push(' </DjareaProvider>')
|
||||
if (hasChannels) {
|
||||
lines.push(` <ChannelProvider connection={connectionRef.current} autoConnect={true}>`)
|
||||
lines.push(` ${innerContent}`)
|
||||
lines.push(` </ChannelProvider>`)
|
||||
} else {
|
||||
lines.push(` ${innerContent}`)
|
||||
}
|
||||
|
||||
lines.push(' </MizanProvider>')
|
||||
lines.push(' )')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// Legacy alias
|
||||
lines.push('/** @deprecated Use MizanContext instead */')
|
||||
lines.push('export const DjangoContext = MizanContext')
|
||||
lines.push('/** @deprecated Use MizanContextProps instead */')
|
||||
lines.push('export type DjangoContextProps = MizanContextProps')
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('/** @deprecated Use MizanHydrationData instead */')
|
||||
lines.push('export type DjangoHydration = MizanHydrationData')
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// ============================================================================
|
||||
// Context Hooks
|
||||
// Global Context Hooks
|
||||
// ============================================================================
|
||||
|
||||
if (contexts.length > 0) {
|
||||
if (globalContexts.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Context Hooks (typed wrappers)')
|
||||
lines.push('// Global Context Hooks')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
for (const ctx of contexts) {
|
||||
for (const ctx of globalContexts) {
|
||||
const pascal = pascalCase(ctx.camelName)
|
||||
lines.push(`/**`)
|
||||
lines.push(` * Get ${ctx.name} context data.`)
|
||||
lines.push(` * @throws if context not loaded yet`)
|
||||
lines.push(` */`)
|
||||
lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
|
||||
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
|
||||
lines.push(` const data = useDjareaContext<${ctx.outputType}>('${ctx.name}')`)
|
||||
lines.push(` if (data === undefined) {`)
|
||||
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
|
||||
lines.push(` }`)
|
||||
lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
|
||||
lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
|
||||
lines.push(` return data`)
|
||||
lines.push(`}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Refresh hooks
|
||||
lines.push('/**')
|
||||
lines.push(' * Get context refresh functions without subscribing to data changes.')
|
||||
lines.push(' * Use this in components that only need to trigger refreshes.')
|
||||
lines.push(' */')
|
||||
lines.push('export function useDjangoRefresh() {')
|
||||
lines.push(' const { refreshContext, refreshAllContexts } = useDjarea()')
|
||||
lines.push('/** Refresh functions for global contexts. */')
|
||||
lines.push('export function useMizanRefresh() {')
|
||||
lines.push(' const { invalidateContext } = useMizan()')
|
||||
lines.push(' return {')
|
||||
for (const ctx of contexts) {
|
||||
for (const ctx of globalContexts) {
|
||||
const pascal = pascalCase(ctx.camelName)
|
||||
lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`)
|
||||
lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`)
|
||||
}
|
||||
lines.push(' refreshAll: refreshAllContexts,')
|
||||
lines.push(' }')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// Legacy alias
|
||||
lines.push('/** @deprecated Use useMizanRefresh instead */')
|
||||
lines.push('export const useDjangoRefresh = useMizanRefresh')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Function Hooks
|
||||
// Named Context Providers
|
||||
// ============================================================================
|
||||
|
||||
if (regularFunctions.length > 0) {
|
||||
if (namedContextEntries.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Function Hooks (typed wrappers)')
|
||||
lines.push('// Named Context Providers')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
for (const fn of regularFunctions) {
|
||||
for (const [ctxName, ctxMeta] of namedContextEntries) {
|
||||
const ctxPascal = toPascalCase(ctxName)
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
const params = ctxMeta.params || {}
|
||||
const paramEntries = Object.entries(params)
|
||||
|
||||
// Internal React context type
|
||||
lines.push(`const ${ctxPascal}ContextInternal = createContext<{`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||
}
|
||||
lines.push(`} | null>(null)`)
|
||||
lines.push('')
|
||||
|
||||
// Props interface
|
||||
lines.push(`export interface ${ctxPascal}ContextProps {`)
|
||||
lines.push(` children: ReactNode`)
|
||||
for (const [pName, pMeta] of paramEntries) {
|
||||
const tsType = jsonTypeToTS(pMeta.type)
|
||||
const optional = pMeta.required ? '' : '?'
|
||||
lines.push(` ${pName}${optional}: ${tsType}`)
|
||||
}
|
||||
lines.push(`}`)
|
||||
lines.push('')
|
||||
|
||||
// Provider component
|
||||
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
|
||||
lines.push(` const mizan = useMizan()`)
|
||||
lines.push(` const [data, setData] = useState<{`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||
}
|
||||
lines.push(` } | null>(null)`)
|
||||
lines.push('')
|
||||
lines.push(` const refetch = useCallback(async () => {`)
|
||||
lines.push(` await mizan.whenReady`)
|
||||
lines.push(` const qs = new URLSearchParams()`)
|
||||
for (const [pName] of paramEntries) {
|
||||
lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`)
|
||||
}
|
||||
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
|
||||
lines.push(` const result = await resp.json()`)
|
||||
lines.push(` if (!result.error) setData(result.data)`)
|
||||
|
||||
// Dependency array: mizan + each param
|
||||
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
|
||||
lines.push(` }, [${deps.join(', ')}])`)
|
||||
lines.push('')
|
||||
lines.push(` useEffect(() => { refetch() }, [refetch])`)
|
||||
lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`)
|
||||
lines.push('')
|
||||
lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}</${ctxPascal}ContextInternal>`)
|
||||
lines.push(`}`)
|
||||
lines.push('')
|
||||
|
||||
// Individual data hooks
|
||||
for (const fn of ctxFunctions) {
|
||||
const hookPascal = pascalCase(fn.camelName)
|
||||
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
|
||||
lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`)
|
||||
lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`)
|
||||
lines.push(` return ctx.${fn.name}`)
|
||||
lines.push(`}`)
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Hooks (with auto-invalidation)
|
||||
// ============================================================================
|
||||
|
||||
if (mutationFunctions.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Mutation Hooks (auto-invalidate on success)')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
for (const fn of mutationFunctions) {
|
||||
const pascal = pascalCase(fn.camelName)
|
||||
const transport = fn.transport || 'http'
|
||||
const affectedContexts = getAffectedContexts(fn.affects)
|
||||
|
||||
lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`)
|
||||
lines.push(`export function use${pascal}() {`)
|
||||
lines.push(` const mizan = useMizan()`)
|
||||
|
||||
if (fn.hasInput) {
|
||||
lines.push(` return useCallback(async (input: ${fn.inputType}) => {`)
|
||||
lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`)
|
||||
} else {
|
||||
lines.push(` return useCallback(async () => {`)
|
||||
lines.push(` const result = await mizan.call<void, ${fn.outputType}>('${fn.name}', undefined, '${transport}')`)
|
||||
}
|
||||
|
||||
// Invalidation
|
||||
if (affectedContexts.length === 1) {
|
||||
lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`)
|
||||
} else if (affectedContexts.length > 1) {
|
||||
lines.push(` await Promise.all([`)
|
||||
for (const ctx of affectedContexts) {
|
||||
lines.push(` mizan.invalidateContext('${ctx}'),`)
|
||||
}
|
||||
lines.push(` ])`)
|
||||
}
|
||||
|
||||
lines.push(` return result`)
|
||||
lines.push(` }, [mizan])`)
|
||||
lines.push(`}`)
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plain Function Hooks
|
||||
// ============================================================================
|
||||
|
||||
if (plainFunctions.length > 0) {
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Function Hooks')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
|
||||
for (const fn of plainFunctions) {
|
||||
const pascal = pascalCase(fn.camelName)
|
||||
// Transport is known at generation time - pass it directly
|
||||
const transport = fn.transport || 'http'
|
||||
|
||||
if (fn.hasInput) {
|
||||
lines.push(`/**`)
|
||||
lines.push(` * Call ${fn.name} server function.`)
|
||||
lines.push(` * Transport: ${transport}`)
|
||||
lines.push(` */`)
|
||||
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
|
||||
lines.push(`export function use${pascal}() {`)
|
||||
lines.push(` return useDjareaCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||
lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||
lines.push(`}`)
|
||||
} else {
|
||||
lines.push(`/**`)
|
||||
lines.push(` * Call ${fn.name} server function.`)
|
||||
lines.push(` * Transport: ${transport}`)
|
||||
lines.push(` */`)
|
||||
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
|
||||
lines.push(`export function use${pascal}() {`)
|
||||
lines.push(` return useDjareaCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||
lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||
lines.push(`}`)
|
||||
}
|
||||
lines.push('')
|
||||
@@ -385,11 +575,11 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
// ============================================================================
|
||||
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// Re-exports from djarea library')
|
||||
lines.push('// Re-exports from mizan library')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
lines.push("export { useDjarea, useDjareaStatus, usePush, DjangoError } from 'djarea'")
|
||||
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'djarea'")
|
||||
lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'")
|
||||
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'")
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
@@ -399,20 +589,20 @@ export function generateDjareaProvider(schema, options = {}) {
|
||||
* Generate server-side hydration helper (runs in Next.js server components).
|
||||
* This is separate from the client file because it needs to run on the server.
|
||||
*/
|
||||
export function generateDjareaServer(schema) {
|
||||
const functions = schema['x-djarea-functions'] || []
|
||||
const contexts = functions.filter(fn => fn.isContext)
|
||||
export function generateMizanServer(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||
|
||||
if (contexts.length === 0) {
|
||||
if (globalContexts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Collect type imports for contexts
|
||||
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean)
|
||||
// Collect type imports for global contexts
|
||||
const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
|
||||
const uniqueTypeImports = [...new Set(typeImports)].sort()
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'//',
|
||||
'// Server-side functions for SSR hydration.',
|
||||
@@ -421,7 +611,7 @@ export function generateDjareaServer(schema) {
|
||||
]
|
||||
|
||||
if (uniqueTypeImports.length > 0) {
|
||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
|
||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
@@ -430,67 +620,66 @@ export function generateDjareaServer(schema) {
|
||||
lines.push('// Hydration Types')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
lines.push('/** Typed hydration data for SSR */')
|
||||
lines.push('export interface DjangoHydration {')
|
||||
for (const ctx of contexts) {
|
||||
lines.push('/** Typed hydration data for SSR (global contexts only) */')
|
||||
lines.push('export interface MizanHydrationData {')
|
||||
for (const ctx of globalContexts) {
|
||||
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('/** @deprecated Use MizanHydrationData instead */')
|
||||
lines.push('export type DjangoHydration = MizanHydrationData')
|
||||
lines.push('')
|
||||
|
||||
// SSR Hydration Helper
|
||||
// SSR Hydration Helper — single bundled GET
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('// SSR Hydration Helper')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
lines.push('/**')
|
||||
lines.push(' * Fetch hydration data for SSR.')
|
||||
lines.push(' * Fetch hydration data for SSR via bundled context endpoint.')
|
||||
lines.push(' *')
|
||||
lines.push(' * Call this in your server component:')
|
||||
lines.push(' * const hydration = await getDjangoHydration(client)')
|
||||
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>')
|
||||
lines.push(' * const hydration = await getMizanHydration(client)')
|
||||
lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
|
||||
lines.push(' */')
|
||||
lines.push('export async function getDjangoHydration(')
|
||||
lines.push('export async function getMizanHydration(')
|
||||
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
|
||||
lines.push('): Promise<DjangoHydration> {')
|
||||
lines.push(' const hydration: DjangoHydration = {}')
|
||||
lines.push('): Promise<MizanHydrationData> {')
|
||||
lines.push(' const hydration: MizanHydrationData = {}')
|
||||
lines.push('')
|
||||
lines.push(' const results = await Promise.allSettled([')
|
||||
for (const ctx of contexts) {
|
||||
lines.push(` client.request('POST', '/api/djarea/call/', { fn: '${ctx.name}', args: {} }),`)
|
||||
lines.push(' try {')
|
||||
lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
|
||||
lines.push(' const result = await response.json()')
|
||||
lines.push(' if (!result.error) {')
|
||||
for (const ctx of globalContexts) {
|
||||
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
|
||||
}
|
||||
lines.push(' ])')
|
||||
lines.push('')
|
||||
|
||||
contexts.forEach((ctx, i) => {
|
||||
lines.push(` if (results[${i}].status === 'fulfilled') {`)
|
||||
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`)
|
||||
lines.push(` if (data.error) {`)
|
||||
lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`)
|
||||
lines.push(` } else {`)
|
||||
lines.push(` hydration.${ctx.camelName} = data.data`)
|
||||
lines.push(` }`)
|
||||
lines.push(` } else {`)
|
||||
lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`)
|
||||
lines.push(' }')
|
||||
})
|
||||
|
||||
lines.push(' } else {')
|
||||
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
|
||||
lines.push(' }')
|
||||
lines.push(' } catch (e) {')
|
||||
lines.push(" console.error('[getMizanHydration] Request failed:', e)")
|
||||
lines.push(' }')
|
||||
lines.push('')
|
||||
lines.push(' return hydration')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('/** @deprecated Use getMizanHydration instead */')
|
||||
lines.push('export const getDjangoHydration = getMizanHydration')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all djarea files.
|
||||
* Generate all mizan files.
|
||||
*/
|
||||
export async function generateDjareaFiles(schema, options = {}) {
|
||||
const types = await generateDjareaTypes(schema)
|
||||
const provider = generateDjareaProvider(schema, options)
|
||||
const server = generateDjareaServer(schema)
|
||||
const forms = generateDjareaForms(schema)
|
||||
export async function generateMizanFiles(schema, options = {}) {
|
||||
const types = await generateMizanTypes(schema)
|
||||
const provider = generateMizanProvider(schema, options)
|
||||
const server = generateMizanServer(schema)
|
||||
const forms = generateMizanForms(schema)
|
||||
|
||||
return { types, provider, server, forms }
|
||||
}
|
||||
@@ -498,8 +687,8 @@ export async function generateDjareaFiles(schema, options = {}) {
|
||||
/**
|
||||
* Generate typed form hooks with Zod schemas.
|
||||
*/
|
||||
export function generateDjareaForms(schema) {
|
||||
const functions = schema['x-djarea-functions'] || []
|
||||
export function generateMizanForms(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
|
||||
// Group form functions by form name
|
||||
const formFunctions = functions.filter(fn => fn.isForm)
|
||||
@@ -535,7 +724,7 @@ export function generateDjareaForms(schema) {
|
||||
const lines = [
|
||||
"'use client'",
|
||||
'',
|
||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
||||
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||
'// Regenerate with: npm run schemas',
|
||||
'',
|
||||
'// Typed form hooks with Zod validation.',
|
||||
@@ -549,7 +738,7 @@ export function generateDjareaForms(schema) {
|
||||
" type DjangoFormState,",
|
||||
" type DjangoFormsetState,",
|
||||
" type FormOptions,",
|
||||
"} from 'djarea'",
|
||||
"} from 'mizan'",
|
||||
'',
|
||||
'// ============================================================================',
|
||||
'// Zod Schemas',
|
||||
@@ -658,7 +847,7 @@ export function generateDjareaForms(schema) {
|
||||
lines.push('// Form Registry')
|
||||
lines.push('// ============================================================================')
|
||||
lines.push('')
|
||||
lines.push('export const DJANGO_FORMS = {')
|
||||
lines.push('export const MIZAN_FORMS = {')
|
||||
for (const [formName, group] of formGroups) {
|
||||
if (!group.schema) continue
|
||||
const pascalName = toPascalCase(formName)
|
||||
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "djarea"
|
||||
name = "mizan"
|
||||
version = "1.0.1"
|
||||
description = "Django + React server functions framework"
|
||||
readme = "README.md"
|
||||
@@ -36,11 +36,11 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/djarea"]
|
||||
packages = ["src/mizan"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||
pythonpath = ["src", "."]
|
||||
testpaths = ["src/djarea/tests"]
|
||||
testpaths = ["src/mizan/tests"]
|
||||
python_classes = ["*Tests", "*Test", "Test*"]
|
||||
python_functions = ["test_*"]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea - Django + React unified framework
|
||||
mizan - Django + React unified framework
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
### 1. urls.py - HTTP endpoint
|
||||
```python
|
||||
from djarea import urls as djarea_urls
|
||||
from mizan import urls as mizan_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('api/djarea/', include(djarea_urls)),
|
||||
path('api/mizan/', include(mizan_urls)),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. asgi.py - WebSocket support (optional)
|
||||
```python
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
|
||||
### 3. Define server functions
|
||||
```python
|
||||
# apps/myapp/clients.py
|
||||
from djarea import client
|
||||
from mizan import client
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
from djarea.setup import djarea_clients
|
||||
djarea_clients('apps')
|
||||
from mizan.setup import mizan_clients
|
||||
mizan_clients('apps')
|
||||
```
|
||||
|
||||
### 5. Frontend - generate types and use
|
||||
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
|
||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
"""
|
||||
|
||||
@@ -88,12 +88,13 @@ from . import forms
|
||||
from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, ServerFunction, client, compose
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
from .setup import (
|
||||
djarea_clients,
|
||||
djarea_module,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
get_channel,
|
||||
get_function,
|
||||
register,
|
||||
@@ -104,9 +105,9 @@ from .setup import (
|
||||
def __getattr__(name):
|
||||
"""Lazy loading for modules that can't be imported at app load time."""
|
||||
if name == "urls":
|
||||
from .urls import urlpatterns as djarea_patterns
|
||||
from .urls import urlpatterns as mizan_patterns
|
||||
|
||||
return djarea_patterns
|
||||
return mizan_patterns
|
||||
if name == "Shape":
|
||||
from .shapes import Shape
|
||||
|
||||
@@ -116,11 +117,11 @@ def __getattr__(name):
|
||||
|
||||
def wrap_asgi(http_application):
|
||||
"""
|
||||
Wrap an ASGI application with Djarea WebSocket support.
|
||||
Wrap an ASGI application with mizan WebSocket support.
|
||||
|
||||
Usage in asgi.py:
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
@@ -156,14 +157,16 @@ def wrap_asgi(http_application):
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Decorators
|
||||
# Decorators & Contexts
|
||||
"client",
|
||||
"compose",
|
||||
"ReactContext",
|
||||
"GlobalContext",
|
||||
"ServerFunction",
|
||||
"ComposedContext",
|
||||
# Setup
|
||||
"djarea_clients",
|
||||
"djarea_module",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
"register",
|
||||
"register_as",
|
||||
"get_function",
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.channels - Real-time WebSocket communication.
|
||||
mizan.channels - Real-time WebSocket communication.
|
||||
|
||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||
Hooks are auto-generated with full TypeScript types.
|
||||
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
|
||||
```python
|
||||
# channels.py
|
||||
from pydantic import BaseModel
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
class ChatChannel(channels.ReactChannel):
|
||||
|
||||
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
|
||||
|
||||
```python
|
||||
# asgi.py
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
|
||||
# Base Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
"""
|
||||
Base class for WebSocket channels.
|
||||
@@ -140,9 +141,7 @@ class ReactChannel:
|
||||
|
||||
Messages returned from receive() are broadcast to this group.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} must implement group()"
|
||||
)
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||
|
||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||
"""
|
||||
@@ -191,9 +190,9 @@ class ReactChannel:
|
||||
"type": "channel.message",
|
||||
"channel": self._registered_name,
|
||||
"params": self._params_dict,
|
||||
"data": message.model_dump(mode='json'),
|
||||
"data": message.model_dump(mode="json"),
|
||||
"message_type": message.__class__.__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -215,7 +214,9 @@ class ReactChannel:
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if not channel_layer:
|
||||
logger.warning(f"No channel layer configured, cannot push to {cls.__name__}")
|
||||
logger.warning(
|
||||
f"No channel layer configured, cannot push to {cls.__name__}"
|
||||
)
|
||||
return
|
||||
|
||||
# Build params model if defined
|
||||
@@ -234,9 +235,9 @@ class ReactChannel:
|
||||
"type": "channel.message",
|
||||
"channel": cls._registered_name,
|
||||
"params": params,
|
||||
"data": message.model_dump(mode='json'),
|
||||
"data": message.model_dump(mode="json"),
|
||||
"message_type": message.__class__.__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
|
||||
channel_class._registered_name = name
|
||||
|
||||
# Validate the channel class
|
||||
if not hasattr(channel_class, 'authorize'):
|
||||
if not hasattr(channel_class, "authorize"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||
if not hasattr(channel_class, 'group'):
|
||||
if not hasattr(channel_class, "group"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||
|
||||
_registry[name] = channel_class
|
||||
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
||||
# WebSocket Consumer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_websocket_application():
|
||||
"""
|
||||
Get the WebSocket application for ASGI.
|
||||
|
||||
Usage in asgi.py:
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
@@ -309,9 +311,11 @@ def get_websocket_application():
|
||||
from .connection import DjangoReactConsumer
|
||||
|
||||
return AuthMiddlewareStack(
|
||||
URLRouter([
|
||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||
])
|
||||
URLRouter(
|
||||
[
|
||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -319,15 +323,14 @@ def get_websocket_application():
|
||||
# Schema Export (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_channels_schema() -> dict:
|
||||
"""
|
||||
Get schema for all registered channels (for TypeScript generation).
|
||||
|
||||
Returns a dict suitable for the frontend code generator.
|
||||
"""
|
||||
schema = {
|
||||
"channels": {}
|
||||
}
|
||||
schema = {"channels": {}}
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
channel_schema = {
|
||||
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
|
||||
}
|
||||
|
||||
# Extract Params schema
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema()
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
channel_schema[
|
||||
"reactMessage"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
|
||||
# Extract DjangoMessage schema
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
channel_schema[
|
||||
"djangoMessage"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
|
||||
schema["channels"][name] = channel_schema
|
||||
|
||||
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
|
||||
) -> None:
|
||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
)
|
||||
|
||||
|
||||
def get_channels_openapi_schema() -> dict:
|
||||
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
|
||||
|
||||
# Create temporary Ninja API for schema generation only
|
||||
schema_api = NinjaAPI(
|
||||
title="Djarea Channels",
|
||||
title="mizan Channels",
|
||||
version="1.0.0",
|
||||
description="Auto-generated schema for djarea channels",
|
||||
description="Auto-generated schema for mizan channels",
|
||||
docs_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
|
||||
}
|
||||
|
||||
# Register Params type
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
params_name = f"{pascal_name}Params"
|
||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||
channel_meta["hasParams"] = True
|
||||
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
|
||||
)
|
||||
|
||||
# Register ReactMessage type
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
react_name = f"{pascal_name}ReactMessage"
|
||||
schema_classes[react_name] = type(react_name, (channel_class.ReactMessage,), {})
|
||||
schema_classes[react_name] = type(
|
||||
react_name, (channel_class.ReactMessage,), {}
|
||||
)
|
||||
channel_meta["hasReactMessage"] = True
|
||||
channel_meta["reactMessageType"] = react_name
|
||||
|
||||
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
|
||||
)
|
||||
|
||||
# Register DjangoMessage type
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
django_name = f"{pascal_name}DjangoMessage"
|
||||
schema_classes[django_name] = type(django_name, (channel_class.DjangoMessage,), {})
|
||||
schema_classes[django_name] = type(
|
||||
django_name, (channel_class.DjangoMessage,), {}
|
||||
)
|
||||
channel_meta["hasDjangoMessage"] = True
|
||||
channel_meta["djangoMessageType"] = django_name
|
||||
|
||||
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add channel metadata extension
|
||||
schema["x-djarea-channels"] = channel_metadata
|
||||
schema["x-mizan-channels"] = channel_metadata
|
||||
|
||||
return schema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
WebSocket consumer for djarea.channels.
|
||||
WebSocket consumer for mizan.channels.
|
||||
|
||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||
|
||||
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self._try_jwt_auth()
|
||||
|
||||
await self.accept()
|
||||
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}")
|
||||
logger.debug(
|
||||
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
|
||||
)
|
||||
|
||||
async def _try_jwt_auth(self):
|
||||
"""
|
||||
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Validate JWT and create JWTUser (no DB query)
|
||||
try:
|
||||
from djarea.client.jwt import decode_token
|
||||
from djarea.jwt.tokens import JWTUser
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
|
||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||
if payload is None:
|
||||
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
elif action == "rpc":
|
||||
await self._handle_rpc(content)
|
||||
else:
|
||||
await self.send_json({
|
||||
"error": f"Unknown action: {action}",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Unknown action: {action}",
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_subscribe(self, content: dict):
|
||||
"""Handle subscription request."""
|
||||
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Get channel class
|
||||
channel_class = get_channel(channel_name)
|
||||
if not channel_class:
|
||||
await self.send_json({
|
||||
"error": f"Unknown channel: {channel_name}",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Unknown channel: {channel_name}",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create subscription key
|
||||
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Check if already subscribed
|
||||
if sub_key in self._subscriptions:
|
||||
await self.send_json({
|
||||
"error": f"Already subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Already subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create channel instance
|
||||
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
try:
|
||||
params_obj = channel_class.Params(**params_dict)
|
||||
except Exception as e:
|
||||
await self.send_json({
|
||||
"error": f"Invalid params: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Invalid params: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Check authorization
|
||||
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
authorized = instance.authorize()
|
||||
except Exception as e:
|
||||
logger.error(f"Authorization error for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": "Authorization failed",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "Authorization failed",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if not authorized:
|
||||
await self.send_json({
|
||||
"error": "Not authorized",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "Not authorized",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Get group and join
|
||||
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
await instance._join_group(group_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join group for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": f"Failed to subscribe: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Failed to subscribe: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Store subscription
|
||||
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||
|
||||
# Confirm subscription
|
||||
await self.send_json({
|
||||
"subscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"subscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||
|
||||
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
except Exception as e:
|
||||
logger.error(f"Error during unsubscribe: {e}")
|
||||
|
||||
await self.send_json({
|
||||
"unsubscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"unsubscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Unsubscribed from {channel_name}")
|
||||
|
||||
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
instance = self._subscriptions.get(sub_key)
|
||||
if not instance:
|
||||
await self.send_json({
|
||||
"error": f"Not subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Not subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
channel_class = instance.__class__
|
||||
|
||||
# Check if channel accepts messages
|
||||
if not channel_class.ReactMessage:
|
||||
await self.send_json({
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Parse message
|
||||
try:
|
||||
msg = channel_class.ReactMessage(**data)
|
||||
except Exception as e:
|
||||
await self.send_json({
|
||||
"error": f"Invalid message: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Invalid message: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Parse params
|
||||
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling message for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": f"Message handling failed: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Message handling failed: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_rpc(self, content: dict):
|
||||
"""
|
||||
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
- Function must be explicitly registered (no arbitrary code execution)
|
||||
- User context from WebSocket session is passed to function
|
||||
"""
|
||||
from djarea.client.executor import execute_function, FunctionError
|
||||
from djarea.setup.registry import get_function
|
||||
from mizan.client.executor import execute_function, FunctionError
|
||||
from mizan.setup.registry import get_function
|
||||
|
||||
request_id = content.get("id")
|
||||
fn_name = content.get("fn")
|
||||
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Validate request structure
|
||||
if not request_id:
|
||||
await self.send_json({
|
||||
"error": "RPC request missing 'id' field",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "RPC request missing 'id' field",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if not fn_name:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Missing 'fn' field",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Missing 'fn' field",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Check if function exists and has websocket=True
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Function '{fn_name}' not found",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Function '{fn_name}' not found",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Only allow functions explicitly marked with websocket=True
|
||||
fn_meta = getattr(fn_class, "_meta", {})
|
||||
if not fn_meta.get("websocket"):
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create request adapter from WebSocket scope
|
||||
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None))
|
||||
ws_request = WebSocketRequest(
|
||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||
)
|
||||
|
||||
# Execute function (Pydantic validation happens inside execute_function)
|
||||
# This is sync, so we need to run it in a thread pool
|
||||
@@ -435,21 +473,25 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Send response
|
||||
if isinstance(result, FunctionError):
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": result.code.value,
|
||||
"message": result.message,
|
||||
**({"details": result.details} if result.details else {}),
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": result.code.value,
|
||||
"message": result.message,
|
||||
**({"details": result.details} if result.details else {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"data": result.data,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"data": result.data,
|
||||
}
|
||||
)
|
||||
|
||||
async def channel_message(self, event: dict):
|
||||
"""
|
||||
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
Called when channel_layer.group_send() is used.
|
||||
Includes channel name and params so the client can route the message.
|
||||
"""
|
||||
await self.send_json({
|
||||
"channel": event.get("channel"),
|
||||
"params": event.get("params", {}),
|
||||
"type": event.get("message_type", "message"),
|
||||
"data": event.get("data", {}),
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"channel": event.get("channel"),
|
||||
"params": event.get("params", {}),
|
||||
"type": event.get("message_type", "message"),
|
||||
"data": event.get("data", {}),
|
||||
}
|
||||
)
|
||||
|
||||
async def push_message(self, event: dict):
|
||||
"""
|
||||
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
Protocol:
|
||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
"""
|
||||
await self.send_json({
|
||||
"type": "push",
|
||||
"topic": event.get("topic"),
|
||||
"data": event.get("data", {}),
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "push",
|
||||
"topic": event.get("topic"),
|
||||
"data": event.get("data", {}),
|
||||
}
|
||||
)
|
||||
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
Djarea Push - Server-initiated messages to clients.
|
||||
mizan Push - Server-initiated messages to clients.
|
||||
|
||||
Simple API for pushing data to subscribed WebSocket connections.
|
||||
|
||||
Usage:
|
||||
# In a server function - push to all subscribers
|
||||
from djarea.push import push
|
||||
from mizan.push import push
|
||||
|
||||
push("room:42", {"type": "new_message", "data": {...}})
|
||||
|
||||
# Subscribe a connection to a topic (call during context fetch)
|
||||
from djarea.push import subscribe
|
||||
from mizan.push import subscribe
|
||||
|
||||
subscribe(request, "room:42")
|
||||
"""
|
||||
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
"""Get channel layer, returning None if channels is not installed."""
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
return get_channel_layer()
|
||||
except ImportError:
|
||||
return None
|
||||
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
def _async_to_sync(coro):
|
||||
"""Wrapper for async_to_sync that handles missing channels."""
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
return async_to_sync(coro)
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
"type": "push.message", # Maps to push_message handler in consumer
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||
"type": "push.message",
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.client - Server function implementation.
|
||||
mizan.client - Server function implementation.
|
||||
|
||||
This subpackage contains everything needed to make server functions work:
|
||||
- The @client decorator
|
||||
@@ -8,12 +8,15 @@ This subpackage contains everything needed to make server functions work:
|
||||
- JWT authentication (integral to server functions)
|
||||
|
||||
Usage:
|
||||
from djarea.client import client, ServerFunction, compose
|
||||
from mizan.client import client, ServerFunction, compose
|
||||
"""
|
||||
|
||||
from .function import (
|
||||
# Decorator
|
||||
client,
|
||||
# Context markers
|
||||
ReactContext,
|
||||
GlobalContext,
|
||||
# Base classes
|
||||
ServerFunction,
|
||||
ComposedContext,
|
||||
@@ -39,6 +42,9 @@ from .executor import (
|
||||
__all__ = [
|
||||
# Decorator
|
||||
"client",
|
||||
# Context markers
|
||||
"ReactContext",
|
||||
"GlobalContext",
|
||||
# Base classes
|
||||
"ServerFunction",
|
||||
"ComposedContext",
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Function Executor
|
||||
mizan Function Executor
|
||||
|
||||
Handles execution of server functions.
|
||||
This is the core of the "Server Functions" feature - callable from React
|
||||
@@ -27,7 +27,7 @@ from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from djarea.setup.registry import get_function
|
||||
from mizan.setup.registry import get_function, get_context_groups
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -134,23 +134,23 @@ def _check_auth_requirement(
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == 'staff':
|
||||
if not getattr(user, 'is_staff', False):
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == 'superuser':
|
||||
if not getattr(user, 'is_superuser', False):
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Superuser access required",
|
||||
@@ -159,6 +159,151 @@ def _check_auth_requirement(
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Determine whether an affects target is a context name or function name.
|
||||
|
||||
Returns:
|
||||
("context", "user", None) — full context invalidation
|
||||
("function", "user_profile", "user") — function within context
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
|
||||
# Check if it's a context name directly
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
|
||||
# Check if it's a function name within a context
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
|
||||
# Not a context or context function — treat as context name anyway
|
||||
# (it might be a non-context function or an as-yet-unregistered context)
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _get_context_param_names(context_name: str) -> set[str]:
|
||||
"""
|
||||
Get the set of parameter names used by functions in a context.
|
||||
|
||||
Returns the union of all Input field names across context functions.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
param_names: set[str] = set()
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
param_names.update(input_cls.model_fields.keys())
|
||||
|
||||
return param_names
|
||||
|
||||
|
||||
def _resolve_invalidation(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve invalidation targets with three-tier auto-scoping.
|
||||
|
||||
Tier 1: Argument name matching — if the mutation's input args overlap
|
||||
with the context's params by name, auto-scope.
|
||||
Tier 2: Auth inference — Edge-side concern, not handled here.
|
||||
Tier 3: Broad fallback — invalidate all instances.
|
||||
|
||||
Also handles function-level targeting: affects='user_profile' resolves
|
||||
to the function name (v1: runtime refetches the whole context anyway).
|
||||
|
||||
Returns a list suitable for both JSON body and header serialization.
|
||||
Returns None if no invalidation needed.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
affects = meta.get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
result = []
|
||||
seen = set()
|
||||
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
# Function-level: use the function name as the invalidation key
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_name in seen:
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
# Resolve the context this target belongs to (for param lookup)
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
# Tier 1: argument name matching
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _get_context_param_names(ctx_for_params)
|
||||
matched = {
|
||||
k: v for k, v in input_data.items()
|
||||
if k in context_params
|
||||
}
|
||||
if matched:
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
# Tier 3: broad fallback
|
||||
result.append(target_name)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
Param values are URL-encoded to prevent delimiter collisions.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}]
|
||||
→ "search;q=hello%20world"
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
parts = []
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params", {})
|
||||
if params:
|
||||
param_str = ";".join(
|
||||
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||
for k, v in sorted(params.items())
|
||||
)
|
||||
parts.append(f"{ctx};{param_str}")
|
||||
else:
|
||||
parts.append(ctx)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def execute_function(
|
||||
request: HttpRequest,
|
||||
fn_name: str,
|
||||
@@ -190,8 +335,15 @@ def execute_function(
|
||||
message=message,
|
||||
)
|
||||
|
||||
# Check auth requirement BEFORE executing
|
||||
# Reject private functions from RPC dispatch
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
if meta.get("private"):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Function is not client-callable",
|
||||
)
|
||||
|
||||
# Check auth requirement BEFORE executing
|
||||
auth_requirement = meta.get("auth")
|
||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||
if auth_error is not None:
|
||||
@@ -224,7 +376,8 @@ def execute_function(
|
||||
if not isinstance(input_data, dict):
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Input must be an object, not " + type(input_data).__name__,
|
||||
message="Input must be an object, not "
|
||||
+ type(input_data).__name__,
|
||||
)
|
||||
validated_input = input_cls(**input_data)
|
||||
elif has_input:
|
||||
@@ -280,10 +433,23 @@ def execute_function(
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
message="An internal error occurred",
|
||||
# Don't expose internal details in production
|
||||
details={"type": type(e).__name__} if logger.isEnabledFor(logging.DEBUG) else None,
|
||||
details={"type": type(e).__name__}
|
||||
if logger.isEnabledFor(logging.DEBUG)
|
||||
else None,
|
||||
)
|
||||
|
||||
# Serialize output (handle None for Optional return types)
|
||||
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
||||
from django.http import HttpResponseBase
|
||||
|
||||
if isinstance(output, HttpResponseBase):
|
||||
# View path — add invalidation header, pass through the response
|
||||
invalidate = _resolve_invalidation(view_class, input_data)
|
||||
if invalidate:
|
||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||
output["Cache-Control"] = "no-store"
|
||||
return output
|
||||
|
||||
# RPC path — serialize output
|
||||
if output is None:
|
||||
return FunctionResult(data=None)
|
||||
return FunctionResult(data=output.model_dump())
|
||||
@@ -313,8 +479,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
from djarea.client.jwt import decode_token
|
||||
from djarea.jwt.tokens import JWTUser
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
|
||||
payload = decode_token(token, expected_type="access")
|
||||
if payload is None:
|
||||
@@ -322,7 +488,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
request.user = JWTUser(payload)
|
||||
request._djarea_jwt_authenticated = True
|
||||
request._mizan_jwt_authenticated = True
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -379,7 +545,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
||||
|
||||
Endpoint: POST /api/djarea/call/
|
||||
Endpoint: POST /api/mizan/call/
|
||||
|
||||
Request body (JSON):
|
||||
{
|
||||
@@ -430,8 +596,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._djarea_form_data = input_data
|
||||
request._djarea_form_files = request.FILES
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
@@ -462,6 +628,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
# Execute the function
|
||||
result = execute_function(request, fn_name, input_data)
|
||||
|
||||
# View path — function returned an HttpResponse directly
|
||||
from django.http import HttpResponseBase
|
||||
if isinstance(result, HttpResponseBase):
|
||||
return result
|
||||
|
||||
# Return appropriate response
|
||||
if isinstance(result, FunctionError):
|
||||
status = {
|
||||
@@ -475,4 +646,144 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
}.get(result.code, 400)
|
||||
return result.to_response(status=status)
|
||||
|
||||
return result.to_response()
|
||||
# RPC path — build response with server-driven invalidation
|
||||
view_class = get_function(fn_name)
|
||||
response_data = {"result": result.data}
|
||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||
|
||||
if invalidate_contexts:
|
||||
response_data["invalidate"] = invalidate_contexts
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Always set the header transport too (Edge reads this)
|
||||
if invalidate_contexts:
|
||||
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def execute_context(
|
||||
request: HttpRequest,
|
||||
context_name: str,
|
||||
params: dict[str, str],
|
||||
) -> FunctionResult | FunctionError:
|
||||
"""
|
||||
Execute all functions in a named context with merged params.
|
||||
|
||||
Each function receives only the params it declares in its Input schema.
|
||||
If any function fails (auth, validation, execution), the entire request fails.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest
|
||||
context_name: Name of the context (e.g., 'user', 'global')
|
||||
params: Query parameters (strings — Pydantic coerces types)
|
||||
|
||||
Returns:
|
||||
FunctionResult with bundled data, or FunctionError
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name)
|
||||
if not fn_names:
|
||||
return FunctionError(
|
||||
code=ErrorCode.NOT_FOUND,
|
||||
message=f"Context '{context_name}' not found",
|
||||
)
|
||||
|
||||
results = {}
|
||||
for fn_name in fn_names:
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
continue
|
||||
|
||||
# Filter params to only those in this function's Input schema
|
||||
input_cls = getattr(view_class, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||
fn_params = {
|
||||
k: v for k, v in params.items()
|
||||
if k in input_cls.model_fields
|
||||
}
|
||||
else:
|
||||
fn_params = None
|
||||
|
||||
result = execute_function(request, fn_name, fn_params)
|
||||
if isinstance(result, FunctionError):
|
||||
return result
|
||||
results[fn_name] = result.data
|
||||
|
||||
return FunctionResult(data=results)
|
||||
|
||||
|
||||
def _jwt_auth_only(view_func):
|
||||
"""
|
||||
Decorator that handles JWT auth for GET endpoints (no CSRF needed for GET).
|
||||
"""
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
has_jwt = _has_jwt_header(request)
|
||||
if has_jwt:
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
# No JWT — session auth (no CSRF needed for GET)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@_jwt_auth_only
|
||||
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
"""
|
||||
Fetch all functions in a named context in a single bundled GET request.
|
||||
|
||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=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
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Server Functions - Core Primitive
|
||||
mizan Server Functions - Core Primitive
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
@@ -20,15 +20,65 @@ Two styles supported:
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, ClassVar, Generic, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Literal,
|
||||
TypeVar,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Valid context modes: 'global', 'local', or False (not a context)
|
||||
ContextMode = Literal['global', 'local', False]
|
||||
# =============================================================================
|
||||
# REACT CONTEXT - Named context marker
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactContext:
|
||||
"""
|
||||
A named context that groups server functions into one provider and one fetch.
|
||||
|
||||
Usage:
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> ProfileShape: ...
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_orders(request, user_id: int) -> list[OrderShape]: ...
|
||||
|
||||
@client(affects=UserContext)
|
||||
def edit_profile(request, name: str) -> dict: ...
|
||||
|
||||
@client(affects=[UserContext, OrderContext])
|
||||
def change_plan(request) -> dict: ...
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("ReactContext name must be a non-empty string")
|
||||
self.name = name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ReactContext({self.name!r})"
|
||||
|
||||
|
||||
# Built-in global context (auto-mounted at root, SSR-hydrated)
|
||||
GlobalContext = ReactContext("global")
|
||||
|
||||
|
||||
# Context parameter type: a ReactContext instance, a raw string, or False
|
||||
ContextMode = ReactContext | str | Literal[False]
|
||||
|
||||
|
||||
TInput = TypeVar("TInput", bound=BaseModel)
|
||||
@@ -137,6 +187,11 @@ class _FunctionWrapper(ServerFunction):
|
||||
else:
|
||||
result = self._wrapped_fn(self.request)
|
||||
|
||||
# View path — return HttpResponse directly (no serialization)
|
||||
from django.http import HttpResponseBase
|
||||
if isinstance(result, HttpResponseBase):
|
||||
return result
|
||||
|
||||
# Wrap primitive returns in the generated output model
|
||||
if self._is_primitive_output:
|
||||
return self._output_cls(result=result)
|
||||
@@ -167,78 +222,107 @@ class _FunctionWrapper(ServerFunction):
|
||||
|
||||
|
||||
# Valid string values for auth parameter
|
||||
_VALID_AUTH_STRINGS = frozenset({'required', 'staff', 'superuser'})
|
||||
_VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
|
||||
|
||||
|
||||
def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
||||
"""Resolve a context parameter to its name string."""
|
||||
if context is False:
|
||||
return False
|
||||
if isinstance(context, ReactContext):
|
||||
return context.name
|
||||
if isinstance(context, str):
|
||||
if not context.strip():
|
||||
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
||||
if context == "local":
|
||||
warnings.warn(
|
||||
"context='local' is deprecated. Use ReactContext('name') instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return context
|
||||
raise ValueError(
|
||||
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
||||
)
|
||||
|
||||
|
||||
# Affects parameter type
|
||||
AffectsTarget = ReactContext | str | type["ServerFunction"]
|
||||
AffectsMode = AffectsTarget | list[AffectsTarget] | None
|
||||
|
||||
|
||||
def client(
|
||||
fn: Callable = None,
|
||||
*,
|
||||
context: ContextMode = False,
|
||||
affects: AffectsMode = None,
|
||||
private: bool = False,
|
||||
route: str | None = None,
|
||||
methods: list[str] | None = None,
|
||||
websocket: bool = False,
|
||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||
"""
|
||||
Register a function as a server function.
|
||||
|
||||
Type annotations define the schema - just like Django Ninja/FastAPI.
|
||||
Function parameters become input fields automatically.
|
||||
|
||||
Args:
|
||||
context: Context mode for React state management.
|
||||
- False (default): Not a context, just a callable function
|
||||
- 'global': Embedded in root DjangoContext, no params, singleton
|
||||
- 'local': Standalone provider, supports params via flat props
|
||||
context: Named context for React state management.
|
||||
- False (default): Not a context, just a callable function.
|
||||
- ReactContext instance: groups functions into a named context.
|
||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||
|
||||
affects: Declare which contexts or functions this mutation invalidates.
|
||||
Mutually exclusive with context=.
|
||||
Scoping is automatic via argument name matching.
|
||||
|
||||
private: If True, the function is not client-callable.
|
||||
- Not exposed as an RPC endpoint
|
||||
- No generated TypeScript
|
||||
- Still participates in the invalidation graph
|
||||
- Use for webhooks, cron jobs, internal mutations
|
||||
|
||||
route: URL route pattern for view-path functions.
|
||||
Mizan registers this route during autodiscovery.
|
||||
Example: '/profile/<user_id>/', '/webhooks/stripe/'
|
||||
|
||||
methods: HTTP methods allowed for the route.
|
||||
Default: ['GET'] for context functions, ['POST'] for mutations.
|
||||
Example: ['POST'], ['GET', 'POST']
|
||||
|
||||
websocket: Enable WebSocket RPC transport (default: False).
|
||||
By default, functions use HTTP-only transport. Enable this for
|
||||
real-time features (chat, gaming, live updates) that benefit
|
||||
from lower latency.
|
||||
|
||||
Note: Forms (DjareaFormMixin) always use HTTP because auth
|
||||
flows require full HTTP request semantics.
|
||||
|
||||
auth: Authentication requirement.
|
||||
- None (default): No auth required (AnonymousUser allowed)
|
||||
- True or 'required': Must be authenticated
|
||||
- 'staff': Must have is_staff=True
|
||||
- 'superuser': Must have is_superuser=True
|
||||
- callable(request) -> bool: Custom check function
|
||||
|
||||
Usage:
|
||||
# Basic HTTP-only function (not a context)
|
||||
@client
|
||||
def echo(request, message: str) -> EchoOutput:
|
||||
return EchoOutput(message=message)
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
# Global context - embedded in DjangoContext, no params
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
# Local context - standalone provider, supports params
|
||||
@client(context='local')
|
||||
def user_profile(request, user_id: int) -> ProfileOutput:
|
||||
return ProfileOutput(...)
|
||||
@client(affects=UserContext)
|
||||
def update_profile(request, user_id: int, name: str) -> dict: ...
|
||||
|
||||
# WebSocket-enabled for real-time
|
||||
@client(websocket=True)
|
||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
||||
return MessageOutput(...)
|
||||
# View with route — Mizan owns the URL
|
||||
@client(context=UserContext, route='/profile/<user_id>/')
|
||||
def profile_page(request, user_id: int) -> HttpResponse: ...
|
||||
|
||||
# Local context with WebSocket (live data)
|
||||
@client(context='local', websocket=True)
|
||||
def live_user_status(request, user_id: int) -> StatusOutput:
|
||||
return StatusOutput(...)
|
||||
# Private webhook — not client-callable, emits invalidation
|
||||
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
||||
def stripe_webhook(request) -> HttpResponse: ...
|
||||
|
||||
Returns:
|
||||
A ServerFunction class that wraps the function
|
||||
"""
|
||||
# Validate context parameter
|
||||
if context not in (False, 'global', 'local'):
|
||||
raise ValueError(
|
||||
f"Invalid context value '{context}'. "
|
||||
f"Must be False, 'global', or 'local'."
|
||||
)
|
||||
# Resolve context to name string
|
||||
resolved_context = _resolve_context(context)
|
||||
|
||||
# Validate affects parameter
|
||||
if affects is not None:
|
||||
if resolved_context is not False:
|
||||
raise ValueError(
|
||||
"context= and affects= are mutually exclusive. "
|
||||
"A function cannot be both a context reader and a mutation."
|
||||
)
|
||||
|
||||
# Validate auth parameter
|
||||
if auth is not None:
|
||||
@@ -249,18 +333,58 @@ def client(
|
||||
)
|
||||
|
||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
||||
return _create_server_function(
|
||||
fn, context=resolved_context, affects=affects,
|
||||
private=private, route=route, methods=methods,
|
||||
websocket=websocket, auth=auth,
|
||||
)
|
||||
|
||||
# Support both @client and @client(...)
|
||||
if fn is not None:
|
||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
||||
return _create_server_function(
|
||||
fn, context=resolved_context, affects=affects,
|
||||
private=private, route=route, methods=methods,
|
||||
websocket=websocket, auth=auth,
|
||||
)
|
||||
return decorator
|
||||
|
||||
|
||||
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
|
||||
"""Normalize the affects parameter into a list of target descriptors."""
|
||||
if affects is None:
|
||||
return None
|
||||
|
||||
items = affects if isinstance(affects, list) else [affects]
|
||||
result = []
|
||||
for item in items:
|
||||
if isinstance(item, ReactContext):
|
||||
result.append({"type": "context", "name": item.name})
|
||||
elif isinstance(item, str):
|
||||
result.append({"type": "context", "name": item})
|
||||
elif isinstance(item, type) and issubclass(item, ServerFunction):
|
||||
fn_meta = getattr(item, "_meta", {})
|
||||
fn_ctx = fn_meta.get("context")
|
||||
result.append({
|
||||
"type": "function",
|
||||
"name": getattr(item, "__name__", str(item)),
|
||||
"context": fn_ctx or None,
|
||||
})
|
||||
else:
|
||||
raise ValueError(
|
||||
f"affects items must be ReactContext instances, context name strings, "
|
||||
f"or @client function references. Got {type(item)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _create_server_function(
|
||||
fn: Callable,
|
||||
*,
|
||||
context: ContextMode = False,
|
||||
context: str | Literal[False] = False,
|
||||
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
||||
private: bool = False,
|
||||
route: str | None = None,
|
||||
methods: list[str] | None = None,
|
||||
websocket: bool = False,
|
||||
auth: bool | str | None = None,
|
||||
) -> type[ServerFunction]:
|
||||
@@ -301,35 +425,45 @@ def _create_server_function(
|
||||
# Get output type from return annotation
|
||||
output_type = hints.get("return")
|
||||
if output_type is None:
|
||||
raise TypeError(
|
||||
f"Server function '{name}' must have a return type annotation"
|
||||
)
|
||||
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
||||
|
||||
# Support primitive return types by wrapping in a model with 'result' field
|
||||
# Also handle Optional[X] / X | None by extracting the non-None type
|
||||
import types
|
||||
# Detect view path: function returns HttpResponse (or has no return annotation
|
||||
# that maps to a model — view functions often just have -> HttpResponse)
|
||||
from django.http import HttpResponseBase
|
||||
is_view_path = (
|
||||
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
|
||||
)
|
||||
|
||||
def is_basemodel_type(t: Any) -> bool:
|
||||
"""Check if type is a BaseModel subclass, handling Optional/Union."""
|
||||
if isinstance(t, type) and issubclass(t, BaseModel):
|
||||
return True
|
||||
# Handle Union types: typing.Union (Optional[X]) and types.UnionType (X | None)
|
||||
origin = get_origin(t)
|
||||
if origin is Union or isinstance(t, types.UnionType):
|
||||
args = get_args(t)
|
||||
# Check if any non-None arg is a BaseModel
|
||||
for arg in args:
|
||||
if arg is not type(None) and isinstance(arg, type) and issubclass(arg, BaseModel):
|
||||
return True
|
||||
return False
|
||||
|
||||
if is_basemodel_type(output_type):
|
||||
output_cls = output_type
|
||||
if is_view_path:
|
||||
# View path — no Pydantic output wrapping needed
|
||||
output_cls = BaseModel # placeholder, never used for serialization
|
||||
is_primitive_output = False
|
||||
else:
|
||||
# Create model wrapper for primitive types (int, str, list, etc.)
|
||||
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
|
||||
is_primitive_output = True
|
||||
# RPC path — resolve output type
|
||||
import types
|
||||
|
||||
def is_basemodel_type(t: Any) -> bool:
|
||||
"""Check if type is a BaseModel subclass, handling Optional/Union."""
|
||||
if isinstance(t, type) and issubclass(t, BaseModel):
|
||||
return True
|
||||
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
|
||||
param_names = [p[0] for p in input_params]
|
||||
@@ -354,10 +488,28 @@ def _create_server_function(
|
||||
# Build metadata
|
||||
meta = {}
|
||||
|
||||
# Context mode: 'global' or 'local' (False means not a context)
|
||||
# View path flag (function returns HttpResponse, no codegen)
|
||||
if is_view_path:
|
||||
meta["view_path"] = True
|
||||
|
||||
# Private flag (not client-callable, no codegen, no RPC endpoint)
|
||||
if private:
|
||||
meta["private"] = True
|
||||
|
||||
# Route (Mizan-owned URL pattern for view-path functions)
|
||||
if route:
|
||||
meta["route"] = route
|
||||
meta["methods"] = methods or (["GET"] if context else ["POST"])
|
||||
|
||||
# Context name (any non-empty string)
|
||||
if context:
|
||||
meta["context"] = context
|
||||
|
||||
# Affects: mutation invalidation targets
|
||||
normalized_affects = _normalize_affects(affects)
|
||||
if normalized_affects:
|
||||
meta["affects"] = normalized_affects
|
||||
|
||||
# WebSocket: enable WebSocket transport
|
||||
if websocket:
|
||||
meta["websocket"] = True
|
||||
@@ -365,7 +517,7 @@ def _create_server_function(
|
||||
# Auth requirement
|
||||
if auth is not None:
|
||||
if auth is True:
|
||||
meta["auth"] = 'required'
|
||||
meta["auth"] = "required"
|
||||
elif callable(auth):
|
||||
meta["auth"] = auth
|
||||
else:
|
||||
@@ -374,7 +526,7 @@ def _create_server_function(
|
||||
if meta:
|
||||
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
||||
|
||||
# Note: Registration happens via discovery (djarea_clients), not here.
|
||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||
# This allows the decorator to be used without import-time side effects.
|
||||
|
||||
return FunctionWrapper
|
||||
@@ -434,7 +586,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
return [item]
|
||||
elif isinstance(item, ComposedContext):
|
||||
return item._leaves.copy()
|
||||
elif hasattr(item, '_leaves'):
|
||||
elif hasattr(item, "_leaves"):
|
||||
# Duck typing for composed contexts
|
||||
return item._leaves.copy()
|
||||
else:
|
||||
@@ -443,11 +595,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
|
||||
def _is_context_enabled(item) -> bool:
|
||||
"""Check if an item is a context-enabled function or composition."""
|
||||
if isinstance(item, ComposedContext) or hasattr(item, '_leaves'):
|
||||
if isinstance(item, ComposedContext) or hasattr(item, "_leaves"):
|
||||
return True
|
||||
if isinstance(item, type) and issubclass(item, ServerFunction):
|
||||
meta = getattr(item, '_meta', {})
|
||||
return meta.get('context') in ('global', 'local')
|
||||
meta = getattr(item, "_meta", {})
|
||||
return bool(meta.get("context"))
|
||||
return False
|
||||
|
||||
|
||||
@@ -460,7 +612,7 @@ def compose(
|
||||
Compose multiple contexts into a single provider.
|
||||
|
||||
Args:
|
||||
*children: Context functions (@client with context='global'|'local')
|
||||
*children: Context functions (@client with a context name)
|
||||
or other @compose functions. All must be unique after flattening.
|
||||
|
||||
on_server: Bundle all calls into a single server request (default: False).
|
||||
@@ -498,18 +650,21 @@ def compose(
|
||||
Returns:
|
||||
A ComposedContext that can be used in other compositions.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> ComposedContext:
|
||||
from djarea.setup.registry import register_compose
|
||||
from mizan.setup.registry import register_compose
|
||||
|
||||
name = fn.__name__
|
||||
|
||||
# Validate: all children must be context-enabled
|
||||
for i, child in enumerate(children):
|
||||
if not _is_context_enabled(child):
|
||||
child_name = getattr(child, 'name', getattr(child, '__name__', str(child)))
|
||||
child_name = getattr(
|
||||
child, "name", getattr(child, "__name__", str(child))
|
||||
)
|
||||
raise ValueError(
|
||||
f"@compose argument {i} ({child_name}) is not context-enabled. "
|
||||
f"All children must have @client(context='global'|'local') or be @compose."
|
||||
f"All children must have @client(context=...) or be @compose."
|
||||
)
|
||||
|
||||
# Flatten to collect all leaves
|
||||
@@ -529,12 +684,16 @@ def compose(
|
||||
|
||||
# Validate transport consistency when on_server=True
|
||||
if on_server:
|
||||
has_websocket = [getattr(leaf, '_meta', {}).get('websocket', False) for leaf in leaves]
|
||||
has_websocket = [
|
||||
getattr(leaf, "_meta", {}).get("websocket", False) for leaf in leaves
|
||||
]
|
||||
|
||||
if websocket:
|
||||
# All must have websocket=True
|
||||
if not all(has_websocket):
|
||||
non_ws = [leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws]
|
||||
non_ws = [
|
||||
leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws
|
||||
]
|
||||
raise ValueError(
|
||||
f"@compose({name}, on_server=True, websocket=True) requires all children "
|
||||
f"to have websocket=True. These are HTTP-only: {non_ws}"
|
||||
@@ -542,7 +701,9 @@ def compose(
|
||||
else:
|
||||
# All must be HTTP-only
|
||||
if any(has_websocket):
|
||||
ws_enabled = [leaf.name for leaf, ws in zip(leaves, has_websocket) if ws]
|
||||
ws_enabled = [
|
||||
leaf.name for leaf, ws in zip(leaves, has_websocket) if ws
|
||||
]
|
||||
raise ValueError(
|
||||
f"@compose({name}, on_server=True, websocket=False) requires all children "
|
||||
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
|
||||
@@ -628,7 +789,7 @@ def create_form_functions(
|
||||
Or use the helper:
|
||||
register_form(ContactForm, 'contact', submit_handler=...)
|
||||
"""
|
||||
from djarea.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
|
||||
# Schema function - returns field definitions
|
||||
class FormSchema(ServerFunction):
|
||||
@@ -644,7 +805,9 @@ def create_form_functions(
|
||||
required=field.required,
|
||||
label=field.label or field.name,
|
||||
help_text=field.help_text or None,
|
||||
choices=[(c.value, c.label) for c in field.choices] if field.choices else None,
|
||||
choices=[(c.value, c.label) for c in field.choices]
|
||||
if field.choices
|
||||
else None,
|
||||
initial=field.initial,
|
||||
)
|
||||
for field in schema.fields
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.client.jwt - JWT authentication for server functions.
|
||||
mizan.client.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
@@ -9,12 +9,12 @@ Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Note: This module is purpose-built for Djarea server functions.
|
||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
"""
|
||||
|
||||
# Token utilities (re-exports from django_jwt_session)
|
||||
from djarea.jwt.tokens import (
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
|
||||
)
|
||||
|
||||
# Settings
|
||||
from djarea.jwt.settings import get_settings, JWTSettings
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
__all__ = [
|
||||
# Token utilities
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea OpenAPI Schema Generator
|
||||
mizan OpenAPI Schema Generator
|
||||
|
||||
Generates OpenAPI 3.0 compatible schema from registered server functions.
|
||||
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
|
||||
@@ -11,7 +11,7 @@ NOTE: Schema export is only available via management command for security.
|
||||
HTTP endpoint has been removed to prevent function enumeration.
|
||||
|
||||
Usage:
|
||||
python manage.py export_djarea_schema
|
||||
python manage.py export_mizan_schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,15 +21,21 @@ import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# Lazy imports to avoid Django settings access at module load time
|
||||
# (asgi.py imports djarea before Django is fully configured)
|
||||
# (asgi.py imports mizan before Django is fully configured)
|
||||
if TYPE_CHECKING:
|
||||
from django import forms
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from djarea.setup.registry import get_registry, get_schema
|
||||
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
|
||||
|
||||
|
||||
__all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
|
||||
__all__ = [
|
||||
"get_schema",
|
||||
"generate_openapi_schema",
|
||||
"generate_openapi_json",
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
|
||||
@@ -167,21 +173,26 @@ def _register_schema_endpoint(
|
||||
and exec() security concerns.
|
||||
"""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
|
||||
# Set annotations directly to the actual type objects (not strings)
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
|
||||
# Register with Ninja
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
)
|
||||
|
||||
|
||||
def generate_openapi_schema() -> dict[str, Any]:
|
||||
"""
|
||||
Generate OpenAPI 3.0 schema for all registered djarea functions.
|
||||
Generate OpenAPI 3.0 schema for all registered mizan functions.
|
||||
|
||||
Uses Django Ninja's schema generation internally to ensure proper
|
||||
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
|
||||
@@ -198,9 +209,9 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
|
||||
# battle-tested Pydantic→OpenAPI conversion
|
||||
schema_api = NinjaAPI(
|
||||
title="Djarea Server Functions",
|
||||
title="mizan Server Functions",
|
||||
version="1.0.0",
|
||||
description="Auto-generated schema for djarea server functions",
|
||||
description="Auto-generated schema for mizan server functions",
|
||||
docs_url=None, # No docs endpoint
|
||||
openapi_url=None, # No openapi endpoint
|
||||
)
|
||||
@@ -234,13 +245,17 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
# Store them in schema_classes so they persist beyond loop scope
|
||||
# Uses create_model to avoid metaclass conflicts with custom base classes
|
||||
if has_input:
|
||||
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls)
|
||||
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls)
|
||||
schema_classes[input_type_name] = create_model(
|
||||
input_type_name, __base__=input_cls
|
||||
)
|
||||
schema_classes[output_type_name] = create_model(
|
||||
output_type_name, __base__=output_cls
|
||||
)
|
||||
|
||||
# Register endpoint using helper to avoid closure capture issues
|
||||
_register_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/djarea/{name}",
|
||||
path=f"/mizan/{name}",
|
||||
operation_id=camel_name,
|
||||
summary=fn_class.__doc__ or f"Call {name}",
|
||||
input_cls=schema_classes.get(input_type_name),
|
||||
@@ -262,6 +277,10 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
|
||||
}
|
||||
|
||||
# Affects metadata (mutation invalidation)
|
||||
if meta.get("affects"):
|
||||
fn_meta_entry["affects"] = meta["affects"]
|
||||
|
||||
# For form schema functions, extract field definitions for Zod generation
|
||||
if meta.get("form") and meta.get("form_role") == "schema":
|
||||
form_class = meta.get("form_class")
|
||||
@@ -279,13 +298,53 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add custom extension with function metadata for provider generation
|
||||
schema["x-djarea-functions"] = function_metadata
|
||||
schema["x-mizan-functions"] = function_metadata
|
||||
|
||||
# Add x-djarea metadata to each operation
|
||||
# Add x-mizan-contexts: grouped context metadata with param elevation
|
||||
context_groups = get_context_groups()
|
||||
if context_groups:
|
||||
contexts_meta: dict[str, Any] = {}
|
||||
for ctx_name, fn_names in context_groups.items():
|
||||
# Analyze params across all functions in the context
|
||||
param_info: dict[str, dict[str, Any]] = {}
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
for field_name, field_info in input_cls.model_fields.items():
|
||||
if field_name not in param_info:
|
||||
annotation = field_info.annotation
|
||||
# Map Python types to JSON schema types
|
||||
type_name = "string"
|
||||
if annotation in (int,):
|
||||
type_name = "integer"
|
||||
elif annotation in (float,):
|
||||
type_name = "number"
|
||||
elif annotation in (bool,):
|
||||
type_name = "boolean"
|
||||
param_info[field_name] = {
|
||||
"type": type_name,
|
||||
"sharedBy": [],
|
||||
}
|
||||
param_info[field_name]["sharedBy"].append(fn_name)
|
||||
|
||||
# A param is required if ALL functions in the context declare it
|
||||
for p_name, p_meta in param_info.items():
|
||||
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
|
||||
|
||||
contexts_meta[ctx_name] = {
|
||||
"functions": fn_names,
|
||||
"params": param_info,
|
||||
}
|
||||
schema["x-mizan-contexts"] = contexts_meta
|
||||
|
||||
# Add x-mizan metadata to each operation
|
||||
for fn_meta in function_metadata:
|
||||
path = f"/djarea/{fn_meta['name']}"
|
||||
path = f"/mizan/{fn_meta['name']}"
|
||||
if path in schema.get("paths", {}):
|
||||
schema["paths"][path]["post"]["x-djarea"] = {
|
||||
schema["paths"][path]["post"]["x-mizan"] = {
|
||||
"transport": fn_meta["transport"],
|
||||
"isContext": fn_meta["isContext"],
|
||||
}
|
||||
@@ -297,3 +356,154 @@ def generate_openapi_json(indent: int = 2) -> str:
|
||||
"""Generate OpenAPI schema as formatted JSON string."""
|
||||
schema = generate_openapi_schema()
|
||||
return json.dumps(schema, indent=indent)
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
Example: {"user": ["/profile/:user_id/"]}
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
"""
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
|
||||
# Common user identity param names for user_scoped detection
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
|
||||
groups = get_context_groups()
|
||||
registry = get_registry()
|
||||
all_functions = registry.get("functions", {})
|
||||
|
||||
manifest: dict[str, Any] = {"contexts": {}, "mutations": {}}
|
||||
|
||||
for ctx_name, fn_names in groups.items():
|
||||
# Collect params and routes from all functions in this context
|
||||
param_names: set[str] = set()
|
||||
functions_meta: list[dict[str, Any]] = []
|
||||
page_routes: list[str] = []
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = all_functions.get(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
is_view = meta.get("view_path", False)
|
||||
|
||||
# Collect param names from Input schema
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if (
|
||||
input_cls
|
||||
and input_cls is not PydanticBaseModel
|
||||
and hasattr(input_cls, "model_fields")
|
||||
):
|
||||
param_names.update(input_cls.model_fields.keys())
|
||||
|
||||
fn_entry: dict[str, Any] = {
|
||||
"name": fn_name,
|
||||
"path": "view" if is_view else "rpc",
|
||||
}
|
||||
|
||||
# Collect routes from view-path functions
|
||||
fn_route = meta.get("route")
|
||||
if fn_route:
|
||||
fn_entry["route"] = fn_route
|
||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||
page_routes.append(fn_route)
|
||||
|
||||
functions_meta.append(fn_entry)
|
||||
|
||||
sorted_params = sorted(param_names)
|
||||
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
|
||||
|
||||
ctx_entry: dict[str, Any] = {
|
||||
"functions": functions_meta,
|
||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||
"params": sorted_params,
|
||||
"user_scoped": user_scoped,
|
||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||
}
|
||||
|
||||
# Add page routes from view-path functions with route=
|
||||
if page_routes:
|
||||
ctx_entry["page_routes"] = page_routes
|
||||
|
||||
# Add externally-declared view URLs
|
||||
if view_urls and ctx_name in view_urls:
|
||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||
|
||||
manifest["contexts"][ctx_name] = ctx_entry
|
||||
|
||||
# Mutations section — all functions with affects=
|
||||
for fn_name, fn_cls in all_functions.items():
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
affects = meta.get("affects")
|
||||
if not affects:
|
||||
continue
|
||||
|
||||
# Resolve context names from affects targets
|
||||
affected_contexts = []
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
affected_contexts.append(target["name"])
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
affected_contexts.append(target["context"])
|
||||
affected_contexts = list(dict.fromkeys(affected_contexts))
|
||||
|
||||
# Determine which params auto-scope
|
||||
auto_scoped = []
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
|
||||
fn_params = set(input_cls.model_fields.keys())
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_params = set()
|
||||
for ctx_fn_name in groups.get(ctx_name, []):
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls:
|
||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
|
||||
ctx_params.update(ctx_input.model_fields.keys())
|
||||
auto_scoped.extend(sorted(fn_params & ctx_params))
|
||||
auto_scoped = list(dict.fromkeys(auto_scoped))
|
||||
|
||||
mutation_entry: dict[str, Any] = {
|
||||
"affects": affected_contexts,
|
||||
}
|
||||
if auto_scoped:
|
||||
mutation_entry["auto_scoped_params"] = auto_scoped
|
||||
if meta.get("private"):
|
||||
mutation_entry["private"] = True
|
||||
if meta.get("route"):
|
||||
mutation_entry["route"] = meta["route"]
|
||||
mutation_entry["methods"] = meta.get("methods", ["POST"])
|
||||
|
||||
manifest["mutations"][fn_name] = mutation_entry
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def generate_edge_manifest_json(
|
||||
indent: int = 2,
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> str:
|
||||
"""Generate Edge manifest as formatted JSON string."""
|
||||
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
|
||||
return json.dumps(manifest, indent=indent, sort_keys=True)
|
||||
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
DjareaFormMixin - Turn Django Forms into server functions.
|
||||
mizanFormMixin - Turn Django Forms into server functions.
|
||||
|
||||
This mixin transforms any Django Form into Djarea server functions,
|
||||
This mixin transforms any Django Form into mizan server functions,
|
||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||
while exposing them through the unified server function API.
|
||||
|
||||
Usage:
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
@@ -98,7 +98,7 @@ def _create_form_input_schema(
|
||||
form = form_class()
|
||||
except TypeError:
|
||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||
fields_dict = getattr(form_class, 'base_fields', {})
|
||||
fields_dict = getattr(form_class, "base_fields", {})
|
||||
else:
|
||||
fields_dict = form.fields
|
||||
|
||||
@@ -125,9 +125,9 @@ def _create_form_input_schema(
|
||||
return model
|
||||
|
||||
|
||||
class DjareaFormMeta(BaseModel):
|
||||
class mizanFormMeta(BaseModel):
|
||||
"""
|
||||
Configuration for a Djarea form.
|
||||
Configuration for a mizan form.
|
||||
|
||||
This Pydantic model provides type-safe configuration with full LSP support,
|
||||
and serializes to JSON for the frontend schema.
|
||||
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
|
||||
enable_formset: bool = False
|
||||
|
||||
|
||||
class DjareaFormMixin:
|
||||
class mizanFormMixin:
|
||||
"""
|
||||
Mixin that exposes a Django Form as Djarea server functions.
|
||||
Mixin that exposes a Django Form as mizan server functions.
|
||||
|
||||
Add this mixin to any Django Form class along with a `djarea` configuration:
|
||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
)
|
||||
@@ -197,10 +197,10 @@ class DjareaFormMixin:
|
||||
"""
|
||||
|
||||
# Configuration - subclasses must define this
|
||||
djarea: ClassVar[DjareaFormMeta]
|
||||
mizan: ClassVar[mizanFormMeta]
|
||||
|
||||
# Track registered forms to avoid duplicate registration
|
||||
_djarea_registered: ClassVar[bool] = False
|
||||
_mizan_registered: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||
@@ -236,9 +236,7 @@ class DjareaFormMixin:
|
||||
return result
|
||||
return None
|
||||
|
||||
def on_submit_failure(
|
||||
self, request: HttpRequest, errors: "FormValidation"
|
||||
) -> None:
|
||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||
"""
|
||||
Called after form validation fails.
|
||||
|
||||
@@ -250,23 +248,23 @@ class DjareaFormMixin:
|
||||
"""Auto-register when a concrete form class is defined."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
# Only register concrete forms with djarea config defined
|
||||
if _is_concrete_djarea_form(cls):
|
||||
# Only register concrete forms with mizan config defined
|
||||
if _is_concrete_mizan_form(cls):
|
||||
_register_form_as_server_functions(cls)
|
||||
|
||||
|
||||
def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
"""
|
||||
Check if a class is a concrete Djarea form ready for registration.
|
||||
Check if a class is a concrete mizan form ready for registration.
|
||||
|
||||
A form is concrete if:
|
||||
1. It has a `djarea` attribute that is a DjareaFormMeta instance
|
||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||
2. It inherits from Django's BaseForm
|
||||
3. It hasn't been registered yet (for this class definition)
|
||||
"""
|
||||
# Must have djarea config (check cls.__dict__ to avoid inheriting)
|
||||
djarea_config = cls.__dict__.get("djarea")
|
||||
if not isinstance(djarea_config, DjareaFormMeta):
|
||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||
mizan_config = cls.__dict__.get("mizan")
|
||||
if not isinstance(mizan_config, mizanFormMeta):
|
||||
return False
|
||||
|
||||
# Must be a Django form
|
||||
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
return False
|
||||
|
||||
# Check if already registered (handle re-imports gracefully)
|
||||
if cls.__dict__.get("_djarea_registered", False):
|
||||
if cls.__dict__.get("_mizan_registered", False):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
|
||||
def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"""
|
||||
Register a Django Form class as Djarea server functions.
|
||||
Register a Django Form class as mizan server functions.
|
||||
|
||||
Creates and registers:
|
||||
- {name}.schema - Returns form field definitions
|
||||
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import validate_form_instance
|
||||
from djarea.setup.registry import register
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
config: DjareaFormMeta = form_class.djarea
|
||||
config: mizanFormMeta = form_class.mizan
|
||||
form_name = config.name
|
||||
|
||||
# Mark as registered
|
||||
form_class._djarea_registered = True
|
||||
form_class._mizan_registered = True
|
||||
|
||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||
@@ -346,7 +347,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
data=input.data if input else {},
|
||||
**init_kwargs,
|
||||
)
|
||||
# Override with DjareaFormMeta values
|
||||
# Override with mizanFormMeta values
|
||||
if config.title is not None:
|
||||
schema.title = config.title
|
||||
if config.subtitle is not None:
|
||||
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
request = self.request
|
||||
|
||||
# Check if we have multipart data from executor
|
||||
if hasattr(request, "_djarea_form_data"):
|
||||
data = request._djarea_form_data
|
||||
files = request._djarea_form_files
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input is not None:
|
||||
# JSON input - already a dict
|
||||
data = input if isinstance(input, dict) else input.model_dump()
|
||||
@@ -474,17 +475,25 @@ def _register_formset_functions(
|
||||
"""Register formset server functions for a form."""
|
||||
from django.forms import formset_factory
|
||||
|
||||
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation
|
||||
from .schemas import (
|
||||
FormsetSchema,
|
||||
FormsetSubmitFail,
|
||||
FormsetSubmitPass,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import build_formset_validation
|
||||
from .formset_utils import forms_to_formset_post_data
|
||||
from djarea.setup.registry import register
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
formset_class = formset_factory(form_class)
|
||||
|
||||
# Generate PascalCase name for schemas
|
||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||
@@ -506,7 +515,7 @@ def _register_formset_functions(
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "formset_schema",
|
||||
}
|
||||
}
|
||||
|
||||
def call(self, input) -> FormsetSchema:
|
||||
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)
|
||||
|
||||
# Handle multipart vs JSON
|
||||
if hasattr(request, "_djarea_form_data"):
|
||||
post_data = request._djarea_form_data
|
||||
files = request._djarea_form_files
|
||||
elif input and hasattr(input, 'forms'):
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
post_data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input and hasattr(input, "forms"):
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
post_data = forms_to_formset_post_data(forms_data)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Djarea Allauth Integration
|
||||
mizan Allauth Integration
|
||||
|
||||
Backend support for django-allauth with Djarea server functions.
|
||||
Backend support for django-allauth with mizan server functions.
|
||||
|
||||
Provides:
|
||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
||||
@@ -11,8 +11,8 @@ Usage:
|
||||
# In your app's apps.py
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
import djarea.allauth.forms # noqa - registers forms
|
||||
import djarea.allauth.contexts # noqa - registers contexts
|
||||
import mizan.allauth.forms # noqa - registers forms
|
||||
import mizan.allauth.contexts # noqa - registers contexts
|
||||
"""
|
||||
|
||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Auth contexts for Djarea Allauth integration.
|
||||
Auth contexts for mizan Allauth integration.
|
||||
|
||||
These are the core auth primitives that the frontend allauth module depends on.
|
||||
Separated into two concerns:
|
||||
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from mizan.client import client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -23,13 +23,14 @@ from djarea.client import client
|
||||
|
||||
class AuthStatusOutput(BaseModel):
|
||||
"""Authentication status and permission guards."""
|
||||
|
||||
is_authenticated: bool
|
||||
user_id: int | None = None
|
||||
is_staff: bool = False
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||
"""
|
||||
Auth status context - provides authentication state and guards.
|
||||
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
"""Full user profile data."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def user(request: HttpRequest) -> UserOutput | None:
|
||||
"""
|
||||
User profile context - provides full user data.
|
||||
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
|
||||
return None
|
||||
|
||||
# Check if we have full user data or just JWT claims
|
||||
if hasattr(req_user, 'email') and req_user.email:
|
||||
if hasattr(req_user, "email") and req_user.email:
|
||||
# Full User object (session auth)
|
||||
return UserOutput(
|
||||
id=req_user.id,
|
||||
email=req_user.email,
|
||||
first_name=getattr(req_user, 'first_name', '') or '',
|
||||
last_name=getattr(req_user, 'last_name', '') or '',
|
||||
first_name=getattr(req_user, "first_name", "") or "",
|
||||
last_name=getattr(req_user, "last_name", "") or "",
|
||||
)
|
||||
|
||||
# JWTUser - need to fetch from DB
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
try:
|
||||
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
|
||||
return UserOutput(
|
||||
id=db_user.id,
|
||||
email=db_user.email,
|
||||
first_name=db_user.first_name or '',
|
||||
last_name=db_user.last_name or '',
|
||||
first_name=db_user.first_name or "",
|
||||
last_name=db_user.last_name or "",
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Allauth forms as Djarea server functions.
|
||||
Allauth forms as mizan server functions.
|
||||
|
||||
This module wraps allauth forms with DjareaFormMixin, exposing them as
|
||||
This module wraps allauth forms with mizanFormMixin, exposing them as
|
||||
typed server functions for the React frontend.
|
||||
|
||||
Each form becomes three server functions:
|
||||
@@ -13,7 +13,7 @@ Import this module in your app's ready() to register the forms:
|
||||
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
import djarea.allauth.forms # noqa
|
||||
import mizan.allauth.forms # noqa
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
# Account forms
|
||||
from allauth.account.forms import (
|
||||
@@ -41,6 +41,7 @@ from allauth.account.forms import (
|
||||
# Password reauthentication form - conditionally import
|
||||
try:
|
||||
from allauth.account.forms import ReauthenticateForm
|
||||
|
||||
HAS_REAUTH = True
|
||||
except ImportError:
|
||||
HAS_REAUTH = False
|
||||
@@ -51,6 +52,7 @@ try:
|
||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
||||
|
||||
HAS_MFA = True
|
||||
except ImportError:
|
||||
HAS_MFA = False
|
||||
@@ -58,22 +60,24 @@ except ImportError:
|
||||
# WebAuthn forms (if available)
|
||||
try:
|
||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
||||
|
||||
HAS_WEBAUTHN = True
|
||||
except ImportError:
|
||||
HAS_WEBAUTHN = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from djarea.forms.schemas import FormValidation
|
||||
from mizan.forms.schemas import FormValidation
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Account Forms
|
||||
# =============================================================================
|
||||
|
||||
class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
||||
|
||||
class mizanLoginForm(LoginForm, mizanFormMixin):
|
||||
"""Sign in with email and password."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="login",
|
||||
title="Sign In",
|
||||
subtitle="Welcome back. Enter your credentials to continue.",
|
||||
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
||||
class mizanSignupForm(SignupForm, mizanFormMixin):
|
||||
"""Create a new account."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="signup",
|
||||
title="Create Account",
|
||||
subtitle="Enter your details to get started.",
|
||||
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
||||
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
||||
"""Add another email address to your account."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="add_email",
|
||||
title="Add Email Address",
|
||||
subtitle="Add another email address to your account.",
|
||||
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
||||
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
||||
"""Change your account password."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="change_password",
|
||||
title="Change Password",
|
||||
subtitle="Update your password to keep your account secure.",
|
||||
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
||||
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
||||
"""Set a password for accounts created via social login."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="set_password",
|
||||
title="Set Password",
|
||||
subtitle="Create a password for your account.",
|
||||
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
||||
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
||||
"""Request a password reset email."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reset_password",
|
||||
title="Reset Password",
|
||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
||||
@@ -185,10 +189,10 @@ class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
||||
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
||||
"""Set a new password using a reset key."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reset_password_from_key",
|
||||
title="Set New Password",
|
||||
subtitle="Enter your new password below.",
|
||||
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
||||
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
||||
"""Request a login code via email."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="request_login_code",
|
||||
title="Sign In with Code",
|
||||
subtitle="Enter your email address and we'll send you a login code.",
|
||||
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
||||
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
||||
"""Confirm a login code."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="confirm_login_code",
|
||||
title="Enter Code",
|
||||
subtitle="Enter the code we sent to your email.",
|
||||
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
||||
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
||||
"""Verify an email with a token."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="user_token",
|
||||
title="Verify Email",
|
||||
subtitle="Enter the verification code from your email.",
|
||||
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
||||
|
||||
# Password reauthentication - conditionally define
|
||||
if HAS_REAUTH:
|
||||
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
|
||||
|
||||
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
||||
"""Re-authenticate with password for sensitive actions."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reauthenticate",
|
||||
title="Confirm Your Identity",
|
||||
subtitle="Please enter your password to continue.",
|
||||
@@ -280,6 +285,7 @@ if HAS_REAUTH:
|
||||
|
||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||
from allauth.account.internal.flows import reauthentication
|
||||
|
||||
reauthentication.reauthenticate_by_password(request)
|
||||
return None
|
||||
|
||||
@@ -289,10 +295,11 @@ if HAS_REAUTH:
|
||||
# =============================================================================
|
||||
|
||||
if HAS_MFA:
|
||||
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
|
||||
|
||||
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
||||
"""Authenticate with MFA during login."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="mfa_authenticate",
|
||||
title="Two-Factor Authentication",
|
||||
subtitle="Enter your authentication code to continue.",
|
||||
@@ -307,10 +314,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin):
|
||||
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
||||
"""Re-authenticate with MFA for sensitive actions."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="mfa_reauthenticate",
|
||||
title="Confirm Your Identity",
|
||||
subtitle="Enter your authentication code to continue.",
|
||||
@@ -325,10 +332,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin):
|
||||
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
||||
"""Activate TOTP authenticator."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="activate_totp",
|
||||
title="Set Up Authenticator",
|
||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
||||
@@ -343,10 +350,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin):
|
||||
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
||||
"""Deactivate TOTP authenticator."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="deactivate_totp",
|
||||
title="Disable Authenticator",
|
||||
subtitle="Enter your password to disable two-factor authentication.",
|
||||
@@ -361,10 +368,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin):
|
||||
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
||||
"""Generate new recovery codes."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="generate_recovery_codes",
|
||||
title="Recovery Codes",
|
||||
subtitle="Generate new recovery codes for your account.",
|
||||
@@ -381,10 +388,11 @@ if HAS_MFA:
|
||||
|
||||
|
||||
if HAS_WEBAUTHN:
|
||||
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
|
||||
|
||||
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
||||
"""Authenticate with WebAuthn security key."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="webauthn_authenticate",
|
||||
title="Security Key",
|
||||
subtitle="Use your security key to authenticate.",
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.jwt - JWT authentication for server functions.
|
||||
mizan.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
@@ -10,10 +10,10 @@ Server Functions:
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Usage in apps.py or urls.py (to register the functions):
|
||||
import djarea.jwt.functions # noqa: F401
|
||||
import mizan.jwt.functions # noqa: F401
|
||||
|
||||
Note: This module is purpose-built for Djarea server functions.
|
||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
"""
|
||||
|
||||
# Server functions (import to register with @client decorator)
|
||||
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
|
||||
|
||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||
# django-ninja's settings access at module load time.
|
||||
# Use: from djarea.jwt.security import jwt_auth
|
||||
# Use: from mizan.jwt.security import jwt_auth
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("JWTAuth", "jwt_auth"):
|
||||
from .security import JWTAuth, jwt_auth
|
||||
|
||||
globals()["JWTAuth"] = JWTAuth
|
||||
globals()["jwt_auth"] = jwt_auth
|
||||
return globals()[name]
|
||||
@@ -1,19 +1,20 @@
|
||||
"""
|
||||
JWT Server Functions
|
||||
|
||||
JWT token operations exposed as djarea server functions.
|
||||
JWT token operations exposed as mizan server functions.
|
||||
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||
"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.jwt.tokens import create_token_pair, refresh_tokens
|
||||
from mizan.client import client
|
||||
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||
|
||||
|
||||
class TokenPairOutput(BaseModel):
|
||||
"""JWT token pair response."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
@@ -21,6 +22,7 @@ class TokenPairOutput(BaseModel):
|
||||
|
||||
class JWTError(BaseModel):
|
||||
"""JWT operation error."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
@@ -45,10 +47,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Get session key - for WebSocket, this comes from the scope
|
||||
session = getattr(request, 'session', None)
|
||||
session = getattr(request, "session", None)
|
||||
if session is None:
|
||||
# WebSocket request adapter - session is a dict, not SessionBase
|
||||
session_key = getattr(request, '_scope', {}).get('session', {}).get('_session_key')
|
||||
session_key = (
|
||||
getattr(request, "_scope", {}).get("session", {}).get("_session_key")
|
||||
)
|
||||
if not session_key:
|
||||
raise PermissionError("No session available")
|
||||
else:
|
||||
@@ -61,8 +65,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
||||
tokens = create_token_pair(
|
||||
user.pk,
|
||||
session_key,
|
||||
is_staff=getattr(user, 'is_staff', False),
|
||||
is_superuser=getattr(user, 'is_superuser', False),
|
||||
is_staff=getattr(user, "is_staff", False),
|
||||
is_superuser=getattr(user, "is_superuser", False),
|
||||
)
|
||||
|
||||
return TokenPairOutput(
|
||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from djarea.channels import get_channels_openapi_schema
|
||||
from mizan.channels import get_channels_openapi_schema
|
||||
|
||||
schema = get_channels_openapi_schema()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Export Djarea Schema
|
||||
Export mizan Schema
|
||||
|
||||
Management command to export the djarea OpenAPI schema for TypeScript code generation.
|
||||
Management command to export the mizan OpenAPI schema for TypeScript code generation.
|
||||
The schema is consumed by openapi-typescript for robust type generation.
|
||||
|
||||
Usage:
|
||||
python manage.py export_djarea_schema # Output to stdout
|
||||
python manage.py export_djarea_schema --output schema.json # Output to file
|
||||
python manage.py export_mizan_schema # Output to stdout
|
||||
python manage.py export_mizan_schema --output schema.json # Output to file
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,11 +14,11 @@ from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from djarea.export import generate_openapi_schema
|
||||
from mizan.export import generate_openapi_schema
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export djarea OpenAPI schema for TypeScript code generation"
|
||||
help = "Export mizan OpenAPI schema for TypeScript code generation"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -44,8 +44,6 @@ class Command(BaseCommand):
|
||||
output_path = Path(options["output"])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json_output)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Schema written to {output_path}")
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
|
||||
else:
|
||||
self.stdout.write(json_output)
|
||||
@@ -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)
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
djarea.setup - Integration and registration utilities.
|
||||
mizan.setup - Integration and registration utilities.
|
||||
|
||||
This subpackage contains everything developers need to integrate Djarea:
|
||||
This subpackage contains everything developers need to integrate mizan:
|
||||
- Registry for server functions and channels
|
||||
- Auto-discovery for apps
|
||||
- Configuration settings
|
||||
|
||||
Usage:
|
||||
from djarea.setup import djarea_clients, register, get_function
|
||||
from mizan.setup import mizan_clients, register, get_function
|
||||
"""
|
||||
|
||||
from .registry import (
|
||||
@@ -25,17 +25,18 @@ from .registry import (
|
||||
get_registry,
|
||||
get_schema,
|
||||
get_contexts,
|
||||
get_context_groups,
|
||||
get_forms,
|
||||
clear_registry,
|
||||
)
|
||||
|
||||
from .discovery import (
|
||||
djarea_clients,
|
||||
djarea_module,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
)
|
||||
|
||||
from .settings import (
|
||||
DjareaSettings,
|
||||
mizanSettings,
|
||||
get_settings,
|
||||
clear_settings_cache,
|
||||
)
|
||||
@@ -57,13 +58,14 @@ __all__ = [
|
||||
"get_registry",
|
||||
"get_schema",
|
||||
"get_contexts",
|
||||
"get_context_groups",
|
||||
"get_forms",
|
||||
"clear_registry",
|
||||
# Discovery
|
||||
"djarea_clients",
|
||||
"djarea_module",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
# Settings
|
||||
"DjareaSettings",
|
||||
"mizanSettings",
|
||||
"get_settings",
|
||||
"clear_settings_cache",
|
||||
]
|
||||
@@ -1,25 +1,25 @@
|
||||
"""
|
||||
Djarea Auto-Discovery
|
||||
mizan Auto-Discovery
|
||||
|
||||
Scans Django apps for server functions following the 'clients' layer convention:
|
||||
- <app>/clients.py
|
||||
- <app>/clients/**/*.py
|
||||
|
||||
Usage in urls.py:
|
||||
from djarea.setup.discovery import djarea_clients
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
|
||||
djarea_clients('apps') # Scans apps/*/clients.py
|
||||
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py
|
||||
mizan_clients('apps') # Scans apps/*/clients.py
|
||||
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
||||
|
||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
|
||||
from .registry import register, get_function
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
|
||||
class _RegisterServerFunctions:
|
||||
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
|
||||
isinstance(member, type)
|
||||
and issubclass(member, ServerFunction)
|
||||
and member is not ServerFunction
|
||||
and hasattr(member, '__name__')
|
||||
and hasattr(member, "__name__")
|
||||
):
|
||||
# Use the function name as registration name
|
||||
fn_name = getattr(member, 'name', None) or member.__name__
|
||||
fn_name = getattr(member, "name", None) or member.__name__
|
||||
|
||||
# Skip already registered (idempotent)
|
||||
if get_function(fn_name) is member:
|
||||
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
|
||||
pass
|
||||
|
||||
|
||||
def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||
"""
|
||||
Discover and register server functions from Django apps.
|
||||
|
||||
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
||||
|
||||
Example:
|
||||
# In urls.py
|
||||
djarea_clients('apps') # Scans apps/*/clients.py
|
||||
djarea_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||
mizan_clients('apps') # Scans apps/*/clients.py
|
||||
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||
"""
|
||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||
visitor.visit(_RegisterServerFunctions())
|
||||
|
||||
|
||||
def djarea_module(module_path: str) -> None:
|
||||
def mizan_module(module_path: str) -> None:
|
||||
"""
|
||||
Register server functions from a specific module.
|
||||
|
||||
Use this for library modules that don't follow the app convention.
|
||||
|
||||
Args:
|
||||
module_path: Full module path (e.g., 'djarea.integrations.allauth')
|
||||
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
||||
|
||||
Example:
|
||||
djarea_module('djarea.integrations.allauth')
|
||||
djarea_module('djarea.jwt.functions')
|
||||
mizan_module('mizan.integrations.allauth')
|
||||
mizan_module('mizan.jwt.functions')
|
||||
"""
|
||||
members = get_members(module_path)
|
||||
handler = _RegisterServerFunctions()
|
||||
handler.on_module('', [], members)
|
||||
handler.on_module("", [], members)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Registry
|
||||
mizan Registry
|
||||
|
||||
Central registration for server functions, channels, and compositions.
|
||||
All items are identified by name.
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from djarea.client.function import ServerFunction, ComposedContext
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.client.function import ServerFunction, ComposedContext
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
|
||||
# Global registries - all use name as key
|
||||
@@ -34,8 +34,8 @@ def register(
|
||||
Returns:
|
||||
The view class (allows use as part of decorator chain)
|
||||
"""
|
||||
from djarea.client.function import ServerFunction
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.client.function import ServerFunction
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
view_class.name = name
|
||||
|
||||
@@ -98,7 +98,7 @@ def register_form(
|
||||
Usage:
|
||||
register_form(ContactForm, 'contact', submit_handler=handle_contact)
|
||||
"""
|
||||
from djarea.client.function import create_form_functions
|
||||
from mizan.client.function import create_form_functions
|
||||
|
||||
schema_fn, validate_fn, submit_fn = create_form_functions(
|
||||
form_class, name, submit_handler
|
||||
@@ -130,9 +130,7 @@ def register_compose(
|
||||
# Same composition being re-registered (reload scenario)
|
||||
_compositions[name] = composed
|
||||
return composed
|
||||
raise ValueError(
|
||||
f"Composition '{name}' already registered by {existing.name}"
|
||||
)
|
||||
raise ValueError(f"Composition '{name}' already registered by {existing.name}")
|
||||
_compositions[name] = composed
|
||||
return composed
|
||||
|
||||
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
# Extract Params schema (only if defined)
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema (only if defined - indicates bidirectional)
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
channel_schema[
|
||||
"react_message"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
channel_schema["bidirectional"] = True
|
||||
|
||||
# Extract DjangoMessage schema (only if defined)
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
channel_schema[
|
||||
"django_message"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
|
||||
channels_schema[name] = channel_schema
|
||||
|
||||
@@ -288,6 +290,21 @@ def get_contexts() -> dict[str, type["ServerFunction"]]:
|
||||
return contexts
|
||||
|
||||
|
||||
def get_context_groups() -> dict[str, list[str]]:
|
||||
"""
|
||||
Group function names by their context string.
|
||||
|
||||
Returns:
|
||||
{"global": ["current_user"], "user": ["user_profile", "user_orders"]}
|
||||
"""
|
||||
groups: dict[str, list[str]] = {}
|
||||
for name, cls in _functions.items():
|
||||
ctx = getattr(cls, "_meta", {}).get("context")
|
||||
if ctx:
|
||||
groups.setdefault(ctx, []).append(name)
|
||||
return groups
|
||||
|
||||
|
||||
def get_forms() -> dict[str, list[type["ServerFunction"]]]:
|
||||
"""
|
||||
Get all server functions that are form-related, grouped by form name.
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Settings
|
||||
mizan Settings
|
||||
|
||||
Configuration is read from Django settings with sensible defaults.
|
||||
"""
|
||||
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class DjareaSettings:
|
||||
"""Djarea configuration."""
|
||||
class mizanSettings:
|
||||
"""mizan configuration."""
|
||||
|
||||
# Whether to expose function names in DEBUG mode errors
|
||||
debug_expose_names: bool
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> DjareaSettings:
|
||||
def get_settings() -> mizanSettings:
|
||||
"""
|
||||
Load Djarea settings from Django settings.
|
||||
Load mizan settings from Django settings.
|
||||
|
||||
Settings:
|
||||
DJAREA_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||
"""
|
||||
return DjareaSettings(
|
||||
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True),
|
||||
return mizanSettings(
|
||||
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user