Update README.md
This commit is contained in:
293
README.md
293
README.md
@@ -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
|
||||
```
|
||||
```
|
||||
Reference in New Issue
Block a user