Как стать автором
Обновить

403, 404 или 405. Разбираемся со статусами во ViewSet’ах DRF

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров3.4K
Это я уезжаю в закат, после попыток сделать 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. 


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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А вы настраивали REST на Django?
25% Да, было больно5
50% Да, все прошло нормально10
10% Не, нафиг надо2
15% REST на Django? Зачем?3
Проголосовали 20 пользователей. Воздержались 3 пользователя.
Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии3

Публикации

Истории

Работа

Data Scientist
77 вакансий
Python разработчик
136 вакансий

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн