Controllers¶
Revel uses Django Ninja Extra controllers to organize API endpoints into cohesive, class-based units. Controllers handle request parsing, authentication, throttling, and response serialization, but not business logic.
Core Patterns¶
Controller Structure¶
All controllers follow the same blueprint:
- Inherit from
UserAwareControllerfor common user/auth functionality - Use
@api_controllerdecorator with consistent tagging - Define authentication and throttling at class level when shared
- Override throttling per-endpoint only for mutations
@api_controller("/api", auth=I18nJWTAuth(), throttle=UserDefaultThrottle())
class MyController(ControllerBase):
@route.post("/items", throttle=WriteThrottle())
def create_item(self, payload: Schema) -> Item:
return Item.objects.create(**payload.model_dump())
@route.patch("/items/{id}", throttle=WriteThrottle())
def update_item(self, id: UUID, payload: UpdateSchema) -> Item:
item = get_object_or_404(Item, id=id, user=self.user())
update_data = payload.model_dump(exclude_unset=True)
if not update_data:
return item
for field, value in update_data.items():
setattr(item, field, value)
item.save(update_fields=list(update_data.keys()))
return item
Throttling Rules¶
| HTTP Method | Default Throttle | Override Needed? |
|---|---|---|
GET | UserDefaultThrottle (class-level) | No |
POST | WriteThrottle | Yes (per-endpoint) |
PATCH | WriteThrottle | Yes (per-endpoint) |
DELETE | WriteThrottle | Yes (per-endpoint) |
Good vs Bad Patterns¶
@api_controller("/api", auth=I18nJWTAuth(), throttle=UserDefaultThrottle())
class MyController(ControllerBase):
@route.post("/items", throttle=WriteThrottle())
def create_item(self, payload: Schema) -> Item:
return Item.objects.create(**payload.model_dump())
@route.patch("/items/{id}", throttle=WriteThrottle())
def update_item(self, id: UUID, payload: UpdateSchema) -> Item:
item = get_object_or_404(Item, id=id, user=self.user())
update_data = payload.model_dump(exclude_unset=True)
if not update_data:
return item
for field, value in update_data.items():
setattr(item, field, value)
item.save(update_fields=list(update_data.keys()))
return item
- Auth/throttle at class level (DRY)
- Flat control flow with early return
model_dump(exclude_unset=True)for partial updatesget_object_or_404for lookups
@api_controller("/api", throttle=AuthThrottle())
class MyController(ControllerBase):
@route.post("/items", auth=I18nJWTAuth(), throttle=WriteThrottle())
def create_item(self, payload: Schema) -> Item:
return Item.objects.create(**payload.model_dump())
@route.patch("/items/{id}", auth=I18nJWTAuth(), throttle=WriteThrottle())
def update_item(self, id: UUID, payload: UpdateSchema) -> Item:
try:
item = Item.objects.get(id=id, user=self.user())
except Item.DoesNotExist:
raise Http404
if payload.field1 is not None:
item.field1 = payload.field1
if payload.field2 is not None:
item.field2 = payload.field2
item.save()
return item
- Repetitive
auth=on every endpoint - Manual try/except instead of
get_object_or_404 - Nested conditionals for each field
save()withoutupdate_fields(updates all columns)
No try-except in views
Never wrap ORM lookups in try/except blocks. Use get_object_or_404() or self.get_object_or_exception() instead. Let Django Ninja's exception handlers do their job.
Pagination¶
Use PageNumberPaginationExtra for paginated list endpoints:
from ninja_extra.pagination import PageNumberPaginationExtra
@api_controller("/api/events", auth=I18nJWTAuth(), throttle=UserDefaultThrottle())
class EventController(ControllerBase):
@route.get("", response=list[EventSchema])
@paginate(PageNumberPaginationExtra)
def list_events(self) -> QuerySet[Event]:
return Event.objects.filter(is_published=True)
Search¶
Use the @searching decorator to add search capabilities:
@route.get("", response=list[EventSchema])
@paginate(PageNumberPaginationExtra)
@searching(Searching, search_fields=["name", "description"])
def list_events(self, filters: t.Annotated[EventFilterSchema, Query(...)]) -> QuerySet[Event]:
return Event.objects.filter(is_published=True)
Schema Conventions¶
ModelSchema: Let It Infer¶
Only declare fields that need special handling
ModelSchema infers field types from the model. Only override fields that need transformation (e.g., enum to string).
Enum Fields¶
Always use the model's enum class
Never use bare str or Literal[...] for enum fields. This ensures the OpenAPI spec documents valid values and keeps a single source of truth.
# Good - enum referenced from the model
class BannerSchema(Schema):
severity: SiteSettings.BannerSeverity
# Bad - bare str loses enum contract
class BannerSchema(Schema):
severity: str
# Bad - Literal duplicates the enum values
class BannerSchema(Schema):
severity: Literal["info", "warning", "error"]
Datetime Fields¶
Always use AwareDatetime
Use pydantic.AwareDatetime for datetime fields, never datetime.datetime. This enforces timezone-aware serialization.
from pydantic import AwareDatetime
# Good
class EventSchema(Schema):
starts_at: AwareDatetime
ends_at: AwareDatetime | None = None
# Bad - datetime.datetime loses timezone enforcement
class EventSchema(Schema):
starts_at: datetime.datetime
Optional JSON Body¶
Django Ninja quirk
For endpoints with an optional JSON body, keep Body(None) in the metadata via the Annotated idiom. Plain Schema | None = None causes "Cannot parse request body" when no JSON is sent.
import typing as t
from ninja import Body
@route.post("/confirm")
def confirm(self, payload: t.Annotated[ConfirmSchema | None, Body(None)] = None) -> Response: ...
Query Optimization¶
Prefetch relationships to avoid N+1 queries
When a schema includes nested relationships, ensure the queryset prefetches them.