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