diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md index 25689edd2a..cf80c8de1e 100644 --- a/docs/api-guide/permissions.md +++ b/docs/api-guide/permissions.md @@ -181,6 +181,9 @@ The default behavior can also be overridden to support custom model permissions. To use custom model permissions, override `DjangoModelPermissions` and set the `.perms_map` property. Refer to the source code for details. +!!! note + `QUERY` is a safe method, so it maps to an empty list of required permissions, as `GET` does. A `.perms_map` override that predates `QUERY` simply omits the key, and a `QUERY` request against it raises `MethodNotAllowed` rather than being permitted by default. + ### DjangoModelPermissionsOrAnonReadOnly Similar to `DjangoModelPermissions`, but also allows unauthenticated users to have read-only access to the API. @@ -211,7 +214,7 @@ To implement a custom permission, override `BasePermission` and implement either The methods should return `True` if the request should be granted access, and `False` otherwise. -If you need to test if a request is a read operation or a write operation, you should check the request method against the constant `SAFE_METHODS`, which is a tuple containing `'GET'`, `'OPTIONS'` and `'HEAD'`. For example: +If you need to test if a request is a read operation or a write operation, you should check the request method against the constant `SAFE_METHODS`, which is a tuple containing `'GET'`, `'OPTIONS'`, `'HEAD'` and `'QUERY'`. For example: if request.method in permissions.SAFE_METHODS: # Check permissions for read-only request diff --git a/docs/api-guide/requests.md b/docs/api-guide/requests.md index 092a2b187d..70a835ebb8 100644 --- a/docs/api-guide/requests.md +++ b/docs/api-guide/requests.md @@ -20,7 +20,7 @@ REST framework's Request objects provide flexible request parsing that allows yo `request.data` returns the parsed content of the request body. This is similar to the standard `request.POST` and `request.FILES` attributes except that: * It includes all parsed content, including *file and non-file* inputs. -* It supports parsing the content of HTTP methods other than `POST`, meaning that you can access the content of `PUT` and `PATCH` requests. +* It supports parsing the content of HTTP methods other than `POST`, meaning that you can access the content of `PUT`, `PATCH` and `QUERY` requests. * It supports REST framework's flexible request parsing, rather than just supporting form data. For example you can handle incoming [JSON data] similarly to how you handle incoming [form data]. For more details see the [parsers documentation]. diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 04f605a03e..767bdae2c0 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -158,12 +158,13 @@ Note that path converters will be used on all URLs registered in the router, inc ### SimpleRouter -This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@action` decorator. +This router includes routes for the standard set of `list`, `create`, `query`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@action` decorator. - + + @@ -186,8 +187,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
URL StyleHTTP MethodActionURL Name
{prefix}/GETlist{basename}-list
{prefix}/GETlist{basename}-list
POSTcreate
QUERYquery
{prefix}/{url_path}/GET, or as specified by `methods` argument`@action(detail=False)` decorated method{basename}-{url_name}
{prefix}/{lookup}/GETretrieve{basename}-detail
PUTupdate
- + + @@ -196,6 +198,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
URL StyleHTTP MethodActionURL Name
[.format]GETautomatically generated root viewapi-root
{prefix}/[.format]GETlist{basename}-list
{prefix}/[.format]GETlist{basename}-list
POSTcreate
QUERYquery
{prefix}/{url_path}/[.format]GET, or as specified by `methods` argument`@action(detail=False)` decorated method{basename}-{url_name}
{prefix}/{lookup}/[.format]GETretrieve{basename}-detail
PUTupdate
{prefix}/{lookup}/{url_path}/[.format]GET, or as specified by `methods` argument`@action(detail=True)` decorated method{basename}-{url_name}
+!!! note + The `query` route is only generated for viewsets that define a `.query()` method, so existing viewsets are unaffected. + As with `SimpleRouter` the trailing slashes on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router. router = DefaultRouter(trailing_slash=False) diff --git a/docs/api-guide/testing.md b/docs/api-guide/testing.md index 4b01e22564..5862cdfee5 100644 --- a/docs/api-guide/testing.md +++ b/docs/api-guide/testing.md @@ -17,7 +17,7 @@ Extends [Django's existing `RequestFactory` class][requestfactory]. ### Creating test requests -The `APIRequestFactory` class supports an almost identical API to Django's standard `RequestFactory` class. This means that the standard `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()` and `.options()` methods are all available. +The `APIRequestFactory` class supports an almost identical API to Django's standard `RequestFactory` class. This means that the standard `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()` and `.options()` methods are all available. A `.query()` method is also provided, for the [`QUERY` method](views.md#the-query-method). from rest_framework.test import APIRequestFactory @@ -132,7 +132,7 @@ Extends [Django's existing `Client` class][client]. ### Making requests -The `APIClient` class supports the same request interface as Django's standard `Client` class. This means that the standard `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()` and `.options()` methods are all available. For example: +The `APIClient` class supports the same request interface as Django's standard `Client` class. This means that the standard `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()` and `.options()` methods are all available, plus `.query()` for the [`QUERY` method](views.md#the-query-method). For example: from rest_framework.test import APIClient diff --git a/docs/api-guide/views.md b/docs/api-guide/views.md index 3851dd699c..6b260842db 100644 --- a/docs/api-guide/views.md +++ b/docs/api-guide/views.md @@ -96,7 +96,7 @@ The following methods are called before dispatching to the handler method. ### Dispatch methods The following methods are called directly by the view's `.dispatch()` method. -These perform any actions that need to occur before or after calling the handler methods such as `.get()`, `.post()`, `put()`, `patch()` and `.delete()`. +These perform any actions that need to occur before or after calling the handler methods such as `.get()`, `.post()`, `put()`, `patch()`, `.delete()` and `.query()`. #### .initial(self, request, \*args, **kwargs) @@ -159,6 +159,31 @@ By default only `GET` methods will be accepted. Other methods will respond with return Response({"message": "Hello, world!"}) +### The `QUERY` method + +REST framework supports the `QUERY` method defined by [RFC 10008][rfc10008]. `QUERY` is safe and idempotent like `GET`, but carries a request body like `POST`, which makes it a good fit for searches whose parameters are too large or too structured for a query string. + +A view opts in simply by defining a `.query()` handler; views that don't define one continue to return "405 Method Not Allowed" for `QUERY`, and don't advertise it in the `Allow` header. + + class SearchNotes(APIView): + def query(self, request, format=None): + notes = Note.objects.filter(**request.data['filters']) + return Response(NoteSerializer(notes, many=True).data) + +Function based views take it in `@api_view`: + + @api_view(['QUERY']) + def search_notes(request): + ... + +The request body is parsed by the usual [parsers][parsers], so `request.data` works exactly as it does for `POST`. + +!!! note + Because `QUERY` is safe, it is included in `permissions.SAFE_METHODS` and requires no permission in the `perms_map` of `DjangoModelPermissions` and `DjangoObjectPermissions`. Handlers must honor that contract and never modify state. See the [permissions documentation][permissions]. + +!!! note + HTML forms cannot issue a `QUERY` request, so the browsable API does not render a form for it. + ### API policy decorators To override the default settings, REST framework provides a set of additional decorators which can be added to your views. These must come *after* (below) the `@api_view` decorator. For example, to create a view that uses a [throttle][throttling] to ensure it can only be called once per day by a particular user, use the `@throttle_classes` decorator, passing a list of throttle classes: @@ -241,4 +266,7 @@ See [ScopedRateThrottle documentation][scoped-rate-throttle] for more details. [scoped-rate-throttle]: throttling.md#scopedratethrottle [classy-drf]: http://www.cdrf.co [api-policy-attributes]: views.md#api-policy-attributes +[parsers]: parsers.md +[permissions]: permissions.md +[rfc10008]: https://www.rfc-editor.org/rfc/rfc10008.html diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 2f1625cda7..ef0f8f0aa7 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -48,6 +48,13 @@ If we need to, we can bind this viewset into two separate views, like so: user_list = UserViewSet.as_view({'get': 'list'}) user_detail = UserViewSet.as_view({'get': 'retrieve'}) +A viewset that defines a `.query()` method also gets a `QUERY` route on the collection URL, for the [`QUERY` method](views.md#the-query-method): + + class UserViewSet(viewsets.ViewSet): + def query(self, request): + queryset = User.objects.filter(**request.data['filters']) + return Response(UserSerializer(queryset, many=True).data) + !!! warning Do not use `.as_view()` with `@action` methods. It bypasses router setup and may ignore action settings like `permission_classes`. Use `DefaultRouter` for actions. diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 67f3a72864..fdd5bf3296 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -52,6 +52,11 @@ def unicode_http_header(value): View.http_method_names = View.http_method_names + ['patch'] +# QUERY method is not yet implemented by Django (Trac #37232) +if 'query' not in View.http_method_names: + View.http_method_names = View.http_method_names + ['query'] + + # Markdown is optional (version 3.0+ required) try: import markdown diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index c34778991c..96757df659 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -298,3 +298,6 @@ def options(self, func): def trace(self, func): return self._map('trace', func) + + def query(self, func): + return self._map('query', func) diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 768f6cb957..c94106b0b4 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -5,7 +5,7 @@ from rest_framework import exceptions -SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS') +SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'QUERY') class OperationHolderMixin: @@ -192,6 +192,7 @@ class DjangoModelPermissions(BasePermission): 'GET': [], 'OPTIONS': [], 'HEAD': [], + 'QUERY': [], 'POST': ['%(app_label)s.add_%(model_name)s'], 'PUT': ['%(app_label)s.change_%(model_name)s'], 'PATCH': ['%(app_label)s.change_%(model_name)s'], @@ -269,6 +270,7 @@ class DjangoObjectPermissions(DjangoModelPermissions): 'GET': [], 'OPTIONS': [], 'HEAD': [], + 'QUERY': [], 'POST': ['%(app_label)s.add_%(model_name)s'], 'PUT': ['%(app_label)s.change_%(model_name)s'], 'PATCH': ['%(app_label)s.change_%(model_name)s'], diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 2b9478e905..1d21e882fe 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -98,7 +98,8 @@ class SimpleRouter(BaseRouter): url=r'^{prefix}{trailing_slash}$', mapping={ 'get': 'list', - 'post': 'create' + 'post': 'create', + 'query': 'query' }, name='{basename}-list', detail=False, diff --git a/rest_framework/schemas/generators.py b/rest_framework/schemas/generators.py index f59e25c213..786f0b269d 100644 --- a/rest_framework/schemas/generators.py +++ b/rest_framework/schemas/generators.py @@ -37,11 +37,12 @@ def endpoint_ordering(endpoint): path, method, callback = endpoint method_priority = { 'GET': 0, - 'POST': 1, - 'PUT': 2, - 'PATCH': 3, - 'DELETE': 4 - }.get(method, 5) + 'QUERY': 1, + 'POST': 2, + 'PUT': 3, + 'PATCH': 4, + 'DELETE': 5 + }.get(method, 6) return (method_priority,) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index c949328a31..24f52f1d6e 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -136,6 +136,7 @@ def __init__(self, tags=None, operation_id_base=None, component_name=None): 'put': 'update', 'patch': 'partialUpdate', 'delete': 'destroy', + 'query': 'query', } def get_operation(self, path, method): @@ -244,7 +245,7 @@ def get_operation_id_base(self, path, method, action): if name.endswith(action.title()): # ListView, UpdateAPIView, ThingDelete ... name = name[:-len(action)] - if action == 'list': + if action == 'list' or is_list_view(path, method, self.view): assert inflection, '`inflection` must be installed for OpenAPI schema support.' name = inflection.pluralize(name) @@ -255,7 +256,9 @@ def get_operation_id(self, path, method): Compute an operation ID from the view type and get_operation_id_base method. """ method_name = getattr(self.view, 'action', method.lower()) - if is_list_view(path, method, self.view): + if method.lower() == 'query': + action = self.method_mapping['query'] + elif is_list_view(path, method, self.view): action = 'list' elif method_name not in self.method_mapping: action = self._to_camel_case(method_name) @@ -647,7 +650,7 @@ def get_reference(self, serializer): return {'$ref': f'#/components/schemas/{self.get_component_name(serializer)}'} def get_request_body(self, path, method): - if method not in ('PUT', 'PATCH', 'POST'): + if method not in ('PUT', 'PATCH', 'POST', 'QUERY'): return {} self.request_media_types = self.map_parsers(path, method) diff --git a/rest_framework/schemas/utils.py b/rest_framework/schemas/utils.py index 60ed698294..45f6a00719 100644 --- a/rest_framework/schemas/utils.py +++ b/rest_framework/schemas/utils.py @@ -15,9 +15,10 @@ def is_list_view(path, method, view): """ if hasattr(view, 'action'): # Viewsets have an explicitly defined action, which we can inspect. - return view.action == 'list' + # `query` is only routed against the collection, like `list`. + return view.action in ('list', 'query') - if method.lower() != 'get': + if method.lower() not in ('get', 'query'): return False if isinstance(view, RetrieveModelMixin): return False diff --git a/rest_framework/test.py b/rest_framework/test.py index 675f789068..5342602126 100644 --- a/rest_framework/test.py +++ b/rest_framework/test.py @@ -231,6 +231,10 @@ def options(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('OPTIONS', path, data, content_type, **extra) + def query(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('QUERY', path, data, content_type, **extra) + def generic(self, method, path, data='', content_type='application/octet-stream', secure=False, **extra): # Include the CONTENT_TYPE, regardless of whether or not data is empty. @@ -337,6 +341,14 @@ def options(self, path, data=None, format=None, content_type=None, response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra) return response + def query(self, path, data=None, format=None, content_type=None, + follow=False, **extra): + response = super().query( + path, data=data, format=format, content_type=content_type, **extra) + if follow: + response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra) + return response + def logout(self): self._credentials = {} diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 98ea2d3d9f..39664c4be6 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -271,6 +271,41 @@ class View(generics.GenericAPIView): assert components['Item']['required'] == ['text'] assert sorted(list(components['Item']['properties'].keys())) == ['read_only', 'text'] + def test_request_body_for_query(self): + """ + QUERY carries a request body, so the schema has to document one. + """ + path = '/' + method = 'QUERY' + + view = create_view( + views.ExampleQueryAPIView, + method, + create_request(path), + ) + inspector = AutoSchema() + inspector.view = view + + request_body = inspector.get_request_body(path, method) + assert request_body['content']['application/json']['schema']['$ref'] == \ + '#/components/schemas/Example' + + def test_query_list_response_is_an_array(self): + path = '/' + method = 'QUERY' + + view = create_view( + views.ExampleQueryAPIView, + method, + create_request(path), + ) + inspector = AutoSchema() + inspector.view = view + + responses = inspector.get_responses(path, method) + schema = responses['200']['content']['application/json']['schema'] + assert schema['type'] == 'array' + def test_invalid_serializer_class_name(self): path = '/' method = 'POST' @@ -759,6 +794,23 @@ def test_operation_id_generation(self): operationId = inspector.get_operation_id(path, method) assert operationId == 'listExamples' + def test_operation_id_query_does_not_collide_with_list(self): + """ + A QUERY list endpoint gets its own operationId rather than reusing the + GET list one, which would be a duplicate. + """ + path = '/' + + inspector = AutoSchema() + inspector.view = create_view( + views.ExampleQueryAPIView, 'GET', create_request(path)) + assert inspector.get_operation_id(path, 'GET') == 'listExamples' + + inspector = AutoSchema() + inspector.view = create_view( + views.ExampleQueryAPIView, 'QUERY', create_request(path)) + assert inspector.get_operation_id(path, 'QUERY') == 'queryExamples' + def test_operation_id_custom_operation_id_base(self): path = '/' method = 'GET' @@ -972,6 +1024,21 @@ def test_operation_id_viewset(self): assert schema['paths']['/account/{id}/']['patch']['operationId'] == 'partialUpdateExampleViewSet' assert schema['paths']['/account/{id}/']['delete']['operationId'] == 'destroyExampleViewSet' + def test_operation_id_viewset_query(self): + router = routers.SimpleRouter() + router.register('account', views.ExampleQueryViewSet, basename="account") + urlpatterns = router.urls + + generator = SchemaGenerator(patterns=urlpatterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + assert schema['paths']['/account/']['get']['operationId'] == 'listExampleQueryViewSets' + assert schema['paths']['/account/']['query']['operationId'] == 'queryExampleQueryViewSets' + assert 'requestBody' in schema['paths']['/account/']['query'] + # QUERY sorts immediately after GET, ahead of the unsafe methods. + assert list(schema['paths']['/account/']) == ['get', 'query'] + def test_serializer_datefield(self): path = '/' method = 'GET' diff --git a/tests/schemas/views.py b/tests/schemas/views.py index c08208bf26..87205ecc90 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -76,6 +76,16 @@ def get(self, *args, **kwargs): return Response(serializer.data) +class ExampleQueryAPIView(generics.GenericAPIView): + serializer_class = ExampleSerializer + + def get(self, *args, **kwargs): + pass + + def query(self, *args, **kwargs): + pass + + class ExampleGenericViewSet(GenericViewSet): serializer_class = ExampleSerializer @@ -228,6 +238,16 @@ def get(self, *args, **kwargs): return Response(serializer.data) +class ExampleQueryViewSet(ViewSet): + serializer_class = ExampleSerializerModel + + def list(self, request): + pass + + def query(self, request): + pass + + class ExampleViewSet(ViewSet): serializer_class = ExampleSerializerModel diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 26391d8cab..8b528d0f5c 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -396,13 +396,18 @@ def test_action(request): def test_method_mapping_http_method(self): from http import HTTPMethod - method_names = [getattr(HTTPMethod, name.upper()) for name in APIView.http_method_names] + # QUERY is not in http.HTTPMethod yet (python/cpython#153309). + mappable = [ + name for name in APIView.http_method_names + if hasattr(HTTPMethod, name.upper()) + ] + method_names = [getattr(HTTPMethod, name.upper()) for name in mappable] @action(detail=False, methods=method_names) def test_action(): raise NotImplementedError - expected_mapping = {name: test_action.__name__ for name in APIView.http_method_names} + expected_mapping = {name: test_action.__name__ for name in mappable} assert test_action.mapping == expected_mapping diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 786ffa1b9b..6a10943598 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -60,7 +60,23 @@ class IgnoredGetQuerySetListView(GetQuerySetListView): _ignore_model_permissions = True +class QueryRootView(RootView): + def query(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class ReadOnlyQueryView(generics.ListCreateAPIView): + queryset = BasicModel.objects.all() + serializer_class = BasicSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def query(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + root_view = RootView.as_view() +query_root_view = QueryRootView.as_view() +read_only_query_view = ReadOnlyQueryView.as_view() api_root_view = DefaultRouter().get_api_root_view() instance_view = InstanceView.as_view() get_queryset_list_view = GetQuerySetListView.as_view() @@ -169,6 +185,51 @@ def test_does_not_have_delete_permissions(self): response = instance_view(request, pk=1) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_query_requires_no_model_permission(self): + """ + QUERY is safe per RFC 10008, so `perms_map` requires nothing for it, + exactly as for GET. + """ + request = factory.query('/', {'search': 'abc'}, format='json', + HTTP_AUTHORIZATION=self.disallowed_credentials) + response = query_root_view(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_query_is_permitted_for_unauthenticated_read_only(self): + request = factory.query('/', {'search': 'abc'}, format='json') + response = read_only_query_view(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_post_is_still_denied_for_unauthenticated_read_only(self): + request = factory.post('/', {'text': 'foobar'}, format='json') + response = read_only_query_view(request) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_overridden_perms_map_without_query_is_method_not_allowed(self): + """ + `perms_map` overrides that predate QUERY keep failing closed rather + than silently permitting it. + """ + + class LegacyPermissions(permissions.DjangoModelPermissions): + perms_map = { + 'GET': [], + 'OPTIONS': [], + 'HEAD': [], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + class LegacyQueryView(QueryRootView): + permission_classes = [LegacyPermissions] + + request = factory.query('/', {'search': 'abc'}, format='json', + HTTP_AUTHORIZATION=self.permitted_credentials) + response = LegacyQueryView.as_view()(request) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + def test_options_permitted(self): request = factory.options( '/', @@ -327,6 +388,34 @@ def get_queryset(self): get_queryset_object_permissions_view = GetQuerysetObjectPermissionInstanceView.as_view() +# QUERY deliberately requires a permission the read-only user does not hold, +# so the 403-vs-404 branch is observable. +class QueryObjectPermissions(permissions.DjangoObjectPermissions): + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': ['%(app_label)s.view_%(model_name)s'], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'QUERY': ['%(app_label)s.change_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + +class QueryObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView): + queryset = BasicPermModel.objects.all() + serializer_class = BasicPermSerializer + authentication_classes = [authentication.BasicAuthentication] + permission_classes = [QueryObjectPermissions] + + def query(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +query_object_permissions_view = QueryObjectPermissionInstanceView.as_view() + + @pytest.mark.usefixtures("reset_sequences") @unittest.skipUnless('guardian' in settings.INSTALLED_APPS, 'django-guardian not installed') class ObjectPermissionsIntegrationTests(TestCase): @@ -450,6 +539,23 @@ def test_cannot_method_not_allowed(self): response = object_permissions_list_view(request) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + # QUERY + def test_can_query_permissions(self): + request = factory.query('/1', {'search': 'abc'}, format='json', + HTTP_AUTHORIZATION=self.credentials['writeonly']) + response = query_object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_cannot_query_permissions_hides_the_object(self): + """ + QUERY is in SAFE_METHODS, so a denied QUERY 404s immediately rather + than falling through to the read-permission re-check and 403ing. + """ + request = factory.query('/1', {'search': 'abc'}, format='json', + HTTP_AUTHORIZATION=self.credentials['readonly']) + response = query_object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + class BasicPerm(permissions.BasePermission): def has_permission(self, request, view): diff --git a/tests/test_request.py b/tests/test_request.py index 0cd0a34fc1..df34f2e01a 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -17,6 +17,7 @@ from rest_framework import status from rest_framework.authentication import SessionAuthentication +from rest_framework.exceptions import UnsupportedMediaType from rest_framework.parsers import ( BaseParser, FormParser, JSONParser, MultiPartParser ) @@ -127,6 +128,33 @@ def test_standard_behaviour_determines_non_form_content_PUT(self): request.parsers = (PlainTextParser(), ) assert request.data == content + def test_standard_behaviour_determines_form_content_QUERY(self): + """ + Ensure request.data returns content for QUERY request with form content. + """ + data = {'qwerty': 'uiop'} + request = Request(factory.query('/', data)) + request.parsers = (FormParser(), MultiPartParser()) + assert list(request.data.items()) == list(data.items()) + + def test_standard_behaviour_determines_json_content_QUERY(self): + """ + Ensure request.data parses a JSON body sent with a QUERY request. + """ + data = {'search': {'field': 'value'}} + request = Request(factory.query('/', data, format='json')) + request.parsers = (JSONParser(),) + assert request.data == data + + def test_unsupported_media_type_QUERY(self): + """ + Ensure QUERY negotiates parsers exactly as POST does. + """ + request = Request(factory.query('/', b'qwerty', content_type='text/plain')) + request.parsers = (JSONParser(),) + with pytest.raises(UnsupportedMediaType): + request.data + def test_calling_data_fails_when_attribute_error_is_raised(self): """ Ensure attribute errors raised when parsing are properly re-raised. diff --git a/tests/test_routers.py b/tests/test_routers.py index 91a6189d57..42fc172ec2 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -153,6 +153,75 @@ def action3_delete(self, request, pk, *args, **kwargs): return Response({'delete': pk}) +class QueryViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + return Response({'method': 'list'}) + + def query(self, request, *args, **kwargs): + return Response({'method': 'query', 'data': request.data}) + + +class TestQueryRoute(URLPatternsTestCase, TestCase): + """ + The router binds QUERY on the list route, but only for viewsets that + actually implement `query()`. + """ + + router = SimpleRouter() + router.register('queryables', QueryViewSet, basename='queryable') + router.register('basics', BasicViewSet, basename='basic') + + urlpatterns = [ + path('api/', include(router.urls)), + ] + + def setUp(self): + self.router = SimpleRouter() + + def test_query_is_bound_when_the_viewset_implements_it(self): + list_route = self.router.get_routes(QueryViewSet)[0] + assert self.router.get_method_map(QueryViewSet, list_route.mapping) == { + 'get': 'list', + 'query': 'query', + } + + def test_query_is_not_bound_when_the_viewset_omits_it(self): + list_route = self.router.get_routes(BasicViewSet)[0] + assert self.router.get_method_map(BasicViewSet, list_route.mapping) == { + 'get': 'list', + } + + def test_url_names_are_unchanged(self): + assert reverse('queryable-list') == '/api/queryables/' + assert reverse('basic-list') == '/api/basics/' + + def test_query_request_reaches_the_handler(self): + response = APIClient().query( + reverse('queryable-list'), {'search': 'abc'}, format='json') + assert response.status_code == 200 + assert json.loads(response.content.decode()) == { + 'method': 'query', 'data': {'search': 'abc'} + } + + def test_query_is_method_not_allowed_without_a_handler(self): + response = APIClient().query( + reverse('basic-list'), {'search': 'abc'}, format='json') + assert response.status_code == 405 + + def test_exception_raised_when_action_applied_to_query_route(self): + class TestViewSet(viewsets.ModelViewSet): + + @action(methods=['post'], detail=False) + def query(self, request, *args, **kwargs): + return Response({'hello': 'world'}) + + router = SimpleRouter() + router.register(r'test', TestViewSet, basename='test') + + with pytest.raises(ImproperlyConfigured): + router.urls + + class TestSimpleRouter(URLPatternsTestCase, TestCase): router = SimpleRouter() router.register('basics', BasicViewSet, basename='basic') diff --git a/tests/test_testing.py b/tests/test_testing.py index 5dffb0d130..bce72cd869 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -72,6 +72,11 @@ def post_view(request): return Response(serializer.validated_data) +@api_view(['QUERY']) +def query_view(request): + return Response(request.data) + + urlpatterns = [ path('view/', view), path('session-view/', session_view), @@ -80,6 +85,7 @@ def post_view(request): path('post-json-view/', post_json_view), path('delete-json-view/', delete_json_view), path('post-view/', post_view), + path('query-view/', query_view), ] @@ -269,6 +275,22 @@ def test_delete_based_on_format(self): assert response.status_code == status.HTTP_204_NO_CONTENT assert response.data is None + def test_query_based_on_format(self): + data = {'search': {'field': 'value'}} + response = self.client.query('/query-view/', data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data == data + + def test_query_encodes_data_based_on_json_content_type(self): + data = {'search': True} + response = self.client.query( + '/query-view/', + data=data, + content_type='application/json' + ) + assert response.status_code == status.HTTP_200_OK + assert response.data == data + class TestAPIRequestFactory(TestCase): def test_csrf_exempt_by_default(self): @@ -363,6 +385,13 @@ def test_request_factory_url_arguments_with_unicode(self): request = factory.get('/view/', {'demo': 'testé'}) assert dict(request.GET) == {'demo': ['testé']} + def test_query_encodes_data(self): + factory = APIRequestFactory() + request = factory.query('/query-view/', {'search': 'abc'}, format='json') + assert request.method == 'QUERY' + assert request.headers['content-type'] == 'application/json' + assert request.body == b'{"search":"abc"}' + def test_empty_request_content_type(self): factory = APIRequestFactory() request = factory.post( diff --git a/tests/test_views.py b/tests/test_views.py index 11044dd635..8b2c5dd3bf 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,9 +1,11 @@ import copy +import importlib from django.test import TestCase from django.views.decorators.vary import vary_on_headers +from django.views.generic import View -from rest_framework import status +from rest_framework import compat, status from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.settings import APISettings, api_settings @@ -35,6 +37,16 @@ def basic_view(request): return {'method': 'PATCH', 'data': request.data} +class QueryView(APIView): + def query(self, request, *args, **kwargs): + return Response({'method': 'QUERY', 'data': request.data}) + + +@api_view(['QUERY']) +def query_view(request): + return Response({'method': 'QUERY', 'data': request.data}) + + class ErrorView(APIView): def get(self, request, *args, **kwargs): raise Exception @@ -83,6 +95,51 @@ def test_400_parse_error(self): assert sanitise_json_error(response.data) == expected +class QueryMethodTests(TestCase): + """ + QUERY (RFC 10008) dispatches through the same generic machinery as every + other method, so a view opts in simply by defining a `query()` handler. + """ + + def test_query_handler_is_dispatched(self): + request = factory.query('/', {'search': 'abc'}, format='json') + response = QueryView.as_view()(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'method': 'QUERY', 'data': {'search': 'abc'}} + + def test_query_handler_is_dispatched_for_function_based_view(self): + request = factory.query('/', {'search': 'abc'}, format='json') + response = query_view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'method': 'QUERY', 'data': {'search': 'abc'}} + + def test_view_without_query_handler_is_method_not_allowed(self): + request = factory.query('/', {'search': 'abc'}, format='json') + response = BasicView.as_view()(request) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_allow_header_advertises_query_only_when_handled(self): + assert 'QUERY' in QueryView().allowed_methods + assert 'QUERY' not in BasicView().allowed_methods + + response = QueryView.as_view()(factory.options('/')) + assert 'QUERY' in response['Allow'].split(', ') + + response = BasicView.as_view()(factory.options('/')) + assert 'QUERY' not in response['Allow'].split(', ') + + def test_compat_patch_is_idempotent(self): + """ + The guard in `compat` makes the patch a no-op once Django implements + QUERY natively (Trac #37232), rather than appending it twice. + """ + assert View.http_method_names.count('query') == 1 + + importlib.reload(compat) + + assert View.http_method_names.count('query') == 1 + + @api_view(['GET']) @vary_on_headers('X-Some-Header,X-Other-Header') def vary_headers_with_default_view(request): diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index d60bc28d13..12276de15f 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -20,6 +20,13 @@ def list(self, request, *args, **kwargs): return Response({'ACTION': 'LIST'}) +class QueryViewSet(GenericViewSet): + def query(self, request, *args, **kwargs): + response = Response({'ACTION': 'QUERY', 'data': request.data}) + response.view = self + return response + + class InstanceViewSet(GenericViewSet): def dispatch(self, request, *args, **kwargs): @@ -200,6 +207,22 @@ def test_login_required_middleware_compat(self): view = ActionViewSet.as_view(actions={'get': 'list'}) assert view.login_required is False + def test_initialize_view_set_with_query_action(self): + request = factory.query('/', {'search': 'abc'}, format='json') + my_view = QueryViewSet.as_view(actions={ + 'query': 'query', + }) + + response = my_view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'ACTION': 'QUERY', 'data': {'search': 'abc'}} + + def test_viewset_action_attr_for_query(self): + view = QueryViewSet.as_view(actions={'query': 'query'}) + + response = view(factory.query('/', {}, format='json')) + assert response.view.action == 'query' + class GetExtraActionsTests(TestCase):