Как проверить данные с помощью DRF
Есть такая частая задача в бэкенд-разработке: “Как проверить данные, прилетевшие от клиента?”. Действительно ли заполнены обязательные поля? А указанный id
существует в БД? А список действительно является списком? Буквально каждый шаг приходится проверять. Выливается всё это к кучу однотипного, простого, но крайне громоздкого кода. Задача это частая, и у неё есть своё название — валидация данных.
Но и проверкой данных дело обычно не заканчивается. Клиент мог прислать строку, а база данных хочет число. Или снова получили строку, но нужна дата datetime.Date
. Приходится писать дополнительный код, преобразующий входные данные к нормальному виду. Потому и называют эту задачу нормализацией данных.
Два этих процесса — проверка данных и нормализация — тесно связаны друг с другом, и, обычно их объединяют вместе. Называют этот новый процесс десериализацией данных.
Валидация + Нормализация = Десериализация
Из туториала вы узнаете как эффективно провести десерилизацию: всё надёжно проверить, нормализовать и при этом избавиться от всего этого громоздкого кода.
Работать будем со страничкой сайта ежегодной конференции по маркетингу TheEvent. Это лендинг, сделанный с помощью Bootstrap 4 и Django. На стартовой странице есть форма покупки билетов. К концу этого туториала вы её оживите, написав ручку API для приёма заявок:
Что надо знать
Туториал предполагает, что вы уже хорошо знакомы с Django: легко пишете свои модели, вьюхи и добавляете урлы. Также вам понадобятся общие представления о работе с Django Rest Framework: кто такие @api_view
, Parsers и Renderes.
Если знаний у вас пока недостаточно, подтяните их с помощью туториалов:
1. Запустите сайт
Прежде всего вам понадобится заготовка сайта. Возьмите код с GitHub и разверните его у себя. В точности следуйте инструкциям в README. Вот ссылка на репозиторий на GitHub.
Если всё сделано правильно, у вас заработает сайт:
Теперь найдите в репозитории файл enrollment/views.py и загляните в код. Там вы найдёте функцию def enroll(request)
. Она принимает заявки на участие в конференции и состоит из двух блоков кода. Первый отвечает за проверку данных:
@api_view(['POST'])
def enroll(request):
if 'contact_phone' not in request.data:
return Response(['Contact phone field is required.'], status=400)
if 'ticket_type' not in request.data:
return Response(['Ticket type field is required.'], status=400)
# TODO check if ticket_type is one of available choices
...
Код проверяет, что клиент указал в заявке оба поля contact_phone
и ticket_type
. А если одного из них не хватает, то сервер сразу вернёт HTTP 400 с описанием проблемы.
Потестировать работу функции можно с помощью Browsable API. Зайдите на страницу http://127.0.0.1:8000/enroll/ и отправьте пустой JSON объект {}
. В ответ прилетит список с одной единственной ошибкой — той, что была обнаружена первой:
[
"Contact phone field is required."
]
Второй блок кода внутри функции def enroll(request)
отвечает за сохранение данных в БД. Он сильно связан с моделью данных в файле enrollment/models.py. Модель включает в себя заявку Application
и набор прикреплённых к ней участников Participant
:
class Application(models.Model):
contact_phone = models.CharField(max_length=20)
ticket_type = models.CharField(max_length=20, db_index=True, ...)
confirmed = models.BooleanField(default=False, db_index=True)
class Participant(models.Model):
application = models.ForeignKey(Application, related_name='participants', on_delete=models.CASCADE)
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)
email = models.EmailField()
Теперь, зная модель данных, можно разобраться со вторым блоком кода функции def enroll(request)
. Он кладёт поступившую заявку в БД и возвращает её id:
@api_view(['POST'])
def enroll(request):
...
participants = request.data.get('participants', []) # TODO validate data!
application = Application.objects.create(
contact_phone=str(request.data['contact_phone']),
ticket_type=str(request.data['ticket_type']),
)
participants = [Participant(application=application, **fields) for fields in participants]
Participant.objects.bulk_create(participants)
return Response({
'application_id': application.id,
})
2. Используйте ValidationError
Вот первый фокус, о котором следует знать: декоратор @api_view
умеет перехватывать некоторые виды исключений и превращать их в объекты Response. В частности исключение ValidationError
будет заменено на Response
со статусом HTTP 400. Используйте это. Так было:
@api_view(['POST'])
def enroll(request):
if 'contact_phone' not in request.data:
return Response(['Contact phone field is required.'], status=400)
if 'ticket_type' not in request.data:
return Response(['Ticket type field is required.'], status=400)
# TODO check if ticket_type is one of available choices
...
Так стало:
from rest_framework.serializers import ValidationError
@api_view(['POST'])
def enroll(request):
if 'contact_phone' not in request.data:
raise ValidationError(['Contact phone field is required.'])
if 'ticket_type' not in request.data:
raise ValidationError(['Ticket type field is required.'])
# TODO check if ticket_type is one of available choices
...
Новый код делает то же самое, что и старый. Если зайти на страницу http://127.0.0.1:8000/enroll/ и отправить пустой JSON объект {}
, то в ответ прилетит HTTP 400 с ошибкой:
[
"Contact phone field is required."
]
Что удивительно, с исключением ValidationError
оказывается работать проще, чем с Response
. Теперь код проверки данных не обязательно держать внутри def enroll(request)
. Можно вынести его наружу в отдельную функцию и тем самым упростить код основной функции enroll
:
from rest_framework.serializers import ValidationError
def validate(data):
if 'contact_phone' not in data:
raise ValidationError(['Contact phone field is required.'])
if 'ticket_type' not in data:
raise ValidationError(['Ticket type field is required.'])
# TODO check if ticket_type is one of available choices
@api_view(['POST'])
def enroll(request):
validate(request.data)
...
3. Соберите все ошибки
Вы уже заметили этот серьёзный недостаток в коде валидации? Мало того, что данные проверены не особо надёжно, так ещё и валидация обрывается сразу после первой же обнаруженной ошибки. Это крайне раздражающая “фича”. Клиенту придется раз за разом отправлять данные на сервер, только для того, чтобы узнать о следующей своей проблеме. Издевательство …
Можно решить проблему в лоб. Допишите код функции def validate(data)
, накопите ошибки внутри списка errors
прежде, чем выкинуть исключение:
def validate(data):
errors = []
if 'contact_phone' not in data:
errors.append('Contact phone field is required.')
if 'ticket_type' not in data:
errors.append('Ticket type field is required.')
# TODO check if ticket_type is one of available choices
if errors:
raise ValidationError(errors)
Проверьте как это работает. Зайдите на страницу http://127.0.0.1:8000/enroll/, отправьте пустой JSON объект {}
. В ответ прилетит сразу пара ошибок:
[
"Contact phone field is required.",
"Ticket type field is required."
]
4. Подключите Serializer
Теперь Django Rest Framework может предложить вам ещё кое-что очень интересное! У него есть замена для вашей функции def validate(data)
. В DRF встроен механизм десериализации на базе всё того же исключения ValidationError
, но с кучей плюшек и готовых решений.
Основной инструмент для десериализации в DRF — это класс Serializer
или, по-русски, сериализатор. Да-да, вам не почудилось. В DRF и сериализацию и десериализацию выполняет один и тот же класс. Причём в этом туториале вы будете использовать Serializer
исключительно в качестве десериализатора. Такая вот странная несостыковка в названиях.
Класс Serializer
позволяет описать схему данных таким образом, что DRF сам сгенерует код аналогичный вашей функции def validate(data)
. Так было:
def validate(data):
errors = []
if 'contact_phone' not in data:
errors.append('Contact phone field is required.')
if 'ticket_type' not in data:
errors.append('Ticket type field is required.')
# TODO check if ticket_type is one of available choices
if errors:
raise ValidationError(errors)
А так стало:
from rest_framework.serializers import Serializer
from rest_framework.serializers import CharField
class ApplicationSerializer(Serializer):
contact_phone = CharField()
ticket_type = CharField()
def validate(data):
serializer = ApplicationSerializer(data=data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
Вот что здесь происходит. Сначала создаётся класс ApplicationSerializer
— он описывает схему данных. Здесь указаны текстовые поля contact_phone
и ticket_type
. DRF будет считать эти поля обязательными для заполнения, если не указать обратное в дополнительных настройках CharField
.
Внутри функции def validate(data)
всего две строки кода. Сначала сериализатор ApplicationSerializer
получает входные данные data
, после чего запускается их проверка с помощью метода is_valid
. Если данные не будут соответствовать схеме, описанной внутри ApplicationSerializer
, то вызов метода is_valid(raise_exception=True)
приведёт к исключению ValidationError
.
Поведение функции def validate(data)
осталось прежним, но код с условиями if
теперь заменён на простую и наглядную декларацию схемы данных. Теперь можно пойти дальше и отказаться от более ненужной функции def validate(data)
. Её код переезжает внутрь enroll
:
from rest_framework.serializers import Serializer
from rest_framework.serializers import CharField
class ApplicationSerializer(Serializer):
contact_phone = CharField()
ticket_type = CharField()
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
...
Проверьте как это работает. Зайдите на страницу http://127.0.0.1:8000/enroll/, отправьте пустой JSON объект {}
. В ответ прилетят ошибки:
{
"contact_phone": [
"This field is required."
],
"ticket_type": [
"This field is required."
]
}
Формат ответа немного отличается от того, что было раньше. Теперь каждая ошибка привязана к полю, в котором она возникла. Это весьма удобно, когда данных становится много и из текста ошибки уже не понять о каком поле идёт речь.
5. Проверьте ticket_type
В коде не хватает ещё одной проверки. База данных требует, чтобы поле ticket_type
соответствовало одному из трёх разрешённых значений. В файле enrollment/models.py есть такой код:
class Application(models.Model):
...
ticket_type = models.CharField(max_length=20, db_index=True, choices=(
('standard-access', 'Standard Access'),
('pro-access', 'Pro Access'),
('premium-access', 'Premium Access'),
))
Настройка choices
разрешает лишь три возможных значения для поля ticket_type
: 'standard-access'
, 'pro-access'
и 'premium-access'
. Добавьте соответствующую проверку в API.
Вам понадобится новый метод validate_ticket_type
. DRF уже знает о том, что в схеме данных есть поле ticket_type
, а потому будет искать и метод с похожим названием вида validate_{fieldname}
. Если такой метод найдётся внутри сериализатора, то DRF автоматически вызовет его в конце проверки данных.
class ApplicationSerializer(Serializer):
contact_phone = CharField()
ticket_type = CharField()
def validate_ticket_type(self, value):
if value not in ['standard-access', 'pro-access', 'premium-access']:
raise ValidationError('Wrong value')
return value
Метод validate_ticket_type
очень похож на старую функцию def validate(data)
. Первое отличие здесь в том, что метод validate_ticket_type
проверяет одно единственное поле ticket_type
и ничего не знает об остальных данных. Второе отличие — метод не только проверяет данные, но и возвращает значение return value
. Дело здесь в том, что DRF позволяет не только проверять данные, но и нормализовать их. Например, можно было привести текст к верхнему регистру или нижнему, обрезать или обработать другим образом. Здесь эта возможность не используется, но DRF требует вернуть значение, и поэтому функция возвращает то, что сама получила на вход — аргумент value
.
Проверьте как это работает. Со страницы http://127.0.0.1:8000/enroll/ отправьте такой JSON:
{"ticket_type": "unknown"}
В ответ прилетит новое сообщение об ошибке 'Wrong value'
:
{
"contact_phone": [
"This field is required."
],
"ticket_type": [
"Wrong value"
]
}
Обратите внимание, что ошибка в поле contact_phone
никак не повлияла на проверку поля ticket_type
. DRF проверяет поля раздельно и независимо друг от друга. Более того, ValidationError
в функциях validate_ticket_type
и is_valid
— это не одно, а два разных исключения. Присмотритесь внимательно к коду:
class ApplicationSerializer(Serializer):
...
def validate_ticket_type(self, value):
if value not in ['standard-access', 'pro-access', 'premium-access']:
raise ValidationError('Wrong value')
return value
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
...
В первом случае ValidationError
содержит описание одной единственной проблемы 'Wrong value'
для поля ticket_type
. Во втором случае исключение получает целый словарь с набором ошибок:
{
"contact_phone": [
"This field is required."
],
"ticket_type": [
"Wrong value"
]
}
Фокус здесь в том, что сериализатор ApplicationSerializer
перехватывает внутри себя все исключения ValidationError
. Сначала он накапливает их, переупаковывает их данные в словарь, а в конце проверки выкидывает новое исключение ValidationError
с полным набором ошибок внутри.
6. Замените код на ModelSerializer
На это магия DRF не заканчивается. Помимо класса Serializer
есть аналогичный класс ModelSerializer
. Отличается он тем, что сам умеет описывать схему данных на основе модели данных. Всё что нужно сделать — это указать на модель данных и перечислить интересующие поля:
from rest_framework.serializers import ModelSerializer
class ApplicationSerializer(ModelSerializer):
class Meta:
model = Application
fields = ['contact_phone', 'ticket_type']
И что совсем здорово, ModelSerializer
проверяет данные идеально точно. Вы помните об ограничении на максимальную длину строки contact_phone
в модели данных? Нет? А ведь она там есть! Файл :
class Application(models.Model):
contact_phone = models.CharField(max_length=20)
...
Вы только полюбуйтесь, насколько точно сработал ModalSerializer
! Добавьте отладочный print
:
class ApplicationSerializer(ModelSerializer):
class Meta:
model = Application
fields = ['contact_phone', 'ticket_type']
print(repr(ApplicationSerializer()))
В консоли вы увидите все настройки сериализатора:
ApplicationSerializer():
contact_phone = CharField(max_length=20)
ticket_type = ChoiceField(choices=(('standard-access', 'Standard Access'), ('pro-access', 'Pro Access'), ('premium-access', 'Premium Access')))
Ну не прекрасно ли это! На самом деле, пропустить что-то в настройках схемы данных довольно просто, поэтому при любой возможности старайтесь использовать ModelSerializer
.
ModelSerializer — когда пишешь данные в БД
И теперь увесистая такая ложка дёгтя… Класс ModelSerializer
превращается в тыкву, если клиент API присылает вам данные с другими названиями полей, отличными от того, что лежит у вас в БД. Если клиент присылает firstname
, а в БД поле называется first_name
, то никакого легального способа переименовать автоматически сгенерированное поле ModelSerializer
у вас не будет. Варианта действий здесь два. Первый — пойти ругаться с фронтендером и требовать переименовать поля. Второй — отказаться от автоматической генерации полей и добавить их вручную. Вот обсуждение проблемы на StackOverflow. А в документации можно почитать про настройку source
.
В остальном поведение ModelSerializer
в точности повторяет обычный Serializer
. Ему точно так же можно добавлять методы, поля и прочие настройки. В любой ситуации Serializer
можно заменить на ModelSerializer
и наоборот.
7. Проверьте участников
Заявка от клиента помимо двух полей contact_phone
и ticket_type
может содержать ещё целый список полей participants
. Им тоже нужна своя валидация.
Подход к проблеме здесь прежний. Просто создайте ещё один ModelSerializer
для модели Participant
. Так выглядил прежний код:
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
participants = request.data.get('participants', []) # TODO validate data!
...
Так выглядит новая версия:
class ParticipantSerializer(ModelSerializer):
class Meta:
model = Participant
fields = ['first_name', 'last_name', 'email']
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
participants = request.data.get('participants', [])
if not isinstance(participants, list):
raise ValidationError('Expects participants field be a list')
for fields in participants:
serializer = ParticipantSerializer(data=fields)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
...
Проверьте как это работает. Со страницы http://127.0.0.1:8000/enroll/ отправьте пустой JSON объект {}
. Ответ будет таким:
{
"contact_phone": [
"This field is required."
],
"ticket_type": [
"This field is required."
]
}
Примечательно, что до проверки поля participants
дело даже не дошло. Снова всплыла старая проблема — валидация прерывается после первого же “сломанного” сериализатора ApplicationSerializer
, и второй ParticipantSerializer
даже не запускается. Решить проблему можно собрав все проверки внутри одного основного сериализатора ApplicationSerializer
. Для этого пригодится новый трюк DRF — вложенные сериализаторы.
8. Объедините сериализаторы
Каждый сериализатор в DRF описывает свою схему данных и, комбинируя их, можно из нескольких простых схем собрать одну большую сложную. Есть несколько способов такой композиции. Например, можно использовать сериализатор подобно обычному полю CharField
:
class ApplicationSerializer(ModelSerializer):
participant = ParticipantSerializer()
class Meta:
model = Application
fields = ['contact_phone', 'ticket_type', 'participant']
Такая схема данных помимо полей contact_phone
и ticket_type
будет ожидать словарь participant
. Вот пример входных данных:
{
"contact_phone": "+1 5612 ...",
"ticket_type": "pro-access",
"participant": {
"first_name": "Bob",
"last_name": "Smith",
"email": "mail@example.com"
}
}
Однако, вам в заявке от клиента прилетает не словарь, а целый список словарей. На этот случай в DRF есть специальный тип поля ListField
. Он представляет из себя список любых объектов: строк, чисел, чего угодно. Даже можно положить туда другой сериализатор:
from rest_framework.serializers import ListField
class ApplicationSerializer(ModelSerializer):
participants = ListField(
child=ParticipantSerializer()
)
class Meta:
model = Application
fields = ['contact_phone', 'ticket_type', 'participants']
Пора проверить как всё это работает. Снова зайдите на страницу http://127.0.0.1:8000/enroll/ и отправьте JSON:
{
"ticket_type": "pro-access",
"participants": [
{ "first_name": "Bob" }
]
}
Ответ сервера будет таким:
{
"contact_phone": [ "This field is required." ],
"participants": {
"0": {
"last_name": [ "This field is required." ],
"email": [ "This field is required." ]
}
}
}
Для ListField
в DRF существует альтернативная короткая форма записи:
class ApplicationSerializer(ModelSerializer):
participants = ParticipantSerializer(many=True) # обратите внимание на many=True
class Meta:
model = Application
fields = ['contact_phone', 'ticket_type', 'participants']
Интересно, как это работает? Без чёрной магии тут не обошлось… Сериализатор ParticipantSerializer
меняет стандартное поведение конструктора __new__
. Обычно конструктор просто создаёт экземпляр своего класса ParticipantSerializer
, но здесь всё хитрее. Конструктор замечает параметр many=True
и вместо экземпляра ParticipantSerializer
создаёт экземпляр другого класса ListField
. Получается тот же результат, что и при явном вызове:
ListField(
child=ParticipantSerializer()
)
9. Откажитесь от сырых данных
Наконец-то можно переписать и вторую последнюю часть кода функции def enroll(request)
— ту часть, где происходит запись в БД.
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
participants = request.data.get('participants', [])
application = Application.objects.create(
contact_phone=str(request.data['contact_phone']),
ticket_type=str(request.data['ticket_type']),
)
participants = [Participant(application=application, **fields) for fields in participants]
Participant.objects.bulk_create(participants)
return Response({
'application_id': application.id,
})
Этот код исправно работает. И выглядит он лаконично. Тогда что же с ним не так?! Здесь нарушен запрет на работу с сырыми данными.
Объект request.data
содержит в себе абсолютно все данные, что прислал вам пользователь. Здесь есть и те поля, что вы надёжно проверили, и те, о проверке которых вы могли забыть. Чистые обработанные данные здесь смешаны с грязными и сырыми. Стоит один раз промахнуться ключом или забыть что-то проверить, и вы получите угрозу взлома сервера и ещё гору мусора в БД в придачу.
Здесь снова выручит сериализатор ApplicationSerializer
. Помимо проверки данных (валидации) он берёт на себя и нормализацию. Все обработанные чистые данные он складывает в атрибут validated_data
. В отличии от request.data
там не может быть сырых данных, а потому использовать validated_data
вполне безопасно. Так будет выглядеть исправленный код:
@api_view(['POST'])
def enroll(request):
serializer = ApplicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # выкинет ValidationError
application = Application.objects.create(
contact_phone=serializer.validated_data['contact_phone'],
ticket_type=serializer.validated_data['ticket_type'],
)
participants_fields = serializer.validated_data['participants']
participants = [Participant(application=application, **fields) for fields in participants_fields]
Participant.objects.bulk_create(participants)
return Response({
'application_id': application.id,
})
Теперь вместо request.data
всюду используется serializer.validated_data
, что защищает код от неосторожной работы с сырыми данными.
Никогда не берите данные напрямую из request.data
Читать дальше
- Официальный туториал по сериализации
- Документация к Serializer
- Документация к Serializer fields
- Marshmallow — альтернативная библиотека для сериализации и десериализации
Попробуйте бесплатные уроки по Python
Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.
Переходите на страницу учебных модулей «Девмана» и выбирайте тему.