Skip to content

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

NINJA_DEVX = {"ASYNC_MODE": "async"}  # every CRUD operation registers its async version

See Async and sync and examples/async_api.

class NoteController(CRUDController[Note, NoteOut, NoteIn]):
    tenant_field = "workspace"

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.