356 lines
10 KiB
Markdown
356 lines
10 KiB
Markdown
# DJAREA
|
|
|
|
A modern Django + React Framework for perfectionists with deadlines.
|
|
|
|
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
|
|
|
|
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
|
|
|
|
No API routing, no serializers, no REST/CRUD bullshit.
|
|
|
|
```python
|
|
@client
|
|
def current_user(request) -> UserShape:
|
|
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
|
```
|
|
|
|
|
|
```tsx
|
|
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
|
|
```
|
|
|
|
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
|
|
|
|
Starts with session auth and upgrades to JWT on login. **It just works**.
|
|
|
|
## What Djarea does
|
|
|
|
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection.
|
|
|
|
```python
|
|
class ArticleShape(Shape[Article]):
|
|
id: int | None = None
|
|
title: str
|
|
author: FlatAuthorShape
|
|
tags: list[TagShape] = []
|
|
```
|
|
|
|
One Djarea **Shape** does three things simultaneously:
|
|
- Defines the Pydantic model for validation and serialization
|
|
- Generates a django-readers spec for a lean, field-scoped SQL query
|
|
- Produces the TypeScript interface on the React side
|
|
|
|
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
|
|
|
|
## Quick start
|
|
|
|
### 1. Django setup
|
|
|
|
```python
|
|
# settings.py
|
|
INSTALLED_APPS = [
|
|
"djarea",
|
|
"myapp",
|
|
]
|
|
|
|
# urls.py
|
|
from django.urls import include, path
|
|
urlpatterns = [
|
|
path("api/djarea/", include("djarea.urls")),
|
|
]
|
|
|
|
# asgi.py (for WebSocket support)
|
|
from djarea import wrap_asgi
|
|
from django.core.asgi import get_asgi_application
|
|
application = wrap_asgi(get_asgi_application())
|
|
```
|
|
|
|
### 2. Define your client functions
|
|
|
|
```python
|
|
# myapp/clients.py
|
|
from djarea.client import client
|
|
from djarea.shapes import Shape
|
|
from pydantic import BaseModel
|
|
|
|
class EchoOutput(BaseModel):
|
|
message: str
|
|
|
|
@client
|
|
def echo(request, text: str) -> EchoOutput:
|
|
return EchoOutput(message=text)
|
|
```
|
|
|
|
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
|
|
|
|
### 3. Generate TypeScript
|
|
|
|
To get your generated React client, set this up in your frontend root:
|
|
|
|
```javascript
|
|
// django.config.mjs
|
|
export default {
|
|
source: {
|
|
django: {
|
|
managePath: '../backend/manage.py',
|
|
command: ['uv', 'run', 'python'],
|
|
},
|
|
},
|
|
output: 'src/api/generated.ts',
|
|
}
|
|
```
|
|
|
|
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
|
|
|
|
```bash
|
|
npx djarea-generate
|
|
```
|
|
|
|
### 4. Use in React
|
|
|
|
```tsx
|
|
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
|
|
|
|
// layout.tsx — one provider, handles everything
|
|
export default function Layout({ children }) {
|
|
return <DjangoContext>{children}</DjangoContext>
|
|
}
|
|
|
|
// page.tsx
|
|
function MyComponent() {
|
|
const user = useCurrentUser()
|
|
const echo = useEcho()
|
|
|
|
const handleClick = async () => {
|
|
try {
|
|
const result = await echo({ text: 'hello' })
|
|
console.log(result.message) // typed
|
|
} catch (e) {
|
|
if (e instanceof DjangoError) {
|
|
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
|
e.getFieldErrors('email') // field-level errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Shapes
|
|
|
|
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
|
|
|
|
```python
|
|
# Full detail page — joins books with chapters
|
|
class AuthorDetailShape(Shape[Author]):
|
|
id: int | None = None
|
|
name: str
|
|
bio: str
|
|
books: list[BookShape] = []
|
|
|
|
# Dropdown menu — two columns, no joins
|
|
class FlatAuthorShape(Shape[Author]):
|
|
id: int | None = None
|
|
name: str
|
|
```
|
|
|
|
```python
|
|
# Detail page: SELECT id, name, bio + prefetch books
|
|
authors = AuthorDetailShape.query()
|
|
|
|
# Dropdown: SELECT id, name. That's it.
|
|
authors = FlatAuthorShape.query()
|
|
```
|
|
|
|
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
|
|
|
|
```python
|
|
@client
|
|
def update_articles(request, articles: list[ArticleShape]) -> dict:
|
|
for article, diff in ArticleShape.diff_many(articles):
|
|
if diff.is_new:
|
|
create_article(article)
|
|
elif diff.changed:
|
|
update_fields(article, diff.changed)
|
|
for tag in diff.tags.created:
|
|
add_tag(article, tag)
|
|
for tag_id in diff.tags.deleted:
|
|
remove_tag(article, tag_id)
|
|
return {"ok": True}
|
|
```
|
|
|
|
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
|
|
|
|
## The `@client` decorator
|
|
|
|
The decorator controls transport, caching, auth, and SSR behavior:
|
|
|
|
| Decorator | React hook | What it does |
|
|
|-----------|-----------|--------------|
|
|
| `@client` | `useEcho()` | HTTP call, returns typed result |
|
|
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
|
|
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
|
|
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
|
|
| `@client(auth=True)` | — | Requires authentication |
|
|
| `@client(auth='staff')` | — | Requires staff status |
|
|
| `@client(auth=my_check)` | — | Custom auth callable |
|
|
|
|
## Forms
|
|
|
|
Django forms become typed React hooks with client-side Zod validation:
|
|
|
|
```python
|
|
class ContactForm(DjareaFormMixin, forms.Form):
|
|
djarea = DjareaFormMeta(
|
|
name="contact",
|
|
title="Contact Us",
|
|
submit_label="Send",
|
|
live_validation=True,
|
|
)
|
|
name = forms.CharField(max_length=100)
|
|
email = forms.EmailField()
|
|
message = forms.CharField(widget=forms.Textarea)
|
|
|
|
def on_submit_success(self, request):
|
|
send_email(self.cleaned_data)
|
|
return {"sent": True}
|
|
```
|
|
|
|
```tsx
|
|
const form = useContactForm()
|
|
|
|
form.schema // field metadata, title, submit label
|
|
form.data // { name: '', email: '', message: '' }
|
|
form.set('email', v) // typed setter
|
|
form.errors // field-level errors (Zod + server)
|
|
form.submit() // → { success: true, data: { sent: true } }
|
|
```
|
|
|
|
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
|
|
|
|
## Channels
|
|
|
|
WebSocket channels with typed messages:
|
|
|
|
```python
|
|
class ChatChannel(ReactChannel):
|
|
class Params(BaseModel):
|
|
room: str
|
|
class ReactMessage(BaseModel):
|
|
text: str
|
|
class DjangoMessage(BaseModel):
|
|
text: str
|
|
user: str
|
|
|
|
def authorize(self, params):
|
|
return self.user.is_authenticated
|
|
|
|
def group(self, params):
|
|
return f"chat_{params.room}"
|
|
|
|
def receive(self, params, msg):
|
|
return self.DjangoMessage(text=msg.text, user=self.user.email)
|
|
```
|
|
|
|
```tsx
|
|
const chat = useChatChannel({ room: 'general' })
|
|
|
|
chat.status // 'connecting' | 'connected' | 'disconnected'
|
|
chat.messages // ChatDjangoMessage[]
|
|
chat.send({ text: 'hello' })
|
|
```
|
|
|
|
## Architecture
|
|
|
|
```
|
|
React app
|
|
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
|
|
├─ useCurrentUser() ← context hook (SSR-hydrated)
|
|
├─ useEcho() ← function hook
|
|
├─ useContactForm() ← form hook (Zod + server validation)
|
|
└─ useChatChannel() ← channel hook (WebSocket)
|
|
│
|
|
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
|
|
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
|
│
|
|
Django executor
|
|
├─ Pydantic input validation
|
|
├─ Auth check
|
|
├─ Function execution
|
|
└─ Pydantic output serialization
|
|
```
|
|
|
|
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
|
|
|
## Code generation
|
|
|
|
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
|
|
|
|
| File | Contents |
|
|
|------|----------|
|
|
| `generated.djarea.ts` | Pydantic model types |
|
|
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
|
|
| `generated.django.server.ts` | SSR hydration helper |
|
|
| `generated.forms.ts` | Form hooks with Zod schemas |
|
|
| `generated.channels.ts` | Channel message types |
|
|
| `generated.channels.hooks.tsx` | Channel hooks |
|
|
| `index.ts` | Re-exports |
|
|
|
|
## Error handling
|
|
|
|
All errors from server functions throw as `DjangoError`:
|
|
|
|
```tsx
|
|
if (e instanceof DjangoError) {
|
|
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
|
|
e.message // human-readable
|
|
e.details // field-level validation errors
|
|
e.isAuthError()
|
|
e.isValidationError()
|
|
e.getFieldErrors('email')
|
|
}
|
|
```
|
|
|
|
## Why RPC instead of REST
|
|
|
|
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
|
|
|
|
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
|
|
|
|
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
|
|
|
|
## Packages
|
|
|
|
| Package | Install |
|
|
|---------|---------|
|
|
| `djarea` (Python) | `pip install djarea` |
|
|
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
|
|
|
|
For WebSocket support: `pip install "djarea[channels]"`
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
# Django
|
|
cd django && uv run pytest
|
|
|
|
# React
|
|
cd react && npm test
|
|
|
|
# E2E (Playwright, real browser + real backend)
|
|
docker compose -f docker-compose.test.yml up -d
|
|
cd e2e/harness && npx djarea-generate && npx playwright test
|
|
|
|
# Everything
|
|
make test-all
|
|
```
|
|
|
|
## Project structure
|
|
|
|
```
|
|
djarea/
|
|
django/ Python package
|
|
react/ TypeScript package
|
|
example/ Integration test backend
|
|
e2e/ Playwright E2E tests
|
|
Makefile Test orchestration
|
|
``` |