Интеграция и кастомизация OpenAPI в Django/Django Rest Framework
Вступление
Статья представляет собой сборник ссылок и рецептов, позволяющих настроить API более гибко, более удобно для frontend разработки. Основная часть статьи будет посвящена интеграции OpenAPI в экосистему Django/DRF.
Коренная мотивация интеграции и поддержки OpenAPI в проекте — документирование API эндпоинтов и возможность для клиентов API генерировать часть кода на базе схемы OpenAPI. Существуют различные решения для генерации клиентских типов/моделей для JS/TS, например, openapi-typescript-codegen, openapi-typescript, или более общее решение для широкого спектра языков.
Из коробки Django/DRF не предоставляет нам автоматическую генерацию схемы API в отличие от некоторых других веб-фреймворков, поэтому будем далее разбираться, как и с помощью чего мы можем это сделать.
Подключаем OpenAPI
Для Django/DRF существуют отличные решения, одни из самых популярных — drf-yasg и drf-spectacular. drf-yasg в целом отличное решение, однако оно не поддерживает OpenAPI 3.0, а мы идём в ногу со временем, поэтому для последующих примеров будет взят drf-spectacular
. Документация у библиотеки отличная, поэтому далее просто её устанавливаем и интегрируем в наш проект, затем добавляем в urls.py redoc/swagger, чтобы можно было пользоваться из UI. drf-spectacular
неплохо строит документацию по обработчикам API методов — action’ов из различных View из DRF, однако, на практике зачастую этого не хватает. Большинство наших потребностей можем покрыть с помощью декоратора @extend_view. Если вкратце, то с помощью него мы можем описать параметры запроса (request), формат ответа и код статуса ответа (responses), описание, примеры использования и др. для текущего API метода. Очень удобно использовать сериализаторы в качестве описания форматов ответа/запроса. Однако, в случае кастомизации форматов запроса/ответа сервера одним декоратором мы уже не обойдёмся и за схемой OpenAPI также необходимо будет следить. Рассмотрим далее некоторые полезные практики, а также проблемы и их решения в рамках drf-spectacular
.
Тестирование схемы
Теперь мы вооружены генератором схемы API, однако, нам необходимо быть уверенными в том, что наша схема действительно соответствует API. Для валидации схемы и проверки её соответствия текущему API можем воспользоваться библиотекой drf-openapi-tester. По сути, всё что нам необходимо — при тестировании API эндпоинтов добавить вызов schema_tester.validate_response(response=response, **kwargs)
, который даст нам знать, всё ли у нас в порядке с генерацией OpenAPI схемы.
Динамические поля сериализаторов
Не всегда хочется создавать несколько сериализаторов ради того, чтобы следующий эндпоинт отдавал немного больше или немного меньше полей относительно уже имеющегося другого эндпоинта. Для решения этой проблемы есть несколько вариантов:- при запросе данных принимать параметр fields
с перечислением необходимых клиенту полей или же самим на стороне backend’а устанавливать ограничения на отдаваемые данные.Недолго думая, делаем выбор в пользу второго варианта, т.к. в случае первого варианта: 1) OpenAPI будет генерировать тип ответа со всеми полями, что не всегда необходимо, 2) Для эндпоинтов может быть настроен разный уровень доступа, а в данном случае мы можем дать доступ клиенту к данным, которые он видеть не должен. В целом, для обоих вариантов есть хорошее решение в виде библиотеки drf-flex-fields. Мне сложно будет сказать, насколько хорошо drf-flex-fields
интегрируется с drf-spectacular/drf-yasg
, т.к. я не стал подключать эту библиотеку и ограничился решением из документации DRF:
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
fields = kwargs.pop('fields', None)
self.ref_name = kwargs.pop('ref_name', None)
super().__init__(*args, **kwargs)
if fields is not None:
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
def get_fields(self):
return super().get_fields()
В случае динамических полей возникает сложность при генерации схемы, т.к. теперь у нас имеются несколько сериализаторов с одинаковыми именами и по итогу на выходе мы имеем не совсем валидную схему. Для этого мы добавили строку self.ref_name = kwargs.pop('ref_name', None)
, позволяющую при использовании одинаковых сериализаторов с разными полями прописывать уникальное название для генерации валидной OpenAPI схемы. Однако, в случае drf-spectacular этого оказалось мало и в код необходимо добавить так называемый OpenApiSerializerExtension
(в терминологии drf-spectacular):
from drf_spectacular.extensions import OpenApiSerializerExtension
class DynamicFieldsModelSerializerExtension(OpenApiSerializerExtension):
target_class = DynamicFieldsModelSerializer
match_subclasses = True
def map_serializer(self, auto_schema, direction):
return auto_schema._map_serializer(self.target, direction, bypass_extensions=True)
def get_name(self, auto_schema, direction):
return self.target.ref_name
Более подробно об этой магии можно почитать в соответствующем issue.Всё готово, теперь при вызове сериализатора можем пользоваться возможностью задавать любые поля и быть спокойным за OpenAPI схему, что можем сделать подобным образом:
CustomSerializer(fields=('id', 'title', 'description'), ref_name='UniqueCustomSerializer')
Кастомный формат ответов (DRF Response)
По умолчанию DRF отдаёт ответ на запрос в виде списка полей. Иногда это не покрывает всех требований и возникает необходимость обернуть ответ, например, в body: {}
, а ошибки в errors: []
. Для этого можно переписать метод render()
у классаrest_framework.renderers.JsonRenderer
, например, так:
from rest_framework.renderers import JSONRenderer
from rest_framework.utils import json
class JSONResponseRenderer(JSONRenderer):
charset = 'utf-8'
def render(self, data, accepted_media_type=None, renderer_context=None):
errors = []
if isinstance(data, dict) and (error := data.get('errors')):
errors.extend(error)
del data['errors']
response_dict = {
'body': data if data else {},
'errors': errors,
}
return json.dumps(response_dict)
и не забудем добавить JSONResponseRenderer
в конфигурацию REST_FRAMEWORK
в settings.py
:
...'DEFAULT_RENDERER_CLASSES': ('path_to_module.JSONResponseRenderer', ),...
В данном случае сразу же возникнет проблема с OpenAPI схемой, т.к. drf-spectacular
будет по-прежнему отдавать стандартный ответ. Для решения проблемы необходимо расширить все сериализаторы из responses
в декораторе @extend_schema
, базовые вариант можно посмотреть в FAQ drf-spectacular, в нашем случае вариант с адаптацией под использование динамических полей может выглядеть следующим образом:
def enveloper(serializer_class=None, many=False, fields=None, ref_name=None):
if serializer_class:
if issubclass(serializer_class, DynamicFieldsModelSerializer):
inner_serializer = serializer_class(many=many, fields=fields, ref_name=ref_name)
else:
inner_serializer = serializer_class(many=many)
component_name = 'Enveloped{}{}{}'.format(
serializer_class.__name__.replace('Serializer', ''),
'List' if many else '',
ref_name if ref_name else ''
)
else:
inner_serializer = serializers.JSONField()
component_name = 'Enveloped{}{}'.format(
ref_name if ref_name else '',
'List' if many else ''
)
@extend_schema_serializer(many=False, component_name=component_name)
class EnvelopeSerializer(serializers.Serializer):
body = inner_serializer
errors = ApiErrorSerializer(many=True)
return EnvelopeSerializer
Немного подробнее о формате ошибок
В предыдущем примере кода задействован сериализатор ApiErrorSerializer
для, соответственно, отдачи ошибок API в теле ответа. Выглядит он достаточно просто:
class ApiErrorSerializer(serializers.Serializer):
code = serializers.CharField(allow_null=True)
message = serializers.CharField()
field = serializers.CharField(allow_null=True)
Но не забываем, что код выше был необходим для генерации схемы, для реальных же ответов можем написать свой обработчик исключений, или воспользоваться библиотекой drf-standardized-errors. С ней наши ошибки будут выглядеть следующим образом:
{
"type": "client_error",
"errors": [
{
"code": "authentication_failed",
"detail": "Incorrect authentication credentials.",
"attr": null
}
]
}
Если мы хотим кастомизировать данный ответ, например, поменять названия полей или убрать атрибут type, то можем переписать ExceptionFormatter
следующим образом:
from drf_standardized_errors.formatter import ExceptionFormatter
from drf_standardized_errors.types import ErrorResponse
class DRFExceptionFormatter(ExceptionFormatter):
def format_error_response(self, error_response: ErrorResponse):
errors_lst = [
{
'code': err.code,
'message': err.detail,
'field': err.attr
}
for err in error_response.errors
]
return {'errors': errors_lst, 'type': error_response.type}
И не забудем добавить его в settings.py
:
DRF_STANDARDIZED_ERRORS = {'EXCEPTION_FORMATTER_CLASS': 'path_to_module.DRFExceptionFormatter'}
СamelCase/lowerCamelCase
Не будем вдаваться в детали на какой стороне лучше менять стиль написания, просто примем за факт, что данная задача может возникнуть. Решение для DRF существует в виде библиотеки djangorestframework-camel-case. В основном всё решается конфигурированием REST_FRAMEWORK
в settings.py
. Описание и интеграцию библиотеки можно посмотреть здесь. Для drf-spectacular мы просто добавляем пару полей в конфигурации SPECTACULAR_SETTINGS
в settings.py
:
SPECTACULAR_SETTINGS = {
...
'POSTPROCESSING_HOOKS': [
'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields'],
'CAMELIZE_NAMES': True,
...
}
А также, раз мы используем модифицированный renderer
, изменим и его согласно запросу на CamelCase
:
from djangorestframework_camel_case.settings import api_settings
from djangorestframework_camel_case.util import camelize
from rest_framework.renderers import JSONRenderer
from rest_framework.utils import json
class JSONResponseRenderer(JSONRenderer):
charset = 'utf-8'
json_underscoreize = api_settings.JSON_UNDERSCOREIZE
def render(self, data, accepted_media_type=None, renderer_context=None):
data = camelize(data, **self.json_underscoreize)
errors = []
if isinstance(data, dict) and (error := data.get('errors')):
errors.extend(error)
del data['errors']
response_dict = {
'body': data if data else {},
'errors': errors,
}
return json.dumps(response_dict)
Заключение
Хорошая документация облегчает взаимодействие с пользователями вашего API, а в случае генерации моделей/типов на основе OpenAPI схемы может ещё и существенно ускорить разработку коллегам.