diff --git a/README.md b/README.md index 60e3e5d..1b031bc 100644 --- a/README.md +++ b/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 {children} } -``` -```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 - └─ ← 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 + └─ ← 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 -``` +``` \ No newline at end of file