Это я уезжаю в закат, после попыток сделать REST на Django.
Это я уезжаю в закат, после попыток сделать REST на Django.

Идея делать нормальный REST на Django – утопия, но некоторые моменты настолько логичные и нет одновременно, что об этом хочется писать. Ниже история про то, как мы сделали ViewSet от GenericViewSet и пары миксинов в DRF, покрыли это все тестами и получили местами странные, но абсолютно обоснованные коды ответов.

Текст может быть полезен новичкам (или чуть более прошаренным) в Django, дабы уложить в голове формирование url’ов и порядок вызова методов permission-классов. Ну а бывалые скажут, что все это баловство и надо было использовать GenericApiView.

Маршрут не определен. 404 или 405?

Стандартная история любого веб приложения - CRUD для пользователя. Решили мы почему-то использовать для этих целей ViewSet, но ручки нужны были не все и чтобы лишнее не вытаскивать, взяли GenericViewSet и нужный Mixin.

Зачем так сложно?

Да, выбор странный, но история умалчивает о причинах такого решения, так что имеем что имеем.

В итоге получили следующую картину:

class UsersViewSet(mixins.UpdateModelMixin, GenericViewSet):
    pass

Все, что внутри класса нас пока не интересует, поэтому опустим этот момент. 

Также у нас были вот такие пути:

router = SimpleRouter()
router.register("users", UsersViewSet, basename="users")

И захотелось нам проверить, что лишние ручки действительно недоступны (чтобы всякие там мимопроходилы их не трогали) и написать на это все дело тестов.

def test_list_user(auth_free_client_and_user):
   client, user = auth_free_client_and_user


   response = client.get("/api/users/")
   assert response.status_code == 404, response.json()


def test_delete_user(auth_free_client_and_user):
   client, user = auth_free_client_and_user


   response = client.delete(f"/api/users/{user.id}/")
   assert response.status_code == 404, response.json()

Внимание вопрос: будет ли это работать? 

Ответ убил

Нет

Человеку, не сильно знакомому с DRF покажется, что наши тесты должны сработать. Но работать они не будут. А чтобы понять почему так происходит, нужно заглянуть в класс Router из DRF, который и формирует эту ошибку.

Как формируется маршрут

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

Причина AssertionError в тесте в том, как определены маршруты в классе Router. Если посмотреть на стандартный SimpleRouter из DRF увидим следующее (источник листинга):

class SimpleRouter(BaseRouter):


   routes = [
       # List route.
       Route(
           url=r'^{prefix}{trailing_slash}$',
           mapping={
               'get': 'list',
               'post': 'create'
           },
           name='{basename}-list',
           detail=False,
           initkwargs={'suffix': 'List'}
       ),
       # Dynamically generated list routes. Generated using
       # @action(detail=False) decorator on methods of the viewset.
       DynamicRoute(
           url=r'^{prefix}/{url_path}{trailing_slash}$',
           name='{basename}-{url_name}',
           detail=False,
           initkwargs={}
       ),
       # Detail route.
       Route(
           url=r'^{prefix}/{lookup}{trailing_slash}$',
           mapping={
               'get': 'retrieve',
               'put': 'update',
               'patch': 'partial_update',
               'delete': 'destroy'
           },
           name='{basename}-detail',
           detail=True,
           initkwargs={'suffix': 'Instance'}
       ),
       # Dynamically generated detail routes. Generated using
       # @action(detail=True) decorator on methods of the viewset.
       DynamicRoute(
           url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
           name='{basename}-{url_name}',
           detail=True,
           initkwargs={}
       ),
   ]

Что важно запомнить: 

  • определен список из объектов Route

  • в каждом объекте задаются:

    • url, который будет сгенерирован 

    • mapping - список из http-метода и соответствующего метода нашего ViewSet 

Еще нам важно увидеть в этом классе следующий метод (источник листинга): 

def get_method_map(self, viewset, method_map):
   """
   Given a viewset, and a mapping of http methods to actions,
   return a new mapping which only includes any mappings that
   are actually implemented by the viewset.
   """
   bound_methods = {}
   for method, action in method_map.items():
       if hasattr(viewset, action):
           bound_methods[method] = action
   return bound_methods

Здесь method_map это mapping из наших Route

Получается, что для:

url=r'^{prefix}{trailing_slash}$' - не вернется ничего, поскольку ни одного метода из mapping нет в нашем ViewSet
url=r'^{prefix}/{lookup}{trailing_slash}$' - вернется словарь {“put”: “update”} 

Ну и наконец, если посмотреть на проверку в get_url все того же SimpleRouter, то увидим следующее (источник листинга):

# Only actions which actually exist on the viewset will be bound
mapping = self.get_method_map(viewset, route.mapping)
if not mapping:
   continue

Итого

Из-за наследования от нашего класса от UpdateModelMixin SimpleRouter создал нам маршрут вида /users/:id, но разрешил там только http-методы PUT и PATCH. Но для DELETE используется тот же маршрут, но другой метод. 

Поэтому первый тест на list будет стучаться на /users, который мы никак не определяли и будет получать в ответ 404, а вот второй тест на delete будет стучаться на существующий маршрут с несуществующим методом и получит в ответ 405.

Работающие тесты будут выглядеть вот так:

def test_list_user(auth_free_client_and_user):
   client, user = auth_free_client_and_user


   response = client.get("/api/users/")
   assert response.status_code == 404, response.json()


def test_delete_user(auth_free_client_and_user):
   client, user = auth_free_client_and_user


   response = client.delete(f"/api/users/{user.id}/")
   assert response.status_code == 405, response.json()

Спасибо, Django!

403 или 404. Показываем только “свои” записи.

Казалось бы: ну ладно, не совсем очевидно, но в принципе логично. Запомнили и разошлись. Но на этом история не закончилась и на том же проекте мы снова наткнулись на неожиданные статусы (хоть и вполне объяснимые).

Определим еще один ViewSet для постов пользователя. Добавим ему permission-класс, который отвечает за то, можно ли мне как пользователю эти методы вызывать.

class PostViewSet(ModelViewSet):
   permission_classes = [IsAuthenticated, UserPermission]

Permission-класс должен отдать нам 403 код ошибки - доступ запрещен - когда мы попытаемся достать чужой пост.

class UserPermission(permissions.BasePermission):


   def has_object_permission(self, request, view, obj):
       """Доступ к объекту."""
       if view.action in {"retrieve", "update", "partial_update"}:
           return obj.user_id == request.user.id
       return False

Но также мы хотим в списке показывать только посты пользователя, поэтому можем переопределить queryset - запрос, по которому достаются данные и доставать сразу с фильтром по пользователю.

class PostViewSet(ModelViewSet):
   permission_classes = [IsAuthenticated, UserPermission]


   def get_queryset(self):
       """Фильтруем по пользователю."""
       return Post.objects.filter(user=self.request.user)

Теперь у нас во всех методах нашего ViewSet будут сразу данные пользователя и ничего лишнего. Но что произойдет если попытаться изменить чужую статью? 

Ожидается, что 403. И если мы хотим покрыть это тестом, то он должен выглядеть как-то так:

def test_update_another_user(auth_client_and_user, another_user):
   client, user = auth_client_and_user


   response = client.patch(
       f"/api/posts/{another_user.id}/",
       {
           "text": "new amazing text",
       },
   )


   assert response.status_code == 403, response.json()

А как будет на самом деле?

А на самом деле будет вот так:

404

А на самом деле все будет зависеть от того, какой метод определен в нашем permission-классе.

А метода там два:

  • has_permission - проверяет возможность действий в принципе;

  • has_object_permission - проверяет возможность действий с конкретным объектом (в нашем случае - постом).

Поскольку мы хотим изменить объект, то нужно определить get_object_permission. Тогда произойдет следующее: Django сначала выполнит get_queryset и от него попытается сделать .get() нашей записи, ничего не найдет и свалится в 404 так и не дойдя до проверки в permission-классе. 

Но, если мы например, не авторизовались, то все-таки получим 403. Потому что проверка авторизации определена в has_permission. (Источник листинга)

class IsAuthenticated(BasePermission):
   """
   Allows access only to authenticated users.
   """


   def has_permission(self, request, view):
       return bool(request.user and request.user.is_authenticated)

А has_permission выполняется до того как достается queryset.

И снова спасибо, Django!

Путь определения статуса

Собираем воедино всё, о чем мы упоминали в тексте. 

Порядок выполнения проверок примерно следующий:

  • проверяем существует ли url в принципе - на этом этапе в случае ошибки будет 404;

  • проверяем доступен ли http метод - здесь при неудаче будет 405;

  • выполняем has_permission из permission_classes - тут 403;

  • get_queryset из ViewSet - тут 404;

  • проверяем has_object_permission - тут снова 403.

Кстати, еще один забавный нюанс: если вы переопределяете методы retrieve, update, delete в своем ViewSet, то has_object_permission может и не вызваться. Подробнее здесь.

Вместо выводов

Как говорится, ежики кололись, плакали, но продолжали жрать кактус пытаться сделать REST на Django. 

Каких-то способов это обойти, кроме как не переопределять get_queryset или выкидывать нужные статусы в нужных ручках самостоятельно найдено не было. Надеемся, что кому-то этот текст сохранит пару нервных клеток при попытках понять, почему вместо 404 вы получили 403 или 405. 


Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными статьями.

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


  1. datacompboy
    18.04.2023 12:26
    +1

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

    Если неважно -- то лучше тесте проверять что ошибка 4хх, а какая именно -- неважно.

    Техника "поменяем тест чтоб проходил" порочна.


    1. technokratiya Автор
      18.04.2023 12:26
      +1

      Отвечает автор статьи:

      Да, согласны. Пожалуй, стоило прописать сразу же, что замена в тесте на 405 статус это, скорее, один из вариантов, и явно не самый лучший. К тому же, при таком статусе могут ещё и к безопасности возникнуть вопросы.

      Но мы решили, что всё же не погружаться в эту тему и оставить тест таким, потому что статья, которая изначально планировалась «лёгким чтивом», по нашему мнению и без того уже выросла в объёме.


  1. funca
    18.04.2023 12:26
    +3

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

    Есть зоопарк из различных RFC, которые в разное время составляли под разные задачи. Попытки систематизировать и формализовать, скажем так, "web-ориентированный ReST" уже были - с четким алгоритмом в какой ситуации какой HTTP status code должен возвращаться. Например, такую работу проделали в Webmachine для Erlang, описав флоу в виде flow-диаграммы, машины состояний и тестов (мне также встречались порты для других языков). Но в массы это не пошло, а жаль - возможно мир стал бы гораздо проще.

    Какого-то единообразия среди фреймворков и технологий нет. Каждый реализует протокол вмеру собственных предпочтений. Наименьший общим знаменателем становится OpenAPI (бывший Swagger). Но он определяет только формат, оставляя протокольную часть за рамками. Остальное решается на уровне соглашений, принятых в конкретной организации.