Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/api-guide/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api-guide/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
11 changes: 8 additions & 3 deletions docs/api-guide/routers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<table border=1>
<tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr>
<tr><td rowspan=2>{prefix}/</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr>
<tr><td rowspan=3>{prefix}/</td><td>GET</td><td>list</td><td rowspan=3>{basename}-list</td></tr></tr>
<tr><td>POST</td><td>create</td></tr>
<tr><td>QUERY</td><td>query</td></tr>
<tr><td>{prefix}/{url_path}/</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=False)` decorated method</td><td>{basename}-{url_name}</td></tr>
<tr><td rowspan=4>{prefix}/{lookup}/</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr>
<tr><td>PUT</td><td>update</td></tr>
Expand All @@ -186,8 +187,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
<table border=1>
<tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr>
<tr><td>[.format]</td><td>GET</td><td>automatically generated root view</td><td>api-root</td></tr></tr>
<tr><td rowspan=2>{prefix}/[.format]</td><td>GET</td><td>list</td><td rowspan=2>{basename}-list</td></tr></tr>
<tr><td rowspan=3>{prefix}/[.format]</td><td>GET</td><td>list</td><td rowspan=3>{basename}-list</td></tr></tr>
<tr><td>POST</td><td>create</td></tr>
<tr><td>QUERY</td><td>query</td></tr>
<tr><td>{prefix}/{url_path}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=False)` decorated method</td><td>{basename}-{url_name}</td></tr>
<tr><td rowspan=4>{prefix}/{lookup}/[.format]</td><td>GET</td><td>retrieve</td><td rowspan=4>{basename}-detail</td></tr></tr>
<tr><td>PUT</td><td>update</td></tr>
Expand All @@ -196,6 +198,9 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
<tr><td>{prefix}/{lookup}/{url_path}/[.format]</td><td>GET, or as specified by `methods` argument</td><td>`@action(detail=True)` decorated method</td><td>{basename}-{url_name}</td></tr>
</table>

!!! 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)
Expand Down
4 changes: 2 additions & 2 deletions docs/api-guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
30 changes: 29 additions & 1 deletion docs/api-guide/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

7 changes: 7 additions & 0 deletions docs/api-guide/viewsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions rest_framework/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 3 additions & 1 deletion rest_framework/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from rest_framework import exceptions

SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')
SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'QUERY')


class OperationHolderMixin:
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
3 changes: 2 additions & 1 deletion rest_framework/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions rest_framework/schemas/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,)


Expand Down
9 changes: 6 additions & 3 deletions rest_framework/schemas/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions rest_framework/schemas/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions rest_framework/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {}

Expand Down
67 changes: 67 additions & 0 deletions tests/schemas/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
Loading