Меня зовут Стас Гаранжа, я выпускник курса «Python-разработчик» в Яндекс.Практикуме. Я хочу помочь начинающим разработчикам, которые приступили к изучению Django Rest Framework (DRF) и хотят разобраться, как устроен этот фреймворк.


Я готовлю цикл статей, в которых расскажу о разных сторонах работы DRF. У меня пока нет значимого практического опыта для описания всех изюминок при работе с этим фреймворком, поэтому в основе статьи — исследование, обобщение и по возможности непротиворечивое изложение того, что у DRF под капотом.


В этой статье разберёмся, как сделать REST API на базе Django Rest Framework, чтобы получить по GET-запросу набор записей из базы данных (БД). Иными словами, рассмотрим, как DRF работает на чтение (о том, как с помощью него создавать, изменять и удалять записи в БД, поговорим в отдельной статье).


Общую схему решения этой задачи мы рассмотрим в первой части статьи. Вторая будет посвящена детальному разбору процесса сериализации данных.



Несколько вводных замечаний:


  • Учебный проект, на основе которого даны все примеры в статье, можно найти в репозитории на Гитхабе.
  • Стиль и объём изложения рассчитаны на тех, кто не знаком с DRF и только начинает свой путь в разработке.
  • Предполагается, что читатель в общих чертах уже знаком с Django и знает основы ООП на Python.

Надеюсь, статья станет хорошим подспорьем изучения DRF и работы с его документацией, прояснит процесс сериализации данных и даст уверенность, что любая магия исчезает, стоит только покопаться под капотом конкретной библиотеки.


API для сайта на Django: общая схема


Задача


На локальном сервере работает одностраничный сайт на Django. На единственной странице сайта по адресу http://localhost:8000 пользователи видят информацию о четырёх североевропейских столицах. Информация попадает на страницу из подключённой к сайту базы данных, в которой есть модель Capital с пятью полями:


id country capital_city capital_population author (FK)
1 Norway Oslo 693500 1
2 Sweden Stockholm 961600 1
3 Finland Helsinki 655300 1
4 Iceland Reykjavik 128800 1

Поле author через внешний ключ (foreign key) связано с моделью User, в которой есть вся информация о пользователе с конкретным id.


Мы хотим получить информацию из базы данных, не открывая сайт в браузере, а сделав запрос из другого Python-приложения.


В каком виде нужно получить информацию:


  • Набор информации должен быть списком из Python-словарей: ключ — название поля записи в таблице Capital, значение — содержимое конкретного поля.
  • Названия стран нас не интересуют — нам нужны названия столиц, численность населения, а также имя сотрудника, который внёс запись в базу. Имя получаем через id автора, указанный в поле author.
  • Для передачи по сети полученные из БД данные должны быть конвертированы в json-формат.

Таким образом, каждую запись, которая при извлечении из базы данных является Python-объектом, принимающее приложение после декодирования json-строки должно получать в виде словаря:


{
  'capital_city': 'Oslo',
  'capital_population': 693500,
  'author': 'test_user'
}

В этом и состоит одно из назначений API — дать возможность различным приложениям доставать из БД сайта информацию в виде структуры данных, которую дальше можно обрабатывать.


Решаем задачу с помощью Django Rest Framework


Задача решается в два шага:


  • Сложный объект (набор записей из Django-модели) нужно превратить в более простую структуру, в нашем случае в список словарей. Понадобится сериалайзер.
  • Сериализованные данные для дальнейшей передачи по сети нужно перевести (отрендерить) в json-формат — универсальный текстовый формат передачи данных, не зависящий от языка реализации. Понадобится рендер.

Небольшое отступление о json. Базовые структуры данных на python кодируются в json и декодируются обратно следующим образом:


Python JSON Пример Python Пример JSON
dict object {'ключ': 'значение'} {"ключ": "значение"}
list, tuple array ['элемент1', 'элемент2'], ('элемент1', 'элемент2') ["элемент1", "элемент2"]
str string 'элемент1' "элемент1"
int, float, int- & float-derived Enums number 5, 4.2 5, 4.2
True true True true
False false False false
None null None null

Создаём сериалайзер


Каждая запись в таблице Capital — объект. И как у любого объекта, у записи есть свои атрибуты. Изучим их на примере первой записи о столице Норвегии, воспользовавшись атрибутом __dict__. Нам доступен словарь, который хранит информацию о динамических (writable) атрибутах объекта:


Capital.objects.first().__dict__

{
    '_state': <django.db.models.base.ModelState object at 0x00000126F2DB0BB0>,
    'id': 1,
    'country': 'Norway',
    'capital_city': 'Oslo',
    'capital_population': 693500, 
    'author_id': 1
}

Каждое поле модели Capital — атрибут объекта конкретной записи. При этом поле author, которое через внешний ключ связано с моделью User и содержит id объектов из неё, в атрибуте записи и в БД получает приставку _id.


Сериалайзер поможет достать данные из нужных атрибутов (полей) записи и сформировать упорядоченный python-словарь — объект класса OrderedDict. Отмечу, что в Python с версии 3.7 и «обычные» словари стали сохранять порядок вставки пар «ключ — значение».


Для сериалайзера нужно описать поля: каждое поле будет отвечать за извлечение и представление данных из корреспондирующего поля табличной записи.


Важный момент: здесь мы рассматриваем сериалайзер на основе базового класса Serializer, чтобы лучше понять принципы его работы. На более высоком уровне абстракции есть класс ModelSerializer, который позволяет частично уйти от ручного создания полей. В этой статье он не рассматривается.


Нас интересуют данные, которые есть в трёх полях каждой табличной записи:


  • поле capital_city,
  • поле capital_population,
  • поле author.

Значит, в сериалайзере должно быть тоже три атрибута-поля.


При создании поля сериалайзера нужно определиться с названием поля и его типом. Назвать поля сериалайзера можно как угодно: именно эти названия будут ключами в словаре, в который сериалайзер преобразует запись из таблицы.


Вот примеры трёх вариантов названий полей сериалайзера:



Но как сериалайзер понимает, в каком порядке стыковать собственные поля с полями табличной записи? Например, если поле сериалайзера условно называется a, то как он определяет, что его нужно состыковать с полем записи capital_city?


Логика такая:


  • При создании поля сериалайзера можно передать аргумент source и в качестве значения указать название поля табличной записи, данные из которого будут пропускаться через поле сериалайзера. Продолжая пример, если поле сериалайзера названо a и при этом указано source='capital_city', то из табличной записи будут извлекаться данные атрибута (поля) capital_city. Именно поэтому на выходе сформируется пара "a": "Oslo".
  • Через точечную нотацию в аргументе source можно передать значение объекта из записи, с которой сериализуемая запись связана через внешний ключ. Так можно достать имя автора из таблицы пользователей, указав source='author.username'.
  • Если аргумент source не передан, то сериалайзер будет искать в табличной записи атрибут с тем же названием, что и название поля сериалайзера. Если не найдёт, появится ошибка AttributeError.
  • Если передать в аргументе source значение, которое совпадает с названием поля сериалайзера, возникнет ошибка AssertionError, a DRF предупредит: такое дублирование избыточно.

Теперь нужно выбрать тип поля сериалайзера. Его нужно соотнести с тем, какие данные извлекаются из корреспондирующего поля табличной записи. Дело в том, что у каждого поля сериалайзера есть собственный метод to_representation. Как следует из названия, задача метода — представить извлечённые из записи данные в определённом виде.


Например, есть поле serializers.IntegerField. Посмотрим на его метод to_representation:


class IntegerField(Field):
    . . .
    def to_representation(self, value):
        return int(value)

Очевидно, этот тип поля сериалайзера нельзя выбирать для данных из табличной записи о названии столицы: int('Осло') вызовет ValueError. А вот для данных о численности населения — самое то.


Выберем следующие типы полей сериалайзера:


Название поля в таблице (модели) Тип поля в таблице (модели) Тип корреспондирующего поля сериалайзера
capital_city models.CharField serializers.CharField
capital_population models.IntegerField serializers.IntegerField
author models.ForeignKey serializers.CharField

О соотношении полей сериалайзера и полей Django-моделей можно прочитать в документации DRF.


Код сериалайзера разместим в том же приложении, где находится Django-модель, под именем serializers.py:


# capitals/serializers.py
from rest_framework import serializers

class CapitalSerializer(serializers.Serializer):
    capital_city = serializers.CharField(max_length=200)
    capital_population = serializers.IntegerField()
    author = serializers.CharField(source='author.username', max_length=200)

В поле CharField указан необязательный параметр max_length, благодаря которому задаётся максимально допустимая длина передаваемого значения. О других параметрах поля написано в документации.


Для полей сериалайзера capital_city и capital_population мы не передаём аргумент source — названия поля сериалайзера и корреспондирующего поля табличной записи совпадают. Для поля author, наоборот, нужен аргумент source. В поле author модели Capital есть только id автора, а нам нужен его username. За этим значением мы идём в таблицу с данными о пользователях, с которой поле author связано по внешнему ключу. Используем точечную нотацию author.username.


Пропущенный через сериалайзер набор табличных записей доступен в атрибуте сериалайзера data. Посмотрим на содержимое этого атрибута, создав тестовый вариант сериалайзера.


Сериалайзер в действии


Обратимся к файлу serializer_example_1.py. Он имитирует работу сериалайзера без необходимости запускать сервер и делать запрос к сайту. После клонирования учебного проекта и установки зависимостей (шаги 1—6 из ридми) достаточно запустить файл как обычный Python-скрипт и посмотреть в консоли результат его работы.


В serializer_example_1.py созданы классы с данными об авторах и о столицах для записей в таблицах:


class User:
    def __init__(self, username):
        self.username = username

class Capital:
    def __init__(self, country, capital_city, capital_population, user: User):
        self.country = country
        self.capital_city = capital_city
        self.capital_population = capital_population
        self.author = user

Созданы объекты соответствующих записей:


author_obj = User('test_user')
capital_1 = Capital('Norway', 'Oslo', 693500, author_obj)
. . . 

Объединены записи в список по подобию кверисета из Django-модели:


queryset = [capital_1, capital_2, capital_3, capital_4]

Объявлен класс сериалайзера: код идентичен тому, который был приведён выше для class CapitalSerializer(serializers.Serializer). Затем создали его экземпляр:


serializer_obj = CapitalSerializer(instance=queryset, many=True)

При создании мы передали сериалайзеру набор записей, которые нужно преобразовать. Они передаются в аргументе instance.


Кроме того, мы указали аргумент many со значением True. Дело в том, что логика работы сериалайзера с одной записью и с набором записей разная. Указывая many=True, мы включаем логику обработки набора записей. В чём она заключается, расскажу во второй части статьи при детальном разборе работы сериалайзера.


Выведем в консоль содержимое атрибута data сериалайзера:


# serializer_obj.data
[
    OrderedDict([('capital_city', 'Oslo'), ('capital_population', 693500),
                 ('author', 'test_user')]),
    OrderedDict([('capital_city', 'Stockholm'), ('capital_population', 961600), 
                 ('author', 'test_user')]),
    ...
]

Каждая запись из набора превратилась в упорядоченный словарь класса OrderedDict. Он находится в Python-модуле collections. Поэтому, если взглянуть на строки импорта в исходном коде restframework.serializers, можно увидеть:


from collections import OrderedDict, defaultdict

В каждом OrderedDict содержится информация только из тех полей табличных записей, которые были состыкованы с полями сериалайзера. Данных о содержимом поля country нет — сериалайзер не настроен доставать эту информацию, потому что мы не создавали корреспондирующего поля в сериалайзере.


Отображаем (рендерим) информацию в формате json


Нам понадобится рендер — объект класса JSONRenderer. В файле serializer_example_2.py мы дополнили импорт — помимо модуля сериалайзеров из restframework мы импортировали модуль рендеров.


Далее необходимо создать экземпляр рендера нужного типа и вызвать у него метод render:


json_render_for_our_data = renderers.JSONRenderer()
data_in_json = json_render_for_our_data.render(serializer_obj.data)

В результате мы увидим байтовую строку с массивом json-объектов:


b'[{"capital_city":"Oslo","capital_population":693500,"author":"test_user"},{"capital_city":"Stockholm","capital_population":961600,"author":"test_user"},...]'

Эта байтовая строка и будет передаваться по сети в атрибуте ответа content, а принимающее приложение будет её декодировать в список из Python-словарей и вытаскивать нужную информацию из каждого.


Что нужно ещё


Итак, мы испытали сериалайзер и посмотрели, как пропущенный через него набор табличных записей был преобразован в json-формат.


Чтобы сайт начал отдавать сериализованные данные, остаётся описать контроллер (view) и указать url-маршрут — эндпоинт, при обращении к которому сайт будет отдавать данные о столичных городах.


Контроллер


Во views.py создадим класс контроллера. Нам понадобятся следующие инструменты DRF:


  • класс APIView, который служит каркасом для контроллера;
  • класс Response, с помощью которого будет создан объект ответа на запрос. Похожая схема есть в «классическом» Django, где в ответ на HTTPRequest должен возвращаться HTTPResponse.

Внутри контроллера описываем один метод — get. Почему он называется именно так?


Логика класса-родителя APIView, а значит, и класса контроллера, такова: в контроллере запускается метод, чьё имя совпадает с именем метода поступившего http-запроса в нижнем регистре. Ровно так же работает родительский View-класс в Django.


Пример: если поступил GET-запрос, то будет задействован метод get контроллера.


В методе get опишем ту же логику, что и в файле с пробным запуском сериалайзера:


  1. Подготовить набор записей.
  2. Создать экземпляр сериалайзера, который может обрабатывать не отдельную запись, а их набор (many=True).
  3. Отрендерить в json-формат данные, полученные от сериалайзера.

# capitals/views.py
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import Capital
from .serializers import CapitalSerializer

class GetCapitalInfoView(APIView):
    def get(self, request):
        # Получаем набор всех записей из таблицы Capital
        queryset = Capital.objects.all()
        # Сериализуем извлечённый набор записей
        serializer_for_queryset = CapitalSerializer(
            instance=queryset, # Передаём набор записей
            many=True # Указываем, что на вход подаётся именно набор записей
        )
        return Response(serializer_for_queryset.data)

В отличие от файла serializer_example_2.py, где мы явно прописывали json-рендер и вызывали у него метод render, в коде контроллера ничего такого нет. Но рендер всё равно отработает: его работа описана под капотом внутри класса-родителя APIView.


После того как отработал метод get, работа контроллера выглядит так:


  1. Объект ответа, который вернул метод get (return Response({'capitals': serializer_for_queryset.data}), передаётся в метод finalize_response родительского класса APIView.
  2. В методе finalize_response объекту ответа добавляются атрибуты:
    • accepted_renderer — им как раз выступает объект JSONRenderer,
    • accepted_media_type — 'application/json',
    • context.

Благодаря этим атрибутам формируется rendered_content: у экземпляра JSONRenderer срабатывает метод render, который возвращает байтовую строку с данными в json-формат. Она помещается в атрибут ответа content.


Маршрут (эндпоинт)


Здесь та же схема действий, как в классическом Django. Подключаем маршруты приложения capitals:


# config/urls.py
from django.urls import include, path

urlpatterns = [
    path('', include('capitals.urls')),
]

Прописываем сам маршрут в приложении capitals и связываем маршрут с контроллером:


# capitals/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('api/capitals/', views.GetCapitalInfoView.as_view()),
]

API в действии


Чтобы посмотреть, как работает API, можно:


  1. Подготовить Python-скрипт, который будет отправлять запрос на адрес http://localhost:8000/api/capitals/ и что-то делать с полученным контентом.
  2. Запустить локальный сервер, на котором работает сайт — python manage.py runserver.
  3. Запустить в терминале Python-скрипт.

Первый шаг уже сделан: в корне учебного проекта есть файл get_info_from_our_site.py. Этот скрипт делает запрос к http://localhost:8000/api/capitals/, декодирует полученный json-ответ и записывает информацию о столицах и их населении в текстовый файл.


Осталось выполнить шаги 2 и 3.


Если всё отработало штатно, в корневой директории проекта появится файл capitals.txt со следующим содержимым:


The population of Oslo is 693500, author - test_user
The population of Stockholm is 961600, author - test_user
The population of Helsinki is 655300, author - test_user
The population of Reykjavik is 128800, author - test_user

Несмотря на то, что пример наивный, он показывает главное: как мы научили
веб-приложение отдавать информацию из базы данных в ответ на запрос, который поступает не от человека через браузер, а от другого приложения. И далее — как это приложение использует полученную информацию.


Browsable API — удобный инструмент для тестирования API на DRF


Django Rest Framework позволяет посмотреть в браузере, какую информацию будет отдавать API при обращении к конкретному маршруту (эндпоинту). Достаточно ввести маршрут в адресную строку, и откроется страница с данными о запросе и результате его выполнения. За такое отображение отвечает BrowsableAPIRenderer.





Итак, мы рассмотрели, как сделать API на базе DRF, чтобы получить по GET-запросу набор записей из Django-модели. Во второй частиhttps://habr.com/ru/company/yandex_praktikum/blog/562050/ подробно разберём работу сериалайзера на чтение.
Если у вас появились вопросы по решению задачи, пишите в комментариях.