Quickstart¶
A private notes API with search, filters, ordering, pagination, ownership and a custom
action. The code below is examples/quickstart, included verbatim and tested in CI.
To start from a generated project instead of reading along, see
manage.py devx_startproject.
1. A model¶
notes/models.py
from django.conf import settings
from django.db import models
class Note(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
body = models.TextField(blank=True)
done = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created", "-id")
def __str__(self) -> str:
return self.title
2. A controller¶
notes/api.py
from datetime import datetime
from typing import Annotated
from django.http import HttpRequest
from ninja import Schema
from ninja_devx import Controller, get, post
from ninja_devx.crud import CRUDController, Instance
from pydantic import Field
from notes.models import Note
class NoteOut(Schema):
id: int
title: str
body: str
done: bool
created: datetime
class NoteIn(Schema):
title: Annotated[str, Field(max_length=200, min_length=1)]
body: str = ""
class NoteController(CRUDController[Note, NoteOut, NoteIn]):
owner_field = "owner" # authentication, ownership and "owner is me" on create
scope_queryset_to_owner = True # lists show only my notes
search_fields = ("title", "body")
filter_fields = {"done": ("exact",)}
ordering_fields = ("created", "title")
@post("/{pk}/complete", response=NoteOut)
def complete(self, request: HttpRequest, note: Instance[Note]) -> Note:
note.done = True
note.save(update_fields=["done"])
return note
class HealthController(Controller):
@get("/", auth=None)
def health(self, request: HttpRequest) -> dict[str, str]:
return {"status": "ok"}
CRUDController[Note, NoteOut, NoteIn]gives list, retrieve, create, update, partial update and delete. The generic arguments are the configuration.owner_field = "owner"requires authentication, sets the owner on create, and checks ownership on every object.Instance[Note]loads the note from the URL, with a 404 when it is missing and the object permissions applied.
3. Mount it¶
config/urls.py
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from ninja import NinjaAPI
from ninja.security import django_auth
from ninja_devx import mount
from notes.api import HealthController, NoteController
api = NinjaAPI(title="Notes", version="1.0.0", auth=django_auth)
mount(api, {"/notes": NoteController, "/health": HealthController})
urlpatterns = [
path("api/", api.urls),
path("accounts/login/", LoginView.as_view(), name="login"),
path("accounts/logout/", LogoutView.as_view(), name="logout"),
]
mount() builds a native Ninja router per controller. python manage.py runserver serves
the interactive docs at /api/docs.
4. Test it¶
notes/tests/test_api.py
import pytest
from django.contrib.auth.models import User
from ninja.testing import TestClient
from config.urls import api
from notes.models import Note
pytestmark = pytest.mark.django_db
@pytest.fixture
def client() -> TestClient:
return TestClient(api)
@pytest.fixture
def ada() -> User:
return User.objects.create(username="ada")
def test_notes_crud(client: TestClient, ada: User) -> None:
created = client.post("/notes/", json={"title": "Buy milk"}, user=ada)
assert created.status_code == 201
note_id = created.json()["id"]
page = client.get("/notes/?search=milk&done=false", user=ada).json()
assert page["count"] == 1
completed = client.post(f"/notes/{note_id}/complete", user=ada)
assert completed.json()["done"] is True
assert client.patch(f"/notes/{note_id}", json={"body": "2 liters"}, user=ada).status_code == 200
assert client.delete(f"/notes/{note_id}", user=ada).status_code == 204
def test_notes_are_private(client: TestClient, ada: User) -> None:
bob = User.objects.create(username="bob")
note = Note.objects.create(owner=ada, title="secret")
assert client.get("/notes/", user=bob).json()["count"] == 0
assert client.get(f"/notes/{note.pk}", user=bob).status_code == 404
assert client.get("/notes/").status_code == 401
def test_validation_and_health(client: TestClient, ada: User) -> None:
assert client.post("/notes/", json={"title": ""}, user=ada).status_code == 422
assert client.get("/health/").json() == {"status": "ok"}
def test_checks_pass() -> None:
from django.core.management import call_command
call_command("check", fail_level="WARNING")
call_command("devx_scaffold", "--check")
What you got¶
| Endpoint | Behavior |
|---|---|
GET /api/notes/?search=&done=&ordering=&page= |
the caller's notes, searched, filtered, ordered and paginated |
POST /api/notes/ |
201; owner set from the request; title validated (1–200 characters) |
GET/PUT/PATCH/DELETE /api/notes/{pk} |
404 for other users' notes |
POST /api/notes/{pk}/complete |
a custom action with the same guarantees |
| OpenAPI | documents 401, 403, 404, 422 and the typed query parameters |
Next steps¶
See Async and sync and examples/async_api.
See Multi-tenancy and examples/saas.
class NoteController(CRUDController[Note, NoteOut, NoteIn]):
service_class = NoteService # writes go through your business rules
See Services and layers and examples/recipes.
For everything else — permissions, tenancy, errors, hooks and the optional contrib
apps — start at the guides index. To see how a controller actually
resolves, run devx_inspect.