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.
| URL Style | HTTP Method | Action | URL Name |
- | {prefix}/ | GET | list | {basename}-list |
+ | {prefix}/ | GET | list | {basename}-list |
| POST | create |
+ | QUERY | query |
| {prefix}/{url_path}/ | GET, or as specified by `methods` argument | `@action(detail=False)` decorated method | {basename}-{url_name} |
| {prefix}/{lookup}/ | GET | retrieve | {basename}-detail |
| PUT | update |
@@ -186,8 +187,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
| URL Style | HTTP Method | Action | URL Name |
| [.format] | GET | automatically generated root view | api-root |
- | {prefix}/[.format] | GET | list | {basename}-list |
+ | {prefix}/[.format] | GET | list | {basename}-list |
| POST | create |
+ | QUERY | query |
| {prefix}/{url_path}/[.format] | GET, or as specified by `methods` argument | `@action(detail=False)` decorated method | {basename}-{url_name} |
| {prefix}/{lookup}/[.format] | GET | retrieve | {basename}-detail |
| PUT | update |
@@ -196,6 +198,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
| {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):