Skip to content

Commit f323989

Browse files
author
Ryan P Kilby
committed
Add method mapping to ViewSet actions
1 parent 9b64818 commit f323989

File tree

6 files changed

+180
-21
lines changed

6 files changed

+180
-21
lines changed

rest_framework/decorators.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ def action(methods=None, detail=None, name=None, url_path=None, url_name=None, *
146146
)
147147

148148
def decorator(func):
149-
func.bind_to_methods = methods
149+
func.mapping = MethodMapper(func, methods)
150+
150151
func.detail = detail
151152
func.name = name if name else pretty_name(func.__name__)
152153
func.url_path = url_path if url_path else func.__name__
@@ -156,10 +157,70 @@ def decorator(func):
156157
'name': func.name,
157158
'description': func.__doc__ or None
158159
})
160+
159161
return func
160162
return decorator
161163

162164

165+
class MethodMapper(dict):
166+
"""
167+
Enables mapping HTTP methods to different ViewSet methods for a single,
168+
logical action.
169+
170+
Example usage:
171+
172+
class MyViewSet(ViewSet):
173+
174+
@action(detail=False)
175+
def example(self, request, **kwargs):
176+
...
177+
178+
@example.mapping.post
179+
def create_example(self, request, **kwargs):
180+
...
181+
"""
182+
183+
def __init__(self, action, methods):
184+
self.action = action
185+
for method in methods:
186+
self[method] = self.action.__name__
187+
188+
def _map(self, method, func):
189+
assert method not in self, (
190+
"Method '%s' has already been mapped to '.%s'." % (method, self[method]))
191+
assert func.__name__ != self.action.__name__, (
192+
"Method mapping does not behave like the property decorator. You "
193+
"cannot use the same method name for each mapping declaration.")
194+
195+
self[method] = func.__name__
196+
197+
return func
198+
199+
def get(self, func):
200+
return self._map('get', func)
201+
202+
def post(self, func):
203+
return self._map('post', func)
204+
205+
def put(self, func):
206+
return self._map('put', func)
207+
208+
def patch(self, func):
209+
return self._map('patch', func)
210+
211+
def delete(self, func):
212+
return self._map('delete', func)
213+
214+
def head(self, func):
215+
return self._map('head', func)
216+
217+
def options(self, func):
218+
return self._map('options', func)
219+
220+
def trace(self, func):
221+
return self._map('trace', func)
222+
223+
163224
def detail_route(methods=None, **kwargs):
164225
"""
165226
Used to mark a method on a ViewSet that should be routed for detail requests.

rest_framework/routers.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,7 @@ def _get_dynamic_route(self, route, action):
208208

209209
return Route(
210210
url=route.url.replace('{url_path}', url_path),
211-
mapping={http_method: action.__name__
212-
for http_method in action.bind_to_methods},
211+
mapping=action.mapping,
213212
name=route.name.replace('{url_name}', action.url_name),
214213
detail=route.detail,
215214
initkwargs=initkwargs,

rest_framework/viewsets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232

3333
def _is_extra_action(attr):
34-
return hasattr(attr, 'bind_to_methods')
34+
return hasattr(attr, 'mapping')
3535

3636

3737
class ViewSetMixin(object):

tests/test_decorators.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def test_defaults(self):
177177
def test_action(request):
178178
"""Description"""
179179

180-
assert test_action.bind_to_methods == ['get']
180+
assert test_action.mapping == {'get': 'test_action'}
181181
assert test_action.detail is True
182182
assert test_action.name == 'Test action'
183183
assert test_action.url_path == 'test_action'
@@ -191,15 +191,69 @@ def test_detail_required(self):
191191
with pytest.raises(AssertionError) as excinfo:
192192
@action()
193193
def test_action(request):
194-
pass
194+
raise NotImplementedError
195195

196196
assert str(excinfo.value) == "@action() missing required argument: 'detail'"
197197

198+
def test_method_mapping_http_methods(self):
199+
# All HTTP methods should be mappable
200+
@action(detail=False, methods=[])
201+
def test_action():
202+
raise NotImplementedError
203+
204+
for name in APIView.http_method_names:
205+
def method():
206+
raise NotImplementedError
207+
208+
# Python 2.x compatibility - cast __name__ to str
209+
method.__name__ = str(name)
210+
getattr(test_action.mapping, name)(method)
211+
212+
# ensure the mapping returns the correct method name
213+
for name in APIView.http_method_names:
214+
assert test_action.mapping[name] == name
215+
216+
def test_method_mapping(self):
217+
@action(detail=False)
218+
def test_action(request):
219+
raise NotImplementedError
220+
221+
@test_action.mapping.post
222+
def test_action_post(request):
223+
raise NotImplementedError
224+
225+
# The secondary handler methods should not have the action attributes
226+
for name in ['mapping', 'detail', 'name', 'url_path', 'url_name', 'kwargs']:
227+
assert hasattr(test_action, name) and not hasattr(test_action_post, name)
228+
229+
def test_method_mapping_already_mapped(self):
230+
@action(detail=True)
231+
def test_action(request):
232+
raise NotImplementedError
233+
234+
msg = "Method 'get' has already been mapped to '.test_action'."
235+
with self.assertRaisesMessage(AssertionError, msg):
236+
@test_action.mapping.get
237+
def test_action_get(request):
238+
raise NotImplementedError
239+
240+
def test_method_mapping_overwrite(self):
241+
@action(detail=True)
242+
def test_action():
243+
raise NotImplementedError
244+
245+
msg = ("Method mapping does not behave like the property decorator. You "
246+
"cannot use the same method name for each mapping declaration.")
247+
with self.assertRaisesMessage(AssertionError, msg):
248+
@test_action.mapping.post
249+
def test_action():
250+
raise NotImplementedError
251+
198252
def test_detail_route_deprecation(self):
199253
with pytest.warns(PendingDeprecationWarning) as record:
200254
@detail_route()
201255
def view(request):
202-
pass
256+
raise NotImplementedError
203257

204258
assert len(record) == 1
205259
assert str(record[0].message) == (
@@ -212,7 +266,7 @@ def test_list_route_deprecation(self):
212266
with pytest.warns(PendingDeprecationWarning) as record:
213267
@list_route()
214268
def view(request):
215-
pass
269+
raise NotImplementedError
216270

217271
assert len(record) == 1
218272
assert str(record[0].message) == (
@@ -226,7 +280,7 @@ def test_route_url_name_from_path(self):
226280
with pytest.warns(PendingDeprecationWarning):
227281
@list_route(url_path='foo_bar')
228282
def view(request):
229-
pass
283+
raise NotImplementedError
230284

231285
assert view.url_path == 'foo_bar'
232286
assert view.url_name == 'foo-bar'

tests/test_routers.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.core.exceptions import ImproperlyConfigured
88
from django.db import models
99
from django.test import TestCase, override_settings
10-
from django.urls import resolve
10+
from django.urls import resolve, reverse
1111

1212
from rest_framework import permissions, serializers, viewsets
1313
from rest_framework.compat import get_regex_pattern
@@ -107,8 +107,23 @@ def action1(self, request, *args, **kwargs):
107107
def action2(self, request, *args, **kwargs):
108108
return Response({'method': 'action2'})
109109

110+
@action(methods=['post'], detail=True)
111+
def action3(self, request, pk, *args, **kwargs):
112+
return Response({'post': pk})
113+
114+
@action3.mapping.delete
115+
def action3_delete(self, request, pk, *args, **kwargs):
116+
return Response({'delete': pk})
117+
118+
119+
class TestSimpleRouter(URLPatternsTestCase, TestCase):
120+
router = SimpleRouter()
121+
router.register('basics', BasicViewSet, base_name='basic')
122+
123+
urlpatterns = [
124+
url(r'^api/', include(router.urls)),
125+
]
110126

111-
class TestSimpleRouter(TestCase):
112127
def setUp(self):
113128
self.router = SimpleRouter()
114129

@@ -127,6 +142,21 @@ def test_action_routes(self):
127142
'delete': 'action2',
128143
}
129144

145+
assert routes[2].url == '^{prefix}/{lookup}/action3{trailing_slash}$'
146+
assert routes[2].mapping == {
147+
'post': 'action3',
148+
'delete': 'action3_delete',
149+
}
150+
151+
def test_multiple_action_handlers(self):
152+
# Standard action
153+
response = self.client.post(reverse('basic-action3', args=[1]))
154+
assert response.data == {'post': '1'}
155+
156+
# Additional handler registered with MethodMapper
157+
response = self.client.delete(reverse('basic-action3', args=[1]))
158+
assert response.data == {'delete': '1'}
159+
130160

131161
class TestRootView(URLPatternsTestCase, TestCase):
132162
urlpatterns = [

tests/test_schemas.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,29 +75,35 @@ def custom_action(self, request, pk):
7575
"""
7676
A description of custom action.
7777
"""
78-
return super(ExampleSerializer, self).retrieve(self, request)
78+
raise NotImplementedError
7979

8080
@action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithDictField)
8181
def custom_action_with_dict_field(self, request, pk):
8282
"""
8383
A custom action using a dict field in the serializer.
8484
"""
85-
return super(ExampleSerializer, self).retrieve(self, request)
85+
raise NotImplementedError
8686

8787
@action(methods=['post'], detail=True, serializer_class=AnotherSerializerWithListFields)
8888
def custom_action_with_list_fields(self, request, pk):
8989
"""
9090
A custom action using both list field and list serializer in the serializer.
9191
"""
92-
return super(ExampleSerializer, self).retrieve(self, request)
92+
raise NotImplementedError
9393

9494
@action(detail=False)
9595
def custom_list_action(self, request):
96-
return super(ExampleViewSet, self).list(self, request)
96+
raise NotImplementedError
9797

9898
@action(methods=['post', 'get'], detail=False, serializer_class=EmptySerializer)
9999
def custom_list_action_multiple_methods(self, request):
100-
return super(ExampleViewSet, self).list(self, request)
100+
"""Custom description."""
101+
raise NotImplementedError
102+
103+
@custom_list_action_multiple_methods.mapping.delete
104+
def custom_list_action_multiple_methods_delete(self, request):
105+
"""Deletion description."""
106+
raise NotImplementedError
101107

102108
def get_serializer(self, *args, **kwargs):
103109
assert self.request
@@ -147,7 +153,8 @@ def test_anonymous_request(self):
147153
'custom_list_action_multiple_methods': {
148154
'read': coreapi.Link(
149155
url='/example/custom_list_action_multiple_methods/',
150-
action='get'
156+
action='get',
157+
description='Custom description.',
151158
)
152159
},
153160
'read': coreapi.Link(
@@ -238,12 +245,19 @@ def test_authenticated_request(self):
238245
'custom_list_action_multiple_methods': {
239246
'read': coreapi.Link(
240247
url='/example/custom_list_action_multiple_methods/',
241-
action='get'
248+
action='get',
249+
description='Custom description.',
242250
),
243251
'create': coreapi.Link(
244252
url='/example/custom_list_action_multiple_methods/',
245-
action='post'
246-
)
253+
action='post',
254+
description='Custom description.',
255+
),
256+
'delete': coreapi.Link(
257+
url='/example/custom_list_action_multiple_methods/',
258+
action='delete',
259+
description='Deletion description.',
260+
),
247261
},
248262
'update': coreapi.Link(
249263
url='/example/{id}/',
@@ -526,7 +540,8 @@ def test_schema_for_regular_views(self):
526540
'custom_list_action_multiple_methods': {
527541
'read': coreapi.Link(
528542
url='/example1/custom_list_action_multiple_methods/',
529-
action='get'
543+
action='get',
544+
description='Custom description.',
530545
)
531546
},
532547
'read': coreapi.Link(

0 commit comments

Comments
 (0)