Django Ninja: API на Django с аннотациями типов
Django Ninja на примерах: установка, NinjaAPI и роутеры, схемы Pydantic, параметры, коды ответов, аутентификация, пагинация, async, Swagger и тесты.
Содержание · 18 разделов
- Что такое Django Ninja и когда его выбирать
- Версии и совместимость
- Установка и первый эндпоинт
- Нужно ли добавлять ninja в INSTALLED_APPS
- NinjaAPI и роутеры: структура проекта
- Модель для примеров
- Схемы: Schema и ModelSchema
- Параметры запроса: путь, строка запроса, тело
- Параметры пути
- Строка запроса и группировка в схему
- Тело запроса
- Заголовки, формы и файлы
- Ошибки валидации: 422
- Ответы и коды статусов
- CRUD для модели
- Аутентификация
- Токен на основе подписи Django
- request.auth и request.user
- CSRF при входе через сессию
- Права доступа
- Пагинация
- Асинхронные эндпоинты
- Документация OpenAPI: /api/docs
- Тестирование: TestClient
- Django Ninja, DRF или FastAPI
- Частые ошибки
- 403 «CSRF check Failed» при POST с сессией
- SynchronousOnlyOperation в async-эндпоинте
- request.user — AnonymousUser при входе по токену
- 422 вместо ожидаемых данных
- Поле ForeignKey приходит как число
- Частые вопросы
- Можно ли использовать Django Ninja и DRF в одном проекте?
- Как сделать версии API v1 и v2?
- Есть ли в Django Ninja классовые представления, как ViewSet?
- Как ограничить частоту запросов?
- Как отдать файл или поток данных?
- Подходит ли Django Ninja для новичка?
- Итог
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.6 | Status(код, данные) вместо кортежа (код, данные), курсорная пагинация, потоковые ответы 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}
| Класс | Параметры запроса | Когда подходит |
|---|---|---|
LimitOffsetPagination | limit, offset | по умолчанию; произвольные срезы списка |
PageNumberPagination | page, page_size | классические страницы в интерфейсе |
CursorPagination | cursor | ленты, где записи часто добавляются, — нет пропусков и дублей |
Класс по умолчанию и размеры задаются в 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 Framework | Django Ninja | FastAPI |
|---|---|---|---|
| Основа | Django | Django | Starlette и Pydantic, без Django |
| Описание эндпоинтов | классы APIView, ViewSet и роутеры | функции с декораторами, Router | функции с декораторами, APIRouter |
| Схемы и валидация | сериализаторы DRF | Pydantic v2: Schema, ModelSchema | Pydantic v2 |
| Готовый CRUD для модели | ModelViewSet + роутер | нет, только сторонние пакеты | нет |
| Async-эндпоинты | представления синхронные, async — пакет adrf | async 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, Cursor | LimitOffset, PageNumber, Cursor | нет встроенной |
| Фильтрация | пакет django-filter | встроенный FilterSchema | параметры запроса вручную |
| Ограничение частоты | встроено | встроено (ninja.throttling) | нет встроенного |
| ORM, миграции, админка | Django | Django | нет: SQLAlchemy или другие библиотеки |
| Тестовый клиент | APIClient, APITestCase | TestClient, TestAsyncClient | TestClient на основе 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