Django-Admin.ru

Django Ninja: API на Django с аннотациями типов

Django Ninja на примерах: установка, NinjaAPI и роутеры, схемы Pydantic, параметры, коды ответов, аутентификация, пагинация, async, Swagger и тесты.

Редакция Django-Admin.ru
· 23 мин чтения

Содержание · 18 разделов
  1. Что такое Django Ninja и когда его выбирать
  2. Версии и совместимость
  3. Установка и первый эндпоинт
  4. Нужно ли добавлять ninja в INSTALLED_APPS
  5. NinjaAPI и роутеры: структура проекта
  6. Модель для примеров
  7. Схемы: Schema и ModelSchema
  8. Параметры запроса: путь, строка запроса, тело
  9. Параметры пути
  10. Строка запроса и группировка в схему
  11. Тело запроса
  12. Заголовки, формы и файлы
  13. Ошибки валидации: 422
  14. Ответы и коды статусов
  15. CRUD для модели
  16. Аутентификация
  17. Токен на основе подписи Django
  18. request.auth и request.user
  19. CSRF при входе через сессию
  20. Права доступа
  21. Пагинация
  22. Асинхронные эндпоинты
  23. Документация OpenAPI: /api/docs
  24. Тестирование: TestClient
  25. Django Ninja, DRF или FastAPI
  26. Частые ошибки
  27. 403 «CSRF check Failed» при POST с сессией
  28. SynchronousOnlyOperation в async-эндпоинте
  29. request.user — AnonymousUser при входе по токену
  30. 422 вместо ожидаемых данных
  31. Поле ForeignKey приходит как число
  32. Частые вопросы
  33. Можно ли использовать Django Ninja и DRF в одном проекте?
  34. Как сделать версии API v1 и v2?
  35. Есть ли в Django Ninja классовые представления, как ViewSet?
  36. Как ограничить частоту запросов?
  37. Как отдать файл или поток данных?
  38. Подходит ли Django Ninja для новичка?
  39. Итог

Django Ninja — библиотека для создания REST API внутри проекта Django. Эндпоинт описывается обычной функцией с декоратором @api.get или @api.post, а аннотации типов у аргументов определяют, откуда брать данные (путь, строка запроса, тело) и как их проверять. Валидацию выполняет Pydantic v2, документация OpenAPI и Swagger UI по адресу /api/docs появляются автоматически. Модели, ORM, админка и аутентификация остаются от Django.

Быстрый ответ: pip install django-ninja, создайте объект NinjaAPI(), опишите функцию с декоратором и подключите api.urls в urls.py. Ниже — полный путь от первого эндпоинта до CRUD с токенами, пагинацией, async-представлениями и тестами, а в конце — таблица для выбора между Django Ninja, Django REST Framework и FastAPI.

Что такое Django Ninja и когда его выбирать

Django Ninja переносит в Django подход FastAPI: схемы данных — это классы Pydantic, параметры функции — это входные данные запроса, а по сигнатурам строится спецификация OpenAPI. В отличие от FastAPI, библиотека работает поверх обычного Django: запросы проходят через middleware, URL регистрируются в urlpatterns, пользователи и сессии берутся из django.contrib.auth.

Django Ninja подходит, если:

  • проект уже на Django или нужны его ORM, миграции и админка, а API хочется описывать аннотациями типов вместо сериализаторов;
  • команда знакома с FastAPI и Pydantic;
  • нужны async-эндпоинты и документация OpenAPI без дополнительных пакетов.

Стоит остаться на Django REST Framework, если API — это в основном типовой CRUD вокруг моделей: ModelViewSet с роутером создаёт пять операций в несколько строк, а в Django Ninja каждую операцию пишут отдельной функцией. Таблица для выбора между тремя вариантами — в конце статьи.

Версии и совместимость

Актуальная версия на PyPI — django-ninja 1.7.1 (сентябрь 2026). Зависимости пакета: Django>=3.1 без верхней границы и pydantic>=2.0,<3.0, то есть библиотека работает только с Pydantic v2. В метаданных пакета заявлена поддержка Django 5.2 LTS, 6.0 и 6.1 и Python до 3.14. Сам Django 6.x требует Python 3.12 или новее.

Примеры статьи проверены на Python 3.12, Django 6.0.9 и 6.1.2, django-ninja 1.7.1 и Pydantic 2.14.

Изменения последних версий, которые встречаются в коде из старых руководств:

ВерсияЧто изменилось
1.0переход на Pydantic v2, схемы ModelSchema описываются через вложенный класс Meta
1.5глобальные декораторы для API и роутеров, фильтры на аннотациях в FilterSchema
1.6Status(код, данные) вместо кортежа (код, данные), курсорная пагинация, потоковые ответы JSONL и SSE, один роутер можно подключать к нескольким API
1.7подтверждена поддержка Django 6.1, заголовок Retry-After в ответе 429, доработан TestClient

Важно: возврат кортежа return 404, {...} из ранних примеров с версии 1.6 устарел. Используйте return Status(404, {...}).

Установка и первый эндпоинт

Понадобится проект Django. Если его ещё нет, установите Django и создайте проект командой django-admin startproject config .. Пакет ставьте в виртуальное окружение.

Windows:

py -m venv .venv
.venv\Scripts\activate
python -m pip install django django-ninja

macOS и Linux:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install django django-ninja

Создайте файл config/api.py рядом с settings.py:

# config/api.py
from ninja import NinjaAPI

api = NinjaAPI(title="Tasks API", version="1.0.0")


@api.get("/hello")
def hello(request, name: str = "мир"):
    return {"message": f"Привет, {name}!"}

Подключите API в корневой urls.py:

# config/urls.py
from django.contrib import admin
from django.urls import path

from .api import api

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", api.urls),
]

Запустите python manage.py runserver и проверьте:

curl "http://127.0.0.1:8000/api/hello?name=Django"
{"message": "Привет, Django!"}

Параметр name не входит в путь, поэтому Ninja ищет его в строке запроса. Значение по умолчанию делает его необязательным. Функция вернула словарь — Ninja сам превратил его в JSON.

Кириллица в ответе экранирована: стандартный рендерер вызывает json.dumps с ensure_ascii=True. Клиенты читают такой JSON корректно, но для отладки удобнее читаемый текст. Подключите свой рендерер:

# config/api.py
from ninja import NinjaAPI
from ninja.renderers import JSONRenderer


class UTF8JSONRenderer(JSONRenderer):
    json_dumps_params = {"ensure_ascii": False}


api = NinjaAPI(title="Tasks API", version="1.0.0", renderer=UTF8JSONRenderer())
{"message": "Привет, Django!"}

Нужно ли добавлять ninja в INSTALLED_APPS

Для работы API — нет. Добавьте "ninja" в INSTALLED_APPS, если нужно:

  • раздавать файлы Swagger UI из статики проекта, без CDN — это важно для закрытых сетей и строгой политики CSP;
  • экспортировать схему OpenAPI командой manage.py export_openapi_schema.
# config/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "ninja",
    "tasks",
]

NinjaAPI и роутеры: структура проекта

Один объект NinjaAPI — это одно API со своей документацией. Эндпоинты отдельных приложений удобно описывать в роутерах (Router) и подключать к API с префиксом:

config/
    api.py        # NinjaAPI, подключение роутеров
    settings.py
    urls.py
tasks/
    api.py        # Router с эндпоинтами задач
    auth.py       # класс аутентификации
    models.py
    schemas.py    # схемы Pydantic
    tests.py
# config/api.py
from ninja import NinjaAPI
from ninja.renderers import JSONRenderer

from tasks.api import router as tasks_router


class UTF8JSONRenderer(JSONRenderer):
    json_dumps_params = {"ensure_ascii": False}


api = NinjaAPI(title="Tasks API", version="1.0.0", renderer=UTF8JSONRenderer())
api.add_router("/tasks/", tasks_router)


@api.get("/hello")
def hello(request, name: str = "мир"):
    return {"message": f"Привет, {name}!"}

Роутер принимает те же настройки, что и API: tags группирует эндпоинты в документации, auth задаёт аутентификацию для всех его операций. Роутеры можно вкладывать друг в друга через router.add_router().

Каждый эндпоинт получает имя URL по имени функции, а пространство имён по умолчанию — api- плюс версия API. Поэтому работает обычный reverse():

from django.urls import reverse

reverse("api-1.0.0:list_tasks")                          # → '/api/tasks/'
reverse("api-1.0.0:get_task", kwargs={"task_id": 5})     # → '/api/tasks/5'

Модель для примеров

Дальше строим API списка задач: у каждой задачи есть владелец, и пользователь видит только свои записи. Подробно о полях — в статье про модели Django.

# tasks/models.py
from django.conf import settings
from django.db import models


class Task(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tasks"
    )

    def __str__(self):
        return self.title
python manage.py makemigrations tasks
python manage.py migrate

Схемы: Schema и ModelSchema

Схема описывает форму входных или выходных данных. Schema — это pydantic.BaseModel с включённым from_attributes=True, поэтому из функции можно вернуть объект модели, и Ninja прочитает его атрибуты. Все возможности Pydantic — Field, валидаторы, вложенные модели — работают так же, как в обычном Pydantic.

# tasks/schemas.py
from ninja import Field, ModelSchema, Schema

from .models import Task


class TaskIn(Schema):
    title: str = Field(min_length=3, max_length=200)
    description: str = ""
    done: bool = False


class TaskPatch(Schema):
    title: str | None = Field(None, min_length=3, max_length=200)
    description: str | None = None
    done: bool | None = None


class TaskOut(ModelSchema):
    owner: str = Field(alias="owner.username")

    class Meta:
        model = Task
        fields = ["id", "title", "description", "done", "created_at"]


class Message(Schema):
    detail: str

Что здесь происходит:

  • TaskIn — тело запроса на создание. Ограничения min_length и max_length проверяются до вызова функции.
  • TaskPatch — частичное обновление: все поля необязательны, а в коде берутся только переданные (exclude_unset=True).
  • TaskOut — ответ. ModelSchema берёт типы полей из модели, список задаётся в Meta.fields. Поле owner переопределено: alias="owner.username" читает имя пользователя по цепочке атрибутов вместо числового owner_id.

У Meta есть ещё exclude (все поля, кроме перечисленных) и fields_optional — делает поля необязательными, что удобно для PATCH-схем на основе модели:

class TaskPatchFromModel(ModelSchema):
    class Meta:
        model = Task
        fields = ["title", "description", "done"]
        fields_optional = "__all__"

Совет: не используйте fields = "__all__" в схемах ответа для моделей с чувствительными полями, например для модели пользователя с password. Явный список полей защищает от утечки данных при добавлении новых полей в модель.

Метод .dict() у схем Ninja — синоним model_dump() из Pydantic v2 и не выдаёт предупреждений об устаревании.

Параметры запроса: путь, строка запроса, тело

Ninja определяет источник параметра по сигнатуре функции:

Как объявлен аргументОткуда берётся значение
имя есть в пути: /tasks/{task_id}путь URL
простой тип: int, str, bool, dateстрока запроса ?done=true
схема Schemaтело запроса JSON
Query[Схема]строка запроса, поля схемы — параметры
Header, Cookieзаголовки и cookie
Form[...], File[UploadedFile]данные формы и файлы multipart/form-data

Типы проверяются и приводятся: строка "true" из URL станет True, "2026-01-01" — объектом date.

Параметры пути

from ninja import Path


@router.get("/items/{item_id}")
def get_item(request, item_id: int = Path(..., ge=1)):
    return {"item_id": item_id}

Path(..., ge=1) добавляет ограничение: запрос /items/0 вернёт 422. В шаблоне пути можно указать конвертер Django: /{int:task_id}. Тогда нечисловой путь вроде /tasks/abc отсекается ещё на уровне URL с обычным ответом 404, а без конвертера Ninja вернёт 422 с ошибкой int_parsing.

Строка запроса и группировка в схему

Несколько фильтров удобно собрать в схему и объявить через Query:

from datetime import date

from ninja import Query, Schema


class TaskFilters(Schema):
    done: bool | None = None
    q: str = ""
    created_after: date | None = None


@router.get("/search")
def search(request, filters: Query[TaskFilters]):
    return filters.dict()

# GET /search?done=true&created_after=2026-01-01
# → {"done": true, "q": "", "created_after": "2026-01-01"}

Для фильтрации QuerySet по полям схемы есть встроенный класс FilterSchema: он превращает заполненные поля в условия filter().

Тело запроса

Если у функции один параметр-схема, тело запроса — это сама схема: {"title": "..."}. Если параметров тела несколько, клиент присылает их под именами аргументов:

from ninja import Body


class Item(Schema):
    name: str


@router.post("/two")
def two(request, item: Item, note: str = Body(...)):
    return {"item": item.name, "note": note}

# POST /two  {"item": {"name": "a"}, "note": "n"}
# → {"item": "a", "note": "n"}

Заголовки, формы и файлы

from ninja import File, Form, Header
from ninja.files import UploadedFile


@router.get("/client")
def client_info(request, user_agent: str = Header(..., alias="User-Agent")):
    return {"ua": user_agent}


@router.post("/upload")
def upload(request, title: Form[str], file: File[UploadedFile]):
    return {"title": title, "name": file.name, "size": file.size}

UploadedFile — это файл Django, его можно передать в FileField модели или сохранить через хранилище.

Ошибки валидации: 422

Если данные не прошли проверку, функция не вызывается, а клиент получает статус 422 со списком ошибок Pydantic. Поле loc указывает, где ошибка: body, query или path.

{"detail": [{"type": "string_too_short", "loc": ["body", "payload", "title"], "msg": "String should have at least 3 characters", "ctx": {"min_length": 3}}, {"type": "bool_parsing", "loc": ["body", "payload", "done"], "msg": "Input should be a valid boolean, unable to interpret input"}]}

Ответы и коды статусов

Аргумент response в декораторе задаёт схему ответа. Ninja валидирует результат функции по этой схеме и отбрасывает лишние поля — так в JSON не попадёт то, чего нет в схеме. Вернуть можно объект модели, QuerySet, словарь или экземпляр схемы.

Если эндпоинт отдаёт разные коды, передайте словарь «код → схема», а нужный код верните через Status:

from ninja import Status


@router.get("/{int:task_id}", response={200: TaskOut, 404: Message})
def get_task(request, task_id: int):
    task = Task.objects.filter(owner=request.auth, id=task_id).first()
    if task is None:
        return Status(404, {"detail": "Задача не найдена"})
    return task

Без Status используется первый код 2xx из словаря. Для ответа без тела объявите response={204: None} и верните Status(204, None).

Ошибку можно и выбросить: raise HttpError(403, "Нет доступа") из ninja.errors даёт ответ {"detail": "Нет доступа"} с нужным кодом. Http404 от get_object_or_404() превращается в JSON-ответ 404: {"detail": "Not Found: No Task matches the given query."}. Для своих исключений регистрируется обработчик:

class ServiceUnavailable(Exception):
    pass


@api.exception_handler(ServiceUnavailable)
def on_unavailable(request, exc):
    return api.create_response(request, {"detail": "Сервис недоступен"}, status=503)

CRUD для модели

Соберём роутер с пятью операциями. Владелец задачи берётся из request.auth — почему не из request.user, объяснено в следующем разделе.

# tasks/api.py
from django.contrib.auth import authenticate
from django.shortcuts import get_object_or_404
from ninja import Router, Schema, Status
from ninja.pagination import PageNumberPagination, paginate
from ninja.security import django_auth

from .auth import BearerAuth, make_token
from .models import Task
from .schemas import Message, TaskIn, TaskOut, TaskPatch

router = Router(tags=["Задачи"], auth=[BearerAuth(), django_auth])


@router.get("/", response=list[TaskOut])
@paginate(PageNumberPagination, page_size=20)
def list_tasks(request, done: bool | None = None, q: str = ""):
    qs = Task.objects.filter(owner=request.auth).select_related("owner")
    if done is not None:
        qs = qs.filter(done=done)
    if q:
        qs = qs.filter(title__icontains=q)
    return qs.order_by("-created_at")


@router.get("/{int:task_id}", response={200: TaskOut, 404: Message})
def get_task(request, task_id: int):
    task = Task.objects.filter(owner=request.auth, id=task_id).first()
    if task is None:
        return Status(404, {"detail": "Задача не найдена"})
    return task


@router.post("/", response={201: TaskOut})
def create_task(request, payload: TaskIn):
    task = Task.objects.create(owner=request.auth, **payload.dict())
    return Status(201, task)


@router.patch("/{int:task_id}", response=TaskOut)
def update_task(request, task_id: int, payload: TaskPatch):
    task = get_object_or_404(Task, owner=request.auth, id=task_id)
    for attr, value in payload.dict(exclude_unset=True).items():
        setattr(task, attr, value)
    task.save()
    return task


@router.delete("/{int:task_id}", response={204: None})
def delete_task(request, task_id: int):
    task = get_object_or_404(Task, owner=request.auth, id=task_id)
    task.delete()
    return Status(204, None)

select_related("owner") загружает пользователя тем же запросом: схема TaskOut читает owner.username у каждой задачи, и без него получилась бы проблема N+1 — отдельный запрос к базе на каждую строку списка.

Создание задачи:

curl -X POST http://127.0.0.1:8000/api/tasks/ \
  -H "Authorization: Bearer <токен>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Написать статью про Django Ninja"}'
{"owner": "anna", "id": 2, "title": "Написать статью про Django Ninja", "description": "", "done": false, "created_at": "2026-10-10T00:17:49.593Z"}

Встроенного аналога ModelViewSet в Django Ninja нет: каждая операция — отдельная функция. Сократить шаблонный код помогают сторонние пакеты django-ninja-extra (контроллеры-классы) и django-ninja-crud, но они добавляют свою модель работы, и их стоит оценивать отдельно.

Аутентификация

Аутентификация задаётся параметром auth у NinjaAPI, Router или отдельной операции. Это вызываемый объект: он получает запрос и возвращает «субъект» (пользователя, ключ, что угодно) или None. Возвращённое значение попадает в request.auth. Если вернулся None, клиент получает 401 {"detail": "Unauthorized"}.

Встроенные варианты из ninja.security:

Класс или объектЧто проверяет
django_authсессию Django: пользователь вошёл через сайт или админку
django_auth_is_staff, django_auth_superuserсессию и флаг is_staff или is_superuser
HttpBearerзаголовок Authorization: Bearer ..., метод authenticate пишете сами
HttpBasicAuthзаголовок Authorization: Basic ... с логином и паролем
APIKeyHeader, APIKeyQuery, APIKeyCookieключ в заголовке, параметре строки запроса или cookie

Если передать список, Ninja проверит способы по очереди до первого успешного. В нашем роутере это [BearerAuth(), django_auth]: внешние клиенты присылают токен, а браузер с открытой сессией админки тоже получает доступ.

Токен на основе подписи Django

Пример ниже не требует отдельной таблицы: токен содержит id пользователя и подписан SECRET_KEY через django.core.signing, срок жизни — сутки.

# tasks/auth.py
from django.contrib.auth import get_user_model
from django.core import signing
from ninja.security import HttpBearer

TOKEN_MAX_AGE = 60 * 60 * 24  # сутки


def make_token(user) -> str:
    return signing.dumps({"uid": user.pk}, salt="api-token")


class BearerAuth(HttpBearer):
    def authenticate(self, request, token):
        try:
            data = signing.loads(token, salt="api-token", max_age=TOKEN_MAX_AGE)
        except signing.BadSignature:
            return None
        User = get_user_model()
        return User.objects.filter(pk=data["uid"], is_active=True).first()

Эндпоинт выдачи токена открыт для всех — auth=None отменяет аутентификацию роутера:

# tasks/api.py (продолжение)
class LoginIn(Schema):
    username: str
    password: str


class TokenOut(Schema):
    token: str


@router.post("/token", auth=None, response={200: TokenOut, 401: Message})
def get_token(request, payload: LoginIn):
    user = authenticate(username=payload.username, password=payload.password)
    if user is None:
        return Status(401, {"detail": "Неверный логин или пароль"})
    return {"token": make_token(user)}

Такой токен нельзя отозвать до истечения срока. Если нужны отзыв, refresh-токены и стандартный формат, используйте JWT: готовая интеграция — пакет django-ninja-jwt, либо проверяйте JWT библиотекой PyJWT внутри своего HttpBearer.

request.auth и request.user

request.user заполняет middleware аутентификации Django по сессии. При входе по токену сессии нет, и request.user остаётся AnonymousUser, а пользователь, которого вернул authenticate(), лежит в request.auth. Поэтому в эндпоинтах с токенами берите request.auth. django_auth тоже возвращает пользователя, так что request.auth работает для обоих способов.

CSRF при входе через сессию

Сами эндпоинты Ninja освобождены от CSRF-проверки Django. Но django_auth и другие способы на cookie проверяют CSRF-токен для небезопасных методов (POST, PUT, PATCH, DELETE): без заголовка X-CSRFToken запрос получит 403 {"detail": "CSRF check Failed"}. Для токенов в заголовке CSRF не нужен.

Права доступа

Отдельного слоя permissions, как permission_classes в DRF, нет. Проверки делают в самом классе аутентификации (как django_auth_is_staff), в теле функции через raise HttpError(403, ...) или в декораторах — их можно повесить на весь роутер. Для разграничения по владельцу, как в примере, достаточно фильтровать QuerySet по request.auth.

Пагинация

Декоратор @paginate превращает функцию, которая возвращает QuerySet, в постраничный эндпоинт. В response указывается схема одного элемента списка, а Ninja оборачивает ответ в items и count:

GET /api/tasks/?done=false

{"items": [{"owner": "anna", "id": 2, "title": "Написать статью про Django Ninja", "description": "", "done": false, "created_at": "2026-10-10T00:17:49.593Z"}], "count": 1}
КлассПараметры запросаКогда подходит
LimitOffsetPaginationlimit, offsetпо умолчанию; произвольные срезы списка
PageNumberPaginationpage, page_sizeклассические страницы в интерфейсе
CursorPaginationcursorленты, где записи часто добавляются, — нет пропусков и дублей

Класс по умолчанию и размеры задаются в settings.py: NINJA_PAGINATION_CLASS, NINJA_PAGINATION_PER_PAGE (по умолчанию 100) и другие.

Важно: у LimitOffsetPagination по умолчанию нет верхней границы limit, и клиент может запросить всю таблицу. Задайте NINJA_PAGINATION_MAX_LIMIT в настройках или max_limit в декораторе.

Асинхронные эндпоинты

Чтобы эндпоинт стал асинхронным, объявите функцию через async def. Ninja сам определит тип функции. Внутри используйте асинхронные методы ORM: aget(), acreate(), acount(), async for по QuerySet. Основы async/await — в статье про асинхронность в Python.

class StatsOut(Schema):
    total: int
    done: int


@router.get("/stats", response=StatsOut)
async def task_stats(request):
    qs = Task.objects.filter(owner=request.auth)
    return {"total": await qs.acount(), "done": await qs.filter(done=True).acount()}
{"total": 1, "done": 1}

Синхронный вызов ORM внутри async def, например Task.objects.count(), завершится ошибкой:

django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.

Для кода без асинхронной версии используйте sync_to_async из asgiref.sync. Синхронные классы аутентификации, как BearerAuth выше, работают и с async-эндпоинтами. @paginate тоже поддерживает async-функции.

Выигрыш async даёт там, где эндпоинт ждёт внешние сервисы: HTTP-запросы к другим API, очереди, медленные операции ввода-вывода. Для этого приложение нужно запускать через ASGI-сервер (Uvicorn, Daphne, Granian). Под WSGI-сервером async-эндпоинт работает, но преимуществ не получит. Для простых запросов к базе синхронные функции под Gunicorn остаются нормальным выбором.

Документация OpenAPI: /api/docs

По адресу /api/docs открывается Swagger UI: в нём видны все эндпоинты, схемы запросов и ответов, и любой запрос можно отправить кнопкой Try it out. Кнопка Authorize принимает токен для HttpBearer. Спецификация в формате JSON лежит в /api/openapi.json (OpenAPI 3.1) — по ней генерируют клиенты для фронтенда и мобильных приложений.

Настройки документации задаются в NinjaAPI:

from django.contrib.admin.views.decorators import staff_member_required
from ninja import NinjaAPI, Redoc

api = NinjaAPI(
    title="Tasks API",
    version="1.0.0",
    description="API списка задач",
    docs=Redoc(),                              # ReDoc вместо Swagger UI
    docs_url="/docs",                          # None — отключить страницу
    docs_decorator=staff_member_required,      # доступ только для персонала
)

Описание эндпоинта берётся из docstring функции, краткое название — из её имени. Для операции можно задать summary, description и tags прямо в декораторе.

Выгрузить схему в файл, например для CI или генератора клиента, можно командой (нужен "ninja" в INSTALLED_APPS):

python manage.py export_openapi_schema --api config.api.api --output schema.json

Тестирование: TestClient

ninja.testing.TestClient вызывает эндпоинты роутера или API напрямую, без HTTP-сервера и разбора URL всего проекта. Для async-эндпоинтов есть TestAsyncClient. Тесты пишутся в обычных классах django.test.TestCase, база данных создаётся тестовым раннером Django.

# tasks/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from ninja.testing import TestAsyncClient, TestClient

from .api import router
from .auth import make_token
from .models import Task


class TaskApiTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user("anna", password="s3cret-pass")
        cls.headers = {"Authorization": f"Bearer {make_token(cls.user)}"}
        cls.api = TestClient(router)

    def test_requires_auth(self):
        response = self.api.get("/")
        self.assertEqual(response.status_code, 401)

    def test_create_and_list(self):
        response = self.api.post("/", json={"title": "Купить молоко"}, headers=self.headers)
        self.assertEqual(response.status_code, 201)
        self.assertEqual(response.json()["owner"], "anna")

        response = self.api.get("/", headers=self.headers)
        self.assertEqual(response.json()["count"], 1)

    def test_validation_error(self):
        response = self.api.post("/", json={"title": "ab"}, headers=self.headers)
        self.assertEqual(response.status_code, 422)

    def test_not_found(self):
        response = self.api.get("/999", headers=self.headers)
        self.assertEqual(response.status_code, 404)


class TaskStatsTests(TestCase):
    async def test_stats(self):
        user = await get_user_model().objects.acreate(username="boris")
        await Task.objects.acreate(owner=user, title="Задача", done=True)
        client = TestAsyncClient(router)
        response = await client.get(
            "/stats", headers={"Authorization": f"Bearer {make_token(user)}"}
        )
        self.assertEqual(response.json(), {"total": 1, "done": 1})
python manage.py test tasks
Found 5 test(s).
.....
----------------------------------------------------------------------
Ran 5 tests in 2.615s

OK

Пути в TestClient(router) указываются относительно роутера: "/" вместо "/api/tasks/". Пользователя для django_auth можно подставить аргументом: self.api.get("/", user=user). Те же тесты запускаются через pytest с плагином pytest-django — подробнее в статье про тестирование Django с pytest.

Важно: не называйте клиент self.client в TestCase. Django перед каждым тестом записывает в этот атрибут свой django.test.Client, и запросы уйдут через полный URLconf — тесты начнут получать 404.

Django Ninja, DRF или FastAPI

Все три инструмента подходят для продакшна. Выбор зависит от того, нужен ли Django, сколько в API типового CRUD и насколько важны асинхронность и типизация.

КритерийDjango REST FrameworkDjango NinjaFastAPI
ОсноваDjangoDjangoStarlette и Pydantic, без Django
Описание эндпоинтовклассы APIView, ViewSet и роутерыфункции с декораторами, Routerфункции с декораторами, APIRouter
Схемы и валидациясериализаторы DRFPydantic v2: Schema, ModelSchemaPydantic v2
Готовый CRUD для моделиModelViewSet + роутернет, только сторонние пакетынет
Async-эндпоинтыпредставления синхронные, async — пакет adrfasync def из коробкиasync def из коробки
OpenAPI и Swaggerотдельный пакет drf-spectacularвстроено: /api/docsвстроено: /docs, /redoc
Аутентификация из коробкисессии, токены, Basic; JWT — djangorestframework-simplejwtсессии, API-ключи, базовые классы Bearer и Basic; JWT — django-ninja-jwtутилиты OAuth2 и Bearer в fastapi.security, JWT — своими силами
Права доступаpermission_classesпроверки в auth, коде или декораторахзависимости (Depends)
ПагинацияPageNumber, LimitOffset, CursorLimitOffset, PageNumber, Cursorнет встроенной
Фильтрацияпакет django-filterвстроенный FilterSchemaпараметры запроса вручную
Ограничение частотывстроеновстроено (ninja.throttling)нет встроенного
ORM, миграции, админкаDjangoDjangoнет: SQLAlchemy или другие библиотеки
Тестовый клиентAPIClient, APITestCaseTestClient, TestAsyncClientTestClient на основе httpx
Сторонние расширениякрупнейшая экосистемаменьше пакетовбольшая экосистема, но без Django

Как выбрать:

  • Проект на DRF уже работает. Оставайтесь на DRF. Если хочется попробовать Ninja, его можно подключить рядом на отдельном префиксе (например, /api/v2/) и писать там новые эндпоинты.
  • Новый проект на Django, API — в основном CRUD вокруг моделей с правами. DRF: ModelViewSet и permission_classes сэкономят больше кода.
  • Новый проект на Django, нужны типы, Pydantic, async и OpenAPI без настройки. Django Ninja.
  • Отдельный сервис без админки и моделей Django. FastAPI — см. руководство по FastAPI и общее сравнение FastAPI, Django и Flask.

О скорости: Pydantic v2 проверяет данные быстрее сериализаторов DRF, но в реальном API время ответа обычно определяют запросы к базе и внешние вызовы. Переходить с одного фреймворка на другой только ради цифр синтетических тестов не стоит — измеряйте на своём проекте.

Частые ошибки

403 «CSRF check Failed» при POST с сессией

Запрос аутентифицирован через django_auth (cookie sessionid), а CSRF-токена нет. Передайте заголовок X-CSRFToken со значением cookie csrftoken или используйте для внешних клиентов токен в заголовке Authorization.

SynchronousOnlyOperation в async-эндпоинте

Внутри async def вызван синхронный метод ORM: get(), count(), перебор QuerySet в обычном for. Замените на aget(), acount(), async for или оберните вызов в sync_to_async.

request.user — AnonymousUser при входе по токену

Пользователь из HttpBearer или API-ключа лежит в request.auth. request.user заполняется только сессией Django.

422 вместо ожидаемых данных

Ninja не вызвал функцию, потому что данные не прошли валидацию. Посмотрите loc в ответе: ["query", "done"] — неверный параметр строки запроса, ["body", "payload", "title"] — поле тела. Частая причина — отправка JSON без заголовка Content-Type: application/json или параметр, который клиент передаёт в теле, а функция ждёт в строке запроса.

Поле ForeignKey приходит как число

ModelSchema описывает внешний ключ как id связанной записи. Чтобы отдать вложенный объект или его поле, объявите поле явно: вложенной схемой или через Field(alias="owner.username"), и добавьте select_related() в запрос.

Частые вопросы

Можно ли использовать Django Ninja и DRF в одном проекте?

Да. Это обычные Django-приложения с разными URL: например, path("api/", include(router.urls)) для DRF и path("api/v2/", api.urls) для Ninja. Модели, пользователи и настройки общие, поэтому эндпоинты переносят постепенно.

Как сделать версии API v1 и v2?

Создайте два объекта NinjaAPI с разными version и подключите их на разные префиксы в urls.py. Пространства имён URL по умолчанию строятся из версии (api-1.0.0, api-2.0.0); при одинаковых версиях задайте urls_namespace явно. С версии 1.6 один и тот же роутер можно подключить к обоим API.

Есть ли в Django Ninja классовые представления, как ViewSet?

Встроенных нет — эндпоинты описываются функциями. Классовый стиль добавляет пакет django-ninja-extra: контроллеры объединяют несколько операций с общими правами и зависимостями. Учтите, что это отдельный проект со своей документацией и графиком выпусков.

Как ограничить частоту запросов?

Передайте throttle в NinjaAPI, Router или декоратор операции. Например, NinjaAPI(throttle=AnonRateThrottle("100/m")) из ninja.throttling ограничит анонимных клиентов. После превышения лимита клиент получит 429 {"detail": "Too many requests."} и заголовок Retry-After с числом секунд до следующей попытки. Счётчики хранятся в кеше Django, поэтому на нескольких процессах нужен общий кеш, например Redis.

Как отдать файл или поток данных?

Эндпоинт может вернуть любой HttpResponse Django, в том числе FileResponse или StreamingHttpResponse: Ninja передаст его клиенту без изменений. Для потоков JSON с версии 1.6 есть типы JSONL[Схема] и SSE[Схема] в response: функция-генератор отдаёт элементы по одному, и каждый проверяется по схеме.

Подходит ли Django Ninja для новичка?

Если вы уже знаете Django и аннотации типов Python, Ninja освоить проще, чем DRF: меньше классов и понятий. Но по DRF больше учебных материалов, ответов на Stack Overflow и вакансий. Для работы в существующих командах полезно знать оба инструмента, а логика эндпоинтов — модели, QuerySet, права — у них общая.

Итог

  • Django Ninja описывает API функциями с аннотациями типов: параметры пути, строки запроса и тела определяются сигнатурой, валидацию выполняет Pydantic v2.
  • Структура: один NinjaAPI в config/api.py, роутеры в приложениях, подключение через api.add_router() и path("api/", api.urls).
  • Схемы ответа задаются response, коды — словарём и Status(код, данные); кортежи из старых примеров устарели.
  • Аутентификация — параметр auth: сессии Django, API-ключи, свой HttpBearer; пользователь лежит в request.auth.
  • @paginate, async def с асинхронным ORM, Swagger UI на /api/docs и TestClient есть из коробки.
  • Для типового CRUD с развитыми правами удобнее DRF, для сервиса без Django — FastAPI. Справочник по всем параметрам библиотеки — в официальной документации Django Ninja.
  • #Django Ninja
  • #REST API
  • #Pydantic
  • #OpenAPI
  • #Django
↑ ↓ выборEnter открытьEsc закрыть