This commit is contained in:
Tom Christie 2013-08-19 20:36:18 +01:00
commit c607414f16
6 changed files with 157 additions and 47 deletions

View File

@ -35,12 +35,12 @@ The example above would generate the following URL patterns:
* URL pattern: `^accounts/$` Name: `'account-list'` * URL pattern: `^accounts/$` Name: `'account-list'`
* URL pattern: `^accounts/{pk}/$` Name: `'account-detail'` * URL pattern: `^accounts/{pk}/$` Name: `'account-detail'`
### Extra link and actions ### Registering additional routes
Any methods on the viewset decorated with `@link` or `@action` will also be routed. Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed.
For example, given a method like this on the `UserViewSet` class: For example, given a method like this on the `UserViewSet` class:
@action(permission_classes=[IsAdminOrIsSelf]) @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
... ...
@ -52,7 +52,7 @@ The following URL pattern would additionally be generated:
## SimpleRouter ## 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 `@link` or `@action` decorators. 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 `@detail_route` or `@list_route` decorators.
<table border=1> <table border=1>
<tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr> <tr><th>URL Style</th><th>HTTP Method</th><th>Action</th><th>URL Name</th></tr>
@ -62,8 +62,8 @@ This router includes routes for the standard set of `list`, `create`, `retrieve`
<tr><td>PUT</td><td>update</td></tr> <tr><td>PUT</td><td>update</td></tr>
<tr><td>PATCH</td><td>partial_update</td></tr> <tr><td>PATCH</td><td>partial_update</td></tr>
<tr><td>DELETE</td><td>destroy</td></tr> <tr><td>DELETE</td><td>destroy</td></tr>
<tr><td rowspan=2>{prefix}/{lookup}/{methodname}/</td><td>GET</td><td>@link decorated method</td><td rowspan=2>{basename}-{methodname}</td></tr> <tr><td rowspan=2>{prefix}/{lookup}/{methodname}/</td><td>GET</td><td>@detail_route decorated method</td><td rowspan=2>{basename}-{methodname}</td></tr>
<tr><td>POST</td><td>@action decorated method</td></tr> <tr><td>POST</td><td>@detail_route decorated method</td></tr>
</table> </table>
By default the URLs created by `SimpleRouter` are appended with a trailing slash. By default the URLs created by `SimpleRouter` are appended with a trailing slash.
@ -86,8 +86,8 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d
<tr><td>PUT</td><td>update</td></tr> <tr><td>PUT</td><td>update</td></tr>
<tr><td>PATCH</td><td>partial_update</td></tr> <tr><td>PATCH</td><td>partial_update</td></tr>
<tr><td>DELETE</td><td>destroy</td></tr> <tr><td>DELETE</td><td>destroy</td></tr>
<tr><td rowspan=2>{prefix}/{lookup}/{methodname}/[.format]</td><td>GET</td><td>@link decorated method</td><td rowspan=2>{basename}-{methodname}</td></tr> <tr><td rowspan=2>{prefix}/{lookup}/{methodname}/[.format]</td><td>GET</td><td>@detail_route decorated method</td><td rowspan=2>{basename}-{methodname}</td></tr>
<tr><td>POST</td><td>@action decorated method</td></tr> <tr><td>POST</td><td>@detail_route decorated method</td></tr>
</table> </table>
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. 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.

View File

@ -92,14 +92,16 @@ The default routers included with REST framework will provide routes for a stand
def destroy(self, request, pk=None): def destroy(self, request, pk=None):
pass pass
If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link` or `@action` decorators. The `@link` decorator will route `GET` requests, and the `@action` decorator will route `POST` requests. If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@detail_route` or `@list_route` decorators.
The `@detail_route` decorator contains `pk` in its URL pattern and is intended for methods which require a single instance. The `@list_route` decorator is intended for methods which operate on a list of objects.
For example: For example:
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import viewsets
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework import viewsets
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from myapp.serializers import UserSerializer, PasswordSerializer from myapp.serializers import UserSerializer, PasswordSerializer
@ -110,7 +112,7 @@ For example:
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
@action() @detail_route(methods=['post'])
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
user = self.get_object() user = self.get_object()
serializer = PasswordSerializer(data=request.DATA) serializer = PasswordSerializer(data=request.DATA)
@ -122,15 +124,22 @@ For example:
return Response(serializer.errors, return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
The `@action` and `@link` decorators can additionally take extra arguments that will be set for the routed view only. For example... @list_route()
def recent_users(self, request):
recent_users = User.objects.all().order('-last_login')
page = self.paginate_queryset(recent_users)
serializer = self.get_pagination_serializer(page)
return Response(serializer.data)
@action(permission_classes=[IsAdminOrIsSelf]) The decorators can additionally take extra arguments that will be set for the routed view only. For example...
@detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf])
def set_password(self, request, pk=None): def set_password(self, request, pk=None):
... ...
The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: By default, the decorators will route `GET` requests, but may also accept other HTTP methods, by using the `methods` argument. For example:
@action(methods=['POST', 'DELETE']) @detail_route(methods=['post', 'delete'])
def unset_password(self, request, pk=None): def unset_password(self, request, pk=None):
... ...
--- ---

View File

@ -25,7 +25,7 @@ Here we've used `ReadOnlyModelViewSet` class to automatically provide the defaul
Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighlight` view classes. We can remove the three views, and again replace them with a single class. Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighlight` view classes. We can remove the three views, and again replace them with a single class.
from rest_framework.decorators import link from rest_framework.decorators import detail_route
class SnippetViewSet(viewsets.ModelViewSet): class SnippetViewSet(viewsets.ModelViewSet):
""" """
@ -39,7 +39,7 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl
permission_classes = (permissions.IsAuthenticatedOrReadOnly, permission_classes = (permissions.IsAuthenticatedOrReadOnly,
IsOwnerOrReadOnly,) IsOwnerOrReadOnly,)
@link(renderer_classes=[renderers.StaticHTMLRenderer]) @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
def highlight(self, request, *args, **kwargs): def highlight(self, request, *args, **kwargs):
snippet = self.get_object() snippet = self.get_object()
return Response(snippet.highlighted) return Response(snippet.highlighted)
@ -49,9 +49,9 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl
This time we've used the `ModelViewSet` class in order to get the complete set of default read and write operations. This time we've used the `ModelViewSet` class in order to get the complete set of default read and write operations.
Notice that we've also used the `@link` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style. Notice that we've also used the `@detail_route` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style.
Custom actions which use the `@link` decorator will respond to `GET` requests. We could have instead used the `@action` decorator if we wanted an action that responded to `POST` requests. Custom actions which use the `@detail_route` decorator will respond to `GET` requests. We can use the `methods` argument if we wanted an action that responded to `POST` requests.
## Binding ViewSets to URLs explicitly ## Binding ViewSets to URLs explicitly

View File

@ -3,13 +3,14 @@ The most important decorator in this module is `@api_view`, which is used
for writing function-based views with REST framework. for writing function-based views with REST framework.
There are also various decorators for setting the API policies on function There are also various decorators for setting the API policies on function
based views, as well as the `@action` and `@link` decorators, which are based views, as well as the `@detail_route` and `@list_route` decorators, which are
used to annotate methods on viewsets that should be included by routers. used to annotate methods on viewsets that should be included by routers.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework.compat import six from rest_framework.compat import six
from rest_framework.views import APIView from rest_framework.views import APIView
import types import types
import warnings
def api_view(http_method_names): def api_view(http_method_names):
@ -109,10 +110,13 @@ def permission_classes(permission_classes):
def link(**kwargs): def link(**kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for GET requests. Used to mark a method on a ViewSet that should be routed for detail GET requests.
""" """
msg = 'link is pending deprecation. Use detail_route instead.'
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
def decorator(func): def decorator(func):
func.bind_to_methods = ['get'] func.bind_to_methods = ['get']
func.detail = True
func.kwargs = kwargs func.kwargs = kwargs
return func return func
return decorator return decorator
@ -120,10 +124,37 @@ def link(**kwargs):
def action(methods=['post'], **kwargs): def action(methods=['post'], **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for POST requests. Used to mark a method on a ViewSet that should be routed for detail POST requests.
""" """
msg = 'action is pending deprecation. Use detail_route instead.'
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
def decorator(func): def decorator(func):
func.bind_to_methods = methods func.bind_to_methods = methods
func.detail = True
func.kwargs = kwargs
return func
return decorator
def detail_route(methods=['get'], **kwargs):
"""
Used to mark a method on a ViewSet that should be routed for detail requests.
"""
def decorator(func):
func.bind_to_methods = methods
func.detail = True
func.kwargs = kwargs
return func
return decorator
def list_route(methods=['get'], **kwargs):
"""
Used to mark a method on a ViewSet that should be routed for list requests.
"""
def decorator(func):
func.bind_to_methods = methods
func.detail = False
func.kwargs = kwargs func.kwargs = kwargs
return func return func
return decorator return decorator

View File

@ -26,6 +26,8 @@ from rest_framework.urlpatterns import format_suffix_patterns
Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs'])
DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs'])
DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs'])
def replace_methodname(format_string, methodname): def replace_methodname(format_string, methodname):
@ -88,6 +90,14 @@ class SimpleRouter(BaseRouter):
name='{basename}-list', name='{basename}-list',
initkwargs={'suffix': 'List'} initkwargs={'suffix': 'List'}
), ),
# Dynamically generated list routes.
# Generated using @list_route decorator
# on methods of the viewset.
DynamicListRoute(
url=r'^{prefix}/{methodname}{trailing_slash}$',
name='{basename}-{methodnamehyphen}',
initkwargs={}
),
# Detail route. # Detail route.
Route( Route(
url=r'^{prefix}/{lookup}{trailing_slash}$', url=r'^{prefix}/{lookup}{trailing_slash}$',
@ -100,13 +110,10 @@ class SimpleRouter(BaseRouter):
name='{basename}-detail', name='{basename}-detail',
initkwargs={'suffix': 'Instance'} initkwargs={'suffix': 'Instance'}
), ),
# Dynamically generated routes. # Dynamically generated detail routes.
# Generated using @action or @link decorators on methods of the viewset. # Generated using @detail_route decorator on methods of the viewset.
Route( DynamicDetailRoute(
url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$',
mapping={
'{httpmethod}': '{methodname}',
},
name='{basename}-{methodnamehyphen}', name='{basename}-{methodnamehyphen}',
initkwargs={} initkwargs={}
), ),
@ -139,25 +146,42 @@ class SimpleRouter(BaseRouter):
Returns a list of the Route namedtuple. Returns a list of the Route namedtuple.
""" """
known_actions = flatten([route.mapping.values() for route in self.routes]) known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)])
# Determine any `@action` or `@link` decorated methods on the viewset # Determine any `@detail_route` or `@list_route` decorated methods on the viewset
dynamic_routes = [] detail_routes = []
list_routes = []
for methodname in dir(viewset): for methodname in dir(viewset):
attr = getattr(viewset, methodname) attr = getattr(viewset, methodname)
httpmethods = getattr(attr, 'bind_to_methods', None) httpmethods = getattr(attr, 'bind_to_methods', None)
detail = getattr(attr, 'detail', True)
if httpmethods: if httpmethods:
if methodname in known_actions: if methodname in known_actions:
raise ImproperlyConfigured('Cannot use @action or @link decorator on ' raise ImproperlyConfigured('Cannot use @detail_route or @list_route '
'method "%s" as it is an existing route' % methodname) 'decorators on method "%s" '
'as it is an existing route' % methodname)
httpmethods = [method.lower() for method in httpmethods] httpmethods = [method.lower() for method in httpmethods]
dynamic_routes.append((httpmethods, methodname)) if detail:
detail_routes.append((httpmethods, methodname))
else:
list_routes.append((httpmethods, methodname))
ret = [] ret = []
for route in self.routes: for route in self.routes:
if route.mapping == {'{httpmethod}': '{methodname}'}: if isinstance(route, DynamicDetailRoute):
# Dynamic routes (@link or @action decorator) # Dynamic detail routes (@detail_route decorator)
for httpmethods, methodname in dynamic_routes: for httpmethods, methodname in detail_routes:
initkwargs = route.initkwargs.copy()
initkwargs.update(getattr(viewset, methodname).kwargs)
ret.append(Route(
url=replace_methodname(route.url, methodname),
mapping=dict((httpmethod, methodname) for httpmethod in httpmethods),
name=replace_methodname(route.name, methodname),
initkwargs=initkwargs,
))
elif isinstance(route, DynamicListRoute):
# Dynamic list routes (@list_route decorator)
for httpmethods, methodname in list_routes:
initkwargs = route.initkwargs.copy() initkwargs = route.initkwargs.copy()
initkwargs.update(getattr(viewset, methodname).kwargs) initkwargs.update(getattr(viewset, methodname).kwargs)
ret.append(Route( ret.append(Route(

View File

@ -4,7 +4,7 @@ from django.test import TestCase
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers, viewsets, permissions from rest_framework import serializers, viewsets, permissions
from rest_framework.compat import include, patterns, url from rest_framework.compat import include, patterns, url
from rest_framework.decorators import link, action from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.routers import SimpleRouter, DefaultRouter
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
@ -18,23 +18,23 @@ class BasicViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return Response({'method': 'list'}) return Response({'method': 'list'})
@action() @detail_route(methods=['post'])
def action1(self, request, *args, **kwargs): def action1(self, request, *args, **kwargs):
return Response({'method': 'action1'}) return Response({'method': 'action1'})
@action() @detail_route(methods=['post'])
def action2(self, request, *args, **kwargs): def action2(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@action(methods=['post', 'delete']) @detail_route(methods=['post', 'delete'])
def action3(self, request, *args, **kwargs): def action3(self, request, *args, **kwargs):
return Response({'method': 'action2'}) return Response({'method': 'action2'})
@link() @detail_route()
def link1(self, request, *args, **kwargs): def link1(self, request, *args, **kwargs):
return Response({'method': 'link1'}) return Response({'method': 'link1'})
@link() @detail_route()
def link2(self, request, *args, **kwargs): def link2(self, request, *args, **kwargs):
return Response({'method': 'link2'}) return Response({'method': 'link2'})
@ -175,7 +175,7 @@ class TestActionKeywordArgs(TestCase):
class TestViewSet(viewsets.ModelViewSet): class TestViewSet(viewsets.ModelViewSet):
permission_classes = [] permission_classes = []
@action(permission_classes=[permissions.AllowAny]) @detail_route(methods=['post'], permission_classes=[permissions.AllowAny])
def custom(self, request, *args, **kwargs): def custom(self, request, *args, **kwargs):
return Response({ return Response({
'permission_classes': self.permission_classes 'permission_classes': self.permission_classes
@ -196,14 +196,14 @@ class TestActionKeywordArgs(TestCase):
class TestActionAppliedToExistingRoute(TestCase): class TestActionAppliedToExistingRoute(TestCase):
""" """
Ensure `@action` decorator raises an except when applied Ensure `@detail_route` decorator raises an except when applied
to an existing route to an existing route
""" """
def test_exception_raised_when_action_applied_to_existing_route(self): def test_exception_raised_when_action_applied_to_existing_route(self):
class TestViewSet(viewsets.ModelViewSet): class TestViewSet(viewsets.ModelViewSet):
@action() @detail_route(methods=['post'])
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
return Response({ return Response({
'hello': 'world' 'hello': 'world'
@ -214,3 +214,49 @@ class TestActionAppliedToExistingRoute(TestCase):
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
self.router.urls self.router.urls
class DynamicListAndDetailViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs):
return Response({'method': 'list'})
@list_route(methods=['post'])
def list_route_post(self, request, *args, **kwargs):
return Response({'method': 'action1'})
@detail_route(methods=['post'])
def detail_route_post(self, request, *args, **kwargs):
return Response({'method': 'action2'})
@list_route()
def list_route_get(self, request, *args, **kwargs):
return Response({'method': 'link1'})
@detail_route()
def detail_route_get(self, request, *args, **kwargs):
return Response({'method': 'link2'})
class TestDynamicListAndDetailRouter(TestCase):
def setUp(self):
self.router = SimpleRouter()
def test_list_and_detail_route_decorators(self):
routes = self.router.get_routes(DynamicListAndDetailViewSet)
decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))]
# Make sure all these endpoints exist and none have been clobbered
for i, endpoint in enumerate(['list_route_get', 'list_route_post', 'detail_route_get', 'detail_route_post']):
route = decorator_routes[i]
# check url listing
if endpoint.startswith('list_'):
self.assertEqual(route.url,
'^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint))
else:
self.assertEqual(route.url,
'^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint))
# check method to function mapping
if endpoint.endswith('_post'):
method_map = 'post'
else:
method_map = 'get'
self.assertEqual(route.mapping[method_map], endpoint)