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/
|
.vscode/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
desktop/frontend/dist/
|
examples/django-react-desktop-app/frontend/dist/
|
||||||
e2e/harness/src/api/generated.*
|
examples/django-react-site/harness/src/api/generated.*
|
||||||
e2e/harness/test-results/
|
examples/django-react-site/harness/test-results/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
|||||||
464
MIZAN.md
Normal file
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
|
.PHONY: install test test-django test-react test-integration docker-up docker-down clean
|
||||||
|
|
||||||
|
DJANGO = packages/mizan-django
|
||||||
|
REACT = packages/mizan-react
|
||||||
|
|
||||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cd django && pip install -e ".[dev,channels]"
|
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||||
cd react && npm install
|
cd $(REACT) && npm install
|
||||||
|
|
||||||
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
# ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test: test-django test-react
|
test: test-django test-react
|
||||||
|
|
||||||
test-django:
|
test-django:
|
||||||
cd django && pytest
|
cd $(DJANGO) && uv run pytest
|
||||||
|
|
||||||
test-react:
|
test-react:
|
||||||
cd react && npm test
|
cd $(REACT) && npm test
|
||||||
|
|
||||||
# ─── Integration Tests ──────────────────────────────────────────────────────
|
# ─── Integration Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test-integration: docker-up
|
test-integration: docker-up
|
||||||
@echo "Waiting for backend..."
|
@echo "Waiting for backend..."
|
||||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done'
|
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||||
cd react && npm run test:integration
|
cd $(REACT) && npm run test:integration
|
||||||
@$(MAKE) docker-down
|
@$(MAKE) docker-down
|
||||||
|
|
||||||
# ─── Docker ──────────────────────────────────────────────────────────────────
|
# ─── Docker ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose -f docker-compose.test.yml up -d --build
|
docker compose -f examples/django-react-site/docker-compose.test.yml up -d --build
|
||||||
@echo "Backend starting at http://localhost:8000"
|
@echo "Backend starting at http://localhost:8000"
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
docker compose -f docker-compose.test.yml down
|
docker compose -f examples/django-react-site/docker-compose.test.yml down
|
||||||
|
|
||||||
# ─── All ─────────────────────────────────────────────────────────────────────
|
# ─── All ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ test-all: test test-integration
|
|||||||
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
docker compose -f examples/django-react-site/docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||||
rm -rf django/src/djarea.egg-info django/dist django/build
|
rm -rf $(DJANGO)/src/mizan.egg-info $(DJANGO)/dist $(DJANGO)/build
|
||||||
rm -rf react/dist react/node_modules
|
rm -rf $(REACT)/dist $(REACT)/node_modules
|
||||||
rm -f example/db.sqlite3
|
rm -f examples/django-react-site/backend/db.sqlite3
|
||||||
|
|||||||
340
README.md
340
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.
|
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
|
||||||
|
|
||||||
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
|
|
||||||
|
|
||||||
No API routing, no serializers, no REST/CRUD bullshit.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@client
|
# Django
|
||||||
def current_user(request) -> UserShape:
|
@client(context='global')
|
||||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
def current_user(request) -> UserOutput:
|
||||||
|
return UserOutput(email=request.user.email)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
|
// React (generated)
|
||||||
|
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
|
||||||
```
|
```
|
||||||
|
|
||||||
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
|
## Packages
|
||||||
|
|
||||||
Starts with session auth and upgrades to JWT on login. **It just works**.
|
| Package | Path | Install |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
|
||||||
|
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
|
||||||
|
|
||||||
## What Djarea does
|
## Quick Start
|
||||||
|
|
||||||
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ArticleShape(Shape[Article]):
|
|
||||||
id: int | None = None
|
|
||||||
title: str
|
|
||||||
author: FlatAuthorShape
|
|
||||||
tags: list[TagShape] = []
|
|
||||||
```
|
|
||||||
|
|
||||||
One Djarea **Shape** does three things simultaneously:
|
|
||||||
- Defines the Pydantic model for validation and serialization
|
|
||||||
- Generates a django-readers spec for a lean, field-scoped SQL query
|
|
||||||
- Produces the TypeScript interface on the React side
|
|
||||||
|
|
||||||
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
### 1. Django setup
|
### 1. Django setup
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# settings.py
|
# settings.py
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"djarea",
|
"mizan",
|
||||||
"myapp",
|
"myapp",
|
||||||
]
|
]
|
||||||
|
|
||||||
# urls.py
|
# urls.py
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/djarea/", include("djarea.urls")),
|
path("api/mizan/", include("mizan.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
# asgi.py (for WebSocket support)
|
# asgi.py (for WebSocket support)
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Define your client functions
|
### 2. Define server functions
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# myapp/clients.py
|
# myapp/mizan_clients.py
|
||||||
from djarea.client import client
|
from django.http import HttpRequest
|
||||||
from djarea.shapes import Shape
|
from mizan.client import client
|
||||||
|
from mizan.setup.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
class EchoOutput(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
@client
|
@client
|
||||||
def echo(request, text: str) -> EchoOutput:
|
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||||
return EchoOutput(message=text)
|
return EchoOutput(message=text)
|
||||||
|
|
||||||
|
register(echo, "echo")
|
||||||
```
|
```
|
||||||
|
|
||||||
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
|
### 3. Register in apps.py
|
||||||
|
|
||||||
### 3. Generate TypeScript
|
```python
|
||||||
|
class MyAppConfig(AppConfig):
|
||||||
|
name = "myapp"
|
||||||
|
|
||||||
To get your generated React client, set this up in your frontend root:
|
def ready(self):
|
||||||
|
import myapp.mizan_clients # noqa: F401
|
||||||
|
```
|
||||||
|
|
||||||
```javascript
|
### 4. Generate TypeScript
|
||||||
// django.config.mjs
|
|
||||||
|
```bash
|
||||||
|
# django.config.mjs
|
||||||
export default {
|
export default {
|
||||||
source: {
|
source: {
|
||||||
django: {
|
django: {
|
||||||
@@ -100,23 +90,27 @@ export default {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx djarea-generate
|
npx mizan-generate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Use in React
|
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
|
||||||
|
|
||||||
|
### 5. Use in React
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
|
// layout.tsx
|
||||||
|
import { DjangoContext } from '@/api'
|
||||||
|
|
||||||
// layout.tsx — one provider, handles everything
|
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
return <DjangoContext>{children}</DjangoContext>
|
return <DjangoContext>{children}</DjangoContext>
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
// page.tsx
|
// page.tsx
|
||||||
|
import { useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const user = useCurrentUser()
|
const user = useCurrentUser()
|
||||||
const echo = useEcho()
|
const echo = useEcho()
|
||||||
@@ -128,79 +122,90 @@ function MyComponent() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DjangoError) {
|
if (e instanceof DjangoError) {
|
||||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||||
e.getFieldErrors('email') // field-level errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shapes
|
## Features
|
||||||
|
|
||||||
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
|
| Backend | Frontend (generated) | Transport |
|
||||||
|
|---------|---------------------|-----------|
|
||||||
|
| `@client` | `useXxx()` | HTTP |
|
||||||
|
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
|
||||||
|
| `@client(context='local')` | `useXxx()` with params | HTTP |
|
||||||
|
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
|
||||||
|
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
|
||||||
|
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
|
||||||
|
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||||
|
| `@compose(...)` | Combined providers | varies |
|
||||||
|
|
||||||
```python
|
## Architecture
|
||||||
# Full detail page — joins books with chapters
|
|
||||||
class AuthorDetailShape(Shape[Author]):
|
|
||||||
id: int | None = None
|
|
||||||
name: str
|
|
||||||
bio: str
|
|
||||||
books: list[BookShape] = []
|
|
||||||
|
|
||||||
# Dropdown menu — two columns, no joins
|
```
|
||||||
class FlatAuthorShape(Shape[Author]):
|
React app
|
||||||
id: int | None = None
|
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
|
||||||
name: str
|
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
|
||||||
|
├─ useEcho() ← generated function hook
|
||||||
|
├─ useContactForm() ← generated form hook (Zod + server validation)
|
||||||
|
└─ useChatChannel() ← generated channel hook (WebSocket)
|
||||||
|
│
|
||||||
|
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
|
||||||
|
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||||
|
│
|
||||||
|
Django executor
|
||||||
|
├─ Pydantic input validation
|
||||||
|
├─ Auth check (session, JWT, or custom)
|
||||||
|
├─ Function execution
|
||||||
|
└─ Pydantic output serialization
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||||
# Detail page: SELECT id, name, bio + prefetch books
|
|
||||||
authors = AuthorDetailShape.query()
|
|
||||||
|
|
||||||
# Dropdown: SELECT id, name. That's it.
|
## Code Generation
|
||||||
authors = FlatAuthorShape.query()
|
|
||||||
|
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
|
||||||
|
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
|
||||||
|
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
|
||||||
|
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
|
||||||
|
| `generated.channels.ts` | Channel message types |
|
||||||
|
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
|
||||||
|
| `index.ts` | Consolidated re-exports |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All errors from server functions are thrown as `DjangoError`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
try {
|
||||||
|
await echo({ text: 'hello' })
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DjangoError) {
|
||||||
|
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
|
||||||
|
e.message // Human-readable message
|
||||||
|
e.details // Field-level validation errors, etc.
|
||||||
|
e.isAuthError()
|
||||||
|
e.isValidationError()
|
||||||
|
e.getFieldErrors('email')
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
|
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
|
||||||
|
|
||||||
```python
|
|
||||||
@client
|
|
||||||
def update_articles(request, articles: list[ArticleShape]) -> dict:
|
|
||||||
for article, diff in ArticleShape.diff_many(articles):
|
|
||||||
if diff.is_new:
|
|
||||||
create_article(article)
|
|
||||||
elif diff.changed:
|
|
||||||
update_fields(article, diff.changed)
|
|
||||||
for tag in diff.tags.created:
|
|
||||||
add_tag(article, tag)
|
|
||||||
for tag_id in diff.tags.deleted:
|
|
||||||
remove_tag(article, tag_id)
|
|
||||||
return {"ok": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
|
|
||||||
|
|
||||||
## The `@client` decorator
|
|
||||||
|
|
||||||
The decorator controls transport, caching, auth, and SSR behavior:
|
|
||||||
|
|
||||||
| Decorator | React hook | What it does |
|
|
||||||
|-----------|-----------|--------------|
|
|
||||||
| `@client` | `useEcho()` | HTTP call, returns typed result |
|
|
||||||
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
|
|
||||||
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
|
|
||||||
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
|
|
||||||
| `@client(auth=True)` | — | Requires authentication |
|
|
||||||
| `@client(auth='staff')` | — | Requires staff status |
|
|
||||||
| `@client(auth=my_check)` | — | Custom auth callable |
|
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
Django forms become typed React hooks with client-side Zod validation:
|
Django forms get typed React hooks with client-side Zod validation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
# Django
|
||||||
djarea = DjareaFormMeta(
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
submit_label="Send",
|
submit_label="Send",
|
||||||
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
|||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
// React (generated)
|
||||||
const form = useContactForm()
|
const form = useContactForm()
|
||||||
|
|
||||||
form.schema // field metadata, title, submit label
|
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
|
||||||
form.data // { name: '', email: '', message: '' }
|
form.data // { name: '', email: '', message: '' }
|
||||||
form.set('email', v) // typed setter
|
form.set('email', v) // typed setter
|
||||||
form.errors // field-level errors (Zod + server)
|
form.errors // field-level errors (Zod + server)
|
||||||
form.submit() // → { success: true, data: { sent: true } }
|
form.submit() // → { success: true, data: { sent: true } }
|
||||||
```
|
```
|
||||||
|
|
||||||
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
|
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
WebSocket channels with typed messages:
|
WebSocket channels with typed messages:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
# Django
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(ReactChannel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
|
|||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
// React (generated)
|
||||||
const chat = useChatChannel({ room: 'general' })
|
const chat = useChatChannel({ room: 'general' })
|
||||||
|
|
||||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||||
@@ -259,111 +265,33 @@ chat.messages // ChatDjangoMessage[]
|
|||||||
chat.send({ text: 'hello' })
|
chat.send({ text: 'hello' })
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
React app
|
|
||||||
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
|
|
||||||
├─ useCurrentUser() ← context hook (SSR-hydrated)
|
|
||||||
├─ useEcho() ← function hook
|
|
||||||
├─ useContactForm() ← form hook (Zod + server validation)
|
|
||||||
└─ useChatChannel() ← channel hook (WebSocket)
|
|
||||||
│
|
|
||||||
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
|
|
||||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
|
||||||
│
|
|
||||||
Django executor
|
|
||||||
├─ Pydantic input validation
|
|
||||||
├─ Auth check
|
|
||||||
├─ Function execution
|
|
||||||
└─ Pydantic output serialization
|
|
||||||
```
|
|
||||||
|
|
||||||
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
|
||||||
|
|
||||||
## Code generation
|
|
||||||
|
|
||||||
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `generated.djarea.ts` | Pydantic model types |
|
|
||||||
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
|
|
||||||
| `generated.django.server.ts` | SSR hydration helper |
|
|
||||||
| `generated.forms.ts` | Form hooks with Zod schemas |
|
|
||||||
| `generated.channels.ts` | Channel message types |
|
|
||||||
| `generated.channels.hooks.tsx` | Channel hooks |
|
|
||||||
| `index.ts` | Re-exports |
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
All errors from server functions throw as `DjangoError`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
if (e instanceof DjangoError) {
|
|
||||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
|
|
||||||
e.message // human-readable
|
|
||||||
e.details // field-level validation errors
|
|
||||||
e.isAuthError()
|
|
||||||
e.isValidationError()
|
|
||||||
e.getFieldErrors('email')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why RPC instead of REST
|
|
||||||
|
|
||||||
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
|
|
||||||
|
|
||||||
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
|
|
||||||
|
|
||||||
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Install |
|
|
||||||
|---------|---------|
|
|
||||||
| `djarea` (Python) | `pip install djarea` |
|
|
||||||
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
|
|
||||||
|
|
||||||
For WebSocket support: `pip install "djarea[channels]"`
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Django
|
# Django unit tests
|
||||||
cd django && uv run pytest
|
cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
|
||||||
|
|
||||||
# React
|
# React unit tests
|
||||||
cd react && npm test
|
cd packages/mizan-react && npm test
|
||||||
|
|
||||||
# E2E (Playwright, real browser + real backend)
|
# E2E integration tests (real browser, real backend)
|
||||||
docker compose -f docker-compose.test.yml up -d
|
docker compose -f examples/django-react-site/docker-compose.test.yml up -d
|
||||||
cd e2e/harness && npx djarea-generate && npx playwright test
|
cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 &
|
||||||
|
npx playwright test
|
||||||
|
|
||||||
# Everything
|
# All at once
|
||||||
make test-all
|
make test-all
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
djarea/
|
mizan/
|
||||||
django/ Python package
|
packages/
|
||||||
react/ TypeScript package
|
mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
|
||||||
example/ Integration test backend
|
mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
|
||||||
e2e/ Playwright E2E tests
|
mizan-react/ React adapter (thin wrapper around runtime)
|
||||||
Makefile Test orchestration
|
examples/
|
||||||
|
django-react-site/ E2E tests + Django backend
|
||||||
|
django-react-desktop-app/ PyWebView desktop app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Disclosure
|
|
||||||
|
|
||||||
Djarea was developed with the assistance of IDE AI Assistance and later with Claude Code.
|
|
||||||
|
|
||||||
The architecture, design decisions, developer experience standards and technical direction are mine. I've been programming for 16 years and have a lot of opinions!
|
|
||||||
|
|
||||||
DX ideas are inspired by the amazing work of these projects and the hardworking folks behind them:
|
|
||||||
- Django Ninja
|
|
||||||
- Django Readers
|
|
||||||
- Django RAPID Architecture
|
|
||||||
- React
|
|
||||||
- Next.js
|
|
||||||
192
ROADMAP.md
Normal file
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
|
#!/usr/bin/env python
|
||||||
"""
|
"""
|
||||||
Djarea Desktop — PyWebView + Django local RPC.
|
mizan Desktop — PyWebView + Django local RPC.
|
||||||
|
|
||||||
Starts a local Django ASGI server and opens a native desktop window.
|
Starts a local Django ASGI server and opens a native desktop window.
|
||||||
All communication between the UI and backend uses Djarea server functions.
|
All communication between the UI and backend uses mizan server functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -63,7 +63,7 @@ def main():
|
|||||||
|
|
||||||
base_url = f"http://{host}:{port}"
|
base_url = f"http://{host}:{port}"
|
||||||
|
|
||||||
if not wait_for_server(f"{base_url}/api/djarea/session/"):
|
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
||||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def main():
|
|||||||
import webview
|
import webview
|
||||||
|
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
title="Djarea Desktop",
|
title="mizan Desktop",
|
||||||
url=base_url,
|
url=base_url,
|
||||||
width=1024,
|
width=1024,
|
||||||
height=768,
|
height=768,
|
||||||
@@ -6,8 +6,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
|||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
|
|
||||||
import backend.djarea_clients # noqa: F401
|
import backend.mizan_clients # noqa: F401
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Desktop RPC server functions.
|
Desktop RPC server functions.
|
||||||
|
|
||||||
Tests Djarea's appropriateness for desktop apps:
|
Tests mizan's appropriateness for desktop apps:
|
||||||
- Local file system access
|
- Local file system access
|
||||||
- SQLite CRUD
|
- SQLite CRUD
|
||||||
- System introspection
|
- System introspection
|
||||||
@@ -20,10 +20,10 @@ from pathlib import Path
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
from djarea.setup.registry import register
|
from mizan.setup.registry import register
|
||||||
from djarea.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
|
|||||||
home_dir: str
|
home_dir: str
|
||||||
cwd: str
|
cwd: str
|
||||||
cpu_count: int
|
cpu_count: int
|
||||||
djarea_version: str
|
mizan_version: str
|
||||||
|
|
||||||
|
|
||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||||
import djarea
|
import mizan
|
||||||
|
|
||||||
return SystemInfoOutput(
|
return SystemInfoOutput(
|
||||||
os_name=platform.system(),
|
os_name=platform.system(),
|
||||||
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
|
|||||||
home_dir=str(Path.home()),
|
home_dir=str(Path.home()),
|
||||||
cwd=os.getcwd(),
|
cwd=os.getcwd(),
|
||||||
cpu_count=os.cpu_count() or 1,
|
cpu_count=os.cpu_count() or 1,
|
||||||
djarea_version=getattr(djarea, "__version__", "dev"),
|
mizan_version=getattr(mizan, "__version__", "dev"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,16 +114,20 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
|||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
try:
|
try:
|
||||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
for entry in sorted(
|
||||||
|
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
entries.append(FileEntry(
|
entries.append(
|
||||||
|
FileEntry(
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
path=str(entry),
|
path=str(entry),
|
||||||
is_dir=entry.is_dir(),
|
is_dir=entry.is_dir(),
|
||||||
size=stat.st_size if not entry.is_dir() else 0,
|
size=stat.st_size if not entry.is_dir() else 0,
|
||||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except (PermissionError, OSError):
|
except (PermissionError, OSError):
|
||||||
continue
|
continue
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
|
|||||||
|
|
||||||
|
|
||||||
@client(websocket=True)
|
@client(websocket=True)
|
||||||
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
|
def create_note(
|
||||||
|
request: HttpRequest, title: str, content: str = "", pinned: bool = False
|
||||||
|
) -> NoteOutput:
|
||||||
from backend.models import Note
|
from backend.models import Note
|
||||||
|
|
||||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||||
@@ -403,7 +409,7 @@ def app_info(request: HttpRequest) -> AppInfoOutput:
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
return AppInfoOutput(
|
return AppInfoOutput(
|
||||||
app_name="Djarea Desktop",
|
app_name="mizan Desktop",
|
||||||
uptime_seconds=round(time.time() - _start_time, 2),
|
uptime_seconds=round(time.time() - _start_time, 2),
|
||||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||||
pid=os.getpid(),
|
pid=os.getpid(),
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Django settings for the Djarea desktop integration test app.
|
Django settings for the mizan desktop integration test app.
|
||||||
|
|
||||||
Runs entirely local: SQLite database, in-memory channel layer,
|
Runs entirely local: SQLite database, in-memory channel layer,
|
||||||
no external services required.
|
no external services required.
|
||||||
@@ -27,7 +27,7 @@ def serve_dist(request, path="index.html"):
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/djarea/", include("djarea.urls")),
|
path("api/mizan/", include("mizan.urls")),
|
||||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||||
path("", serve_dist),
|
path("", serve_dist),
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Djarea Desktop</title>
|
<title>mizan Desktop</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "djarea-desktop-frontend",
|
"name": "mizan-desktop-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rythazhur/djarea": "file:../../react",
|
"@rythazhur/mizan": "file:../../react",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea'
|
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
||||||
|
|
||||||
// ─── System Info ────────────────────────────────────────────────────────────
|
// ─── System Info ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SystemInfo() {
|
function SystemInfo() {
|
||||||
const { call } = useDjarea()
|
const { call } = useMizan()
|
||||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,7 +33,7 @@ function SystemInfo() {
|
|||||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatusBar() {
|
function StatusBar() {
|
||||||
const status = useDjareaStatus()
|
const status = useMizanStatus()
|
||||||
return (
|
return (
|
||||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||||
{status}
|
{status}
|
||||||
@@ -46,7 +46,7 @@ function StatusBar() {
|
|||||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||||
|
|
||||||
function Notes() {
|
function Notes() {
|
||||||
const { call } = useDjarea()
|
const { call } = useMizan()
|
||||||
const [notes, setNotes] = useState<Note[]>([])
|
const [notes, setNotes] = useState<Note[]>([])
|
||||||
const [selected, setSelected] = useState<Note | null>(null)
|
const [selected, setSelected] = useState<Note | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -140,7 +140,7 @@ function Notes() {
|
|||||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||||
|
|
||||||
function FileBrowser() {
|
function FileBrowser() {
|
||||||
const { call } = useDjarea()
|
const { call } = useMizan()
|
||||||
const [dir, setDir] = useState('~')
|
const [dir, setDir] = useState('~')
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||||
const [parent, setParent] = useState<string | null>(null)
|
const [parent, setParent] = useState<string | null>(null)
|
||||||
@@ -184,17 +184,17 @@ function FileBrowser() {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}>
|
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<h1 style={{ fontSize: 24, color: '#fff' }}>Djarea Desktop</h1>
|
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
<SystemInfo />
|
<SystemInfo />
|
||||||
<Notes />
|
<Notes />
|
||||||
<FileBrowser />
|
<FileBrowser />
|
||||||
</div>
|
</div>
|
||||||
</DjareaProvider>
|
</MizanProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "djarea-desktop"
|
name = "mizan-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Desktop integration test app for Djarea"
|
description = "Desktop integration test app for mizan"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"djarea[channels]",
|
"mizan[channels]",
|
||||||
"uvicorn[standard]>=0.30",
|
"uvicorn[standard]>=0.30",
|
||||||
"pywebview[qt]>=5.0",
|
"pywebview[qt]>=5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
djarea = { path = "../django", editable = true }
|
mizan = { path = "../django", editable = true }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
# Ensure migrations run before tests
|
# Ensure migrations run before tests
|
||||||
def pytest_configure():
|
def pytest_configure():
|
||||||
# Import djarea_clients to trigger function registration
|
# Import mizan_clients to trigger function registration
|
||||||
import backend.djarea_clients # noqa: F401
|
import backend.mizan_clients # noqa: F401
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
REAL integration tests for the Djarea RPC framework layer.
|
REAL integration tests for the mizan RPC framework layer.
|
||||||
|
|
||||||
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
||||||
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
||||||
@@ -14,7 +14,7 @@ from django.test import LiveServerTestCase
|
|||||||
|
|
||||||
class RealHTTPMixin:
|
class RealHTTPMixin:
|
||||||
def _session_init(self):
|
def _session_init(self):
|
||||||
url = f"{self.live_server_url}/api/djarea/session/"
|
url = f"{self.live_server_url}/api/mizan/session/"
|
||||||
resp = urlopen(Request(url))
|
resp = urlopen(Request(url))
|
||||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||||
for cookie in cookies:
|
for cookie in cookies:
|
||||||
@@ -26,7 +26,7 @@ class RealHTTPMixin:
|
|||||||
self._cookies = ""
|
self._cookies = ""
|
||||||
|
|
||||||
def _call(self, fn: str, args: dict | None = None):
|
def _call(self, fn: str, args: dict | None = None):
|
||||||
url = f"{self.live_server_url}/api/djarea/call/"
|
url = f"{self.live_server_url}/api/mizan/call/"
|
||||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||||
req = Request(url, data=body, method="POST")
|
req = Request(url, data=body, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
@@ -37,7 +37,13 @@ class RealHTTPMixin:
|
|||||||
resp = urlopen(req)
|
resp = urlopen(req)
|
||||||
return json.loads(resp.read())
|
return json.loads(resp.read())
|
||||||
|
|
||||||
def _raw_post(self, path: str, body: bytes | str, content_type: str = "application/json", include_csrf: bool = False):
|
def _raw_post(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
body: bytes | str,
|
||||||
|
content_type: str = "application/json",
|
||||||
|
include_csrf: bool = False,
|
||||||
|
):
|
||||||
"""Raw POST without the call() envelope — for testing malformed requests."""
|
"""Raw POST without the call() envelope — for testing malformed requests."""
|
||||||
url = f"{self.live_server_url}{path}"
|
url = f"{self.live_server_url}{path}"
|
||||||
if isinstance(body, str):
|
if isinstance(body, str):
|
||||||
@@ -55,7 +61,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
|
|
||||||
def test_session_endpoint_sets_csrf_cookie(self):
|
def test_session_endpoint_sets_csrf_cookie(self):
|
||||||
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
||||||
url = f"{self.live_server_url}/api/djarea/session/"
|
url = f"{self.live_server_url}/api/mizan/session/"
|
||||||
resp = urlopen(Request(url))
|
resp = urlopen(Request(url))
|
||||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
|
|
||||||
def test_call_without_csrf_is_rejected(self):
|
def test_call_without_csrf_is_rejected(self):
|
||||||
"""POST /call/ without CSRF token must fail."""
|
"""POST /call/ without CSRF token must fail."""
|
||||||
url = f"{self.live_server_url}/api/djarea/call/"
|
url = f"{self.live_server_url}/api/mizan/call/"
|
||||||
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
||||||
req = Request(url, data=body, method="POST")
|
req = Request(url, data=body, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
@@ -134,7 +140,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
|
|
||||||
def test_get_method_rejected(self):
|
def test_get_method_rejected(self):
|
||||||
"""GET to /call/ should be rejected."""
|
"""GET to /call/ should be rejected."""
|
||||||
url = f"{self.live_server_url}/api/djarea/call/"
|
url = f"{self.live_server_url}/api/mizan/call/"
|
||||||
try:
|
try:
|
||||||
resp = urlopen(Request(url))
|
resp = urlopen(Request(url))
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
@@ -147,7 +153,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
self._session_init()
|
self._session_init()
|
||||||
try:
|
try:
|
||||||
resp = self._raw_post(
|
resp = self._raw_post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
body="not valid json{{{",
|
body="not valid json{{{",
|
||||||
include_csrf=True,
|
include_csrf=True,
|
||||||
)
|
)
|
||||||
@@ -162,7 +168,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
self._session_init()
|
self._session_init()
|
||||||
try:
|
try:
|
||||||
resp = self._raw_post(
|
resp = self._raw_post(
|
||||||
"/api/djarea/call/",
|
"/api/mizan/call/",
|
||||||
body=json.dumps({"not_fn": "hello"}),
|
body=json.dumps({"not_fn": "hello"}),
|
||||||
include_csrf=True,
|
include_csrf=True,
|
||||||
)
|
)
|
||||||
@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
|
|||||||
|
|
||||||
class RealHTTPMixin:
|
class RealHTTPMixin:
|
||||||
def _session_init(self):
|
def _session_init(self):
|
||||||
url = f"{self.live_server_url}/api/djarea/session/"
|
url = f"{self.live_server_url}/api/mizan/session/"
|
||||||
resp = urlopen(Request(url))
|
resp = urlopen(Request(url))
|
||||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||||
for cookie in cookies:
|
for cookie in cookies:
|
||||||
@@ -24,7 +24,7 @@ class RealHTTPMixin:
|
|||||||
self._cookies = ""
|
self._cookies = ""
|
||||||
|
|
||||||
def _call(self, fn: str, args: dict | None = None):
|
def _call(self, fn: str, args: dict | None = None):
|
||||||
url = f"{self.live_server_url}/api/djarea/call/"
|
url = f"{self.live_server_url}/api/mizan/call/"
|
||||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||||
req = Request(url, data=body, method="POST")
|
req = Request(url, data=body, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
@@ -105,6 +105,7 @@ class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
|
|
||||||
# Verify it's gone
|
# Verify it's gone
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get_data = self._call("get_note", {"id": note_id})
|
get_data = self._call("get_note", {"id": note_id})
|
||||||
self.assertTrue(get_data["error"])
|
self.assertTrue(get_data["error"])
|
||||||
@@ -18,8 +18,8 @@ class RealHTTPMixin:
|
|||||||
"""Makes real HTTP requests to the live server."""
|
"""Makes real HTTP requests to the live server."""
|
||||||
|
|
||||||
def _session_init(self):
|
def _session_init(self):
|
||||||
"""Hit /session/ to get CSRF cookie, like DjareaProvider does."""
|
"""Hit /session/ to get CSRF cookie, like mizanProvider does."""
|
||||||
url = f"{self.live_server_url}/api/djarea/session/"
|
url = f"{self.live_server_url}/api/mizan/session/"
|
||||||
req = Request(url)
|
req = Request(url)
|
||||||
resp = urlopen(req)
|
resp = urlopen(req)
|
||||||
# Extract csrftoken from Set-Cookie header
|
# Extract csrftoken from Set-Cookie header
|
||||||
@@ -33,8 +33,8 @@ class RealHTTPMixin:
|
|||||||
self._cookies = ""
|
self._cookies = ""
|
||||||
|
|
||||||
def _call(self, fn: str, args: dict | None = None):
|
def _call(self, fn: str, args: dict | None = None):
|
||||||
"""Make a real POST to /api/djarea/call/ with CSRF token."""
|
"""Make a real POST to /api/mizan/call/ with CSRF token."""
|
||||||
url = f"{self.live_server_url}/api/djarea/call/"
|
url = f"{self.live_server_url}/api/mizan/call/"
|
||||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||||
req = Request(url, data=body, method="POST")
|
req = Request(url, data=body, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
@@ -80,7 +80,7 @@ class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
data = self._call("app_info")
|
data = self._call("app_info")
|
||||||
|
|
||||||
self.assertFalse(data["error"])
|
self.assertFalse(data["error"])
|
||||||
self.assertEqual(data["data"]["app_name"], "Djarea Desktop")
|
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
|
||||||
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,11 +89,12 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._session_init()
|
self._session_init()
|
||||||
self.test_dir = Path.home() / ".djarea-test"
|
self.test_dir = Path.home() / ".mizan-test"
|
||||||
self.test_dir.mkdir(exist_ok=True)
|
self.test_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
if self.test_dir.exists():
|
if self.test_dir.exists():
|
||||||
shutil.rmtree(self.test_dir)
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
@@ -116,7 +117,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
test_content = "Hello from a REAL HTTP integration test!"
|
test_content = "Hello from a REAL HTTP integration test!"
|
||||||
|
|
||||||
# Write
|
# Write
|
||||||
write_data = self._call("write_file", {"path": test_path, "content": test_content})
|
write_data = self._call(
|
||||||
|
"write_file", {"path": test_path, "content": test_content}
|
||||||
|
)
|
||||||
self.assertFalse(write_data["error"])
|
self.assertFalse(write_data["error"])
|
||||||
self.assertEqual(write_data["data"]["path"], test_path)
|
self.assertEqual(write_data["data"]["path"], test_path)
|
||||||
|
|
||||||
@@ -130,7 +133,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
|||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = self._call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
|
data = self._call(
|
||||||
|
"write_file", {"path": "/tmp/escape.txt", "content": "nope"}
|
||||||
|
)
|
||||||
# If we get here, check the response has an error
|
# If we get here, check the response has an error
|
||||||
self.assertTrue(data["error"])
|
self.assertTrue(data["error"])
|
||||||
self.assertEqual(data["code"], "FORBIDDEN")
|
self.assertEqual(data["code"], "FORBIDDEN")
|
||||||
@@ -7,12 +7,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
gcc \
|
gcc \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install djarea from local source with channels support
|
# Install mizan from local source with channels support
|
||||||
COPY django/ /app/django/
|
COPY packages/mizan-django/ /app/django/
|
||||||
RUN pip install --no-cache-dir /app/django[channels] daphne
|
RUN pip install --no-cache-dir /app/django[channels] daphne
|
||||||
|
|
||||||
# Copy example app
|
# Copy example app
|
||||||
COPY example/ /app/example/
|
COPY examples/django-react-site/backend/ /app/example/
|
||||||
|
|
||||||
WORKDIR /app/example
|
WORKDIR /app/example
|
||||||
|
|
||||||
@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
|
|||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import testapp.djarea_clients # noqa: F401
|
import testapp.mizan_clients # noqa: F401
|
||||||
@@ -6,9 +6,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
|
|||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
|
|
||||||
# Register server functions and channels before building the ASGI app
|
# Register server functions and channels before building the ASGI app
|
||||||
import testapp.djarea_clients # noqa: F401
|
import testapp.mizan_clients # noqa: F401
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
@@ -11,12 +11,12 @@ from django import forms
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import ServerFunction, client
|
from mizan.client import ServerFunction, client
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
from djarea.setup.registry import register, register_form, register_as
|
from mizan.setup.registry import register, register_form, register_as
|
||||||
from djarea.channels import register as register_channel
|
from mizan.channels import register as register_channel
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
from djarea.jwt import jwt_obtain, jwt_refresh
|
from mizan.jwt import jwt_obtain, jwt_refresh
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -57,9 +57,9 @@ class WhoamiOutput(BaseModel):
|
|||||||
@client(auth=True)
|
@client(auth=True)
|
||||||
def whoami(request: HttpRequest) -> WhoamiOutput:
|
def whoami(request: HttpRequest) -> WhoamiOutput:
|
||||||
return WhoamiOutput(
|
return WhoamiOutput(
|
||||||
user_id=getattr(request.user, 'id', None),
|
user_id=getattr(request.user, "id", None),
|
||||||
email=getattr(request.user, 'email', ''),
|
email=getattr(request.user, "email", ""),
|
||||||
is_staff=getattr(request.user, 'is_staff', False),
|
is_staff=getattr(request.user, "is_staff", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,18 +197,20 @@ register_channel(PresenceChannel, "presence")
|
|||||||
|
|
||||||
|
|
||||||
# --- Staff-only ---
|
# --- Staff-only ---
|
||||||
@client(auth='staff')
|
@client(auth="staff")
|
||||||
def staff_only(request: HttpRequest) -> EchoOutput:
|
def staff_only(request: HttpRequest) -> EchoOutput:
|
||||||
return EchoOutput(message=f"staff:{request.user.email}")
|
return EchoOutput(message=f"staff:{request.user.email}")
|
||||||
|
|
||||||
|
|
||||||
register(staff_only, "staff_only")
|
register(staff_only, "staff_only")
|
||||||
|
|
||||||
|
|
||||||
# --- Superuser-only ---
|
# --- Superuser-only ---
|
||||||
@client(auth='superuser')
|
@client(auth="superuser")
|
||||||
def superuser_only(request: HttpRequest) -> EchoOutput:
|
def superuser_only(request: HttpRequest) -> EchoOutput:
|
||||||
return EchoOutput(message=f"superuser:{request.user.email}")
|
return EchoOutput(message=f"superuser:{request.user.email}")
|
||||||
|
|
||||||
|
|
||||||
register(superuser_only, "superuser_only")
|
register(superuser_only, "superuser_only")
|
||||||
|
|
||||||
|
|
||||||
@@ -216,12 +218,14 @@ register(superuser_only, "superuser_only")
|
|||||||
def check_verified_email(request):
|
def check_verified_email(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
return getattr(request.user, 'email', '').endswith('@verified.com')
|
return getattr(request.user, "email", "").endswith("@verified.com")
|
||||||
|
|
||||||
|
|
||||||
@client(auth=check_verified_email)
|
@client(auth=check_verified_email)
|
||||||
def verified_only(request: HttpRequest) -> EchoOutput:
|
def verified_only(request: HttpRequest) -> EchoOutput:
|
||||||
return EchoOutput(message="verified")
|
return EchoOutput(message="verified")
|
||||||
|
|
||||||
|
|
||||||
register(verified_only, "verified_only")
|
register(verified_only, "verified_only")
|
||||||
|
|
||||||
|
|
||||||
@@ -235,7 +239,8 @@ class CurrentUserOutput(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
is_staff: bool
|
is_staff: bool
|
||||||
|
|
||||||
@client(context='global')
|
|
||||||
|
@client(context="global")
|
||||||
def current_user(request: HttpRequest) -> CurrentUserOutput:
|
def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return CurrentUserOutput(
|
return CurrentUserOutput(
|
||||||
@@ -245,16 +250,19 @@ def current_user(request: HttpRequest) -> CurrentUserOutput:
|
|||||||
)
|
)
|
||||||
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
|
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
|
||||||
|
|
||||||
|
|
||||||
register(current_user, "current_user")
|
register(current_user, "current_user")
|
||||||
|
|
||||||
|
|
||||||
class GreetOutput(BaseModel):
|
class GreetOutput(BaseModel):
|
||||||
greeting: str
|
greeting: str
|
||||||
|
|
||||||
@client(context='local')
|
|
||||||
|
@client(context="local")
|
||||||
def greet(request: HttpRequest, name: str) -> GreetOutput:
|
def greet(request: HttpRequest, name: str) -> GreetOutput:
|
||||||
return GreetOutput(greeting=f"Hello, {name}!")
|
return GreetOutput(greeting=f"Hello, {name}!")
|
||||||
|
|
||||||
|
|
||||||
register(greet, "greet")
|
register(greet, "greet")
|
||||||
|
|
||||||
|
|
||||||
@@ -267,9 +275,11 @@ class MultiplyInput(BaseModel):
|
|||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
|
||||||
|
|
||||||
class MultiplyOutput(BaseModel):
|
class MultiplyOutput(BaseModel):
|
||||||
product: int
|
product: int
|
||||||
|
|
||||||
|
|
||||||
@register_as("multiply")
|
@register_as("multiply")
|
||||||
class Multiply(ServerFunction):
|
class Multiply(ServerFunction):
|
||||||
Input = MultiplyInput
|
Input = MultiplyInput
|
||||||
@@ -288,6 +298,7 @@ class Multiply(ServerFunction):
|
|||||||
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
||||||
raise NotImplementedError("This feature is not yet implemented")
|
raise NotImplementedError("This feature is not yet implemented")
|
||||||
|
|
||||||
|
|
||||||
register(not_implemented_fn, "not_implemented_fn")
|
register(not_implemented_fn, "not_implemented_fn")
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +306,7 @@ register(not_implemented_fn, "not_implemented_fn")
|
|||||||
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
||||||
raise RuntimeError("Unexpected internal failure")
|
raise RuntimeError("Unexpected internal failure")
|
||||||
|
|
||||||
|
|
||||||
register(buggy_fn, "buggy_fn")
|
register(buggy_fn, "buggy_fn")
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +316,7 @@ def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
|
|||||||
raise PermissionError("Wrong secret")
|
raise PermissionError("Wrong secret")
|
||||||
return EchoOutput(message="access granted")
|
return EchoOutput(message="access granted")
|
||||||
|
|
||||||
|
|
||||||
register(permission_check_fn, "permission_check_fn")
|
register(permission_check_fn, "permission_check_fn")
|
||||||
|
|
||||||
|
|
||||||
@@ -315,21 +328,22 @@ register(permission_check_fn, "permission_check_fn")
|
|||||||
@client(websocket=True, auth=True)
|
@client(websocket=True, auth=True)
|
||||||
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
|
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
|
||||||
return WhoamiOutput(
|
return WhoamiOutput(
|
||||||
user_id=getattr(request.user, 'id', None),
|
user_id=getattr(request.user, "id", None),
|
||||||
email=getattr(request.user, 'email', ''),
|
email=getattr(request.user, "email", ""),
|
||||||
is_staff=getattr(request.user, 'is_staff', False),
|
is_staff=getattr(request.user, "is_staff", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
register(ws_whoami, "ws_whoami")
|
register(ws_whoami, "ws_whoami")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DjareaFormMixin Forms
|
# mizanFormMixin Forms
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
subtitle="We'd love to hear from you",
|
subtitle="We'd love to hear from you",
|
||||||
@@ -351,8 +365,8 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ItemForm(DjareaFormMixin, forms.Form):
|
class ItemForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="item",
|
name="item",
|
||||||
title="Items",
|
title="Items",
|
||||||
submit_label="Save Items",
|
submit_label="Save Items",
|
||||||
@@ -363,7 +377,10 @@ class ItemForm(DjareaFormMixin, forms.Form):
|
|||||||
quantity = forms.IntegerField(min_value=1, label="Quantity")
|
quantity = forms.IntegerField(min_value=1, label="Quantity")
|
||||||
|
|
||||||
def on_submit_success(self, request):
|
def on_submit_success(self, request):
|
||||||
return {"label": self.cleaned_data["label"], "qty": self.cleaned_data["quantity"]}
|
return {
|
||||||
|
"label": self.cleaned_data["label"],
|
||||||
|
"qty": self.cleaned_data["quantity"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -376,11 +393,12 @@ class PrivateChannel(ReactChannel):
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
def authorize(self, params=None):
|
def authorize(self, params=None):
|
||||||
return getattr(self.user, 'is_authenticated', False)
|
return getattr(self.user, "is_authenticated", False)
|
||||||
|
|
||||||
def group(self, params=None):
|
def group(self, params=None):
|
||||||
return "private_global"
|
return "private_global"
|
||||||
|
|
||||||
|
|
||||||
register_channel(PrivateChannel, "private")
|
register_channel(PrivateChannel, "private")
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"djarea",
|
"mizan",
|
||||||
"testapp",
|
"testapp",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/djarea/", include("djarea.urls")),
|
path("api/mizan/", include("mizan.urls")),
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Djarea E2E Integration Tests
|
* mizan E2E Integration Tests
|
||||||
*
|
*
|
||||||
* Real Chromium → Real React app (generated hooks) → Real Django backend
|
* Real Chromium → Real React app (generated hooks) → Real Django backend
|
||||||
*
|
*
|
||||||
* Every test uses the generated Djarea API, not raw call() or fetch().
|
* Every test uses the generated mizan API, not raw call() or fetch().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
|
|||||||
expect(result.fields.password).toBeDefined()
|
expect(result.fields.password).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('useContactForm loads schema with DjareaFormMeta', async ({ page }) => {
|
test('useContactForm loads schema with mizanFormMeta', async ({ page }) => {
|
||||||
await fixture(page, 'form-contact-schema')
|
await fixture(page, 'form-contact-schema')
|
||||||
const result = await getResult(page)
|
const result = await getResult(page)
|
||||||
expect(result.title).toBe('Contact Us')
|
expect(result.title).toBe('Contact Us')
|
||||||
@@ -6,8 +6,8 @@ services:
|
|||||||
|
|
||||||
django:
|
django:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ../..
|
||||||
dockerfile: Dockerfile.test
|
dockerfile: examples/django-react-site/Dockerfile.test
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -2,17 +2,17 @@ import path from 'path'
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const root = path.resolve(__dirname, '../..')
|
const root = path.resolve(__dirname, '../../..')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
projectId: 'e2e-harness',
|
projectId: 'e2e-harness',
|
||||||
|
|
||||||
source: {
|
source: {
|
||||||
django: {
|
django: {
|
||||||
managePath: path.join(root, 'example/manage.py'),
|
managePath: path.join(root, 'examples/django-react-site/backend/manage.py'),
|
||||||
command: [path.join(root, 'django/.venv/bin/python')],
|
command: [path.join(root, 'packages/mizan-django/.venv/bin/python')],
|
||||||
env: {
|
env: {
|
||||||
PYTHONPATH: `${path.join(root, 'django/src')}:${path.join(root, 'example')}`,
|
PYTHONPATH: `${path.join(root, 'packages/mizan-django/src')}:${path.join(root, 'examples/django-react-site/backend')}`,
|
||||||
DJANGO_SETTINGS_MODULE: 'testapp.settings',
|
DJANGO_SETTINGS_MODULE: 'testapp.settings',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head><meta charset="UTF-8" /><title>Djarea E2E Harness</title></head>
|
<head><meta charset="UTF-8" /><title>mizan E2E Harness</title></head>
|
||||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "djarea-e2e-harness",
|
"name": "mizan-e2e-harness",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite --port 5174"
|
"dev": "vite --port 5174"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rythazhur/djarea": "file:../../react",
|
"@rythazhur/mizan": "file:../../react",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -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:
|
* Import everything from here:
|
||||||
*
|
*
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// AUTO-GENERATED by djarea - do not edit manually
|
// AUTO-GENERATED by mizan - do not edit manually
|
||||||
// Regenerate with: npm run schemas
|
// Regenerate with: npm run schemas
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Djarea Provider & Hooks
|
// mizan Provider & Hooks
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -55,9 +55,9 @@ export {
|
|||||||
useJwtObtain,
|
useJwtObtain,
|
||||||
useJwtRefresh,
|
useJwtRefresh,
|
||||||
|
|
||||||
// Re-exports from djarea library
|
// Re-exports from mizan library
|
||||||
useDjarea,
|
usemizan,
|
||||||
useDjareaStatus,
|
usemizanStatus,
|
||||||
usePush,
|
usePush,
|
||||||
DjangoError,
|
DjangoError,
|
||||||
type ConnectionStatus,
|
type ConnectionStatus,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* E2E Test Fixtures
|
* E2E Test Fixtures
|
||||||
*
|
*
|
||||||
* Each fixture uses GENERATED Djarea hooks (not raw call()).
|
* Each fixture uses GENERATED mizan hooks (not raw call()).
|
||||||
* Playwright reads the DOM to verify behavior.
|
* Playwright reads the DOM to verify behavior.
|
||||||
*
|
*
|
||||||
* URL hash selects the fixture: #echo, #add, #multiply, etc.
|
* URL hash selects the fixture: #echo, #add, #multiply, etc.
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
// Generated typed hooks — the actual Djarea API
|
// Generated typed hooks — the actual mizan API
|
||||||
import {
|
import {
|
||||||
DjangoContext,
|
DjangoContext,
|
||||||
useEcho,
|
useEcho,
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
usePermissionCheckFn,
|
usePermissionCheckFn,
|
||||||
useCurrentUser,
|
useCurrentUser,
|
||||||
DjangoError,
|
DjangoError,
|
||||||
useDjarea,
|
useMizan,
|
||||||
} from './api/generated.django'
|
} from './api/generated.django'
|
||||||
import { useContactForm, useLoginForm } from './api/generated.forms'
|
import { useContactForm, useLoginForm } from './api/generated.forms'
|
||||||
import { useChatChannel } from './api/generated.channels.hooks'
|
import { useChatChannel } from './api/generated.channels.hooks'
|
||||||
@@ -121,7 +121,7 @@ function Multiply() {
|
|||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
// Deliberately call a non-existent function via the raw primitive
|
// Deliberately call a non-existent function via the raw primitive
|
||||||
const { call } = useDjarea()
|
const { call } = useMizan()
|
||||||
const [error, setError] = useState<unknown>()
|
const [error, setError] = useState<unknown>()
|
||||||
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
|
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
|
||||||
return <Result error={error} />
|
return <Result error={error} />
|
||||||
@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<DjangoContext baseUrl="/api/djarea">
|
<DjangoContext baseUrl="/api/mizan">
|
||||||
<Fixtures />
|
<Fixtures />
|
||||||
</DjangoContext>
|
</DjangoContext>
|
||||||
)
|
)
|
||||||
@@ -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",
|
"version": "1.0.0",
|
||||||
"description": "Django + React server functions framework.",
|
"description": "Django + React server functions framework.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from '@playwright/test'
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: '.',
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
# djarea (Python)
|
# mizan (Python)
|
||||||
|
|
||||||
Django server functions framework. See the [monorepo root](../README.md) for full documentation.
|
Django server functions framework. See the [monorepo root](../README.md) for full documentation.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
|
uv add "mizan[channels,allauth] @ git+https://git.impactsoundworks.com/isw/mizan.git#subdirectory=django"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# settings.py
|
# settings.py
|
||||||
INSTALLED_APPS = ["djarea", ...]
|
INSTALLED_APPS = ["mizan", ...]
|
||||||
|
|
||||||
# urls.py
|
# urls.py
|
||||||
path("api/djarea/", include("djarea.urls"))
|
path("api/mizan/", include("mizan.urls"))
|
||||||
|
|
||||||
# asgi.py (optional, for WebSocket)
|
# asgi.py (optional, for WebSocket)
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
```
|
```
|
||||||
|
|
||||||
## Define Functions
|
## Define Functions
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.setup.registry import register
|
from mizan.setup.registry import register
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class Output(BaseModel):
|
class Output(BaseModel):
|
||||||
@@ -43,7 +43,7 @@ Register in `apps.py`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import myapp.djarea_clients
|
import myapp.mizan_clients
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
@@ -65,10 +65,10 @@ def ready(self):
|
|||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(name="contact", title="Contact Us")
|
mizan = mizanFormMeta(name="contact", title="Contact Us")
|
||||||
name = forms.CharField()
|
name = forms.CharField()
|
||||||
email = forms.EmailField()
|
email = forms.EmailField()
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
|
|||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
class ChatChannel(ReactChannel):
|
class ChatChannel(ReactChannel):
|
||||||
class Params(BaseModel):
|
class Params(BaseModel):
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Djarea Code Generator CLI
|
* mizan Code Generator CLI
|
||||||
*
|
*
|
||||||
* Generate TypeScript types, React provider, and hooks from Django schemas.
|
* Generate TypeScript types, React provider, and hooks from Django schemas.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* npx djarea-generate # Run once
|
* npx mizan-generate # Run once
|
||||||
* npx djarea-generate --watch # Watch mode
|
* npx mizan-generate --watch # Watch mode
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fetchChannelsSchema, fetchDjareaSchema } from './lib/fetch.mjs'
|
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
|
||||||
import { generateDjareaFiles } from './lib/djarea.mjs'
|
import { generateMizanFiles } from './lib/mizan.mjs'
|
||||||
import { generateChannelsFiles } from './lib/channels.mjs'
|
import { generateChannelsFiles } from './lib/channels.mjs'
|
||||||
import { generateIndex } from './lib/index.mjs'
|
import { generateIndex } from './lib/index.mjs'
|
||||||
|
|
||||||
// Use cwd — the script runs via `npx djarea-generate` from the frontend root
|
// Use cwd — the script runs via `npx mizan-generate` from the frontend root
|
||||||
const frontendDir = process.cwd()
|
const frontendDir = process.cwd()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,21 +70,21 @@ async function writeOutput(filePath, content) {
|
|||||||
async function generate(config, options = {}) {
|
async function generate(config, options = {}) {
|
||||||
const { output } = options
|
const { output } = options
|
||||||
|
|
||||||
console.log('[djarea] Starting schema generation...')
|
console.log('[mizan] Starting schema generation...')
|
||||||
|
|
||||||
const outputPath = output || config.output || 'src/api/generated.ts'
|
const outputPath = output || config.output || 'src/api/generated.ts'
|
||||||
|
|
||||||
let channelsSchema = null
|
let channelsSchema = null
|
||||||
let djareaSchema = null
|
let mizanSchema = null
|
||||||
|
|
||||||
// Fetch and generate channels if available
|
// Fetch and generate channels if available
|
||||||
try {
|
try {
|
||||||
console.log('[djarea] Fetching channels schema...')
|
console.log('[mizan] Fetching channels schema...')
|
||||||
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
|
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
|
||||||
|
|
||||||
const channelCount = channelsSchema['x-djarea-channels']?.length || 0
|
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
|
||||||
if (channelCount > 0) {
|
if (channelCount > 0) {
|
||||||
console.log(`[djarea] Found ${channelCount} channels`)
|
console.log(`[mizan] Found ${channelCount} channels`)
|
||||||
|
|
||||||
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
|
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
|
||||||
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
|
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
|
||||||
@@ -95,85 +95,85 @@ async function generate(config, options = {}) {
|
|||||||
|
|
||||||
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
|
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
|
||||||
|
|
||||||
console.log(`[djarea] Generating -> ${channelsTypesPath}`)
|
console.log(`[mizan] Generating -> ${channelsTypesPath}`)
|
||||||
await writeOutput(fullChannelsTypesPath, channelsTypes)
|
await writeOutput(fullChannelsTypesPath, channelsTypes)
|
||||||
|
|
||||||
if (channelsHooks) {
|
if (channelsHooks) {
|
||||||
console.log(`[djarea] Generating -> ${channelsHooksPath}`)
|
console.log(`[mizan] Generating -> ${channelsHooksPath}`)
|
||||||
await writeOutput(fullChannelsHooksPath, channelsHooks)
|
await writeOutput(fullChannelsHooksPath, channelsHooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[djarea] Generating -> ${channelsSchemaPath}`)
|
console.log(`[mizan] Generating -> ${channelsSchemaPath}`)
|
||||||
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
|
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
|
||||||
} else {
|
} else {
|
||||||
console.log('[djarea] No channels registered, skipping channels generation')
|
console.log('[mizan] No channels registered, skipping channels generation')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`[djarea] Channels schema not available: ${err.message}`)
|
console.log(`[mizan] Channels schema not available: ${err.message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and generate djarea files
|
// Fetch and generate mizan files
|
||||||
try {
|
try {
|
||||||
console.log('[djarea] Fetching djarea schema...')
|
console.log('[mizan] Fetching mizan schema...')
|
||||||
djareaSchema = await fetchDjareaSchema(config.source, frontendDir)
|
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
|
||||||
|
|
||||||
const functionCount = djareaSchema['x-djarea-functions']?.length || 0
|
const functionCount = mizanSchema['x-mizan-functions']?.length || 0
|
||||||
if (functionCount > 0) {
|
if (functionCount > 0) {
|
||||||
console.log(`[djarea] Found ${functionCount} djarea functions`)
|
console.log(`[mizan] Found ${functionCount} mizan functions`)
|
||||||
|
|
||||||
const djareaTypesPath = outputPath.replace(/\.ts$/, '.djarea.ts')
|
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
|
||||||
const fullDjareaTypesPath = path.resolve(frontendDir, djareaTypesPath)
|
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
|
||||||
const djareaProviderPath = outputPath.replace(/\.ts$/, '.django.tsx')
|
const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
|
||||||
const fullDjareaProviderPath = path.resolve(frontendDir, djareaProviderPath)
|
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
|
||||||
const djareaServerPath = outputPath.replace(/\.ts$/, '.django.server.ts')
|
const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts')
|
||||||
const fullDjareaServerPath = path.resolve(frontendDir, djareaServerPath)
|
const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath)
|
||||||
const djareaFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
|
const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
|
||||||
const fullDjareaFormsPath = path.resolve(frontendDir, djareaFormsPath)
|
const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath)
|
||||||
const djareaSchemaPath = outputPath.replace(/\.ts$/, '.djarea.schema.json')
|
const mizanSchemaPath = outputPath.replace(/\.ts$/, '.mizan.schema.json')
|
||||||
const fullDjareaSchemaPath = path.resolve(frontendDir, djareaSchemaPath)
|
const fullMizanSchemaPath = path.resolve(frontendDir, mizanSchemaPath)
|
||||||
|
|
||||||
const hasChannels = (channelsSchema?.['x-djarea-channels']?.length || 0) > 0
|
const hasChannels = (channelsSchema?.['x-mizan-channels']?.length || 0) > 0
|
||||||
const { types: djareaTypes, provider: djareaProvider, server: djareaServer, forms: djareaForms } = await generateDjareaFiles(djareaSchema, { hasChannels })
|
const { types: mizanTypes, provider: mizanProvider, server: mizanServer, forms: mizanForms } = await generateMizanFiles(mizanSchema, { hasChannels })
|
||||||
|
|
||||||
console.log(`[djarea] Generating -> ${djareaTypesPath}`)
|
console.log(`[mizan] Generating -> ${mizanTypesPath}`)
|
||||||
await writeOutput(fullDjareaTypesPath, djareaTypes)
|
await writeOutput(fullMizanTypesPath, mizanTypes)
|
||||||
|
|
||||||
if (djareaProvider) {
|
if (mizanProvider) {
|
||||||
console.log(`[djarea] Generating -> ${djareaProviderPath}`)
|
console.log(`[mizan] Generating -> ${mizanProviderPath}`)
|
||||||
await writeOutput(fullDjareaProviderPath, djareaProvider)
|
await writeOutput(fullMizanProviderPath, mizanProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (djareaServer) {
|
if (mizanServer) {
|
||||||
console.log(`[djarea] Generating -> ${djareaServerPath}`)
|
console.log(`[mizan] Generating -> ${mizanServerPath}`)
|
||||||
await writeOutput(fullDjareaServerPath, djareaServer)
|
await writeOutput(fullMizanServerPath, mizanServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (djareaForms) {
|
if (mizanForms) {
|
||||||
console.log(`[djarea] Generating -> ${djareaFormsPath}`)
|
console.log(`[mizan] Generating -> ${mizanFormsPath}`)
|
||||||
await writeOutput(fullDjareaFormsPath, djareaForms)
|
await writeOutput(fullMizanFormsPath, mizanForms)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[djarea] Generating -> ${djareaSchemaPath}`)
|
console.log(`[mizan] Generating -> ${mizanSchemaPath}`)
|
||||||
await writeOutput(fullDjareaSchemaPath, JSON.stringify(djareaSchema, null, 2))
|
await writeOutput(fullMizanSchemaPath, JSON.stringify(mizanSchema, null, 2))
|
||||||
} else {
|
} else {
|
||||||
console.log('[djarea] No djarea functions registered, skipping djarea generation')
|
console.log('[mizan] No mizan functions registered, skipping mizan generation')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`[djarea] Djarea schema not available: ${err.message}`)
|
console.log(`[mizan] mizan schema not available: ${err.message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate consolidated index.ts
|
// Generate consolidated index.ts
|
||||||
const indexPath = path.dirname(outputPath) + '/index.ts'
|
const indexPath = path.dirname(outputPath) + '/index.ts'
|
||||||
const fullIndexPath = path.resolve(frontendDir, indexPath)
|
const fullIndexPath = path.resolve(frontendDir, indexPath)
|
||||||
|
|
||||||
console.log(`[djarea] Generating -> ${indexPath}`)
|
console.log(`[mizan] Generating -> ${indexPath}`)
|
||||||
const indexContent = generateIndex({
|
const indexContent = generateIndex({
|
||||||
channelsSchema,
|
channelsSchema,
|
||||||
djareaSchema,
|
mizanSchema,
|
||||||
})
|
})
|
||||||
await writeOutput(fullIndexPath, indexContent)
|
await writeOutput(fullIndexPath, indexContent)
|
||||||
|
|
||||||
console.log('[djarea] Generation complete!')
|
console.log('[mizan] Generation complete!')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,7 +194,7 @@ async function watch(config, options) {
|
|||||||
try {
|
try {
|
||||||
await generate(config, options)
|
await generate(config, options)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[djarea] Generation failed:', err.message)
|
console.error('[mizan] Generation failed:', err.message)
|
||||||
} finally {
|
} finally {
|
||||||
running = false
|
running = false
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ async function watch(config, options) {
|
|||||||
|
|
||||||
await runGenerate()
|
await runGenerate()
|
||||||
|
|
||||||
console.log('[djarea] Watching for changes (press Ctrl+C to stop)...')
|
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
|
||||||
|
|
||||||
if (config.source.django) {
|
if (config.source.django) {
|
||||||
const { watch: chokidarWatch } = await import('chokidar')
|
const { watch: chokidarWatch } = await import('chokidar')
|
||||||
@@ -221,14 +221,14 @@ async function watch(config, options) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watcher.on('change', (filePath) => {
|
watcher.on('change', (filePath) => {
|
||||||
console.log(`[djarea] Detected change: ${path.relative(djangoDir, filePath)}`)
|
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
|
||||||
if (timeout) clearTimeout(timeout)
|
if (timeout) clearTimeout(timeout)
|
||||||
timeout = setTimeout(runGenerate, debounce)
|
timeout = setTimeout(runGenerate, debounce)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\n[djarea] Stopping watch mode...')
|
console.log('\n[mizan] Stopping watch mode...')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -252,10 +252,10 @@ async function main() {
|
|||||||
output = args[++i]
|
output = args[++i]
|
||||||
} else if (args[i] === '--help' || args[i] === '-h') {
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
||||||
console.log(`
|
console.log(`
|
||||||
Djarea Code Generator - Generate TypeScript from Django schemas
|
mizan Code Generator - Generate TypeScript from Django schemas
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
npx djarea-generate [options]
|
npx mizan-generate [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c, --config <path> Config file path (default: django.config.mjs)
|
-c, --config <path> Config file path (default: django.config.mjs)
|
||||||
@@ -278,6 +278,6 @@ Options:
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
console.error('[djarea] Error:', err.message)
|
console.error('[mizan] Error:', err.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
@@ -16,7 +16,7 @@ export async function generateChannelsTypes(schema) {
|
|||||||
const typesCode = astToString(ast)
|
const typesCode = astToString(ast)
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
'// ============================================================================',
|
'// ============================================================================',
|
||||||
@@ -27,8 +27,8 @@ export async function generateChannelsTypes(schema) {
|
|||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Extract channel metadata from x-djarea-channels extension
|
// Extract channel metadata from x-mizan-channels extension
|
||||||
const channels = schema['x-djarea-channels'] || []
|
const channels = schema['x-mizan-channels'] || []
|
||||||
|
|
||||||
if (channels.length > 0) {
|
if (channels.length > 0) {
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
@@ -86,7 +86,7 @@ export async function generateChannelsTypes(schema) {
|
|||||||
* Generate channel hooks from metadata.
|
* Generate channel hooks from metadata.
|
||||||
*/
|
*/
|
||||||
export function generateChannelsHooks(schema) {
|
export function generateChannelsHooks(schema) {
|
||||||
const channels = schema['x-djarea-channels'] || []
|
const channels = schema['x-mizan-channels'] || []
|
||||||
|
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -95,10 +95,10 @@ export function generateChannelsHooks(schema) {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"'use client'",
|
"'use client'",
|
||||||
'',
|
'',
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
"import { useChannel, type ChannelSubscription } from 'djarea/channels'",
|
"import { useChannel, type ChannelSubscription } from 'mizan/channels'",
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Schema Fetching
|
* Schema Fetching
|
||||||
*
|
*
|
||||||
* Fetches djarea and channels schemas from Django management commands.
|
* Fetches mizan and channels schemas from Django management commands.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
@@ -78,11 +78,11 @@ export async function fetchChannelsSchema(source, cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch djarea schema from Django.
|
* Fetch mizan schema from Django.
|
||||||
*/
|
*/
|
||||||
export async function fetchDjareaSchema(source, cwd) {
|
export async function fetchMizanSchema(source, cwd) {
|
||||||
if (!source.django) {
|
if (!source.django) {
|
||||||
throw new Error('Djarea schema export requires django source configuration')
|
throw new Error('mizan schema export requires django source configuration')
|
||||||
}
|
}
|
||||||
return runDjangoCommand(source, cwd, 'export_djarea_schema')
|
return runDjangoCommand(source, cwd, 'export_mizan_schema')
|
||||||
}
|
}
|
||||||
@@ -5,122 +5,134 @@
|
|||||||
* from the generated files for clean imports.
|
* from the generated files for clean imports.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
function pascalCase(str) {
|
||||||
* Extract context hooks from djarea schema.
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
* Returns hook names in PascalCase (e.g., useAuthStatus, useUser).
|
}
|
||||||
*/
|
|
||||||
function extractContextHooks(djareaSchema) {
|
|
||||||
const functions = djareaSchema?.['x-djarea-functions'] || []
|
|
||||||
const contexts = functions.filter(fn => fn.isContext)
|
|
||||||
|
|
||||||
return contexts.map(ctx => {
|
function toPascalCase(str) {
|
||||||
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1)
|
return str
|
||||||
return `use${pascal}`
|
.split(/[.\-_]/)
|
||||||
}).sort()
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the consolidated index.ts file.
|
* Generate the consolidated index.ts file.
|
||||||
*
|
|
||||||
* @param {Object} options - Generation options
|
|
||||||
* @param {Object} options.channelsSchema - Channels schema (optional)
|
|
||||||
* @param {Object} options.djareaSchema - Djarea schema (optional)
|
|
||||||
* @returns {string} Generated index.ts content
|
|
||||||
*/
|
*/
|
||||||
export function generateIndex({ channelsSchema, djareaSchema }) {
|
export function generateIndex({ channelsSchema, mizanSchema }) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'/**',
|
'/**',
|
||||||
' * Djarea API - Consolidated Exports',
|
' * mizan API - Consolidated Exports',
|
||||||
' *',
|
' *',
|
||||||
' * Import everything from here:',
|
' * Import everything from here:',
|
||||||
' *',
|
' *',
|
||||||
' * @example',
|
' * @example',
|
||||||
' * ```tsx',
|
' * ```tsx',
|
||||||
' * import {',
|
' * import {',
|
||||||
' * DjangoContext,',
|
' * MizanContext,',
|
||||||
' * useUser,',
|
' * useCurrentUser,',
|
||||||
' * useEcho,',
|
' * useEcho,',
|
||||||
' * useChatChannel,',
|
' * useChatChannel,',
|
||||||
' * DjangoError,',
|
|
||||||
' * } from \'@/api\'',
|
' * } from \'@/api\'',
|
||||||
' * ```',
|
' * ```',
|
||||||
' */',
|
' */',
|
||||||
'',
|
'',
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
// ==========================================================================
|
const functions = mizanSchema?.['x-mizan-functions'] || []
|
||||||
// Djarea Provider & Hooks (from generated.django.tsx)
|
const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
|
||||||
// ==========================================================================
|
const hasMizan = functions.length > 0
|
||||||
|
|
||||||
const functions = djareaSchema?.['x-djarea-functions'] || []
|
if (hasMizan) {
|
||||||
const hasDjarea = functions.length > 0
|
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||||
|
|
||||||
if (hasDjarea) {
|
|
||||||
const contextHooks = extractContextHooks(djareaSchema)
|
|
||||||
const contexts = functions.filter(fn => fn.isContext)
|
|
||||||
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||||
|
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
|
||||||
|
|
||||||
lines.push('// =============================================================================')
|
lines.push('// =============================================================================')
|
||||||
lines.push('// Djarea Provider & Hooks')
|
lines.push('// mizan Provider & Hooks')
|
||||||
lines.push('// =============================================================================')
|
lines.push('// =============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
// Server exports (getDjangoHydration runs in server components)
|
// Server exports
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push('export {')
|
lines.push('export {')
|
||||||
|
lines.push(' getMizanHydration,')
|
||||||
lines.push(' getDjangoHydration,')
|
lines.push(' getDjangoHydration,')
|
||||||
|
lines.push(' type MizanHydrationData,')
|
||||||
lines.push(' type DjangoHydration,')
|
lines.push(' type DjangoHydration,')
|
||||||
lines.push("} from './generated.django.server'")
|
lines.push("} from './generated.server'")
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client exports
|
// Client exports
|
||||||
lines.push('export {')
|
lines.push('export {')
|
||||||
lines.push(' // Provider')
|
lines.push(' // Provider')
|
||||||
|
lines.push(' MizanContext,')
|
||||||
|
lines.push(' type MizanContextProps,')
|
||||||
lines.push(' DjangoContext,')
|
lines.push(' DjangoContext,')
|
||||||
lines.push(' type DjangoContextProps,')
|
lines.push(' type DjangoContextProps,')
|
||||||
|
|
||||||
if (contexts.length > 0) {
|
// Global context hooks
|
||||||
|
if (globalContexts.length > 0) {
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' // Context hooks')
|
lines.push(' // Global context hooks')
|
||||||
for (const hookName of contextHooks) {
|
for (const ctx of globalContexts) {
|
||||||
lines.push(` ${hookName},`)
|
const hookPascal = pascalCase(ctx.camelName)
|
||||||
|
lines.push(` use${hookPascal},`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' // Refresh hooks')
|
lines.push(' // Refresh hooks')
|
||||||
|
lines.push(' useMizanRefresh,')
|
||||||
lines.push(' useDjangoRefresh,')
|
lines.push(' useDjangoRefresh,')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Named context providers and hooks
|
||||||
|
if (namedContextEntries.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push(' // Named context providers')
|
||||||
|
for (const [ctxName, ctxMeta] of namedContextEntries) {
|
||||||
|
const ctxPascal = toPascalCase(ctxName)
|
||||||
|
lines.push(` ${ctxPascal}Context,`)
|
||||||
|
// Hooks for this context's functions
|
||||||
|
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||||
|
for (const fn of ctxFunctions) {
|
||||||
|
const hookPascal = pascalCase(fn.camelName)
|
||||||
|
lines.push(` use${hookPascal},`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function hooks (mutations + plain)
|
||||||
if (regularFunctions.length > 0) {
|
if (regularFunctions.length > 0) {
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' // Function hooks')
|
lines.push(' // Function hooks')
|
||||||
for (const fn of regularFunctions) {
|
for (const fn of regularFunctions) {
|
||||||
const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1)
|
const pascal = pascalCase(fn.camelName)
|
||||||
lines.push(` use${pascal},`)
|
lines.push(` use${pascal},`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' // Re-exports from djarea library')
|
lines.push(' // Re-exports from mizan library')
|
||||||
lines.push(' useDjarea,')
|
lines.push(' useMizan,')
|
||||||
lines.push(' useDjareaStatus,')
|
lines.push(' useMizanStatus,')
|
||||||
lines.push(' usePush,')
|
lines.push(' usePush,')
|
||||||
lines.push(' DjangoError,')
|
lines.push(' DjangoError,')
|
||||||
lines.push(' type ConnectionStatus,')
|
lines.push(' type ConnectionStatus,')
|
||||||
lines.push(' type PushMessage,')
|
lines.push(' type PushMessage,')
|
||||||
lines.push(' type PushListener,')
|
lines.push(' type PushListener,')
|
||||||
lines.push("} from './generated.django'")
|
lines.push("} from './generated.provider'")
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Channel Hooks (from generated.channels.hooks.tsx)
|
// Channel Hooks
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
const channels = channelsSchema?.['x-djarea-channels'] || []
|
const channels = channelsSchema?.['x-mizan-channels'] || []
|
||||||
|
|
||||||
if (channels.length > 0) {
|
if (channels.length > 0) {
|
||||||
lines.push('// =============================================================================')
|
lines.push('// =============================================================================')
|
||||||
@@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, djareaSchema }) {
|
|||||||
lines.push("} from './generated.channels.hooks'")
|
lines.push("} from './generated.channels.hooks'")
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
// Channel types
|
|
||||||
lines.push('// =============================================================================')
|
lines.push('// =============================================================================')
|
||||||
lines.push('// Channel Types')
|
lines.push('// Channel Types')
|
||||||
lines.push('// =============================================================================')
|
lines.push('// =============================================================================')
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Djarea Code Generator
|
* mizan Code Generator
|
||||||
*
|
*
|
||||||
* Generates TypeScript types and React provider from Djarea OpenAPI schema.
|
* Generates TypeScript types and React provider from mizan OpenAPI schema.
|
||||||
* Uses openapi-typescript for robust type generation.
|
* Uses openapi-typescript for robust type generation.
|
||||||
*
|
*
|
||||||
* Output structure:
|
* Output structure:
|
||||||
* - generated.djarea.ts - Types only (from OpenAPI)
|
* - generated.mizan.ts - Types only (from OpenAPI)
|
||||||
* - generated.provider.tsx - Typed provider wrapping DjareaProvider + hooks
|
* - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks
|
||||||
* - generated.forms.ts - Typed form hooks with Zod schemas
|
* - generated.forms.ts - Typed form hooks with Zod schemas
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ function buildSchemaExports(schemaNames) {
|
|||||||
/**
|
/**
|
||||||
* Generate the types file using openapi-typescript.
|
* Generate the types file using openapi-typescript.
|
||||||
*/
|
*/
|
||||||
export async function generateDjareaTypes(schema) {
|
export async function generateMizanTypes(schema) {
|
||||||
// Generate types using openapi-typescript
|
// Generate types using openapi-typescript
|
||||||
const ast = await openapiTS(schema)
|
const ast = await openapiTS(schema)
|
||||||
const schemaNames = getSchemaNamesFromAst(ast)
|
const schemaNames = getSchemaNamesFromAst(ast)
|
||||||
const typesCode = astToString(ast)
|
const typesCode = astToString(ast)
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
'// ============================================================================',
|
'// ============================================================================',
|
||||||
@@ -104,11 +104,11 @@ export async function generateDjareaTypes(schema) {
|
|||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Extract function metadata from x-djarea-functions extension
|
// Extract function metadata from x-mizan-functions extension
|
||||||
const functions = schema['x-djarea-functions'] || []
|
const functions = schema['x-mizan-functions'] || []
|
||||||
|
|
||||||
if (functions.length > 0) {
|
if (functions.length > 0) {
|
||||||
lines.push('export const DJANGO_FUNCTIONS = {')
|
lines.push('export const MIZAN_FUNCTIONS = {')
|
||||||
for (const fn of functions) {
|
for (const fn of functions) {
|
||||||
lines.push(` ${fn.camelName}: {`)
|
lines.push(` ${fn.camelName}: {`)
|
||||||
lines.push(` name: '${fn.name}',`)
|
lines.push(` name: '${fn.name}',`)
|
||||||
@@ -119,7 +119,7 @@ export async function generateDjareaTypes(schema) {
|
|||||||
}
|
}
|
||||||
lines.push('} as const')
|
lines.push('} as const')
|
||||||
} else {
|
} else {
|
||||||
lines.push('export const DJANGO_FUNCTIONS = {} as const')
|
lines.push('export const MIZAN_FUNCTIONS = {} as const')
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('')
|
lines.push('')
|
||||||
@@ -128,24 +128,56 @@ export async function generateDjareaTypes(schema) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the React provider that wraps DjareaProvider with typed hooks.
|
* Extract unique context names from an affects array.
|
||||||
|
* Both context-level and function-level affects resolve to context names.
|
||||||
|
*/
|
||||||
|
function getAffectedContexts(affects) {
|
||||||
|
const contexts = new Set()
|
||||||
|
for (const target of affects) {
|
||||||
|
if (target.type === 'context') {
|
||||||
|
contexts.add(target.name)
|
||||||
|
} else if (target.type === 'function' && target.context) {
|
||||||
|
contexts.add(target.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...contexts]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map JSON schema type string to TypeScript type.
|
||||||
|
*/
|
||||||
|
function jsonTypeToTS(type) {
|
||||||
|
if (type === 'integer' || type === 'number') return 'number'
|
||||||
|
if (type === 'boolean') return 'boolean'
|
||||||
|
return 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the React provider that wraps MizanProvider with typed hooks.
|
||||||
*
|
*
|
||||||
* The generated provider:
|
* The generated provider:
|
||||||
* - Wraps DjareaProvider (from djarea library)
|
* - MizanContext: Root provider with global context bundled fetch
|
||||||
* - Passes context names for auto-fetch
|
* - Named context providers: <UserContext user_id={...}>
|
||||||
* - Provides typed hooks for contexts and functions
|
* - Mutation hooks with auto-invalidation
|
||||||
|
* - Plain function hooks
|
||||||
*/
|
*/
|
||||||
export function generateDjareaProvider(schema, options = {}) {
|
export function generateMizanProvider(schema, options = {}) {
|
||||||
const { hasChannels = false } = options
|
const { hasChannels = false } = options
|
||||||
const functions = schema['x-djarea-functions'] || []
|
const functions = schema['x-mizan-functions'] || []
|
||||||
|
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||||
|
|
||||||
if (functions.length === 0) {
|
if (functions.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate contexts from regular functions
|
// Partition functions
|
||||||
const contexts = functions.filter(fn => fn.isContext)
|
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||||
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||||
|
const mutationFunctions = regularFunctions.filter(fn => fn.affects)
|
||||||
|
const plainFunctions = regularFunctions.filter(fn => !fn.affects)
|
||||||
|
|
||||||
|
// Named context groups (everything except 'global')
|
||||||
|
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
|
||||||
|
|
||||||
// Collect type imports
|
// Collect type imports
|
||||||
const typeImports = []
|
const typeImports = []
|
||||||
@@ -162,36 +194,36 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"'use client'",
|
"'use client'",
|
||||||
'',
|
'',
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
'// This file provides typed wrappers around the djarea library.',
|
'// This file provides typed wrappers around the mizan library.',
|
||||||
'// - DjangoContext: Typed provider wrapping DjareaProvider',
|
'// - MizanContext: Root provider with global context',
|
||||||
'// - Typed hooks: useAuthStatus(), useUser(), etc.',
|
'// - Named context providers: <UserContext user_id={...}>',
|
||||||
|
'// - Typed hooks with auto-invalidation',
|
||||||
'',
|
'',
|
||||||
"import { type ReactNode, useCallback } from 'react'",
|
"import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'",
|
||||||
"import {",
|
"import {",
|
||||||
" DjareaProvider,",
|
" MizanProvider,",
|
||||||
" useDjarea,",
|
" useMizan,",
|
||||||
" useDjareaContext,",
|
" useMizanContext,",
|
||||||
" useDjareaCall,",
|
" useMizanCall,",
|
||||||
" type DjareaHydration,",
|
" type MizanHydration,",
|
||||||
" type Transport,",
|
" type Transport,",
|
||||||
"} from 'djarea'",
|
"} from 'mizan'",
|
||||||
...(hasChannels ? [
|
...(hasChannels ? [
|
||||||
"import { ChannelProvider, ChannelConnection } from 'djarea/channels'",
|
"import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
|
||||||
"import { useRef } from 'react'",
|
|
||||||
] : []),
|
] : []),
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
if (uniqueTypeImports.length > 0) {
|
if (uniqueTypeImports.length > 0) {
|
||||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
|
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Hydration types
|
// Hydration types (global contexts only)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
@@ -199,20 +231,19 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push('/** Typed hydration data for SSR */')
|
lines.push('/** Typed hydration data for SSR (global contexts only) */')
|
||||||
lines.push('export interface DjangoHydration {')
|
lines.push('export interface MizanHydrationData {')
|
||||||
for (const ctx of contexts) {
|
for (const ctx of globalContexts) {
|
||||||
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
||||||
}
|
}
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
lines.push('/** Convert typed hydration to djarea format */')
|
lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
|
||||||
lines.push('function toDjareaHydration(hydration?: DjangoHydration): DjareaHydration | undefined {')
|
|
||||||
lines.push(' if (!hydration) return undefined')
|
lines.push(' if (!hydration) return undefined')
|
||||||
lines.push(' const result: DjareaHydration = {}')
|
lines.push(' const result: MizanHydration = {}')
|
||||||
for (const ctx of contexts) {
|
for (const ctx of globalContexts) {
|
||||||
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
|
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
|
||||||
}
|
}
|
||||||
lines.push(' return result')
|
lines.push(' return result')
|
||||||
@@ -221,50 +252,81 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Provider
|
// Global Context Loader (inner component, fetches GET /ctx/global/)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
if (globalContexts.length > 0) {
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('// Global Context Loader')
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {')
|
||||||
|
lines.push(' const mizan = useMizan()')
|
||||||
|
lines.push(' const loaded = useRef(false)')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(' useEffect(() => {')
|
||||||
|
lines.push(' if (loaded.current) return')
|
||||||
|
lines.push(' loaded.current = true')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(' ;(async () => {')
|
||||||
|
lines.push(' await mizan.whenReady')
|
||||||
|
lines.push(' try {')
|
||||||
|
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
|
||||||
|
lines.push(' const result = await response.json()')
|
||||||
|
lines.push(' if (!result.error) {')
|
||||||
|
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
|
||||||
|
lines.push(' mizan.setContextData(name, data)')
|
||||||
|
lines.push(' }')
|
||||||
|
lines.push(' }')
|
||||||
|
lines.push(' } catch (e) {')
|
||||||
|
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
|
||||||
|
lines.push(' }')
|
||||||
|
lines.push(' })()')
|
||||||
|
lines.push(' }, [mizan])')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(' return <>{children}</>')
|
||||||
|
lines.push('}')
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Root Provider (MizanContext)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('// Provider')
|
lines.push('// Root Provider')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
lines.push('export interface DjangoContextProps {')
|
lines.push('export interface MizanContextProps {')
|
||||||
lines.push(' children: ReactNode')
|
lines.push(' children: ReactNode')
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push(' /** SSR hydration data */')
|
lines.push(' /** SSR hydration data (global contexts only) */')
|
||||||
lines.push(' hydration?: DjangoHydration')
|
lines.push(' hydration?: MizanHydrationData')
|
||||||
}
|
}
|
||||||
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
|
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
|
||||||
lines.push(' wsUrl?: string')
|
lines.push(' wsUrl?: string')
|
||||||
lines.push(' /** Base URL for HTTP fallback (default: /api/djarea) */')
|
lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */')
|
||||||
lines.push(' baseUrl?: string')
|
lines.push(' baseUrl?: string')
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
// Context names array for DjareaProvider
|
|
||||||
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
|
|
||||||
|
|
||||||
lines.push('/**')
|
lines.push('/**')
|
||||||
lines.push(' * Typed Django context provider.')
|
lines.push(' * Root mizan provider. Mount at your app root.')
|
||||||
lines.push(' *')
|
|
||||||
lines.push(' * Wraps DjareaProvider with:')
|
|
||||||
lines.push(' * - Typed hydration')
|
|
||||||
lines.push(' * - Auto-fetch for registered contexts')
|
|
||||||
lines.push(' *')
|
lines.push(' *')
|
||||||
lines.push(' * Usage:')
|
lines.push(' * Usage:')
|
||||||
lines.push(' * <DjangoContext hydration={hydration}>')
|
lines.push(' * <MizanContext hydration={hydration}>')
|
||||||
lines.push(' * <App />')
|
lines.push(' * <App />')
|
||||||
lines.push(' * </DjangoContext>')
|
lines.push(' * </MizanContext>')
|
||||||
lines.push(' */')
|
lines.push(' */')
|
||||||
lines.push('export function DjangoContext({')
|
lines.push('export function MizanContext({')
|
||||||
lines.push(' children,')
|
lines.push(' children,')
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push(' hydration,')
|
lines.push(' hydration,')
|
||||||
}
|
}
|
||||||
lines.push(' wsUrl,')
|
lines.push(' wsUrl,')
|
||||||
lines.push(' baseUrl,')
|
lines.push(' baseUrl,')
|
||||||
lines.push('}: DjangoContextProps) {')
|
lines.push('}: MizanContextProps) {')
|
||||||
|
|
||||||
if (hasChannels) {
|
if (hasChannels) {
|
||||||
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
|
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
|
||||||
@@ -274,11 +336,11 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the JSX tree
|
||||||
lines.push(' return (')
|
lines.push(' return (')
|
||||||
lines.push(' <DjareaProvider')
|
lines.push(' <MizanProvider')
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push(' hydration={toDjareaHydration(hydration)}')
|
lines.push(' hydration={toMizanHydration(hydration)}')
|
||||||
lines.push(` contexts={[${contextNames}]}`)
|
|
||||||
}
|
}
|
||||||
lines.push(' wsUrl={wsUrl}')
|
lines.push(' wsUrl={wsUrl}')
|
||||||
lines.push(' baseUrl={baseUrl}')
|
lines.push(' baseUrl={baseUrl}')
|
||||||
@@ -287,93 +349,221 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
}
|
}
|
||||||
lines.push(' >')
|
lines.push(' >')
|
||||||
|
|
||||||
if (hasChannels) {
|
// Inner content: GlobalContextLoader wraps children if needed
|
||||||
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>')
|
let innerContent = '{children}'
|
||||||
lines.push(' {children}')
|
if (globalContexts.length > 0) {
|
||||||
lines.push(' </ChannelProvider>')
|
innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
|
||||||
} else {
|
|
||||||
lines.push(' {children}')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(' </DjareaProvider>')
|
if (hasChannels) {
|
||||||
|
lines.push(` <ChannelProvider connection={connectionRef.current} autoConnect={true}>`)
|
||||||
|
lines.push(` ${innerContent}`)
|
||||||
|
lines.push(` </ChannelProvider>`)
|
||||||
|
} else {
|
||||||
|
lines.push(` ${innerContent}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(' </MizanProvider>')
|
||||||
lines.push(' )')
|
lines.push(' )')
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
|
// Legacy alias
|
||||||
|
lines.push('/** @deprecated Use MizanContext instead */')
|
||||||
|
lines.push('export const DjangoContext = MizanContext')
|
||||||
|
lines.push('/** @deprecated Use MizanContextProps instead */')
|
||||||
|
lines.push('export type DjangoContextProps = MizanContextProps')
|
||||||
|
if (globalContexts.length > 0) {
|
||||||
|
lines.push('/** @deprecated Use MizanHydrationData instead */')
|
||||||
|
lines.push('export type DjangoHydration = MizanHydrationData')
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Context Hooks
|
// Global Context Hooks
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
if (contexts.length > 0) {
|
if (globalContexts.length > 0) {
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('// Context Hooks (typed wrappers)')
|
lines.push('// Global Context Hooks')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
for (const ctx of contexts) {
|
for (const ctx of globalContexts) {
|
||||||
const pascal = pascalCase(ctx.camelName)
|
const pascal = pascalCase(ctx.camelName)
|
||||||
lines.push(`/**`)
|
lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
|
||||||
lines.push(` * Get ${ctx.name} context data.`)
|
|
||||||
lines.push(` * @throws if context not loaded yet`)
|
|
||||||
lines.push(` */`)
|
|
||||||
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
|
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
|
||||||
lines.push(` const data = useDjareaContext<${ctx.outputType}>('${ctx.name}')`)
|
lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
|
||||||
lines.push(` if (data === undefined) {`)
|
lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
|
||||||
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
|
|
||||||
lines.push(` }`)
|
|
||||||
lines.push(` return data`)
|
lines.push(` return data`)
|
||||||
lines.push(`}`)
|
lines.push(`}`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh hooks
|
lines.push('/** Refresh functions for global contexts. */')
|
||||||
lines.push('/**')
|
lines.push('export function useMizanRefresh() {')
|
||||||
lines.push(' * Get context refresh functions without subscribing to data changes.')
|
lines.push(' const { invalidateContext } = useMizan()')
|
||||||
lines.push(' * Use this in components that only need to trigger refreshes.')
|
|
||||||
lines.push(' */')
|
|
||||||
lines.push('export function useDjangoRefresh() {')
|
|
||||||
lines.push(' const { refreshContext, refreshAllContexts } = useDjarea()')
|
|
||||||
lines.push(' return {')
|
lines.push(' return {')
|
||||||
for (const ctx of contexts) {
|
for (const ctx of globalContexts) {
|
||||||
const pascal = pascalCase(ctx.camelName)
|
const pascal = pascalCase(ctx.camelName)
|
||||||
lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`)
|
lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`)
|
||||||
}
|
}
|
||||||
lines.push(' refreshAll: refreshAllContexts,')
|
|
||||||
lines.push(' }')
|
lines.push(' }')
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
|
// Legacy alias
|
||||||
|
lines.push('/** @deprecated Use useMizanRefresh instead */')
|
||||||
|
lines.push('export const useDjangoRefresh = useMizanRefresh')
|
||||||
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Function Hooks
|
// Named Context Providers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
if (regularFunctions.length > 0) {
|
if (namedContextEntries.length > 0) {
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('// Function Hooks (typed wrappers)')
|
lines.push('// Named Context Providers')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
for (const fn of regularFunctions) {
|
for (const [ctxName, ctxMeta] of namedContextEntries) {
|
||||||
|
const ctxPascal = toPascalCase(ctxName)
|
||||||
|
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||||
|
const params = ctxMeta.params || {}
|
||||||
|
const paramEntries = Object.entries(params)
|
||||||
|
|
||||||
|
// Internal React context type
|
||||||
|
lines.push(`const ${ctxPascal}ContextInternal = createContext<{`)
|
||||||
|
for (const fn of ctxFunctions) {
|
||||||
|
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||||
|
}
|
||||||
|
lines.push(`} | null>(null)`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
// Props interface
|
||||||
|
lines.push(`export interface ${ctxPascal}ContextProps {`)
|
||||||
|
lines.push(` children: ReactNode`)
|
||||||
|
for (const [pName, pMeta] of paramEntries) {
|
||||||
|
const tsType = jsonTypeToTS(pMeta.type)
|
||||||
|
const optional = pMeta.required ? '' : '?'
|
||||||
|
lines.push(` ${pName}${optional}: ${tsType}`)
|
||||||
|
}
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
|
||||||
|
lines.push(` const mizan = useMizan()`)
|
||||||
|
lines.push(` const [data, setData] = useState<{`)
|
||||||
|
for (const fn of ctxFunctions) {
|
||||||
|
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||||
|
}
|
||||||
|
lines.push(` } | null>(null)`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(` const refetch = useCallback(async () => {`)
|
||||||
|
lines.push(` await mizan.whenReady`)
|
||||||
|
lines.push(` const qs = new URLSearchParams()`)
|
||||||
|
for (const [pName] of paramEntries) {
|
||||||
|
lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`)
|
||||||
|
}
|
||||||
|
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
|
||||||
|
lines.push(` const result = await resp.json()`)
|
||||||
|
lines.push(` if (!result.error) setData(result.data)`)
|
||||||
|
|
||||||
|
// Dependency array: mizan + each param
|
||||||
|
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
|
||||||
|
lines.push(` }, [${deps.join(', ')}])`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(` useEffect(() => { refetch() }, [refetch])`)
|
||||||
|
lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}</${ctxPascal}ContextInternal>`)
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
// Individual data hooks
|
||||||
|
for (const fn of ctxFunctions) {
|
||||||
|
const hookPascal = pascalCase(fn.camelName)
|
||||||
|
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
|
||||||
|
lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`)
|
||||||
|
lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`)
|
||||||
|
lines.push(` return ctx.${fn.name}`)
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mutation Hooks (with auto-invalidation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
if (mutationFunctions.length > 0) {
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('// Mutation Hooks (auto-invalidate on success)')
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const fn of mutationFunctions) {
|
||||||
|
const pascal = pascalCase(fn.camelName)
|
||||||
|
const transport = fn.transport || 'http'
|
||||||
|
const affectedContexts = getAffectedContexts(fn.affects)
|
||||||
|
|
||||||
|
lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`)
|
||||||
|
lines.push(`export function use${pascal}() {`)
|
||||||
|
lines.push(` const mizan = useMizan()`)
|
||||||
|
|
||||||
|
if (fn.hasInput) {
|
||||||
|
lines.push(` return useCallback(async (input: ${fn.inputType}) => {`)
|
||||||
|
lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`)
|
||||||
|
} else {
|
||||||
|
lines.push(` return useCallback(async () => {`)
|
||||||
|
lines.push(` const result = await mizan.call<void, ${fn.outputType}>('${fn.name}', undefined, '${transport}')`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidation
|
||||||
|
if (affectedContexts.length === 1) {
|
||||||
|
lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`)
|
||||||
|
} else if (affectedContexts.length > 1) {
|
||||||
|
lines.push(` await Promise.all([`)
|
||||||
|
for (const ctx of affectedContexts) {
|
||||||
|
lines.push(` mizan.invalidateContext('${ctx}'),`)
|
||||||
|
}
|
||||||
|
lines.push(` ])`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(` return result`)
|
||||||
|
lines.push(` }, [mizan])`)
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plain Function Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
if (plainFunctions.length > 0) {
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('// Function Hooks')
|
||||||
|
lines.push('// ============================================================================')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const fn of plainFunctions) {
|
||||||
const pascal = pascalCase(fn.camelName)
|
const pascal = pascalCase(fn.camelName)
|
||||||
// Transport is known at generation time - pass it directly
|
|
||||||
const transport = fn.transport || 'http'
|
const transport = fn.transport || 'http'
|
||||||
|
|
||||||
if (fn.hasInput) {
|
if (fn.hasInput) {
|
||||||
lines.push(`/**`)
|
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
|
||||||
lines.push(` * Call ${fn.name} server function.`)
|
|
||||||
lines.push(` * Transport: ${transport}`)
|
|
||||||
lines.push(` */`)
|
|
||||||
lines.push(`export function use${pascal}() {`)
|
lines.push(`export function use${pascal}() {`)
|
||||||
lines.push(` return useDjareaCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||||
lines.push(`}`)
|
lines.push(`}`)
|
||||||
} else {
|
} else {
|
||||||
lines.push(`/**`)
|
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
|
||||||
lines.push(` * Call ${fn.name} server function.`)
|
|
||||||
lines.push(` * Transport: ${transport}`)
|
|
||||||
lines.push(` */`)
|
|
||||||
lines.push(`export function use${pascal}() {`)
|
lines.push(`export function use${pascal}() {`)
|
||||||
lines.push(` return useDjareaCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
|
||||||
lines.push(`}`)
|
lines.push(`}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
@@ -385,11 +575,11 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('// Re-exports from djarea library')
|
lines.push('// Re-exports from mizan library')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push("export { useDjarea, useDjareaStatus, usePush, DjangoError } from 'djarea'")
|
lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'")
|
||||||
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'djarea'")
|
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'")
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
@@ -399,20 +589,20 @@ export function generateDjareaProvider(schema, options = {}) {
|
|||||||
* Generate server-side hydration helper (runs in Next.js server components).
|
* Generate server-side hydration helper (runs in Next.js server components).
|
||||||
* This is separate from the client file because it needs to run on the server.
|
* This is separate from the client file because it needs to run on the server.
|
||||||
*/
|
*/
|
||||||
export function generateDjareaServer(schema) {
|
export function generateMizanServer(schema) {
|
||||||
const functions = schema['x-djarea-functions'] || []
|
const functions = schema['x-mizan-functions'] || []
|
||||||
const contexts = functions.filter(fn => fn.isContext)
|
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||||
|
|
||||||
if (contexts.length === 0) {
|
if (globalContexts.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect type imports for contexts
|
// Collect type imports for global contexts
|
||||||
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean)
|
const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
|
||||||
const uniqueTypeImports = [...new Set(typeImports)].sort()
|
const uniqueTypeImports = [...new Set(typeImports)].sort()
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'//',
|
'//',
|
||||||
'// Server-side functions for SSR hydration.',
|
'// Server-side functions for SSR hydration.',
|
||||||
@@ -421,7 +611,7 @@ export function generateDjareaServer(schema) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if (uniqueTypeImports.length > 0) {
|
if (uniqueTypeImports.length > 0) {
|
||||||
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
|
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,67 +620,66 @@ export function generateDjareaServer(schema) {
|
|||||||
lines.push('// Hydration Types')
|
lines.push('// Hydration Types')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('/** Typed hydration data for SSR */')
|
lines.push('/** Typed hydration data for SSR (global contexts only) */')
|
||||||
lines.push('export interface DjangoHydration {')
|
lines.push('export interface MizanHydrationData {')
|
||||||
for (const ctx of contexts) {
|
for (const ctx of globalContexts) {
|
||||||
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
|
||||||
}
|
}
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
lines.push('/** @deprecated Use MizanHydrationData instead */')
|
||||||
|
lines.push('export type DjangoHydration = MizanHydrationData')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
// SSR Hydration Helper
|
// SSR Hydration Helper — single bundled GET
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('// SSR Hydration Helper')
|
lines.push('// SSR Hydration Helper')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('/**')
|
lines.push('/**')
|
||||||
lines.push(' * Fetch hydration data for SSR.')
|
lines.push(' * Fetch hydration data for SSR via bundled context endpoint.')
|
||||||
lines.push(' *')
|
lines.push(' *')
|
||||||
lines.push(' * Call this in your server component:')
|
lines.push(' * Call this in your server component:')
|
||||||
lines.push(' * const hydration = await getDjangoHydration(client)')
|
lines.push(' * const hydration = await getMizanHydration(client)')
|
||||||
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>')
|
lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
|
||||||
lines.push(' */')
|
lines.push(' */')
|
||||||
lines.push('export async function getDjangoHydration(')
|
lines.push('export async function getMizanHydration(')
|
||||||
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
|
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
|
||||||
lines.push('): Promise<DjangoHydration> {')
|
lines.push('): Promise<MizanHydrationData> {')
|
||||||
lines.push(' const hydration: DjangoHydration = {}')
|
lines.push(' const hydration: MizanHydrationData = {}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' const results = await Promise.allSettled([')
|
lines.push(' try {')
|
||||||
for (const ctx of contexts) {
|
lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
|
||||||
lines.push(` client.request('POST', '/api/djarea/call/', { fn: '${ctx.name}', args: {} }),`)
|
lines.push(' const result = await response.json()')
|
||||||
|
lines.push(' if (!result.error) {')
|
||||||
|
for (const ctx of globalContexts) {
|
||||||
|
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
|
||||||
}
|
}
|
||||||
lines.push(' ])')
|
lines.push(' } else {')
|
||||||
lines.push('')
|
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
|
||||||
|
lines.push(' }')
|
||||||
contexts.forEach((ctx, i) => {
|
lines.push(' } catch (e) {')
|
||||||
lines.push(` if (results[${i}].status === 'fulfilled') {`)
|
lines.push(" console.error('[getMizanHydration] Request failed:', e)")
|
||||||
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`)
|
|
||||||
lines.push(` if (data.error) {`)
|
|
||||||
lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`)
|
|
||||||
lines.push(` } else {`)
|
|
||||||
lines.push(` hydration.${ctx.camelName} = data.data`)
|
|
||||||
lines.push(` }`)
|
|
||||||
lines.push(` } else {`)
|
|
||||||
lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`)
|
|
||||||
lines.push(' }')
|
lines.push(' }')
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(' return hydration')
|
lines.push(' return hydration')
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
lines.push('/** @deprecated Use getMizanHydration instead */')
|
||||||
|
lines.push('export const getDjangoHydration = getMizanHydration')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate all djarea files.
|
* Generate all mizan files.
|
||||||
*/
|
*/
|
||||||
export async function generateDjareaFiles(schema, options = {}) {
|
export async function generateMizanFiles(schema, options = {}) {
|
||||||
const types = await generateDjareaTypes(schema)
|
const types = await generateMizanTypes(schema)
|
||||||
const provider = generateDjareaProvider(schema, options)
|
const provider = generateMizanProvider(schema, options)
|
||||||
const server = generateDjareaServer(schema)
|
const server = generateMizanServer(schema)
|
||||||
const forms = generateDjareaForms(schema)
|
const forms = generateMizanForms(schema)
|
||||||
|
|
||||||
return { types, provider, server, forms }
|
return { types, provider, server, forms }
|
||||||
}
|
}
|
||||||
@@ -498,8 +687,8 @@ export async function generateDjareaFiles(schema, options = {}) {
|
|||||||
/**
|
/**
|
||||||
* Generate typed form hooks with Zod schemas.
|
* Generate typed form hooks with Zod schemas.
|
||||||
*/
|
*/
|
||||||
export function generateDjareaForms(schema) {
|
export function generateMizanForms(schema) {
|
||||||
const functions = schema['x-djarea-functions'] || []
|
const functions = schema['x-mizan-functions'] || []
|
||||||
|
|
||||||
// Group form functions by form name
|
// Group form functions by form name
|
||||||
const formFunctions = functions.filter(fn => fn.isForm)
|
const formFunctions = functions.filter(fn => fn.isForm)
|
||||||
@@ -535,7 +724,7 @@ export function generateDjareaForms(schema) {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"'use client'",
|
"'use client'",
|
||||||
'',
|
'',
|
||||||
'// AUTO-GENERATED by djarea - do not edit manually',
|
'// AUTO-GENERATED by mizan - do not edit manually',
|
||||||
'// Regenerate with: npm run schemas',
|
'// Regenerate with: npm run schemas',
|
||||||
'',
|
'',
|
||||||
'// Typed form hooks with Zod validation.',
|
'// Typed form hooks with Zod validation.',
|
||||||
@@ -549,7 +738,7 @@ export function generateDjareaForms(schema) {
|
|||||||
" type DjangoFormState,",
|
" type DjangoFormState,",
|
||||||
" type DjangoFormsetState,",
|
" type DjangoFormsetState,",
|
||||||
" type FormOptions,",
|
" type FormOptions,",
|
||||||
"} from 'djarea'",
|
"} from 'mizan'",
|
||||||
'',
|
'',
|
||||||
'// ============================================================================',
|
'// ============================================================================',
|
||||||
'// Zod Schemas',
|
'// Zod Schemas',
|
||||||
@@ -658,7 +847,7 @@ export function generateDjareaForms(schema) {
|
|||||||
lines.push('// Form Registry')
|
lines.push('// Form Registry')
|
||||||
lines.push('// ============================================================================')
|
lines.push('// ============================================================================')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('export const DJANGO_FORMS = {')
|
lines.push('export const MIZAN_FORMS = {')
|
||||||
for (const [formName, group] of formGroups) {
|
for (const [formName, group] of formGroups) {
|
||||||
if (!group.schema) continue
|
if (!group.schema) continue
|
||||||
const pascalName = toPascalCase(formName)
|
const pascalName = toPascalCase(formName)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "djarea"
|
name = "mizan"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
description = "Django + React server functions framework"
|
description = "Django + React server functions framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -36,11 +36,11 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/djarea"]
|
packages = ["src/mizan"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
pythonpath = ["src", "."]
|
pythonpath = ["src", "."]
|
||||||
testpaths = ["src/djarea/tests"]
|
testpaths = ["src/mizan/tests"]
|
||||||
python_classes = ["*Tests", "*Test", "Test*"]
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea - Django + React unified framework
|
mizan - Django + React unified framework
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
Server functions are the core primitive. Everything else builds on them.
|
||||||
|
|
||||||
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
|
|||||||
|
|
||||||
### 1. urls.py - HTTP endpoint
|
### 1. urls.py - HTTP endpoint
|
||||||
```python
|
```python
|
||||||
from djarea import urls as djarea_urls
|
from mizan import urls as mizan_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('api/djarea/', include(djarea_urls)),
|
path('api/mizan/', include(mizan_urls)),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. asgi.py - WebSocket support (optional)
|
### 2. asgi.py - WebSocket support (optional)
|
||||||
```python
|
```python
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
|
|||||||
### 3. Define server functions
|
### 3. Define server functions
|
||||||
```python
|
```python
|
||||||
# apps/myapp/clients.py
|
# apps/myapp/clients.py
|
||||||
from djarea import client
|
from mizan import client
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class EchoOutput(BaseModel):
|
class EchoOutput(BaseModel):
|
||||||
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
|
|||||||
```python
|
```python
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from djarea.setup import djarea_clients
|
from mizan.setup import mizan_clients
|
||||||
djarea_clients('apps')
|
mizan_clients('apps')
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Frontend - generate types and use
|
### 5. Frontend - generate types and use
|
||||||
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
|
|||||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||||
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP |
|
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -88,12 +88,13 @@ from . import forms
|
|||||||
from . import setup
|
from . import setup
|
||||||
from .channels import ReactChannel
|
from .channels import ReactChannel
|
||||||
from .channels import register as register_channel
|
from .channels import register as register_channel
|
||||||
from .client import ComposedContext, ServerFunction, client, compose
|
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||||
|
|
||||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
from .setup import (
|
from .setup import (
|
||||||
djarea_clients,
|
mizan_clients,
|
||||||
djarea_module,
|
mizan_module,
|
||||||
get_channel,
|
get_channel,
|
||||||
get_function,
|
get_function,
|
||||||
register,
|
register,
|
||||||
@@ -104,9 +105,9 @@ from .setup import (
|
|||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
"""Lazy loading for modules that can't be imported at app load time."""
|
"""Lazy loading for modules that can't be imported at app load time."""
|
||||||
if name == "urls":
|
if name == "urls":
|
||||||
from .urls import urlpatterns as djarea_patterns
|
from .urls import urlpatterns as mizan_patterns
|
||||||
|
|
||||||
return djarea_patterns
|
return mizan_patterns
|
||||||
if name == "Shape":
|
if name == "Shape":
|
||||||
from .shapes import Shape
|
from .shapes import Shape
|
||||||
|
|
||||||
@@ -116,11 +117,11 @@ def __getattr__(name):
|
|||||||
|
|
||||||
def wrap_asgi(http_application):
|
def wrap_asgi(http_application):
|
||||||
"""
|
"""
|
||||||
Wrap an ASGI application with Djarea WebSocket support.
|
Wrap an ASGI application with mizan WebSocket support.
|
||||||
|
|
||||||
Usage in asgi.py:
|
Usage in asgi.py:
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
from djarea import wrap_asgi
|
from mizan import wrap_asgi
|
||||||
|
|
||||||
application = wrap_asgi(get_asgi_application())
|
application = wrap_asgi(get_asgi_application())
|
||||||
|
|
||||||
@@ -156,14 +157,16 @@ def wrap_asgi(http_application):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Decorators
|
# Decorators & Contexts
|
||||||
"client",
|
"client",
|
||||||
"compose",
|
"compose",
|
||||||
|
"ReactContext",
|
||||||
|
"GlobalContext",
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
# Setup
|
# Setup
|
||||||
"djarea_clients",
|
"mizan_clients",
|
||||||
"djarea_module",
|
"mizan_module",
|
||||||
"register",
|
"register",
|
||||||
"register_as",
|
"register_as",
|
||||||
"get_function",
|
"get_function",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.channels - Real-time WebSocket communication.
|
mizan.channels - Real-time WebSocket communication.
|
||||||
|
|
||||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||||
Hooks are auto-generated with full TypeScript types.
|
Hooks are auto-generated with full TypeScript types.
|
||||||
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
|
|||||||
```python
|
```python
|
||||||
# channels.py
|
# channels.py
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
class ChatChannel(channels.ReactChannel):
|
class ChatChannel(channels.ReactChannel):
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# asgi.py
|
# asgi.py
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Base Classes
|
# Base Classes
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ReactChannel:
|
class ReactChannel:
|
||||||
"""
|
"""
|
||||||
Base class for WebSocket channels.
|
Base class for WebSocket channels.
|
||||||
@@ -140,9 +141,7 @@ class ReactChannel:
|
|||||||
|
|
||||||
Messages returned from receive() are broadcast to this group.
|
Messages returned from receive() are broadcast to this group.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||||
f"{self.__class__.__name__} must implement group()"
|
|
||||||
)
|
|
||||||
|
|
||||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||||
"""
|
"""
|
||||||
@@ -191,9 +190,9 @@ class ReactChannel:
|
|||||||
"type": "channel.message",
|
"type": "channel.message",
|
||||||
"channel": self._registered_name,
|
"channel": self._registered_name,
|
||||||
"params": self._params_dict,
|
"params": self._params_dict,
|
||||||
"data": message.model_dump(mode='json'),
|
"data": message.model_dump(mode="json"),
|
||||||
"message_type": message.__class__.__name__,
|
"message_type": message.__class__.__name__,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -215,7 +214,9 @@ class ReactChannel:
|
|||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
logger.warning(f"No channel layer configured, cannot push to {cls.__name__}")
|
logger.warning(
|
||||||
|
f"No channel layer configured, cannot push to {cls.__name__}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build params model if defined
|
# Build params model if defined
|
||||||
@@ -234,9 +235,9 @@ class ReactChannel:
|
|||||||
"type": "channel.message",
|
"type": "channel.message",
|
||||||
"channel": cls._registered_name,
|
"channel": cls._registered_name,
|
||||||
"params": params,
|
"params": params,
|
||||||
"data": message.model_dump(mode='json'),
|
"data": message.model_dump(mode="json"),
|
||||||
"message_type": message.__class__.__name__,
|
"message_type": message.__class__.__name__,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
|
|||||||
channel_class._registered_name = name
|
channel_class._registered_name = name
|
||||||
|
|
||||||
# Validate the channel class
|
# Validate the channel class
|
||||||
if not hasattr(channel_class, 'authorize'):
|
if not hasattr(channel_class, "authorize"):
|
||||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||||
if not hasattr(channel_class, 'group'):
|
if not hasattr(channel_class, "group"):
|
||||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||||
|
|
||||||
_registry[name] = channel_class
|
_registry[name] = channel_class
|
||||||
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
|||||||
# WebSocket Consumer
|
# WebSocket Consumer
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_websocket_application():
|
def get_websocket_application():
|
||||||
"""
|
"""
|
||||||
Get the WebSocket application for ASGI.
|
Get the WebSocket application for ASGI.
|
||||||
|
|
||||||
Usage in asgi.py:
|
Usage in asgi.py:
|
||||||
from djarea import channels
|
from mizan import channels
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
@@ -309,9 +311,11 @@ def get_websocket_application():
|
|||||||
from .connection import DjangoReactConsumer
|
from .connection import DjangoReactConsumer
|
||||||
|
|
||||||
return AuthMiddlewareStack(
|
return AuthMiddlewareStack(
|
||||||
URLRouter([
|
URLRouter(
|
||||||
|
[
|
||||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -319,15 +323,14 @@ def get_websocket_application():
|
|||||||
# Schema Export (for TypeScript generation)
|
# Schema Export (for TypeScript generation)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_channels_schema() -> dict:
|
def get_channels_schema() -> dict:
|
||||||
"""
|
"""
|
||||||
Get schema for all registered channels (for TypeScript generation).
|
Get schema for all registered channels (for TypeScript generation).
|
||||||
|
|
||||||
Returns a dict suitable for the frontend code generator.
|
Returns a dict suitable for the frontend code generator.
|
||||||
"""
|
"""
|
||||||
schema = {
|
schema = {"channels": {}}
|
||||||
"channels": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, channel_class in _registry.items():
|
for name, channel_class in _registry.items():
|
||||||
channel_schema = {
|
channel_schema = {
|
||||||
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Extract Params schema
|
# Extract Params schema
|
||||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
|
|
||||||
# Extract ReactMessage schema
|
# Extract ReactMessage schema
|
||||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||||
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"reactMessage"
|
||||||
|
] = channel_class.ReactMessage.model_json_schema()
|
||||||
|
|
||||||
# Extract DjangoMessage schema
|
# Extract DjangoMessage schema
|
||||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||||
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"djangoMessage"
|
||||||
|
] = channel_class.DjangoMessage.model_json_schema()
|
||||||
|
|
||||||
schema["channels"][name] = channel_schema
|
schema["channels"][name] = channel_schema
|
||||||
|
|
||||||
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
|
|
||||||
def endpoint(request, data):
|
def endpoint(request, data):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
endpoint.__annotations__ = {"data": input_cls}
|
endpoint.__annotations__ = {"data": input_cls}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def endpoint(request):
|
def endpoint(request):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||||
|
endpoint
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_channels_openapi_schema() -> dict:
|
def get_channels_openapi_schema() -> dict:
|
||||||
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
|
|
||||||
# Create temporary Ninja API for schema generation only
|
# Create temporary Ninja API for schema generation only
|
||||||
schema_api = NinjaAPI(
|
schema_api = NinjaAPI(
|
||||||
title="Djarea Channels",
|
title="mizan Channels",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="Auto-generated schema for djarea channels",
|
description="Auto-generated schema for mizan channels",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Register Params type
|
# Register Params type
|
||||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||||
params_name = f"{pascal_name}Params"
|
params_name = f"{pascal_name}Params"
|
||||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||||
channel_meta["hasParams"] = True
|
channel_meta["hasParams"] = True
|
||||||
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register ReactMessage type
|
# Register ReactMessage type
|
||||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||||
react_name = f"{pascal_name}ReactMessage"
|
react_name = f"{pascal_name}ReactMessage"
|
||||||
schema_classes[react_name] = type(react_name, (channel_class.ReactMessage,), {})
|
schema_classes[react_name] = type(
|
||||||
|
react_name, (channel_class.ReactMessage,), {}
|
||||||
|
)
|
||||||
channel_meta["hasReactMessage"] = True
|
channel_meta["hasReactMessage"] = True
|
||||||
channel_meta["reactMessageType"] = react_name
|
channel_meta["reactMessageType"] = react_name
|
||||||
|
|
||||||
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register DjangoMessage type
|
# Register DjangoMessage type
|
||||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||||
django_name = f"{pascal_name}DjangoMessage"
|
django_name = f"{pascal_name}DjangoMessage"
|
||||||
schema_classes[django_name] = type(django_name, (channel_class.DjangoMessage,), {})
|
schema_classes[django_name] = type(
|
||||||
|
django_name, (channel_class.DjangoMessage,), {}
|
||||||
|
)
|
||||||
channel_meta["hasDjangoMessage"] = True
|
channel_meta["hasDjangoMessage"] = True
|
||||||
channel_meta["djangoMessageType"] = django_name
|
channel_meta["djangoMessageType"] = django_name
|
||||||
|
|
||||||
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
|
|||||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||||
|
|
||||||
# Add channel metadata extension
|
# Add channel metadata extension
|
||||||
schema["x-djarea-channels"] = channel_metadata
|
schema["x-mizan-channels"] = channel_metadata
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket consumer for djarea.channels.
|
WebSocket consumer for mizan.channels.
|
||||||
|
|
||||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||||
|
|
||||||
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
await self._try_jwt_auth()
|
await self._try_jwt_auth()
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}")
|
logger.debug(
|
||||||
|
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _try_jwt_auth(self):
|
async def _try_jwt_auth(self):
|
||||||
"""
|
"""
|
||||||
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Validate JWT and create JWTUser (no DB query)
|
# Validate JWT and create JWTUser (no DB query)
|
||||||
try:
|
try:
|
||||||
from djarea.client.jwt import decode_token
|
from mizan.client.jwt import decode_token
|
||||||
from djarea.jwt.tokens import JWTUser
|
from mizan.jwt.tokens import JWTUser
|
||||||
|
|
||||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
elif action == "rpc":
|
elif action == "rpc":
|
||||||
await self._handle_rpc(content)
|
await self._handle_rpc(content)
|
||||||
else:
|
else:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Unknown action: {action}",
|
"error": f"Unknown action: {action}",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_subscribe(self, content: dict):
|
async def _handle_subscribe(self, content: dict):
|
||||||
"""Handle subscription request."""
|
"""Handle subscription request."""
|
||||||
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
# Get channel class
|
# Get channel class
|
||||||
channel_class = get_channel(channel_name)
|
channel_class = get_channel(channel_name)
|
||||||
if not channel_class:
|
if not channel_class:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Unknown channel: {channel_name}",
|
"error": f"Unknown channel: {channel_name}",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create subscription key
|
# Create subscription key
|
||||||
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Check if already subscribed
|
# Check if already subscribed
|
||||||
if sub_key in self._subscriptions:
|
if sub_key in self._subscriptions:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Already subscribed to {channel_name}",
|
"error": f"Already subscribed to {channel_name}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create channel instance
|
# Create channel instance
|
||||||
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
try:
|
try:
|
||||||
params_obj = channel_class.Params(**params_dict)
|
params_obj = channel_class.Params(**params_dict)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Invalid params: {e}",
|
"error": f"Invalid params: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check authorization
|
# Check authorization
|
||||||
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
authorized = instance.authorize()
|
authorized = instance.authorize()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Authorization error for {channel_name}: {e}")
|
logger.error(f"Authorization error for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "Authorization failed",
|
"error": "Authorization failed",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not authorized:
|
if not authorized:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "Not authorized",
|
"error": "Not authorized",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get group and join
|
# Get group and join
|
||||||
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
await instance._join_group(group_name)
|
await instance._join_group(group_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to join group for {channel_name}: {e}")
|
logger.error(f"Failed to join group for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Failed to subscribe: {e}",
|
"error": f"Failed to subscribe: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store subscription
|
# Store subscription
|
||||||
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||||
|
|
||||||
# Confirm subscription
|
# Confirm subscription
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"subscribed": True,
|
"subscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||||
|
|
||||||
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during unsubscribe: {e}")
|
logger.error(f"Error during unsubscribe: {e}")
|
||||||
|
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"unsubscribed": True,
|
"unsubscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"params": params_dict,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(f"Unsubscribed from {channel_name}")
|
logger.debug(f"Unsubscribed from {channel_name}")
|
||||||
|
|
||||||
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
instance = self._subscriptions.get(sub_key)
|
instance = self._subscriptions.get(sub_key)
|
||||||
if not instance:
|
if not instance:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Not subscribed to {channel_name}",
|
"error": f"Not subscribed to {channel_name}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_class = instance.__class__
|
channel_class = instance.__class__
|
||||||
|
|
||||||
# Check if channel accepts messages
|
# Check if channel accepts messages
|
||||||
if not channel_class.ReactMessage:
|
if not channel_class.ReactMessage:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Channel {channel_name} does not accept messages",
|
"error": f"Channel {channel_name} does not accept messages",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse message
|
# Parse message
|
||||||
try:
|
try:
|
||||||
msg = channel_class.ReactMessage(**data)
|
msg = channel_class.ReactMessage(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Invalid message: {e}",
|
"error": f"Invalid message: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse params
|
# Parse params
|
||||||
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling message for {channel_name}: {e}")
|
logger.error(f"Error handling message for {channel_name}: {e}")
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": f"Message handling failed: {e}",
|
"error": f"Message handling failed: {e}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_rpc(self, content: dict):
|
async def _handle_rpc(self, content: dict):
|
||||||
"""
|
"""
|
||||||
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
- Function must be explicitly registered (no arbitrary code execution)
|
- Function must be explicitly registered (no arbitrary code execution)
|
||||||
- User context from WebSocket session is passed to function
|
- User context from WebSocket session is passed to function
|
||||||
"""
|
"""
|
||||||
from djarea.client.executor import execute_function, FunctionError
|
from mizan.client.executor import execute_function, FunctionError
|
||||||
from djarea.setup.registry import get_function
|
from mizan.setup.registry import get_function
|
||||||
|
|
||||||
request_id = content.get("id")
|
request_id = content.get("id")
|
||||||
fn_name = content.get("fn")
|
fn_name = content.get("fn")
|
||||||
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Validate request structure
|
# Validate request structure
|
||||||
if not request_id:
|
if not request_id:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "RPC request missing 'id' field",
|
"error": "RPC request missing 'id' field",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "BAD_REQUEST",
|
"code": "BAD_REQUEST",
|
||||||
"message": "Missing 'fn' field",
|
"message": "Missing 'fn' field",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if function exists and has websocket=True
|
# Check if function exists and has websocket=True
|
||||||
fn_class = get_function(fn_name)
|
fn_class = get_function(fn_name)
|
||||||
if fn_class is None:
|
if fn_class is None:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "NOT_FOUND",
|
"code": "NOT_FOUND",
|
||||||
"message": f"Function '{fn_name}' not found",
|
"message": f"Function '{fn_name}' not found",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only allow functions explicitly marked with websocket=True
|
# Only allow functions explicitly marked with websocket=True
|
||||||
fn_meta = getattr(fn_class, "_meta", {})
|
fn_meta = getattr(fn_class, "_meta", {})
|
||||||
if not fn_meta.get("websocket"):
|
if not fn_meta.get("websocket"):
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "FORBIDDEN",
|
"code": "FORBIDDEN",
|
||||||
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.",
|
"message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create request adapter from WebSocket scope
|
# Create request adapter from WebSocket scope
|
||||||
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None))
|
ws_request = WebSocketRequest(
|
||||||
|
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||||
|
)
|
||||||
|
|
||||||
# Execute function (Pydantic validation happens inside execute_function)
|
# Execute function (Pydantic validation happens inside execute_function)
|
||||||
# This is sync, so we need to run it in a thread pool
|
# This is sync, so we need to run it in a thread pool
|
||||||
@@ -435,7 +473,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
# Send response
|
# Send response
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
@@ -443,13 +482,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
"message": result.message,
|
"message": result.message,
|
||||||
**({"details": result.details} if result.details else {}),
|
**({"details": result.details} if result.details else {}),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"data": result.data,
|
"data": result.data,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def channel_message(self, event: dict):
|
async def channel_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
Called when channel_layer.group_send() is used.
|
Called when channel_layer.group_send() is used.
|
||||||
Includes channel name and params so the client can route the message.
|
Includes channel name and params so the client can route the message.
|
||||||
"""
|
"""
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"channel": event.get("channel"),
|
"channel": event.get("channel"),
|
||||||
"params": event.get("params", {}),
|
"params": event.get("params", {}),
|
||||||
"type": event.get("message_type", "message"),
|
"type": event.get("message_type", "message"),
|
||||||
"data": event.get("data", {}),
|
"data": event.get("data", {}),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def push_message(self, event: dict):
|
async def push_message(self, event: dict):
|
||||||
"""
|
"""
|
||||||
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
Protocol:
|
Protocol:
|
||||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||||
"""
|
"""
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"type": "push",
|
"type": "push",
|
||||||
"topic": event.get("topic"),
|
"topic": event.get("topic"),
|
||||||
"data": event.get("data", {}),
|
"data": event.get("data", {}),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Push - Server-initiated messages to clients.
|
mizan Push - Server-initiated messages to clients.
|
||||||
|
|
||||||
Simple API for pushing data to subscribed WebSocket connections.
|
Simple API for pushing data to subscribed WebSocket connections.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# In a server function - push to all subscribers
|
# In a server function - push to all subscribers
|
||||||
from djarea.push import push
|
from mizan.push import push
|
||||||
|
|
||||||
push("room:42", {"type": "new_message", "data": {...}})
|
push("room:42", {"type": "new_message", "data": {...}})
|
||||||
|
|
||||||
# Subscribe a connection to a topic (call during context fetch)
|
# Subscribe a connection to a topic (call during context fetch)
|
||||||
from djarea.push import subscribe
|
from mizan.push import subscribe
|
||||||
|
|
||||||
subscribe(request, "room:42")
|
subscribe(request, "room:42")
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
|||||||
"""Get channel layer, returning None if channels is not installed."""
|
"""Get channel layer, returning None if channels is not installed."""
|
||||||
try:
|
try:
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
return get_channel_layer()
|
return get_channel_layer()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
return None
|
||||||
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
|||||||
def _async_to_sync(coro):
|
def _async_to_sync(coro):
|
||||||
"""Wrapper for async_to_sync that handles missing channels."""
|
"""Wrapper for async_to_sync that handles missing channels."""
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
return async_to_sync(coro)
|
return async_to_sync(coro)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
channel_layer = _get_channel_layer()
|
channel_layer = _get_channel_layer()
|
||||||
if not channel_layer:
|
if not channel_layer:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"No channel layer configured, cannot push to topic '%s'", topic
|
"No channel layer configured, cannot push to topic '%s'", topic
|
||||||
)
|
)
|
||||||
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
|||||||
"type": "push.message", # Maps to push_message handler in consumer
|
"type": "push.message", # Maps to push_message handler in consumer
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
|
|||||||
"type": "push.message",
|
"type": "push.message",
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.client - Server function implementation.
|
mizan.client - Server function implementation.
|
||||||
|
|
||||||
This subpackage contains everything needed to make server functions work:
|
This subpackage contains everything needed to make server functions work:
|
||||||
- The @client decorator
|
- The @client decorator
|
||||||
@@ -8,12 +8,15 @@ This subpackage contains everything needed to make server functions work:
|
|||||||
- JWT authentication (integral to server functions)
|
- JWT authentication (integral to server functions)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from djarea.client import client, ServerFunction, compose
|
from mizan.client import client, ServerFunction, compose
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .function import (
|
from .function import (
|
||||||
# Decorator
|
# Decorator
|
||||||
client,
|
client,
|
||||||
|
# Context markers
|
||||||
|
ReactContext,
|
||||||
|
GlobalContext,
|
||||||
# Base classes
|
# Base classes
|
||||||
ServerFunction,
|
ServerFunction,
|
||||||
ComposedContext,
|
ComposedContext,
|
||||||
@@ -39,6 +42,9 @@ from .executor import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Decorator
|
# Decorator
|
||||||
"client",
|
"client",
|
||||||
|
# Context markers
|
||||||
|
"ReactContext",
|
||||||
|
"GlobalContext",
|
||||||
# Base classes
|
# Base classes
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Function Executor
|
mizan Function Executor
|
||||||
|
|
||||||
Handles execution of server functions.
|
Handles execution of server functions.
|
||||||
This is the core of the "Server Functions" feature - callable from React
|
This is the core of the "Server Functions" feature - callable from React
|
||||||
@@ -27,7 +27,7 @@ from django.http import HttpRequest, JsonResponse
|
|||||||
from django.views.decorators.csrf import csrf_protect
|
from django.views.decorators.csrf import csrf_protect
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from djarea.setup.registry import get_function
|
from mizan.setup.registry import get_function, get_context_groups
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
@@ -134,23 +134,23 @@ def _check_auth_requirement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check authentication (required for all string-based auth)
|
# Check authentication (required for all string-based auth)
|
||||||
if not getattr(user, 'is_authenticated', False):
|
if not getattr(user, "is_authenticated", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.UNAUTHORIZED,
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
message="Authentication required",
|
message="Authentication required",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check staff requirement
|
# Check staff requirement
|
||||||
if auth_requirement == 'staff':
|
if auth_requirement == "staff":
|
||||||
if not getattr(user, 'is_staff', False):
|
if not getattr(user, "is_staff", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message="Staff access required",
|
message="Staff access required",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check superuser requirement
|
# Check superuser requirement
|
||||||
elif auth_requirement == 'superuser':
|
elif auth_requirement == "superuser":
|
||||||
if not getattr(user, 'is_superuser', False):
|
if not getattr(user, "is_superuser", False):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.FORBIDDEN,
|
code=ErrorCode.FORBIDDEN,
|
||||||
message="Superuser access required",
|
message="Superuser access required",
|
||||||
@@ -159,6 +159,151 @@ def _check_auth_requirement(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
|
"""
|
||||||
|
Determine whether an affects target is a context name or function name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
("context", "user", None) — full context invalidation
|
||||||
|
("function", "user_profile", "user") — function within context
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
|
||||||
|
# Check if it's a context name directly
|
||||||
|
if target_name in groups:
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
# Check if it's a function name within a context
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
if target_name in fn_names:
|
||||||
|
return ("function", target_name, ctx_name)
|
||||||
|
|
||||||
|
# Not a context or context function — treat as context name anyway
|
||||||
|
# (it might be a non-context function or an as-yet-unregistered context)
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context_param_names(context_name: str) -> set[str]:
|
||||||
|
"""
|
||||||
|
Get the set of parameter names used by functions in a context.
|
||||||
|
|
||||||
|
Returns the union of all Input field names across context functions.
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
param_names: set[str] = set()
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
return param_names
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_invalidation(
|
||||||
|
view_class: type | None,
|
||||||
|
input_data: dict[str, Any] | None = None,
|
||||||
|
) -> list[str | dict[str, Any]] | None:
|
||||||
|
"""
|
||||||
|
Resolve invalidation targets with three-tier auto-scoping.
|
||||||
|
|
||||||
|
Tier 1: Argument name matching — if the mutation's input args overlap
|
||||||
|
with the context's params by name, auto-scope.
|
||||||
|
Tier 2: Auth inference — Edge-side concern, not handled here.
|
||||||
|
Tier 3: Broad fallback — invalidate all instances.
|
||||||
|
|
||||||
|
Also handles function-level targeting: affects='user_profile' resolves
|
||||||
|
to the function name (v1: runtime refetches the whole context anyway).
|
||||||
|
|
||||||
|
Returns a list suitable for both JSON body and header serialization.
|
||||||
|
Returns None if no invalidation needed.
|
||||||
|
"""
|
||||||
|
if view_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
target_name = target["name"]
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
# Function-level: use the function name as the invalidation key
|
||||||
|
target_name = target["name"]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(target_name)
|
||||||
|
|
||||||
|
# Resolve the context this target belongs to (for param lookup)
|
||||||
|
resolved = _resolve_affects_target(target_name)
|
||||||
|
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||||
|
|
||||||
|
# Tier 1: argument name matching
|
||||||
|
if input_data and ctx_for_params:
|
||||||
|
context_params = _get_context_param_names(ctx_for_params)
|
||||||
|
matched = {
|
||||||
|
k: v for k, v in input_data.items()
|
||||||
|
if k in context_params
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
result.append({"context": target_name, "params": matched})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tier 3: broad fallback
|
||||||
|
result.append(target_name)
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_invalidate_header(
|
||||||
|
invalidate: list[str | dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||||
|
|
||||||
|
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||||
|
Param values are URL-encoded to prevent delimiter collisions.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
["user"] → "user"
|
||||||
|
["user", "notifications"] → "user, notifications"
|
||||||
|
[{"context": "user", "params": {"user_id": 5}}]
|
||||||
|
→ "user;user_id=5"
|
||||||
|
[{"context": "search", "params": {"q": "hello world"}}]
|
||||||
|
→ "search;q=hello%20world"
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for entry in invalidate:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
parts.append(entry)
|
||||||
|
elif isinstance(entry, dict):
|
||||||
|
ctx = entry["context"]
|
||||||
|
params = entry.get("params", {})
|
||||||
|
if params:
|
||||||
|
param_str = ";".join(
|
||||||
|
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||||
|
for k, v in sorted(params.items())
|
||||||
|
)
|
||||||
|
parts.append(f"{ctx};{param_str}")
|
||||||
|
else:
|
||||||
|
parts.append(ctx)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def execute_function(
|
def execute_function(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
fn_name: str,
|
fn_name: str,
|
||||||
@@ -190,8 +335,15 @@ def execute_function(
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check auth requirement BEFORE executing
|
# Reject private functions from RPC dispatch
|
||||||
meta = getattr(view_class, "_meta", {})
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
if meta.get("private"):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Function is not client-callable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check auth requirement BEFORE executing
|
||||||
auth_requirement = meta.get("auth")
|
auth_requirement = meta.get("auth")
|
||||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||||
if auth_error is not None:
|
if auth_error is not None:
|
||||||
@@ -224,7 +376,8 @@ def execute_function(
|
|||||||
if not isinstance(input_data, dict):
|
if not isinstance(input_data, dict):
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
code=ErrorCode.BAD_REQUEST,
|
code=ErrorCode.BAD_REQUEST,
|
||||||
message="Input must be an object, not " + type(input_data).__name__,
|
message="Input must be an object, not "
|
||||||
|
+ type(input_data).__name__,
|
||||||
)
|
)
|
||||||
validated_input = input_cls(**input_data)
|
validated_input = input_cls(**input_data)
|
||||||
elif has_input:
|
elif has_input:
|
||||||
@@ -280,10 +433,23 @@ def execute_function(
|
|||||||
code=ErrorCode.INTERNAL_ERROR,
|
code=ErrorCode.INTERNAL_ERROR,
|
||||||
message="An internal error occurred",
|
message="An internal error occurred",
|
||||||
# Don't expose internal details in production
|
# Don't expose internal details in production
|
||||||
details={"type": type(e).__name__} if logger.isEnabledFor(logging.DEBUG) else None,
|
details={"type": type(e).__name__}
|
||||||
|
if logger.isEnabledFor(logging.DEBUG)
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Serialize output (handle None for Optional return types)
|
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
||||||
|
from django.http import HttpResponseBase
|
||||||
|
|
||||||
|
if isinstance(output, HttpResponseBase):
|
||||||
|
# View path — add invalidation header, pass through the response
|
||||||
|
invalidate = _resolve_invalidation(view_class, input_data)
|
||||||
|
if invalidate:
|
||||||
|
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||||
|
output["Cache-Control"] = "no-store"
|
||||||
|
return output
|
||||||
|
|
||||||
|
# RPC path — serialize output
|
||||||
if output is None:
|
if output is None:
|
||||||
return FunctionResult(data=None)
|
return FunctionResult(data=None)
|
||||||
return FunctionResult(data=output.model_dump())
|
return FunctionResult(data=output.model_dump())
|
||||||
@@ -313,8 +479,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from djarea.client.jwt import decode_token
|
from mizan.client.jwt import decode_token
|
||||||
from djarea.jwt.tokens import JWTUser
|
from mizan.jwt.tokens import JWTUser
|
||||||
|
|
||||||
payload = decode_token(token, expected_type="access")
|
payload = decode_token(token, expected_type="access")
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@@ -322,7 +488,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
|||||||
|
|
||||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||||
request.user = JWTUser(payload)
|
request.user = JWTUser(payload)
|
||||||
request._djarea_jwt_authenticated = True
|
request._mizan_jwt_authenticated = True
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@@ -379,7 +545,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
||||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
||||||
|
|
||||||
Endpoint: POST /api/djarea/call/
|
Endpoint: POST /api/mizan/call/
|
||||||
|
|
||||||
Request body (JSON):
|
Request body (JSON):
|
||||||
{
|
{
|
||||||
@@ -430,8 +596,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||||
|
|
||||||
# Attach parsed form data and files to request for form functions
|
# Attach parsed form data and files to request for form functions
|
||||||
request._djarea_form_data = input_data
|
request._mizan_form_data = input_data
|
||||||
request._djarea_form_files = request.FILES
|
request._mizan_form_files = request.FILES
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON body - standard RPC
|
# JSON body - standard RPC
|
||||||
@@ -462,6 +628,11 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
# Execute the function
|
# Execute the function
|
||||||
result = execute_function(request, fn_name, input_data)
|
result = execute_function(request, fn_name, input_data)
|
||||||
|
|
||||||
|
# View path — function returned an HttpResponse directly
|
||||||
|
from django.http import HttpResponseBase
|
||||||
|
if isinstance(result, HttpResponseBase):
|
||||||
|
return result
|
||||||
|
|
||||||
# Return appropriate response
|
# Return appropriate response
|
||||||
if isinstance(result, FunctionError):
|
if isinstance(result, FunctionError):
|
||||||
status = {
|
status = {
|
||||||
@@ -475,4 +646,144 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
}.get(result.code, 400)
|
}.get(result.code, 400)
|
||||||
return result.to_response(status=status)
|
return result.to_response(status=status)
|
||||||
|
|
||||||
return result.to_response()
|
# RPC path — build response with server-driven invalidation
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
response_data = {"result": result.data}
|
||||||
|
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||||
|
|
||||||
|
if invalidate_contexts:
|
||||||
|
response_data["invalidate"] = invalidate_contexts
|
||||||
|
|
||||||
|
response = JsonResponse(response_data)
|
||||||
|
response["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
# Always set the header transport too (Edge reads this)
|
||||||
|
if invalidate_contexts:
|
||||||
|
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def execute_context(
|
||||||
|
request: HttpRequest,
|
||||||
|
context_name: str,
|
||||||
|
params: dict[str, str],
|
||||||
|
) -> FunctionResult | FunctionError:
|
||||||
|
"""
|
||||||
|
Execute all functions in a named context with merged params.
|
||||||
|
|
||||||
|
Each function receives only the params it declares in its Input schema.
|
||||||
|
If any function fails (auth, validation, execution), the entire request fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Django HttpRequest
|
||||||
|
context_name: Name of the context (e.g., 'user', 'global')
|
||||||
|
params: Query parameters (strings — Pydantic coerces types)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FunctionResult with bundled data, or FunctionError
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name)
|
||||||
|
if not fn_names:
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.NOT_FOUND,
|
||||||
|
message=f"Context '{context_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for fn_name in fn_names:
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
if view_class is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter params to only those in this function's Input schema
|
||||||
|
input_cls = getattr(view_class, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||||
|
fn_params = {
|
||||||
|
k: v for k, v in params.items()
|
||||||
|
if k in input_cls.model_fields
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
fn_params = None
|
||||||
|
|
||||||
|
result = execute_function(request, fn_name, fn_params)
|
||||||
|
if isinstance(result, FunctionError):
|
||||||
|
return result
|
||||||
|
results[fn_name] = result.data
|
||||||
|
|
||||||
|
return FunctionResult(data=results)
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_auth_only(view_func):
|
||||||
|
"""
|
||||||
|
Decorator that handles JWT auth for GET endpoints (no CSRF needed for GET).
|
||||||
|
"""
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
|
has_jwt = _has_jwt_header(request)
|
||||||
|
if has_jwt:
|
||||||
|
if _try_jwt_auth(request):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
|
message="Invalid or expired JWT token",
|
||||||
|
).to_response(status=401)
|
||||||
|
# No JWT — session auth (no CSRF needed for GET)
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@_jwt_auth_only
|
||||||
|
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||||
|
"""
|
||||||
|
Fetch all functions in a named context in a single bundled GET request.
|
||||||
|
|
||||||
|
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶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.
|
Server functions are the core primitive. Everything else builds on them.
|
||||||
|
|
||||||
@@ -20,15 +20,65 @@ Two styles supported:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import warnings
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable, ClassVar, Generic, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
ClassVar,
|
||||||
|
Generic,
|
||||||
|
Literal,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
get_args,
|
||||||
|
get_origin,
|
||||||
|
get_type_hints,
|
||||||
|
)
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
# Valid context modes: 'global', 'local', or False (not a context)
|
# =============================================================================
|
||||||
ContextMode = Literal['global', 'local', False]
|
# REACT CONTEXT - Named context marker
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ReactContext:
|
||||||
|
"""
|
||||||
|
A named context that groups server functions into one provider and one fetch.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
UserContext = ReactContext('user')
|
||||||
|
|
||||||
|
@client(context=UserContext)
|
||||||
|
def user_profile(request, user_id: int) -> ProfileShape: ...
|
||||||
|
|
||||||
|
@client(context=UserContext)
|
||||||
|
def user_orders(request, user_id: int) -> list[OrderShape]: ...
|
||||||
|
|
||||||
|
@client(affects=UserContext)
|
||||||
|
def edit_profile(request, name: str) -> dict: ...
|
||||||
|
|
||||||
|
@client(affects=[UserContext, OrderContext])
|
||||||
|
def change_plan(request) -> dict: ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
raise ValueError("ReactContext name must be a non-empty string")
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"ReactContext({self.name!r})"
|
||||||
|
|
||||||
|
|
||||||
|
# Built-in global context (auto-mounted at root, SSR-hydrated)
|
||||||
|
GlobalContext = ReactContext("global")
|
||||||
|
|
||||||
|
|
||||||
|
# Context parameter type: a ReactContext instance, a raw string, or False
|
||||||
|
ContextMode = ReactContext | str | Literal[False]
|
||||||
|
|
||||||
|
|
||||||
TInput = TypeVar("TInput", bound=BaseModel)
|
TInput = TypeVar("TInput", bound=BaseModel)
|
||||||
@@ -137,6 +187,11 @@ class _FunctionWrapper(ServerFunction):
|
|||||||
else:
|
else:
|
||||||
result = self._wrapped_fn(self.request)
|
result = self._wrapped_fn(self.request)
|
||||||
|
|
||||||
|
# View path — return HttpResponse directly (no serialization)
|
||||||
|
from django.http import HttpResponseBase
|
||||||
|
if isinstance(result, HttpResponseBase):
|
||||||
|
return result
|
||||||
|
|
||||||
# Wrap primitive returns in the generated output model
|
# Wrap primitive returns in the generated output model
|
||||||
if self._is_primitive_output:
|
if self._is_primitive_output:
|
||||||
return self._output_cls(result=result)
|
return self._output_cls(result=result)
|
||||||
@@ -167,77 +222,106 @@ class _FunctionWrapper(ServerFunction):
|
|||||||
|
|
||||||
|
|
||||||
# Valid string values for auth parameter
|
# Valid string values for auth parameter
|
||||||
_VALID_AUTH_STRINGS = frozenset({'required', 'staff', 'superuser'})
|
_VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
||||||
|
"""Resolve a context parameter to its name string."""
|
||||||
|
if context is False:
|
||||||
|
return False
|
||||||
|
if isinstance(context, ReactContext):
|
||||||
|
return context.name
|
||||||
|
if isinstance(context, str):
|
||||||
|
if not context.strip():
|
||||||
|
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
||||||
|
if context == "local":
|
||||||
|
warnings.warn(
|
||||||
|
"context='local' is deprecated. Use ReactContext('name') instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
raise ValueError(
|
||||||
|
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Affects parameter type
|
||||||
|
AffectsTarget = ReactContext | str | type["ServerFunction"]
|
||||||
|
AffectsMode = AffectsTarget | list[AffectsTarget] | None
|
||||||
|
|
||||||
|
|
||||||
def client(
|
def client(
|
||||||
fn: Callable = None,
|
fn: Callable = None,
|
||||||
*,
|
*,
|
||||||
context: ContextMode = False,
|
context: ContextMode = False,
|
||||||
|
affects: AffectsMode = None,
|
||||||
|
private: bool = False,
|
||||||
|
route: str | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||||
"""
|
"""
|
||||||
Register a function as a server function.
|
Register a function as a server function.
|
||||||
|
|
||||||
Type annotations define the schema - just like Django Ninja/FastAPI.
|
|
||||||
Function parameters become input fields automatically.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: Context mode for React state management.
|
context: Named context for React state management.
|
||||||
- False (default): Not a context, just a callable function
|
- False (default): Not a context, just a callable function.
|
||||||
- 'global': Embedded in root DjangoContext, no params, singleton
|
- ReactContext instance: groups functions into a named context.
|
||||||
- 'local': Standalone provider, supports params via flat props
|
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||||
|
|
||||||
|
affects: Declare which contexts or functions this mutation invalidates.
|
||||||
|
Mutually exclusive with context=.
|
||||||
|
Scoping is automatic via argument name matching.
|
||||||
|
|
||||||
|
private: If True, the function is not client-callable.
|
||||||
|
- Not exposed as an RPC endpoint
|
||||||
|
- No generated TypeScript
|
||||||
|
- Still participates in the invalidation graph
|
||||||
|
- Use for webhooks, cron jobs, internal mutations
|
||||||
|
|
||||||
|
route: URL route pattern for view-path functions.
|
||||||
|
Mizan registers this route during autodiscovery.
|
||||||
|
Example: '/profile/<user_id>/', '/webhooks/stripe/'
|
||||||
|
|
||||||
|
methods: HTTP methods allowed for the route.
|
||||||
|
Default: ['GET'] for context functions, ['POST'] for mutations.
|
||||||
|
Example: ['POST'], ['GET', 'POST']
|
||||||
|
|
||||||
websocket: Enable WebSocket RPC transport (default: False).
|
websocket: Enable WebSocket RPC transport (default: False).
|
||||||
By default, functions use HTTP-only transport. Enable this for
|
|
||||||
real-time features (chat, gaming, live updates) that benefit
|
|
||||||
from lower latency.
|
|
||||||
|
|
||||||
Note: Forms (DjareaFormMixin) always use HTTP because auth
|
|
||||||
flows require full HTTP request semantics.
|
|
||||||
|
|
||||||
auth: Authentication requirement.
|
auth: Authentication requirement.
|
||||||
- None (default): No auth required (AnonymousUser allowed)
|
|
||||||
- True or 'required': Must be authenticated
|
|
||||||
- 'staff': Must have is_staff=True
|
|
||||||
- 'superuser': Must have is_superuser=True
|
|
||||||
- callable(request) -> bool: Custom check function
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Basic HTTP-only function (not a context)
|
UserContext = ReactContext('user')
|
||||||
@client
|
|
||||||
def echo(request, message: str) -> EchoOutput:
|
|
||||||
return EchoOutput(message=message)
|
|
||||||
|
|
||||||
# Global context - embedded in DjangoContext, no params
|
@client(context=UserContext)
|
||||||
@client(context='global')
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||||
def current_user(request) -> UserOutput:
|
|
||||||
return UserOutput(email=request.user.email)
|
|
||||||
|
|
||||||
# Local context - standalone provider, supports params
|
@client(affects=UserContext)
|
||||||
@client(context='local')
|
def update_profile(request, user_id: int, name: str) -> dict: ...
|
||||||
def user_profile(request, user_id: int) -> ProfileOutput:
|
|
||||||
return ProfileOutput(...)
|
|
||||||
|
|
||||||
# WebSocket-enabled for real-time
|
# View with route — Mizan owns the URL
|
||||||
@client(websocket=True)
|
@client(context=UserContext, route='/profile/<user_id>/')
|
||||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
def profile_page(request, user_id: int) -> HttpResponse: ...
|
||||||
return MessageOutput(...)
|
|
||||||
|
|
||||||
# Local context with WebSocket (live data)
|
# Private webhook — not client-callable, emits invalidation
|
||||||
@client(context='local', websocket=True)
|
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
||||||
def live_user_status(request, user_id: int) -> StatusOutput:
|
def stripe_webhook(request) -> HttpResponse: ...
|
||||||
return StatusOutput(...)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A ServerFunction class that wraps the function
|
A ServerFunction class that wraps the function
|
||||||
"""
|
"""
|
||||||
# Validate context parameter
|
# Resolve context to name string
|
||||||
if context not in (False, 'global', 'local'):
|
resolved_context = _resolve_context(context)
|
||||||
|
|
||||||
|
# Validate affects parameter
|
||||||
|
if affects is not None:
|
||||||
|
if resolved_context is not False:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid context value '{context}'. "
|
"context= and affects= are mutually exclusive. "
|
||||||
f"Must be False, 'global', or 'local'."
|
"A function cannot be both a context reader and a mutation."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate auth parameter
|
# Validate auth parameter
|
||||||
@@ -249,18 +333,58 @@ def client(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
return _create_server_function(
|
||||||
|
fn, context=resolved_context, affects=affects,
|
||||||
|
private=private, route=route, methods=methods,
|
||||||
|
websocket=websocket, auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
# Support both @client and @client(...)
|
# Support both @client and @client(...)
|
||||||
if fn is not None:
|
if fn is not None:
|
||||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
return _create_server_function(
|
||||||
|
fn, context=resolved_context, affects=affects,
|
||||||
|
private=private, route=route, methods=methods,
|
||||||
|
websocket=websocket, auth=auth,
|
||||||
|
)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_affects(affects: AffectsMode) -> list[dict[str, str]] | None:
|
||||||
|
"""Normalize the affects parameter into a list of target descriptors."""
|
||||||
|
if affects is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = affects if isinstance(affects, list) else [affects]
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, ReactContext):
|
||||||
|
result.append({"type": "context", "name": item.name})
|
||||||
|
elif isinstance(item, str):
|
||||||
|
result.append({"type": "context", "name": item})
|
||||||
|
elif isinstance(item, type) and issubclass(item, ServerFunction):
|
||||||
|
fn_meta = getattr(item, "_meta", {})
|
||||||
|
fn_ctx = fn_meta.get("context")
|
||||||
|
result.append({
|
||||||
|
"type": "function",
|
||||||
|
"name": getattr(item, "__name__", str(item)),
|
||||||
|
"context": fn_ctx or None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"affects items must be ReactContext instances, context name strings, "
|
||||||
|
f"or @client function references. Got {type(item)}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _create_server_function(
|
def _create_server_function(
|
||||||
fn: Callable,
|
fn: Callable,
|
||||||
*,
|
*,
|
||||||
context: ContextMode = False,
|
context: str | Literal[False] = False,
|
||||||
|
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
||||||
|
private: bool = False,
|
||||||
|
route: str | None = None,
|
||||||
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | None = None,
|
auth: bool | str | None = None,
|
||||||
) -> type[ServerFunction]:
|
) -> type[ServerFunction]:
|
||||||
@@ -301,25 +425,36 @@ def _create_server_function(
|
|||||||
# Get output type from return annotation
|
# Get output type from return annotation
|
||||||
output_type = hints.get("return")
|
output_type = hints.get("return")
|
||||||
if output_type is None:
|
if output_type is None:
|
||||||
raise TypeError(
|
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
||||||
f"Server function '{name}' must have a return type annotation"
|
|
||||||
|
# Detect view path: function returns HttpResponse (or has no return annotation
|
||||||
|
# that maps to a model — view functions often just have -> HttpResponse)
|
||||||
|
from django.http import HttpResponseBase
|
||||||
|
is_view_path = (
|
||||||
|
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Support primitive return types by wrapping in a model with 'result' field
|
if is_view_path:
|
||||||
# Also handle Optional[X] / X | None by extracting the non-None type
|
# View path — no Pydantic output wrapping needed
|
||||||
|
output_cls = BaseModel # placeholder, never used for serialization
|
||||||
|
is_primitive_output = False
|
||||||
|
else:
|
||||||
|
# RPC path — resolve output type
|
||||||
import types
|
import types
|
||||||
|
|
||||||
def is_basemodel_type(t: Any) -> bool:
|
def is_basemodel_type(t: Any) -> bool:
|
||||||
"""Check if type is a BaseModel subclass, handling Optional/Union."""
|
"""Check if type is a BaseModel subclass, handling Optional/Union."""
|
||||||
if isinstance(t, type) and issubclass(t, BaseModel):
|
if isinstance(t, type) and issubclass(t, BaseModel):
|
||||||
return True
|
return True
|
||||||
# Handle Union types: typing.Union (Optional[X]) and types.UnionType (X | None)
|
|
||||||
origin = get_origin(t)
|
origin = get_origin(t)
|
||||||
if origin is Union or isinstance(t, types.UnionType):
|
if origin is Union or isinstance(t, types.UnionType):
|
||||||
args = get_args(t)
|
args = get_args(t)
|
||||||
# Check if any non-None arg is a BaseModel
|
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if arg is not type(None) and isinstance(arg, type) and issubclass(arg, BaseModel):
|
if (
|
||||||
|
arg is not type(None)
|
||||||
|
and isinstance(arg, type)
|
||||||
|
and issubclass(arg, BaseModel)
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -327,7 +462,6 @@ def _create_server_function(
|
|||||||
output_cls = output_type
|
output_cls = output_type
|
||||||
is_primitive_output = False
|
is_primitive_output = False
|
||||||
else:
|
else:
|
||||||
# Create model wrapper for primitive types (int, str, list, etc.)
|
|
||||||
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
|
output_cls = create_model(f"{fn.__name__}_Output", result=(output_type, ...))
|
||||||
is_primitive_output = True
|
is_primitive_output = True
|
||||||
|
|
||||||
@@ -354,10 +488,28 @@ def _create_server_function(
|
|||||||
# Build metadata
|
# Build metadata
|
||||||
meta = {}
|
meta = {}
|
||||||
|
|
||||||
# Context mode: 'global' or 'local' (False means not a context)
|
# View path flag (function returns HttpResponse, no codegen)
|
||||||
|
if is_view_path:
|
||||||
|
meta["view_path"] = True
|
||||||
|
|
||||||
|
# Private flag (not client-callable, no codegen, no RPC endpoint)
|
||||||
|
if private:
|
||||||
|
meta["private"] = True
|
||||||
|
|
||||||
|
# Route (Mizan-owned URL pattern for view-path functions)
|
||||||
|
if route:
|
||||||
|
meta["route"] = route
|
||||||
|
meta["methods"] = methods or (["GET"] if context else ["POST"])
|
||||||
|
|
||||||
|
# Context name (any non-empty string)
|
||||||
if context:
|
if context:
|
||||||
meta["context"] = context
|
meta["context"] = context
|
||||||
|
|
||||||
|
# Affects: mutation invalidation targets
|
||||||
|
normalized_affects = _normalize_affects(affects)
|
||||||
|
if normalized_affects:
|
||||||
|
meta["affects"] = normalized_affects
|
||||||
|
|
||||||
# WebSocket: enable WebSocket transport
|
# WebSocket: enable WebSocket transport
|
||||||
if websocket:
|
if websocket:
|
||||||
meta["websocket"] = True
|
meta["websocket"] = True
|
||||||
@@ -365,7 +517,7 @@ def _create_server_function(
|
|||||||
# Auth requirement
|
# Auth requirement
|
||||||
if auth is not None:
|
if auth is not None:
|
||||||
if auth is True:
|
if auth is True:
|
||||||
meta["auth"] = 'required'
|
meta["auth"] = "required"
|
||||||
elif callable(auth):
|
elif callable(auth):
|
||||||
meta["auth"] = auth
|
meta["auth"] = auth
|
||||||
else:
|
else:
|
||||||
@@ -374,7 +526,7 @@ def _create_server_function(
|
|||||||
if meta:
|
if meta:
|
||||||
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
||||||
|
|
||||||
# Note: Registration happens via discovery (djarea_clients), not here.
|
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||||
# This allows the decorator to be used without import-time side effects.
|
# This allows the decorator to be used without import-time side effects.
|
||||||
|
|
||||||
return FunctionWrapper
|
return FunctionWrapper
|
||||||
@@ -434,7 +586,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
|||||||
return [item]
|
return [item]
|
||||||
elif isinstance(item, ComposedContext):
|
elif isinstance(item, ComposedContext):
|
||||||
return item._leaves.copy()
|
return item._leaves.copy()
|
||||||
elif hasattr(item, '_leaves'):
|
elif hasattr(item, "_leaves"):
|
||||||
# Duck typing for composed contexts
|
# Duck typing for composed contexts
|
||||||
return item._leaves.copy()
|
return item._leaves.copy()
|
||||||
else:
|
else:
|
||||||
@@ -443,11 +595,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
|||||||
|
|
||||||
def _is_context_enabled(item) -> bool:
|
def _is_context_enabled(item) -> bool:
|
||||||
"""Check if an item is a context-enabled function or composition."""
|
"""Check if an item is a context-enabled function or composition."""
|
||||||
if isinstance(item, ComposedContext) or hasattr(item, '_leaves'):
|
if isinstance(item, ComposedContext) or hasattr(item, "_leaves"):
|
||||||
return True
|
return True
|
||||||
if isinstance(item, type) and issubclass(item, ServerFunction):
|
if isinstance(item, type) and issubclass(item, ServerFunction):
|
||||||
meta = getattr(item, '_meta', {})
|
meta = getattr(item, "_meta", {})
|
||||||
return meta.get('context') in ('global', 'local')
|
return bool(meta.get("context"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -460,7 +612,7 @@ def compose(
|
|||||||
Compose multiple contexts into a single provider.
|
Compose multiple contexts into a single provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*children: Context functions (@client with context='global'|'local')
|
*children: Context functions (@client with a context name)
|
||||||
or other @compose functions. All must be unique after flattening.
|
or other @compose functions. All must be unique after flattening.
|
||||||
|
|
||||||
on_server: Bundle all calls into a single server request (default: False).
|
on_server: Bundle all calls into a single server request (default: False).
|
||||||
@@ -498,18 +650,21 @@ def compose(
|
|||||||
Returns:
|
Returns:
|
||||||
A ComposedContext that can be used in other compositions.
|
A ComposedContext that can be used in other compositions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable) -> ComposedContext:
|
def decorator(fn: Callable) -> ComposedContext:
|
||||||
from djarea.setup.registry import register_compose
|
from mizan.setup.registry import register_compose
|
||||||
|
|
||||||
name = fn.__name__
|
name = fn.__name__
|
||||||
|
|
||||||
# Validate: all children must be context-enabled
|
# Validate: all children must be context-enabled
|
||||||
for i, child in enumerate(children):
|
for i, child in enumerate(children):
|
||||||
if not _is_context_enabled(child):
|
if not _is_context_enabled(child):
|
||||||
child_name = getattr(child, 'name', getattr(child, '__name__', str(child)))
|
child_name = getattr(
|
||||||
|
child, "name", getattr(child, "__name__", str(child))
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"@compose argument {i} ({child_name}) is not context-enabled. "
|
f"@compose argument {i} ({child_name}) is not context-enabled. "
|
||||||
f"All children must have @client(context='global'|'local') or be @compose."
|
f"All children must have @client(context=...) or be @compose."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Flatten to collect all leaves
|
# Flatten to collect all leaves
|
||||||
@@ -529,12 +684,16 @@ def compose(
|
|||||||
|
|
||||||
# Validate transport consistency when on_server=True
|
# Validate transport consistency when on_server=True
|
||||||
if on_server:
|
if on_server:
|
||||||
has_websocket = [getattr(leaf, '_meta', {}).get('websocket', False) for leaf in leaves]
|
has_websocket = [
|
||||||
|
getattr(leaf, "_meta", {}).get("websocket", False) for leaf in leaves
|
||||||
|
]
|
||||||
|
|
||||||
if websocket:
|
if websocket:
|
||||||
# All must have websocket=True
|
# All must have websocket=True
|
||||||
if not all(has_websocket):
|
if not all(has_websocket):
|
||||||
non_ws = [leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws]
|
non_ws = [
|
||||||
|
leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws
|
||||||
|
]
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"@compose({name}, on_server=True, websocket=True) requires all children "
|
f"@compose({name}, on_server=True, websocket=True) requires all children "
|
||||||
f"to have websocket=True. These are HTTP-only: {non_ws}"
|
f"to have websocket=True. These are HTTP-only: {non_ws}"
|
||||||
@@ -542,7 +701,9 @@ def compose(
|
|||||||
else:
|
else:
|
||||||
# All must be HTTP-only
|
# All must be HTTP-only
|
||||||
if any(has_websocket):
|
if any(has_websocket):
|
||||||
ws_enabled = [leaf.name for leaf, ws in zip(leaves, has_websocket) if ws]
|
ws_enabled = [
|
||||||
|
leaf.name for leaf, ws in zip(leaves, has_websocket) if ws
|
||||||
|
]
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"@compose({name}, on_server=True, websocket=False) requires all children "
|
f"@compose({name}, on_server=True, websocket=False) requires all children "
|
||||||
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
|
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
|
||||||
@@ -628,7 +789,7 @@ def create_form_functions(
|
|||||||
Or use the helper:
|
Or use the helper:
|
||||||
register_form(ContactForm, 'contact', submit_handler=...)
|
register_form(ContactForm, 'contact', submit_handler=...)
|
||||||
"""
|
"""
|
||||||
from djarea.forms.schema_utils import build_form_schema
|
from mizan.forms.schema_utils import build_form_schema
|
||||||
|
|
||||||
# Schema function - returns field definitions
|
# Schema function - returns field definitions
|
||||||
class FormSchema(ServerFunction):
|
class FormSchema(ServerFunction):
|
||||||
@@ -644,7 +805,9 @@ def create_form_functions(
|
|||||||
required=field.required,
|
required=field.required,
|
||||||
label=field.label or field.name,
|
label=field.label or field.name,
|
||||||
help_text=field.help_text or None,
|
help_text=field.help_text or None,
|
||||||
choices=[(c.value, c.label) for c in field.choices] if field.choices else None,
|
choices=[(c.value, c.label) for c in field.choices]
|
||||||
|
if field.choices
|
||||||
|
else None,
|
||||||
initial=field.initial,
|
initial=field.initial,
|
||||||
)
|
)
|
||||||
for field in schema.fields
|
for field in schema.fields
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.client.jwt - JWT authentication for server functions.
|
mizan.client.jwt - JWT authentication for server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
- Server functions for obtaining/refreshing JWT tokens
|
||||||
@@ -9,12 +9,12 @@ Server Functions:
|
|||||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||||
- jwt_refresh: Refresh tokens using a refresh token
|
- jwt_refresh: Refresh tokens using a refresh token
|
||||||
|
|
||||||
Note: This module is purpose-built for Djarea server functions.
|
Note: This module is purpose-built for mizan server functions.
|
||||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Token utilities (re-exports from django_jwt_session)
|
# Token utilities (re-exports from django_jwt_session)
|
||||||
from djarea.jwt.tokens import (
|
from mizan.jwt.tokens import (
|
||||||
create_token_pair,
|
create_token_pair,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
from djarea.jwt.settings import get_settings, JWTSettings
|
from mizan.jwt.settings import get_settings, JWTSettings
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Token utilities
|
# Token utilities
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea OpenAPI Schema Generator
|
mizan OpenAPI Schema Generator
|
||||||
|
|
||||||
Generates OpenAPI 3.0 compatible schema from registered server functions.
|
Generates OpenAPI 3.0 compatible schema from registered server functions.
|
||||||
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
|
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
|
||||||
@@ -11,7 +11,7 @@ NOTE: Schema export is only available via management command for security.
|
|||||||
HTTP endpoint has been removed to prevent function enumeration.
|
HTTP endpoint has been removed to prevent function enumeration.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py export_djarea_schema
|
python manage.py export_mizan_schema
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,15 +21,21 @@ import re
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
# Lazy imports to avoid Django settings access at module load time
|
# Lazy imports to avoid Django settings access at module load time
|
||||||
# (asgi.py imports djarea before Django is fully configured)
|
# (asgi.py imports mizan before Django is fully configured)
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django import forms
|
from django import forms
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
from djarea.setup.registry import get_registry, get_schema
|
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
|
__all__ = [
|
||||||
|
"get_schema",
|
||||||
|
"generate_openapi_schema",
|
||||||
|
"generate_openapi_json",
|
||||||
|
"generate_edge_manifest",
|
||||||
|
"generate_edge_manifest_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
|
def _extract_form_fields(form_class: type) -> list[dict[str, Any]]:
|
||||||
@@ -167,21 +173,26 @@ def _register_schema_endpoint(
|
|||||||
and exec() security concerns.
|
and exec() security concerns.
|
||||||
"""
|
"""
|
||||||
if input_cls is not None:
|
if input_cls is not None:
|
||||||
|
|
||||||
def endpoint(request, data):
|
def endpoint(request, data):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Set annotations directly to the actual type objects (not strings)
|
# Set annotations directly to the actual type objects (not strings)
|
||||||
endpoint.__annotations__ = {"data": input_cls}
|
endpoint.__annotations__ = {"data": input_cls}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def endpoint(request):
|
def endpoint(request):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Register with Ninja
|
# Register with Ninja
|
||||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||||
|
endpoint
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_openapi_schema() -> dict[str, Any]:
|
def generate_openapi_schema() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate OpenAPI 3.0 schema for all registered djarea functions.
|
Generate OpenAPI 3.0 schema for all registered mizan functions.
|
||||||
|
|
||||||
Uses Django Ninja's schema generation internally to ensure proper
|
Uses Django Ninja's schema generation internally to ensure proper
|
||||||
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
|
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
|
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
|
||||||
# battle-tested Pydantic→OpenAPI conversion
|
# battle-tested Pydantic→OpenAPI conversion
|
||||||
schema_api = NinjaAPI(
|
schema_api = NinjaAPI(
|
||||||
title="Djarea Server Functions",
|
title="mizan Server Functions",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="Auto-generated schema for djarea server functions",
|
description="Auto-generated schema for mizan server functions",
|
||||||
docs_url=None, # No docs endpoint
|
docs_url=None, # No docs endpoint
|
||||||
openapi_url=None, # No openapi endpoint
|
openapi_url=None, # No openapi endpoint
|
||||||
)
|
)
|
||||||
@@ -234,13 +245,17 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
# Store them in schema_classes so they persist beyond loop scope
|
# Store them in schema_classes so they persist beyond loop scope
|
||||||
# Uses create_model to avoid metaclass conflicts with custom base classes
|
# Uses create_model to avoid metaclass conflicts with custom base classes
|
||||||
if has_input:
|
if has_input:
|
||||||
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls)
|
schema_classes[input_type_name] = create_model(
|
||||||
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls)
|
input_type_name, __base__=input_cls
|
||||||
|
)
|
||||||
|
schema_classes[output_type_name] = create_model(
|
||||||
|
output_type_name, __base__=output_cls
|
||||||
|
)
|
||||||
|
|
||||||
# Register endpoint using helper to avoid closure capture issues
|
# Register endpoint using helper to avoid closure capture issues
|
||||||
_register_schema_endpoint(
|
_register_schema_endpoint(
|
||||||
api=schema_api,
|
api=schema_api,
|
||||||
path=f"/djarea/{name}",
|
path=f"/mizan/{name}",
|
||||||
operation_id=camel_name,
|
operation_id=camel_name,
|
||||||
summary=fn_class.__doc__ or f"Call {name}",
|
summary=fn_class.__doc__ or f"Call {name}",
|
||||||
input_cls=schema_classes.get(input_type_name),
|
input_cls=schema_classes.get(input_type_name),
|
||||||
@@ -262,6 +277,10 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
|
"formRole": meta.get("form_role"), # "schema", "validate", "submit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Affects metadata (mutation invalidation)
|
||||||
|
if meta.get("affects"):
|
||||||
|
fn_meta_entry["affects"] = meta["affects"]
|
||||||
|
|
||||||
# For form schema functions, extract field definitions for Zod generation
|
# For form schema functions, extract field definitions for Zod generation
|
||||||
if meta.get("form") and meta.get("form_role") == "schema":
|
if meta.get("form") and meta.get("form_role") == "schema":
|
||||||
form_class = meta.get("form_class")
|
form_class = meta.get("form_class")
|
||||||
@@ -279,13 +298,53 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||||
|
|
||||||
# Add custom extension with function metadata for provider generation
|
# Add custom extension with function metadata for provider generation
|
||||||
schema["x-djarea-functions"] = function_metadata
|
schema["x-mizan-functions"] = function_metadata
|
||||||
|
|
||||||
# Add x-djarea metadata to each operation
|
# Add x-mizan-contexts: grouped context metadata with param elevation
|
||||||
|
context_groups = get_context_groups()
|
||||||
|
if context_groups:
|
||||||
|
contexts_meta: dict[str, Any] = {}
|
||||||
|
for ctx_name, fn_names in context_groups.items():
|
||||||
|
# Analyze params across all functions in the context
|
||||||
|
param_info: dict[str, dict[str, Any]] = {}
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
for field_name, field_info in input_cls.model_fields.items():
|
||||||
|
if field_name not in param_info:
|
||||||
|
annotation = field_info.annotation
|
||||||
|
# Map Python types to JSON schema types
|
||||||
|
type_name = "string"
|
||||||
|
if annotation in (int,):
|
||||||
|
type_name = "integer"
|
||||||
|
elif annotation in (float,):
|
||||||
|
type_name = "number"
|
||||||
|
elif annotation in (bool,):
|
||||||
|
type_name = "boolean"
|
||||||
|
param_info[field_name] = {
|
||||||
|
"type": type_name,
|
||||||
|
"sharedBy": [],
|
||||||
|
}
|
||||||
|
param_info[field_name]["sharedBy"].append(fn_name)
|
||||||
|
|
||||||
|
# A param is required if ALL functions in the context declare it
|
||||||
|
for p_name, p_meta in param_info.items():
|
||||||
|
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
|
||||||
|
|
||||||
|
contexts_meta[ctx_name] = {
|
||||||
|
"functions": fn_names,
|
||||||
|
"params": param_info,
|
||||||
|
}
|
||||||
|
schema["x-mizan-contexts"] = contexts_meta
|
||||||
|
|
||||||
|
# Add x-mizan metadata to each operation
|
||||||
for fn_meta in function_metadata:
|
for fn_meta in function_metadata:
|
||||||
path = f"/djarea/{fn_meta['name']}"
|
path = f"/mizan/{fn_meta['name']}"
|
||||||
if path in schema.get("paths", {}):
|
if path in schema.get("paths", {}):
|
||||||
schema["paths"][path]["post"]["x-djarea"] = {
|
schema["paths"][path]["post"]["x-mizan"] = {
|
||||||
"transport": fn_meta["transport"],
|
"transport": fn_meta["transport"],
|
||||||
"isContext": fn_meta["isContext"],
|
"isContext": fn_meta["isContext"],
|
||||||
}
|
}
|
||||||
@@ -297,3 +356,154 @@ def generate_openapi_json(indent: int = 2) -> str:
|
|||||||
"""Generate OpenAPI schema as formatted JSON string."""
|
"""Generate OpenAPI schema as formatted JSON string."""
|
||||||
schema = generate_openapi_schema()
|
schema = generate_openapi_schema()
|
||||||
return json.dumps(schema, indent=indent)
|
return json.dumps(schema, indent=indent)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest(
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||||
|
patterns and params for CDN cache purging.
|
||||||
|
|
||||||
|
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||||
|
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||||
|
1. Looks up 'user' in the manifest
|
||||||
|
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||||
|
3. Purges the resolved URLs + the context API endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The Mizan API mount point (default: /api/mizan)
|
||||||
|
view_urls: Optional mapping of context names to URL patterns for
|
||||||
|
view-path functions. These are URLs that Edge should
|
||||||
|
also purge when a context is invalidated.
|
||||||
|
Example: {"user": ["/profile/:user_id/"]}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest dict suitable for JSON serialization.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel as PydanticBaseModel
|
||||||
|
|
||||||
|
# Common user identity param names for user_scoped detection
|
||||||
|
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||||
|
|
||||||
|
groups = get_context_groups()
|
||||||
|
registry = get_registry()
|
||||||
|
all_functions = registry.get("functions", {})
|
||||||
|
|
||||||
|
manifest: dict[str, Any] = {"contexts": {}, "mutations": {}}
|
||||||
|
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
# Collect params and routes from all functions in this context
|
||||||
|
param_names: set[str] = set()
|
||||||
|
functions_meta: list[dict[str, Any]] = []
|
||||||
|
page_routes: list[str] = []
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = all_functions.get(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
is_view = meta.get("view_path", False)
|
||||||
|
|
||||||
|
# Collect param names from Input schema
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if (
|
||||||
|
input_cls
|
||||||
|
and input_cls is not PydanticBaseModel
|
||||||
|
and hasattr(input_cls, "model_fields")
|
||||||
|
):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
fn_entry: dict[str, Any] = {
|
||||||
|
"name": fn_name,
|
||||||
|
"path": "view" if is_view else "rpc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect routes from view-path functions
|
||||||
|
fn_route = meta.get("route")
|
||||||
|
if fn_route:
|
||||||
|
fn_entry["route"] = fn_route
|
||||||
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
|
page_routes.append(fn_route)
|
||||||
|
|
||||||
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
|
sorted_params = sorted(param_names)
|
||||||
|
user_scoped = bool(param_names & _USER_SCOPED_PARAMS)
|
||||||
|
|
||||||
|
ctx_entry: dict[str, Any] = {
|
||||||
|
"functions": functions_meta,
|
||||||
|
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||||
|
"params": sorted_params,
|
||||||
|
"user_scoped": user_scoped,
|
||||||
|
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add page routes from view-path functions with route=
|
||||||
|
if page_routes:
|
||||||
|
ctx_entry["page_routes"] = page_routes
|
||||||
|
|
||||||
|
# Add externally-declared view URLs
|
||||||
|
if view_urls and ctx_name in view_urls:
|
||||||
|
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||||
|
|
||||||
|
manifest["contexts"][ctx_name] = ctx_entry
|
||||||
|
|
||||||
|
# Mutations section — all functions with affects=
|
||||||
|
for fn_name, fn_cls in all_functions.items():
|
||||||
|
meta = getattr(fn_cls, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve context names from affects targets
|
||||||
|
affected_contexts = []
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
affected_contexts.append(target["name"])
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
affected_contexts.append(target["context"])
|
||||||
|
affected_contexts = list(dict.fromkeys(affected_contexts))
|
||||||
|
|
||||||
|
# Determine which params auto-scope
|
||||||
|
auto_scoped = []
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not PydanticBaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
fn_params = set(input_cls.model_fields.keys())
|
||||||
|
for ctx_name in affected_contexts:
|
||||||
|
ctx_params = set()
|
||||||
|
for ctx_fn_name in groups.get(ctx_name, []):
|
||||||
|
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||||
|
if ctx_fn_cls:
|
||||||
|
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||||
|
if ctx_input and ctx_input is not PydanticBaseModel and hasattr(ctx_input, "model_fields"):
|
||||||
|
ctx_params.update(ctx_input.model_fields.keys())
|
||||||
|
auto_scoped.extend(sorted(fn_params & ctx_params))
|
||||||
|
auto_scoped = list(dict.fromkeys(auto_scoped))
|
||||||
|
|
||||||
|
mutation_entry: dict[str, Any] = {
|
||||||
|
"affects": affected_contexts,
|
||||||
|
}
|
||||||
|
if auto_scoped:
|
||||||
|
mutation_entry["auto_scoped_params"] = auto_scoped
|
||||||
|
if meta.get("private"):
|
||||||
|
mutation_entry["private"] = True
|
||||||
|
if meta.get("route"):
|
||||||
|
mutation_entry["route"] = meta["route"]
|
||||||
|
mutation_entry["methods"] = meta.get("methods", ["POST"])
|
||||||
|
|
||||||
|
manifest["mutations"][fn_name] = mutation_entry
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def generate_edge_manifest_json(
|
||||||
|
indent: int = 2,
|
||||||
|
base_url: str = "/api/mizan",
|
||||||
|
view_urls: dict[str, list[str]] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate Edge manifest as formatted JSON string."""
|
||||||
|
manifest = generate_edge_manifest(base_url=base_url, view_urls=view_urls)
|
||||||
|
return json.dumps(manifest, indent=indent, sort_keys=True)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
DjareaFormMixin - Turn Django Forms into server functions.
|
mizanFormMixin - Turn Django Forms into server functions.
|
||||||
|
|
||||||
This mixin transforms any Django Form into Djarea server functions,
|
This mixin transforms any Django Form into mizan server functions,
|
||||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||||
while exposing them through the unified server function API.
|
while exposing them through the unified server function API.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from django import forms
|
from django import forms
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
submit_label="Send",
|
submit_label="Send",
|
||||||
@@ -98,7 +98,7 @@ def _create_form_input_schema(
|
|||||||
form = form_class()
|
form = form_class()
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||||
fields_dict = getattr(form_class, 'base_fields', {})
|
fields_dict = getattr(form_class, "base_fields", {})
|
||||||
else:
|
else:
|
||||||
fields_dict = form.fields
|
fields_dict = form.fields
|
||||||
|
|
||||||
@@ -125,9 +125,9 @@ def _create_form_input_schema(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
class DjareaFormMeta(BaseModel):
|
class mizanFormMeta(BaseModel):
|
||||||
"""
|
"""
|
||||||
Configuration for a Djarea form.
|
Configuration for a mizan form.
|
||||||
|
|
||||||
This Pydantic model provides type-safe configuration with full LSP support,
|
This Pydantic model provides type-safe configuration with full LSP support,
|
||||||
and serializes to JSON for the frontend schema.
|
and serializes to JSON for the frontend schema.
|
||||||
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
|
|||||||
enable_formset: bool = False
|
enable_formset: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DjareaFormMixin:
|
class mizanFormMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that exposes a Django Form as Djarea server functions.
|
Mixin that exposes a Django Form as mizan server functions.
|
||||||
|
|
||||||
Add this mixin to any Django Form class along with a `djarea` configuration:
|
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||||
|
|
||||||
class ContactForm(DjareaFormMixin, forms.Form):
|
class ContactForm(mizanFormMixin, forms.Form):
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="contact",
|
name="contact",
|
||||||
title="Contact Us",
|
title="Contact Us",
|
||||||
)
|
)
|
||||||
@@ -197,10 +197,10 @@ class DjareaFormMixin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Configuration - subclasses must define this
|
# Configuration - subclasses must define this
|
||||||
djarea: ClassVar[DjareaFormMeta]
|
mizan: ClassVar[mizanFormMeta]
|
||||||
|
|
||||||
# Track registered forms to avoid duplicate registration
|
# Track registered forms to avoid duplicate registration
|
||||||
_djarea_registered: ClassVar[bool] = False
|
_mizan_registered: ClassVar[bool] = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||||
@@ -236,9 +236,7 @@ class DjareaFormMixin:
|
|||||||
return result
|
return result
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_submit_failure(
|
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||||
self, request: HttpRequest, errors: "FormValidation"
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Called after form validation fails.
|
Called after form validation fails.
|
||||||
|
|
||||||
@@ -250,23 +248,23 @@ class DjareaFormMixin:
|
|||||||
"""Auto-register when a concrete form class is defined."""
|
"""Auto-register when a concrete form class is defined."""
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
# Only register concrete forms with djarea config defined
|
# Only register concrete forms with mizan config defined
|
||||||
if _is_concrete_djarea_form(cls):
|
if _is_concrete_mizan_form(cls):
|
||||||
_register_form_as_server_functions(cls)
|
_register_form_as_server_functions(cls)
|
||||||
|
|
||||||
|
|
||||||
def _is_concrete_djarea_form(cls: type) -> bool:
|
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a class is a concrete Djarea form ready for registration.
|
Check if a class is a concrete mizan form ready for registration.
|
||||||
|
|
||||||
A form is concrete if:
|
A form is concrete if:
|
||||||
1. It has a `djarea` attribute that is a DjareaFormMeta instance
|
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||||
2. It inherits from Django's BaseForm
|
2. It inherits from Django's BaseForm
|
||||||
3. It hasn't been registered yet (for this class definition)
|
3. It hasn't been registered yet (for this class definition)
|
||||||
"""
|
"""
|
||||||
# Must have djarea config (check cls.__dict__ to avoid inheriting)
|
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||||
djarea_config = cls.__dict__.get("djarea")
|
mizan_config = cls.__dict__.get("mizan")
|
||||||
if not isinstance(djarea_config, DjareaFormMeta):
|
if not isinstance(mizan_config, mizanFormMeta):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Must be a Django form
|
# Must be a Django form
|
||||||
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if already registered (handle re-imports gracefully)
|
# Check if already registered (handle re-imports gracefully)
|
||||||
if cls.__dict__.get("_djarea_registered", False):
|
if cls.__dict__.get("_mizan_registered", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
|||||||
|
|
||||||
def _register_form_as_server_functions(form_class: type) -> None:
|
def _register_form_as_server_functions(form_class: type) -> None:
|
||||||
"""
|
"""
|
||||||
Register a Django Form class as Djarea server functions.
|
Register a Django Form class as mizan server functions.
|
||||||
|
|
||||||
Creates and registers:
|
Creates and registers:
|
||||||
- {name}.schema - Returns form field definitions
|
- {name}.schema - Returns form field definitions
|
||||||
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import validate_form_instance
|
from .validation_utils import validate_form_instance
|
||||||
from djarea.setup.registry import register
|
from mizan.setup.registry import register
|
||||||
from djarea.client.function import ServerFunction
|
from mizan.client.function import ServerFunction
|
||||||
|
|
||||||
config: DjareaFormMeta = form_class.djarea
|
config: mizanFormMeta = form_class.mizan
|
||||||
form_name = config.name
|
form_name = config.name
|
||||||
|
|
||||||
# Mark as registered
|
# Mark as registered
|
||||||
form_class._djarea_registered = True
|
form_class._mizan_registered = True
|
||||||
|
|
||||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
pascal_name = "".join(
|
||||||
|
word.capitalize()
|
||||||
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||||
@@ -346,7 +347,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
data=input.data if input else {},
|
data=input.data if input else {},
|
||||||
**init_kwargs,
|
**init_kwargs,
|
||||||
)
|
)
|
||||||
# Override with DjareaFormMeta values
|
# Override with mizanFormMeta values
|
||||||
if config.title is not None:
|
if config.title is not None:
|
||||||
schema.title = config.title
|
schema.title = config.title
|
||||||
if config.subtitle is not None:
|
if config.subtitle is not None:
|
||||||
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
|||||||
request = self.request
|
request = self.request
|
||||||
|
|
||||||
# Check if we have multipart data from executor
|
# Check if we have multipart data from executor
|
||||||
if hasattr(request, "_djarea_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
data = request._djarea_form_data
|
data = request._mizan_form_data
|
||||||
files = request._djarea_form_files
|
files = request._mizan_form_files
|
||||||
elif input is not None:
|
elif input is not None:
|
||||||
# JSON input - already a dict
|
# JSON input - already a dict
|
||||||
data = input if isinstance(input, dict) else input.model_dump()
|
data = input if isinstance(input, dict) else input.model_dump()
|
||||||
@@ -474,17 +475,25 @@ def _register_formset_functions(
|
|||||||
"""Register formset server functions for a form."""
|
"""Register formset server functions for a form."""
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
|
||||||
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation
|
from .schemas import (
|
||||||
|
FormsetSchema,
|
||||||
|
FormsetSubmitFail,
|
||||||
|
FormsetSubmitPass,
|
||||||
|
FormsetValidation,
|
||||||
|
)
|
||||||
from .schema_utils import build_form_schema
|
from .schema_utils import build_form_schema
|
||||||
from .validation_utils import build_formset_validation
|
from .validation_utils import build_formset_validation
|
||||||
from .formset_utils import forms_to_formset_post_data
|
from .formset_utils import forms_to_formset_post_data
|
||||||
from djarea.setup.registry import register
|
from mizan.setup.registry import register
|
||||||
from djarea.client.function import ServerFunction
|
from mizan.client.function import ServerFunction
|
||||||
|
|
||||||
formset_class = formset_factory(form_class)
|
formset_class = formset_factory(form_class)
|
||||||
|
|
||||||
# Generate PascalCase name for schemas
|
# Generate PascalCase name for schemas
|
||||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
pascal_name = "".join(
|
||||||
|
word.capitalize()
|
||||||
|
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||||
@@ -590,10 +599,10 @@ def _register_formset_functions(
|
|||||||
init_kwargs = form_class.get_init_kwargs(request)
|
init_kwargs = form_class.get_init_kwargs(request)
|
||||||
|
|
||||||
# Handle multipart vs JSON
|
# Handle multipart vs JSON
|
||||||
if hasattr(request, "_djarea_form_data"):
|
if hasattr(request, "_mizan_form_data"):
|
||||||
post_data = request._djarea_form_data
|
post_data = request._mizan_form_data
|
||||||
files = request._djarea_form_files
|
files = request._mizan_form_files
|
||||||
elif input and hasattr(input, 'forms'):
|
elif input and hasattr(input, "forms"):
|
||||||
# Input.forms is already a list of dicts
|
# Input.forms is already a list of dicts
|
||||||
forms_data = input.forms
|
forms_data = input.forms
|
||||||
post_data = forms_to_formset_post_data(forms_data)
|
post_data = forms_to_formset_post_data(forms_data)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Allauth Integration
|
mizan Allauth Integration
|
||||||
|
|
||||||
Backend support for django-allauth with Djarea server functions.
|
Backend support for django-allauth with mizan server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
- Auth contexts (auth_status, user) - required by frontend allauth module
|
||||||
@@ -11,8 +11,8 @@ Usage:
|
|||||||
# In your app's apps.py
|
# In your app's apps.py
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import djarea.allauth.forms # noqa - registers forms
|
import mizan.allauth.forms # noqa - registers forms
|
||||||
import djarea.allauth.contexts # noqa - registers contexts
|
import mizan.allauth.contexts # noqa - registers contexts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Auth contexts for Djarea Allauth integration.
|
Auth contexts for mizan Allauth integration.
|
||||||
|
|
||||||
These are the core auth primitives that the frontend allauth module depends on.
|
These are the core auth primitives that the frontend allauth module depends on.
|
||||||
Separated into two concerns:
|
Separated into two concerns:
|
||||||
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -23,13 +23,14 @@ from djarea.client import client
|
|||||||
|
|
||||||
class AuthStatusOutput(BaseModel):
|
class AuthStatusOutput(BaseModel):
|
||||||
"""Authentication status and permission guards."""
|
"""Authentication status and permission guards."""
|
||||||
|
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
is_staff: bool = False
|
is_staff: bool = False
|
||||||
is_superuser: bool = False
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
@client(context='global')
|
@client(context="global")
|
||||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||||
"""
|
"""
|
||||||
Auth status context - provides authentication state and guards.
|
Auth status context - provides authentication state and guards.
|
||||||
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
|||||||
|
|
||||||
class UserOutput(BaseModel):
|
class UserOutput(BaseModel):
|
||||||
"""Full user profile data."""
|
"""Full user profile data."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
first_name: str = ""
|
first_name: str = ""
|
||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
@client(context='global')
|
@client(context="global")
|
||||||
def user(request: HttpRequest) -> UserOutput | None:
|
def user(request: HttpRequest) -> UserOutput | None:
|
||||||
"""
|
"""
|
||||||
User profile context - provides full user data.
|
User profile context - provides full user data.
|
||||||
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if we have full user data or just JWT claims
|
# Check if we have full user data or just JWT claims
|
||||||
if hasattr(req_user, 'email') and req_user.email:
|
if hasattr(req_user, "email") and req_user.email:
|
||||||
# Full User object (session auth)
|
# Full User object (session auth)
|
||||||
return UserOutput(
|
return UserOutput(
|
||||||
id=req_user.id,
|
id=req_user.id,
|
||||||
email=req_user.email,
|
email=req_user.email,
|
||||||
first_name=getattr(req_user, 'first_name', '') or '',
|
first_name=getattr(req_user, "first_name", "") or "",
|
||||||
last_name=getattr(req_user, 'last_name', '') or '',
|
last_name=getattr(req_user, "last_name", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# JWTUser - need to fetch from DB
|
# JWTUser - need to fetch from DB
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
|
|||||||
return UserOutput(
|
return UserOutput(
|
||||||
id=db_user.id,
|
id=db_user.id,
|
||||||
email=db_user.email,
|
email=db_user.email,
|
||||||
first_name=db_user.first_name or '',
|
first_name=db_user.first_name or "",
|
||||||
last_name=db_user.last_name or '',
|
last_name=db_user.last_name or "",
|
||||||
)
|
)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Allauth forms as Djarea server functions.
|
Allauth forms as mizan server functions.
|
||||||
|
|
||||||
This module wraps allauth forms with DjareaFormMixin, exposing them as
|
This module wraps allauth forms with mizanFormMixin, exposing them as
|
||||||
typed server functions for the React frontend.
|
typed server functions for the React frontend.
|
||||||
|
|
||||||
Each form becomes three server functions:
|
Each form becomes three server functions:
|
||||||
@@ -13,7 +13,7 @@ Import this module in your app's ready() to register the forms:
|
|||||||
|
|
||||||
class MyAppConfig(AppConfig):
|
class MyAppConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import djarea.allauth.forms # noqa
|
import mizan.allauth.forms # noqa
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||||
|
|
||||||
# Account forms
|
# Account forms
|
||||||
from allauth.account.forms import (
|
from allauth.account.forms import (
|
||||||
@@ -41,6 +41,7 @@ from allauth.account.forms import (
|
|||||||
# Password reauthentication form - conditionally import
|
# Password reauthentication form - conditionally import
|
||||||
try:
|
try:
|
||||||
from allauth.account.forms import ReauthenticateForm
|
from allauth.account.forms import ReauthenticateForm
|
||||||
|
|
||||||
HAS_REAUTH = True
|
HAS_REAUTH = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_REAUTH = False
|
HAS_REAUTH = False
|
||||||
@@ -51,6 +52,7 @@ try:
|
|||||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
||||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
||||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
||||||
|
|
||||||
HAS_MFA = True
|
HAS_MFA = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_MFA = False
|
HAS_MFA = False
|
||||||
@@ -58,22 +60,24 @@ except ImportError:
|
|||||||
# WebAuthn forms (if available)
|
# WebAuthn forms (if available)
|
||||||
try:
|
try:
|
||||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
||||||
|
|
||||||
HAS_WEBAUTHN = True
|
HAS_WEBAUTHN = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_WEBAUTHN = False
|
HAS_WEBAUTHN = False
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from djarea.forms.schemas import FormValidation
|
from mizan.forms.schemas import FormValidation
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Account Forms
|
# Account Forms
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
|
||||||
|
class mizanLoginForm(LoginForm, mizanFormMixin):
|
||||||
"""Sign in with email and password."""
|
"""Sign in with email and password."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="login",
|
name="login",
|
||||||
title="Sign In",
|
title="Sign In",
|
||||||
subtitle="Welcome back. Enter your credentials to continue.",
|
subtitle="Welcome back. Enter your credentials to continue.",
|
||||||
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
class mizanSignupForm(SignupForm, mizanFormMixin):
|
||||||
"""Create a new account."""
|
"""Create a new account."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="signup",
|
name="signup",
|
||||||
title="Create Account",
|
title="Create Account",
|
||||||
subtitle="Enter your details to get started.",
|
subtitle="Enter your details to get started.",
|
||||||
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
||||||
"""Add another email address to your account."""
|
"""Add another email address to your account."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="add_email",
|
name="add_email",
|
||||||
title="Add Email Address",
|
title="Add Email Address",
|
||||||
subtitle="Add another email address to your account.",
|
subtitle="Add another email address to your account.",
|
||||||
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
||||||
"""Change your account password."""
|
"""Change your account password."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="change_password",
|
name="change_password",
|
||||||
title="Change Password",
|
title="Change Password",
|
||||||
subtitle="Update your password to keep your account secure.",
|
subtitle="Update your password to keep your account secure.",
|
||||||
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
||||||
"""Set a password for accounts created via social login."""
|
"""Set a password for accounts created via social login."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="set_password",
|
name="set_password",
|
||||||
title="Set Password",
|
title="Set Password",
|
||||||
subtitle="Create a password for your account.",
|
subtitle="Create a password for your account.",
|
||||||
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
||||||
"""Request a password reset email."""
|
"""Request a password reset email."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reset_password",
|
name="reset_password",
|
||||||
title="Reset Password",
|
title="Reset Password",
|
||||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
||||||
@@ -185,10 +189,10 @@ class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
||||||
"""Set a new password using a reset key."""
|
"""Set a new password using a reset key."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reset_password_from_key",
|
name="reset_password_from_key",
|
||||||
title="Set New Password",
|
title="Set New Password",
|
||||||
subtitle="Enter your new password below.",
|
subtitle="Enter your new password below.",
|
||||||
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
||||||
"""Request a login code via email."""
|
"""Request a login code via email."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="request_login_code",
|
name="request_login_code",
|
||||||
title="Sign In with Code",
|
title="Sign In with Code",
|
||||||
subtitle="Enter your email address and we'll send you a login code.",
|
subtitle="Enter your email address and we'll send you a login code.",
|
||||||
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
||||||
"""Confirm a login code."""
|
"""Confirm a login code."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="confirm_login_code",
|
name="confirm_login_code",
|
||||||
title="Enter Code",
|
title="Enter Code",
|
||||||
subtitle="Enter the code we sent to your email.",
|
subtitle="Enter the code we sent to your email.",
|
||||||
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
||||||
"""Verify an email with a token."""
|
"""Verify an email with a token."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="user_token",
|
name="user_token",
|
||||||
title="Verify Email",
|
title="Verify Email",
|
||||||
subtitle="Enter the verification code from your email.",
|
subtitle="Enter the verification code from your email.",
|
||||||
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
|||||||
|
|
||||||
# Password reauthentication - conditionally define
|
# Password reauthentication - conditionally define
|
||||||
if HAS_REAUTH:
|
if HAS_REAUTH:
|
||||||
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
|
|
||||||
|
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
||||||
"""Re-authenticate with password for sensitive actions."""
|
"""Re-authenticate with password for sensitive actions."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="reauthenticate",
|
name="reauthenticate",
|
||||||
title="Confirm Your Identity",
|
title="Confirm Your Identity",
|
||||||
subtitle="Please enter your password to continue.",
|
subtitle="Please enter your password to continue.",
|
||||||
@@ -280,6 +285,7 @@ if HAS_REAUTH:
|
|||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||||
from allauth.account.internal.flows import reauthentication
|
from allauth.account.internal.flows import reauthentication
|
||||||
|
|
||||||
reauthentication.reauthenticate_by_password(request)
|
reauthentication.reauthenticate_by_password(request)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -289,10 +295,11 @@ if HAS_REAUTH:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
if HAS_MFA:
|
if HAS_MFA:
|
||||||
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
|
|
||||||
|
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
||||||
"""Authenticate with MFA during login."""
|
"""Authenticate with MFA during login."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="mfa_authenticate",
|
name="mfa_authenticate",
|
||||||
title="Two-Factor Authentication",
|
title="Two-Factor Authentication",
|
||||||
subtitle="Enter your authentication code to continue.",
|
subtitle="Enter your authentication code to continue.",
|
||||||
@@ -307,10 +314,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin):
|
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
||||||
"""Re-authenticate with MFA for sensitive actions."""
|
"""Re-authenticate with MFA for sensitive actions."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="mfa_reauthenticate",
|
name="mfa_reauthenticate",
|
||||||
title="Confirm Your Identity",
|
title="Confirm Your Identity",
|
||||||
subtitle="Enter your authentication code to continue.",
|
subtitle="Enter your authentication code to continue.",
|
||||||
@@ -325,10 +332,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin):
|
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
||||||
"""Activate TOTP authenticator."""
|
"""Activate TOTP authenticator."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="activate_totp",
|
name="activate_totp",
|
||||||
title="Set Up Authenticator",
|
title="Set Up Authenticator",
|
||||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
subtitle="Enter the code from your authenticator app to complete setup.",
|
||||||
@@ -343,10 +350,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin):
|
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
||||||
"""Deactivate TOTP authenticator."""
|
"""Deactivate TOTP authenticator."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="deactivate_totp",
|
name="deactivate_totp",
|
||||||
title="Disable Authenticator",
|
title="Disable Authenticator",
|
||||||
subtitle="Enter your password to disable two-factor authentication.",
|
subtitle="Enter your password to disable two-factor authentication.",
|
||||||
@@ -361,10 +368,10 @@ if HAS_MFA:
|
|||||||
self.save()
|
self.save()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin):
|
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
||||||
"""Generate new recovery codes."""
|
"""Generate new recovery codes."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="generate_recovery_codes",
|
name="generate_recovery_codes",
|
||||||
title="Recovery Codes",
|
title="Recovery Codes",
|
||||||
subtitle="Generate new recovery codes for your account.",
|
subtitle="Generate new recovery codes for your account.",
|
||||||
@@ -381,10 +388,11 @@ if HAS_MFA:
|
|||||||
|
|
||||||
|
|
||||||
if HAS_WEBAUTHN:
|
if HAS_WEBAUTHN:
|
||||||
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
|
|
||||||
|
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
||||||
"""Authenticate with WebAuthn security key."""
|
"""Authenticate with WebAuthn security key."""
|
||||||
|
|
||||||
djarea = DjareaFormMeta(
|
mizan = mizanFormMeta(
|
||||||
name="webauthn_authenticate",
|
name="webauthn_authenticate",
|
||||||
title="Security Key",
|
title="Security Key",
|
||||||
subtitle="Use your security key to authenticate.",
|
subtitle="Use your security key to authenticate.",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
djarea.jwt - JWT authentication for server functions.
|
mizan.jwt - JWT authentication for server functions.
|
||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
- Server functions for obtaining/refreshing JWT tokens
|
- Server functions for obtaining/refreshing JWT tokens
|
||||||
@@ -10,10 +10,10 @@ Server Functions:
|
|||||||
- jwt_refresh: Refresh tokens using a refresh token
|
- jwt_refresh: Refresh tokens using a refresh token
|
||||||
|
|
||||||
Usage in apps.py or urls.py (to register the functions):
|
Usage in apps.py or urls.py (to register the functions):
|
||||||
import djarea.jwt.functions # noqa: F401
|
import mizan.jwt.functions # noqa: F401
|
||||||
|
|
||||||
Note: This module is purpose-built for Djarea server functions.
|
Note: This module is purpose-built for mizan server functions.
|
||||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Server functions (import to register with @client decorator)
|
# Server functions (import to register with @client decorator)
|
||||||
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
|
|||||||
|
|
||||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||||
# django-ninja's settings access at module load time.
|
# django-ninja's settings access at module load time.
|
||||||
# Use: from djarea.jwt.security import jwt_auth
|
# Use: from mizan.jwt.security import jwt_auth
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name):
|
def __getattr__(name):
|
||||||
if name in ("JWTAuth", "jwt_auth"):
|
if name in ("JWTAuth", "jwt_auth"):
|
||||||
from .security import JWTAuth, jwt_auth
|
from .security import JWTAuth, jwt_auth
|
||||||
|
|
||||||
globals()["JWTAuth"] = JWTAuth
|
globals()["JWTAuth"] = JWTAuth
|
||||||
globals()["jwt_auth"] = jwt_auth
|
globals()["jwt_auth"] = jwt_auth
|
||||||
return globals()[name]
|
return globals()[name]
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
JWT Server Functions
|
JWT Server Functions
|
||||||
|
|
||||||
JWT token operations exposed as djarea server functions.
|
JWT token operations exposed as mizan server functions.
|
||||||
Works over WebSocket RPC (primary) or HTTP fallback.
|
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from djarea.client import client
|
from mizan.client import client
|
||||||
from djarea.jwt.tokens import create_token_pair, refresh_tokens
|
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||||
|
|
||||||
|
|
||||||
class TokenPairOutput(BaseModel):
|
class TokenPairOutput(BaseModel):
|
||||||
"""JWT token pair response."""
|
"""JWT token pair response."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
expires_in: int
|
expires_in: int
|
||||||
@@ -21,6 +22,7 @@ class TokenPairOutput(BaseModel):
|
|||||||
|
|
||||||
class JWTError(BaseModel):
|
class JWTError(BaseModel):
|
||||||
"""JWT operation error."""
|
"""JWT operation error."""
|
||||||
|
|
||||||
error: str
|
error: str
|
||||||
|
|
||||||
|
|
||||||
@@ -45,10 +47,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
|||||||
raise PermissionError("Authentication required")
|
raise PermissionError("Authentication required")
|
||||||
|
|
||||||
# Get session key - for WebSocket, this comes from the scope
|
# Get session key - for WebSocket, this comes from the scope
|
||||||
session = getattr(request, 'session', None)
|
session = getattr(request, "session", None)
|
||||||
if session is None:
|
if session is None:
|
||||||
# WebSocket request adapter - session is a dict, not SessionBase
|
# WebSocket request adapter - session is a dict, not SessionBase
|
||||||
session_key = getattr(request, '_scope', {}).get('session', {}).get('_session_key')
|
session_key = (
|
||||||
|
getattr(request, "_scope", {}).get("session", {}).get("_session_key")
|
||||||
|
)
|
||||||
if not session_key:
|
if not session_key:
|
||||||
raise PermissionError("No session available")
|
raise PermissionError("No session available")
|
||||||
else:
|
else:
|
||||||
@@ -61,8 +65,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
|||||||
tokens = create_token_pair(
|
tokens = create_token_pair(
|
||||||
user.pk,
|
user.pk,
|
||||||
session_key,
|
session_key,
|
||||||
is_staff=getattr(user, 'is_staff', False),
|
is_staff=getattr(user, "is_staff", False),
|
||||||
is_superuser=getattr(user, 'is_superuser', False),
|
is_superuser=getattr(user, "is_superuser", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
return TokenPairOutput(
|
return TokenPairOutput(
|
||||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from djarea.channels import get_channels_openapi_schema
|
from mizan.channels import get_channels_openapi_schema
|
||||||
|
|
||||||
schema = get_channels_openapi_schema()
|
schema = get_channels_openapi_schema()
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Export Djarea Schema
|
Export mizan Schema
|
||||||
|
|
||||||
Management command to export the djarea OpenAPI schema for TypeScript code generation.
|
Management command to export the mizan OpenAPI schema for TypeScript code generation.
|
||||||
The schema is consumed by openapi-typescript for robust type generation.
|
The schema is consumed by openapi-typescript for robust type generation.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py export_djarea_schema # Output to stdout
|
python manage.py export_mizan_schema # Output to stdout
|
||||||
python manage.py export_djarea_schema --output schema.json # Output to file
|
python manage.py export_mizan_schema --output schema.json # Output to file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -14,11 +14,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from djarea.export import generate_openapi_schema
|
from mizan.export import generate_openapi_schema
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Export djarea OpenAPI schema for TypeScript code generation"
|
help = "Export mizan OpenAPI schema for TypeScript code generation"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -44,8 +44,6 @@ class Command(BaseCommand):
|
|||||||
output_path = Path(options["output"])
|
output_path = Path(options["output"])
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_path.write_text(json_output)
|
output_path.write_text(json_output)
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
|
||||||
self.style.SUCCESS(f"Schema written to {output_path}")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.stdout.write(json_output)
|
self.stdout.write(json_output)
|
||||||
@@ -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
|
- Registry for server functions and channels
|
||||||
- Auto-discovery for apps
|
- Auto-discovery for apps
|
||||||
- Configuration settings
|
- Configuration settings
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from djarea.setup import djarea_clients, register, get_function
|
from mizan.setup import mizan_clients, register, get_function
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .registry import (
|
from .registry import (
|
||||||
@@ -25,17 +25,18 @@ from .registry import (
|
|||||||
get_registry,
|
get_registry,
|
||||||
get_schema,
|
get_schema,
|
||||||
get_contexts,
|
get_contexts,
|
||||||
|
get_context_groups,
|
||||||
get_forms,
|
get_forms,
|
||||||
clear_registry,
|
clear_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .discovery import (
|
from .discovery import (
|
||||||
djarea_clients,
|
mizan_clients,
|
||||||
djarea_module,
|
mizan_module,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .settings import (
|
from .settings import (
|
||||||
DjareaSettings,
|
mizanSettings,
|
||||||
get_settings,
|
get_settings,
|
||||||
clear_settings_cache,
|
clear_settings_cache,
|
||||||
)
|
)
|
||||||
@@ -57,13 +58,14 @@ __all__ = [
|
|||||||
"get_registry",
|
"get_registry",
|
||||||
"get_schema",
|
"get_schema",
|
||||||
"get_contexts",
|
"get_contexts",
|
||||||
|
"get_context_groups",
|
||||||
"get_forms",
|
"get_forms",
|
||||||
"clear_registry",
|
"clear_registry",
|
||||||
# Discovery
|
# Discovery
|
||||||
"djarea_clients",
|
"mizan_clients",
|
||||||
"djarea_module",
|
"mizan_module",
|
||||||
# Settings
|
# Settings
|
||||||
"DjareaSettings",
|
"mizanSettings",
|
||||||
"get_settings",
|
"get_settings",
|
||||||
"clear_settings_cache",
|
"clear_settings_cache",
|
||||||
]
|
]
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Auto-Discovery
|
mizan Auto-Discovery
|
||||||
|
|
||||||
Scans Django apps for server functions following the 'clients' layer convention:
|
Scans Django apps for server functions following the 'clients' layer convention:
|
||||||
- <app>/clients.py
|
- <app>/clients.py
|
||||||
- <app>/clients/**/*.py
|
- <app>/clients/**/*.py
|
||||||
|
|
||||||
Usage in urls.py:
|
Usage in urls.py:
|
||||||
from djarea.setup.discovery import djarea_clients
|
from mizan.setup.discovery import mizan_clients
|
||||||
|
|
||||||
djarea_clients('apps') # Scans apps/*/clients.py
|
mizan_clients('apps') # Scans apps/*/clients.py
|
||||||
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py
|
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
||||||
|
|
||||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
This replaces manual "import to register" patterns with explicit auto-discovery.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members
|
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||||
|
|
||||||
from .registry import register, get_function
|
from .registry import register, get_function
|
||||||
from djarea.client.function import ServerFunction
|
from mizan.client.function import ServerFunction
|
||||||
|
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
class _RegisterServerFunctions:
|
||||||
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
|
|||||||
isinstance(member, type)
|
isinstance(member, type)
|
||||||
and issubclass(member, ServerFunction)
|
and issubclass(member, ServerFunction)
|
||||||
and member is not ServerFunction
|
and member is not ServerFunction
|
||||||
and hasattr(member, '__name__')
|
and hasattr(member, "__name__")
|
||||||
):
|
):
|
||||||
# Use the function name as registration name
|
# Use the function name as registration name
|
||||||
fn_name = getattr(member, 'name', None) or member.__name__
|
fn_name = getattr(member, "name", None) or member.__name__
|
||||||
|
|
||||||
# Skip already registered (idempotent)
|
# Skip already registered (idempotent)
|
||||||
if get_function(fn_name) is member:
|
if get_function(fn_name) is member:
|
||||||
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||||
"""
|
"""
|
||||||
Discover and register server functions from Django apps.
|
Discover and register server functions from Django apps.
|
||||||
|
|
||||||
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
# In urls.py
|
# In urls.py
|
||||||
djarea_clients('apps') # Scans apps/*/clients.py
|
mizan_clients('apps') # Scans apps/*/clients.py
|
||||||
djarea_clients('apps', 'functions') # Scans apps/*/functions.py
|
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||||
"""
|
"""
|
||||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||||
visitor.visit(_RegisterServerFunctions())
|
visitor.visit(_RegisterServerFunctions())
|
||||||
|
|
||||||
|
|
||||||
def djarea_module(module_path: str) -> None:
|
def mizan_module(module_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
Register server functions from a specific module.
|
Register server functions from a specific module.
|
||||||
|
|
||||||
Use this for library modules that don't follow the app convention.
|
Use this for library modules that don't follow the app convention.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
module_path: Full module path (e.g., 'djarea.integrations.allauth')
|
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
djarea_module('djarea.integrations.allauth')
|
mizan_module('mizan.integrations.allauth')
|
||||||
djarea_module('djarea.jwt.functions')
|
mizan_module('mizan.jwt.functions')
|
||||||
"""
|
"""
|
||||||
members = get_members(module_path)
|
members = get_members(module_path)
|
||||||
handler = _RegisterServerFunctions()
|
handler = _RegisterServerFunctions()
|
||||||
handler.on_module('', [], members)
|
handler.on_module("", [], members)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Registry
|
mizan Registry
|
||||||
|
|
||||||
Central registration for server functions, channels, and compositions.
|
Central registration for server functions, channels, and compositions.
|
||||||
All items are identified by name.
|
All items are identified by name.
|
||||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from djarea.client.function import ServerFunction, ComposedContext
|
from mizan.client.function import ServerFunction, ComposedContext
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
|
|
||||||
# Global registries - all use name as key
|
# Global registries - all use name as key
|
||||||
@@ -34,8 +34,8 @@ def register(
|
|||||||
Returns:
|
Returns:
|
||||||
The view class (allows use as part of decorator chain)
|
The view class (allows use as part of decorator chain)
|
||||||
"""
|
"""
|
||||||
from djarea.client.function import ServerFunction
|
from mizan.client.function import ServerFunction
|
||||||
from djarea.channels import ReactChannel
|
from mizan.channels import ReactChannel
|
||||||
|
|
||||||
view_class.name = name
|
view_class.name = name
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def register_form(
|
|||||||
Usage:
|
Usage:
|
||||||
register_form(ContactForm, 'contact', submit_handler=handle_contact)
|
register_form(ContactForm, 'contact', submit_handler=handle_contact)
|
||||||
"""
|
"""
|
||||||
from djarea.client.function import create_form_functions
|
from mizan.client.function import create_form_functions
|
||||||
|
|
||||||
schema_fn, validate_fn, submit_fn = create_form_functions(
|
schema_fn, validate_fn, submit_fn = create_form_functions(
|
||||||
form_class, name, submit_handler
|
form_class, name, submit_handler
|
||||||
@@ -130,9 +130,7 @@ def register_compose(
|
|||||||
# Same composition being re-registered (reload scenario)
|
# Same composition being re-registered (reload scenario)
|
||||||
_compositions[name] = composed
|
_compositions[name] = composed
|
||||||
return composed
|
return composed
|
||||||
raise ValueError(
|
raise ValueError(f"Composition '{name}' already registered by {existing.name}")
|
||||||
f"Composition '{name}' already registered by {existing.name}"
|
|
||||||
)
|
|
||||||
_compositions[name] = composed
|
_compositions[name] = composed
|
||||||
return composed
|
return composed
|
||||||
|
|
||||||
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Extract Params schema (only if defined)
|
# Extract Params schema (only if defined)
|
||||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||||
|
|
||||||
# Extract ReactMessage schema (only if defined - indicates bidirectional)
|
# Extract ReactMessage schema (only if defined - indicates bidirectional)
|
||||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"react_message"
|
||||||
|
] = channel_class.ReactMessage.model_json_schema()
|
||||||
channel_schema["bidirectional"] = True
|
channel_schema["bidirectional"] = True
|
||||||
|
|
||||||
# Extract DjangoMessage schema (only if defined)
|
# Extract DjangoMessage schema (only if defined)
|
||||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
channel_schema[
|
||||||
|
"django_message"
|
||||||
|
] = channel_class.DjangoMessage.model_json_schema()
|
||||||
|
|
||||||
channels_schema[name] = channel_schema
|
channels_schema[name] = channel_schema
|
||||||
|
|
||||||
@@ -288,6 +290,21 @@ def get_contexts() -> dict[str, type["ServerFunction"]]:
|
|||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_groups() -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Group function names by their context string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"global": ["current_user"], "user": ["user_profile", "user_orders"]}
|
||||||
|
"""
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for name, cls in _functions.items():
|
||||||
|
ctx = getattr(cls, "_meta", {}).get("context")
|
||||||
|
if ctx:
|
||||||
|
groups.setdefault(ctx, []).append(name)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def get_forms() -> dict[str, list[type["ServerFunction"]]]:
|
def get_forms() -> dict[str, list[type["ServerFunction"]]]:
|
||||||
"""
|
"""
|
||||||
Get all server functions that are form-related, grouped by form name.
|
Get all server functions that are form-related, grouped by form name.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Settings
|
mizan Settings
|
||||||
|
|
||||||
Configuration is read from Django settings with sensible defaults.
|
Configuration is read from Django settings with sensible defaults.
|
||||||
"""
|
"""
|
||||||
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DjareaSettings:
|
class mizanSettings:
|
||||||
"""Djarea configuration."""
|
"""mizan configuration."""
|
||||||
|
|
||||||
# Whether to expose function names in DEBUG mode errors
|
# Whether to expose function names in DEBUG mode errors
|
||||||
debug_expose_names: bool
|
debug_expose_names: bool
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> DjareaSettings:
|
def get_settings() -> mizanSettings:
|
||||||
"""
|
"""
|
||||||
Load Djarea settings from Django settings.
|
Load mizan settings from Django settings.
|
||||||
|
|
||||||
Settings:
|
Settings:
|
||||||
DJAREA_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||||
"""
|
"""
|
||||||
return DjareaSettings(
|
return mizanSettings(
|
||||||
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True),
|
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user