Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Идея делать нормальный 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
- тут 403get_queryset
изViewSet
- тут 404проверяем
has_object_permission
- тут снова 403
Кстати, еще один забавный нюанс: если вы переопределяете методы retrieve
, update
, delete
в своем ViewSet
, то has_object_permission
может и не вызваться. Подробнее здесь.
Вместо выводов
Как говорится, ежики кололись, плакали, но продолжали жрать кактус пытаться сделать REST на Django.
Каких-то способов это обойти, кроме как не переопределять get_queryset или выкидывать нужные статусы в нужных ручках самостоятельно найдено не было. Надеемся, что кому-то этот текст сохранит пару нервных клеток при попытках понять, почему вместо 404 вы получили 403 или 405.
Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными статьями.