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

от автора

Это я уезжаю в закат, после попыток сделать 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 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/729844/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *