---
title: "Auto-generated HTTP API"
slug: expose-api
section: guides
order: 14
level: intermediate
description: "Flip a switch and get a REST endpoint — expose djust handlers as a typed OpenAPI 3.1 surface for mobile clients, AI agents, and CLI tools."
---

# Auto-generated HTTP API

djust already has everything an HTTP API needs: routing by handler name, parameter coercion from JSON, permission checks, rate limiting, and typed validation. The `expose_api` flag flips the transport — same handler, same security model, same validation, but callable over plain HTTP/JSON without a WebSocket.

## What You Get

- `POST /djust/api/<view_slug>/<handler_name>/` — one endpoint per marked handler
- OpenAPI 3.1 schema at `/djust/api/openapi.json` — auto-generated from handler type hints and `@event_handler` metadata
- Shared auth: Django session cookies + CSRF by default; pluggable token auth via `api_auth_classes`
- Shared rate-limiting bucket with the WebSocket transport
- `api_response()` helper for transport-conditional returns (HTTP vs WS)

## Quick Start

### 1. Mark handlers to expose

```python
from djust import LiveView
from djust.decorators import event_handler, permission_required

class OrderView(LiveView):
    template_name = 'order.html'

    @event_handler(expose_api=True)
    def update_status(self, order_id: int, status: str, **kwargs):
        order = Order.objects.get(pk=order_id)
        order.status = status
        order.save(update_fields=['status'])
        return {'order_id': order_id, 'status': status}

    @event_handler(expose_api=True)
    @permission_required('orders.change_order')
    def refund(self, order_id: int, reason: str = '', **kwargs):
        order = Order.objects.get(pk=order_id)
        order.status = 'refunded'
        order.save(update_fields=['status'])
        return {'order_id': order_id, 'refunded': True}
```

### 2. Point your client at the schema

```
GET /djust/api/openapi.json
```

Returns a valid OpenAPI 3.1 document. Every `expose_api=True` handler appears as a `POST` path under `/djust/api/<view_slug>/<handler_name>/`. The schema includes:

- `requestBody` derived from the handler's type hints
- `parameters` for path/query binding
- `responses` with error codes (400 validation, 401 unauthenticated, 403 forbidden, 404 not found, 429 rate-limited, 500 handler exception)
- `security` from `api_auth_classes`

### 3. Call it

```bash
curl -X POST https://yourapp.com/djust/api/order/update_status/ \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $(python -c "from django.templatetags.csrf import get_token; print(get_token())")" \
  -d '{"order_id": 42, "status": "shipped"}'
```

Response (success):

```json
{
  "result": {"order_id": 42, "status": "shipped"},
  "assigns": {}
}
```

Response (validation error):

```json
{
  "error": "validation_error",
  "details": {
    "order_id": ["This field is required."]
  }
}
```

## URL shape

```
POST /djust/api/<view_slug>/<handler_name>/
```

- `<view_slug>` is derived from the view's URL name (the name Django's URL resolver assigns). Override with a class-level `api_name` attribute on the view.
- Named parameters come from the request body JSON.
- Positional parameters are not supported over HTTP — handlers that rely on positional binding from `dj-click="handler('v')"` must still declare named parameters to be callable over HTTP.

## Response envelope

```json
{
  "result": <handler return value or null>,
  "assigns": { <changed public assigns only> }
}
```

`assigns` reflects what changed during the handler run. djust uses the same diff machinery it uses for WebSocket responses — only the assigns that actually changed are included.

## Transport-conditional returns

Shape the HTTP envelope with an `api_response()` **method on your view**. It
is a convention, not a helper — there is nothing to import:

```python
class ChartView(LiveView):
    @event_handler(expose_api=True)
    def get_chart_data(self, metric: str = "", **kwargs):
        self.chart_data = compute_metric(metric)
        return self.chart_data

    def api_response(self, value):
        """Receives the handler's return value; returns the HTTP envelope."""
        return {"result": value}
```

The envelope is resolved first-match-wins:

1. `@event_handler(expose_api=True, serialize=...)` on the handler
2. `api_response()` on the view — DRY for a shape shared across handlers
3. Pass-through: the handler's return value

Over WebSocket the same handler behaves normally — the return value is
ignored and djust patches assigns from the diff.

## Auth

By default the HTTP transport uses Django's session auth plus CSRF — the same as a browser form submit. Pluggable via `api_auth_classes`:

```python
from djust.api import SessionAuth

class OrderView(LiveView):
    api_auth_classes = [SessionAuth]  # default; can be omitted
```

For token-based clients (mobile, CLI, server-to-server), implement the auth class contract:

```python
class TokenAuth:
    def authenticate(self, request):
        # Return the user object, or None to fall through to the next class
        token = request.headers.get('Authorization', '').replace('Bearer ', '')
        try:
            return User.objects.get(api_token=token)
        except User.DoesNotExist:
            return None
```

If no auth class authenticates, djust returns `401 Unauthorized`.

Set `csrf_exempt = True` on a class to disable CSRF for that view (header/token auth doesn't need it):

```python
class OrderView(LiveView):
    api_auth_classes = [TokenAuth]
    csrf_exempt = True
```

## Permission flow

Handler-level `@permission_required(...)` and view-level `login_required` / `permission_required` run identically over HTTP and WebSocket. The same `401` / `403` response codes apply.

```python
@event_handler(expose_api=True)
@permission_required('orders.change_order')  # 403 if user lacks the permission
def refund(self, order_id: int, **kwargs):
    ...
```

## OpenAPI schema

The schema is live — it updates whenever handler signatures change. Regenerate it on demand:

```
GET /djust/api/openapi.json
```

The schema includes:

- **`paths`** — one `POST` per `expose_api=True` handler
- **Request body** — JSON object with the handler's named parameters; type hints map to JSON Schema types (`int` → `integer`, `bool` → `boolean`, `str` → `string`, `UUID` → `string` with `format: uuid`)
- **Response `200`** — `{result, assigns}` envelope
- **Response `400/401/403/404/429/500`** — `{error, details}` envelope
- **`security`** — reflects `api_auth_classes` (e.g., `Cookie` for `SessionAuth`)

### Customizing the schema

Add a description or deprecate a handler via the decorator:

```python
@event_handler(
    expose_api=True,
    description="Mark an order as shipped. Idempotent.",
    deprecated=False,
)
def update_status(self, order_id: int, status: str, **kwargs):
    ...
```

## Rate limiting

The HTTP and WebSocket transports share the same per-handler token bucket. A handler limited to 60 calls/minute via `rate_limit(...)` will count HTTP calls and WS calls in the same bucket.

## Error codes

| Status | `error` field | Meaning |
|--------|--------------|---------|
| `400` | `validation_error` | Handler params failed type coercion or `@field_validator` |
| `401` | `unauthenticated` | No valid session / token |
| `403` | `permission_denied` | Authenticated but lacks required permission |
| `404` | `not_found` | View or handler not found, or `expose_api=True` not set |
| `429` | `rate_limited` | Per-handler rate limit exceeded |
| `500` | `handler_error` | Handler raised an exception; details in `details.traceback` (DEBUG only) |

## Limitations

- **No file upload over HTTP.** The chunked WebSocket upload path is separate. Use the WebSocket transport for uploads.
- **Stateless only.** Each HTTP request is an independent call — there is no session binding. If your handler needs session state, populate it from the database on each call.
- **No streaming responses.** Handlers that return `StreamingHttpResponse` cannot be exposed via `expose_api`.
- **Handlers must accept named kwargs.** The HTTP transport passes all params as keyword arguments. Positional-only handlers cannot be called over HTTP.

## Exposing a handler: checklist

```
@event_handler(expose_api=True)          # 1. Opt in
def my_handler(self, param: int, **kwargs):  # 2. Typed params → schema
    ...
    return api_response(result=...)      # 3. (optional) transport-conditional
```

That's it. djust wires the URL, generates the schema entry, and dispatches the call — reusing every security and validation check you already have on the WebSocket path.
