Оверинженеринг при документировании ViewSets Django REST Framework

Случается в нашей жизни, уважаемые коллеги, что хочешь сделать как проще, а получается как у новичка. И, что интересно, существует не мало мощных инструментов, которые предлагают простое решение в обмен на душу. Я имею ввиду, что цена абстракции бывает несоразмерна красоте её использования. Для меня примером такого неравноценного обмена стал Django Rest Framework 3.4.0, его механизм ViewSets и необходимость вывести подробную документацию по разрабатываемому API.

Начнём с простого: мой любимый формат работы с DRF — писать только APIView потомков. С одной стороны, это повторяющийся код, а с другой — вполне лаконичное решение с прогнозируемым и управляемым юзкейсом. Во-первых, с вероятностью 95%, мы не будем вешать на один эндпоинт несколько сериалазеров. Во-вторых, мы можем точнее настроить привязку URL. Но, со временем начинаешь задумываться:, а всё ли я сделал правильно? Может, пора отойти от идеи проверенного годами REST консерватизма? Тем более, что DRF имеет достаточно неплохой слой абстракции: ViewSets.

Идея ViewSets проста: у нас есть обслуживаемая модель, и нам не надо сочинять свои эндпоинты или описывать их отдельными классами. Достаточно одного класса, который самостоятельно регистрирует views, проводит привязку urls и т.д. Т.е. это очень много шаблонов, запакованных в коробочку, повязанную голубой ленточкой. Задача стояла относительно стандартная:

1. Есть кастомный профиль пользователя.
2. У него есть дополнительные поля.
3. При регистрации мы используем REST и вручную определяем, какие поля обязательны, а какие нет (override полей модели на уровне DRF).
4. Логин генерируется автоматически.
5. У профиля есть связь с инвайтом, а инвайт связан с организацией, которая этот инвайт выписала.

После некоторого раздумья было решено сделать 2 или 3 сериалайзера. Абсолютно точно идёт отдельный сериалайзер на create. Отдельный — на view. Возможно, но не факт, что понадобится третий — на update (change). Классическая схема REST приложения выглядела бы так:

serializers.py

class UserCreateSerializer(serializers.ModelSerializer):
    pass


class UserViewSerializer(serializers.ModelSerializer):
    pass


class UserUpdateSerializer(serializers.ModelSerializer):
    pass

views.py

class UserCreateView(APIView):
    pass


class UserDetailsView(APIView):
    pass


class UserUpdateView(APIView):
    pass

После небольшого рефакторинга, мы можем получить один APIView:

views.py

class UserApiView(APIView):
    
    def get(self, request, *args, **kwargs):
        return self.__list_view(request) if 'pk' not in self.kwargs else self.__detail_view(request)

    def post(self, request, *args, **kwargs):
        return self.__create_view(request) if 'pk' not in self.kwargs else self.__update_view(request)

Как видите, особой надобности во ViewSet нету. Трэйс запроса происходит ровно одной строчкой, но нам доступны функции get, post, put и иже с ними. К тому же, если нам вдруг не понравится результат, мы всегда сможем вернуться к формату трёх отдельных классов эндпоинтов. У этого метода есть ещё один плюс: когда вы ставите приложение для автоматической документации (Swagger или DRF Docs), то получаете предсказуемый вывод: либо три эндпоинта, либо один эндпоинт с тремя описанными методами.

Однако, давайте перейдём к абстракции ViewSet:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def list(self, request, *args, **kwargs):
        serializer_class = self.serializer_classes['list']
        pass

    def create(self, request, *args, **kwargs):
        serializer_class = self.serializer_classes['create']

Во-первых, бросается в глаза обилие кода. Его гораздо больше, чем в варианте с одним эндпоинтом и тремя методами. Во-вторых, видим довольно приятную декларативность кода, которая, кстати, нам скоро пройдётся ножом по попе.

Итак, наша проблема заключается в том, что Swagger и DRF Docs не будут работать с этим вьюсетом правильно.

Я не копался в коде Swagger, но, думаю, не погрешу, если скажу, что он получает методы эндпоинта так:

1. Get urlpattern
2. Endpoint = urlpattern.callback
3. Methods = endpoint.available_methods

Обратите внимание на тот факт, что callback запрашивается без создания инстанса, либо обращения к методу as_view, который получает аргументом request. Давайте проверим нашу теорию:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        logger.warn(self.request)
        logger.warn(self.actions)
        return UserViewSerializer

    # Actions here...

Мы получим 500 ошибку с информацией о том, что объект UserViewSet не имеет атрибута request. Если мы уберём проблемную строчку, то получим вторую ошибку: этот объект не имеет атрибута actions. Так происходит потому, что ViewSetMixin выставляет actions при наличии request, хотя, логичнее было бы сделать список доступных actions в виде classproperty (ведь при наследовании миксина стандартные действия закрепляются по имени и условиям срабатывания).

Но сейчас нас не интересует что было бы, если бы у бабушки были мудики (словарь Даля, если не ошибаюсь). У нас есть интерфейс, который нельзя задокументировать. Вот же огорчение!

Задокументировать интерфейс на Swagger у меня не получилось. Костыль решения проблемы кроется в том самом методе get_serializer_class (), который вы видели в предыдущем сниппете. И Swagger, и DRF Docs используют его, чтобы получить текущий сериалайзер. Мы можем предположить, что наш код должен выглядеть так:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        return self.serializer_classes.get(self.action, UserViewSerializer)

    # Actions here...

Но мы помним, что на момент срабатывания get_serializer_class, self.action не существует как атрибута. Это вызывает 500 ошибку и не позволяет использовать данный кейс. Изучив оба решения (Swagger, DRF Docs), я остановился на последнем. И тут же получил ещё одну проблему:

 — сегодня 27 июля 2016 года, и код DRF Docs из ветки мастера отличается от кода DRF Docs, который ставится через pypi или путём скачивания репозитория GIT.

Не знаю, глюк ли это, но, видимо, git отдаёт код, отмеченный как релиз 0.0.11, а разработчики имели дерзость обновить мастер без релиза. Fail!

Проблема пока решается костылём — подменой api_endpoint.py в пакете. Вы прекрасно понимаете, что это не вариант. Тут у меня два пути развития кода: либо я дождусь, пока разработчики выкатят новый релиз, либо вернусь к варианту наследования от APIView. Сегодня уже нет времени и сил это делать. Разбираясь с кодом этого файла (который рабочий — в мастере), я наткнулся на два интересных фрагментв. Вот первый из них:

api_endpoint.py

    def __get_serializer_class__(self):
        if hasattr(self.callback.cls, 'serializer_class'):
            return self.callback.cls.serializer_class

        if hasattr(self.callback.cls, 'get_serializer_class'):
            return self.callback.cls.get_serializer_class(self.pattern.callback.cls())

Дело в том, что наша реализация ViewSet будет всегда содержать property serializer_class = None. Логично было бы поменять проверку местами, чтобы в приоритете исследовать динамическую смену сериалайзера.

Второй момент:

api_endpoint.py

    view_methods = [force_str(m).upper() for m in self.callback.cls.http_method_names if hasattr(self.callback.cls, m)]
    return viewset_methods + view_methods

Вот если вы воткнёте стоппер между этими двумя строками и попытаетесь получить self.callback.actions, то вы получите тот словарь, которого нам не хватает для работы. Конечно, тут можно было подключиться к разработке и добавить отдельную логику для документирования actions…, но оно нам даром не надо. Сейчас я жду от разработчиков DRF Docs принятия issue с первой проблемой (serializer_class = None) и надеюсь на скорый релиз. Если его не случается, возвращаюсь к варианту с APIView. Что же касается метода получения сериалайзера, то выглядит он так:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        if not hasattr(self, 'action'):
            action = 'create' if 'POST' in self.allowed_methods else 'list'
        else:
            action = self.action
        return self.serializer_classes.get(action, UserViewSerializer)

    # Actions here...

Остаётся надеяться, что метод update не создаст проблем при добавлении. Ещё одна небольшая ремарка: мне пришлось всё-таки добавить метод post:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):

    #...

    def post(self, *args, **kwargs):
        return super().post(*args, **kwargs)

    # Actions here...

Без него DRF Docs не смог получить allowed_methods, да и у Swagger были проблемы.

Вот так, уважаемые коллеги, при обращении к высокому уровню абстракции фрэймворка, я столкнулся с проблемой архитектурной. Сводится она к простому выводу: «Виноват сам». Хотя, вопрос, разумеется, спорный, ведь ViewSets — инструмент удобный и официальный. Однако, невооружённым взглядом видно, что вопрос регистрации actions в классе не проработан. Отсюда и нежелание разработчиков документаторов нормально обрабатывать actions. Исход ситуации прост: сегодня легче использовать отдельные API Views, чем шаблоны представлений для модели. По крайней мере, в большинстве известных REST движков или фреймворков, умеющих создавать REST, вы, скорее всего, не увидите подобных абстракций. И очень большой вопрос: нужны ли они вообще?

Комментарии (2)

  • 27 июля 2016 в 19:37

    0

    > Во-первых, бросается в глаза обилие кода. Его гораздо больше, чем в варианте с одним эндпоинтом и тремя методами.

    Ага, а сколько кода (причем дублируемого) будет в вашем __list_view и __detail_view? А в примере с UserViewSet — это полный код. Осталось только указать queryset и другие атрибуты. Никаких »# Actions here…» там больше нет, всё в миксинах.

    > Как видите, особой надобности во ViewSet нету. Трэйс запроса происходит ровно одной строчкой, но нам доступны функции get, post, put и иже с ними.

    Что значит надобности? Зависит от решаемой задачи. ViewSet и дженерики хороши в двух случаях: когда всё просто и когда всё сложно.
    Когда всё просто — это наследовался от ModelViewSet, указал queryset, serializer_class и endpoint готов.
    Когда всё сложно — это ViewSet с различными (в том числе кастомными) миксинами, которые можно применять во всех ViewSet проекта и расширять/изменять их по мере необходимости через методы (типа perform_update у UpdateModelMixin). В итоге имеем правильную и красивую архитектуру приложения без своих костылей.

    На счёт Swagger — да, я все эти проблемы побороть не смог. Да что там, даже ApiRoot (тот, который для рендеринга карты урлов на главной) ломается. Но если исследовать проблему чуть глубже — становится понятно, что оно и не может работать. Нужно использовать другой путь, а это обычное дело в разработке.

    • 27 июля 2016 в 21:34

      0

      Собственно отсюда и выводы. Если подумать, то сам ViewSet — это лишний сахар. С точки зрения какого-нибудь JS, безусловно, проще создать кучу объектов, у которых весь функционал будет записываться декларативно. А если взглянуть на стиль вьюсетов внимательнее, то можно найти очень много общего с JQuery.

© Habrahabr.ru