Update README.md

This commit is contained in:
2026-03-31 17:15:06 +00:00
parent 51ed2b28c5
commit e8cf00fe0f

291
README.md
View File

@@ -1,29 +1,41 @@
# Djarea
Django + React server functions framework. RPC, not REST.
Django + React server functions. RPC, not REST.
You define Python functions. Djarea generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
Write a Python function. Djarea generates a typed React hook. No routes, no serializers, no endpoint boilerplate.
```python
# Django
@client(context='global')
def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
@client
def current_user(request) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
```
```tsx
// React (generated)
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
const user = useCurrentUser() // typed, cached, SSR-hydrated
```
## Packages
The decorator is the API contract. The Shape is the query plan. The hook is generated. That's it.
| Package | Path | Install |
|---------|------|---------|
| `djarea` (Python) | `django/` | `uv add "djarea[channels] @ git+..."` |
| `@rythazhur/djarea` (TypeScript) | `react/` | `npm install @rythazhur/djarea@git+...` |
## What Djarea does
## Quick Start
A `@client` function in Django becomes a callable hook in React. The function's type signature controls everything — input validation, output serialization, TypeScript types, and SQL projection.
```python
class ArticleShape(Shape[Article]):
id: int | None = None
title: str
author: FlatAuthorShape
tags: list[TagShape] = []
```
This Shape does three things at once:
- Defines the Pydantic model for validation and serialization
- Generates the django-readers spec, so the SQL query selects exactly these fields and nothing else
- Produces the TypeScript type on the React side
One definition. Three layers stay in sync automatically.
## Quick start
### 1. Django setup
@@ -50,35 +62,24 @@ application = wrap_asgi(get_asgi_application())
```python
# myapp/djarea_clients.py
from django.http import HttpRequest
from djarea.client import client
from djarea.setup.registry import register
from djarea.shapes import Shape
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request: HttpRequest, text: str) -> EchoOutput:
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
### 3. Register in apps.py
Functions in `djarea_clients.py` are discovered automatically — same convention as `models.py`.
```python
class MyAppConfig(AppConfig):
name = "myapp"
### 3. Generate TypeScript
def ready(self):
import myapp.djarea_clients # noqa: F401
```
### 4. Generate TypeScript
```bash
# django.config.mjs
```javascript
// django.config.mjs
export default {
source: {
django: {
@@ -94,23 +95,17 @@ export default {
npx djarea-generate
```
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
### 5. Use in React
### 4. Use in React
```tsx
// layout.tsx
import { DjangoContext } from '@/api'
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
// layout.tsx — one provider, handles everything
export default function Layout({ children }) {
return <DjangoContext>{children}</DjangoContext>
}
```
```tsx
// page.tsx
import { useEcho, useCurrentUser, DjangoError } from '@/api'
function MyComponent() {
const user = useCurrentUser()
const echo = useEcho()
@@ -121,89 +116,78 @@ function MyComponent() {
console.log(result.message) // typed
} catch (e) {
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
}
}
}
}
```
## Features
## Shapes
| 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 |
| `DjareaFormMixin` | `useXxxForm()` + Zod validation | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket |
| `@compose(...)` | Combined providers | varies |
Shapes are Djarea's projection system. 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.
## Architecture
```python
# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
```
React app
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
├─ useEcho() ← generated function hook
├─ useContactForm() ← generated form hook (Zod + server validation)
└─ useChatChannel() ← generated channel hook (WebSocket)
├─ HTTP: POST /api/djarea/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
# Dropdown menu — two columns, no joins
class FlatAuthorShape(Shape[Author]):
id: int | None = None
name: str
```
The generated `DjangoContext` is the **only provider** needed. It wraps `DjareaProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
```python
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()
## Code Generation
`npx djarea-generate` reads Django schemas (no running server needed) and produces:
| File | Contents |
|------|----------|
| `generated.djarea.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')
}
}
# Dropdown: SELECT id, name. That's it.
authors = FlatAuthorShape.query()
```
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
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 get typed React hooks with client-side Zod validation:
Django forms become typed React hooks with client-side Zod validation:
```python
# Django
class ContactForm(DjareaFormMixin, forms.Form):
djarea = DjareaFormMeta(
name="contact",
@@ -221,22 +205,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
```
```tsx
// React (generated)
const form = useContactForm()
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
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
# Django
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
@@ -257,7 +241,6 @@ class ChatChannel(ReactChannel):
```
```tsx
// React (generated)
const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected'
@@ -265,32 +248,98 @@ 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 unit tests
cd django && uv sync --extra dev --extra channels && uv run pytest
# Django
cd django && uv run pytest
# React unit tests
# React
cd react && npm test
# E2E integration tests (real browser, real backend)
# E2E (Playwright, real browser + real backend)
docker compose -f docker-compose.test.yml up -d
cd e2e/harness && npm install && npx djarea-generate && npx vite --port 5174 &
npx playwright test
cd e2e/harness && npx djarea-generate && npx playwright test
# All at once
# Everything
make test-all
```
## Project Structure
## Project structure
```
djarea/
django/ Python package (djarea)
react/ TypeScript package (@rythazhur/djarea)
example/ Integration test backend (Docker)
desktop/ PyWebView desktop test app
e2e/ Playwright E2E tests + React harness
django/ Python package
react/ TypeScript package
example/ Integration test backend
e2e/ Playwright E2E tests
Makefile Test orchestration
```