Skip to content

Commit c1348cf

Browse files
committed
Add SimplePathRouter
1 parent 90eaf51 commit c1348cf

File tree

3 files changed

+265
-47
lines changed

3 files changed

+265
-47
lines changed

docs/api-guide/routers.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ The router will match lookup values containing any characters except slashes and
173173
lookup_field = 'my_model_id'
174174
lookup_value_regex = '[0-9a-f]{32}'
175175

176+
## SimplePathRouter
177+
178+
This router is similar to `SimpleRouter` as above, but instead of _regexs_ it uses [path converters][path-convertes-topic-reference] to build urls.
179+
180+
**Note**: this router is available only with Django 2.x or above, since this feature was introduced in 2.0. See [release note][simplified-routing-release-note]
181+
176182
## DefaultRouter
177183

178184
This router is similar to `SimpleRouter` as above, but additionally includes a default API root view, that returns a response containing hyperlinks to all the list views. It also generates routes for optional `.json` style format suffixes.
@@ -340,3 +346,5 @@ The [`DRF-extensions` package][drf-extensions] provides [routers][drf-extensions
340346
[drf-extensions-customizable-endpoint-names]: https://chibisov.github.io/drf-extensions/docs/#controller-endpoint-name
341347
[url-namespace-docs]: https://docs.djangoproject.com/en/1.11/topics/http/urls/#url-namespaces
342348
[include-api-reference]: https://docs.djangoproject.com/en/2.0/ref/urls/#include
349+
[simplified-routing-release-note]: https://docs.djangoproject.com/en/2.0/releases/2.0/#simplified-url-routing-syntax
350+
[path-convertes-topic-reference]: https://docs.djangoproject.com/en/2.0/topics/http/urls/#path-converters

rest_framework/routers.py

Lines changed: 167 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from django.urls import NoReverseMatch
2222

2323
from rest_framework import views
24+
from rest_framework.compat import path
2425
from rest_framework.response import Response
2526
from rest_framework.reverse import reverse
2627
from rest_framework.schemas import SchemaGenerator
@@ -79,50 +80,10 @@ def urls(self):
7980
return self._urls
8081

8182

82-
class SimpleRouter(BaseRouter):
83-
84-
routes = [
85-
# List route.
86-
Route(
87-
url=r'^{prefix}{trailing_slash}$',
88-
mapping={
89-
'get': 'list',
90-
'post': 'create'
91-
},
92-
name='{basename}-list',
93-
detail=False,
94-
initkwargs={'suffix': 'List'}
95-
),
96-
# Dynamically generated list routes. Generated using
97-
# @action(detail=False) decorator on methods of the viewset.
98-
DynamicRoute(
99-
url=r'^{prefix}/{url_path}{trailing_slash}$',
100-
name='{basename}-{url_name}',
101-
detail=False,
102-
initkwargs={}
103-
),
104-
# Detail route.
105-
Route(
106-
url=r'^{prefix}/{lookup}{trailing_slash}$',
107-
mapping={
108-
'get': 'retrieve',
109-
'put': 'update',
110-
'patch': 'partial_update',
111-
'delete': 'destroy'
112-
},
113-
name='{basename}-detail',
114-
detail=True,
115-
initkwargs={'suffix': 'Instance'}
116-
),
117-
# Dynamically generated detail routes. Generated using
118-
# @action(detail=True) decorator on methods of the viewset.
119-
DynamicRoute(
120-
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
121-
name='{basename}-{url_name}',
122-
detail=True,
123-
initkwargs={}
124-
),
125-
]
83+
class AbstractSimpleRouter(BaseRouter):
84+
"""
85+
Base class for SimpleRouter and SimplePathRouter.
86+
"""
12687

12788
def __init__(self, trailing_slash=True):
12889
self.trailing_slash = '/' if trailing_slash else ''
@@ -203,6 +164,52 @@ def get_method_map(self, viewset, method_map):
203164
bound_methods[method] = action
204165
return bound_methods
205166

167+
168+
class SimpleRouter(AbstractSimpleRouter):
169+
170+
routes = [
171+
# List route.
172+
Route(
173+
url=r'^{prefix}{trailing_slash}$',
174+
mapping={
175+
'get': 'list',
176+
'post': 'create'
177+
},
178+
name='{basename}-list',
179+
detail=False,
180+
initkwargs={'suffix': 'List'}
181+
),
182+
# Dynamically generated list routes. Generated using
183+
# @action(detail=False) decorator on methods of the viewset.
184+
DynamicRoute(
185+
url=r'^{prefix}/{url_path}{trailing_slash}$',
186+
name='{basename}-{url_name}',
187+
detail=False,
188+
initkwargs={}
189+
),
190+
# Detail route.
191+
Route(
192+
url=r'^{prefix}/{lookup}{trailing_slash}$',
193+
mapping={
194+
'get': 'retrieve',
195+
'put': 'update',
196+
'patch': 'partial_update',
197+
'delete': 'destroy'
198+
},
199+
name='{basename}-detail',
200+
detail=True,
201+
initkwargs={'suffix': 'Instance'}
202+
),
203+
# Dynamically generated detail routes. Generated using
204+
# @action(detail=True) decorator on methods of the viewset.
205+
DynamicRoute(
206+
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
207+
name='{basename}-{url_name}',
208+
detail=True,
209+
initkwargs={}
210+
),
211+
]
212+
206213
def get_lookup_regex(self, viewset, lookup_prefix=''):
207214
"""
208215
Given a viewset, return the portion of URL regex that is used
@@ -270,6 +277,122 @@ def get_urls(self):
270277
return ret
271278

272279

280+
class SimplePathRouter(AbstractSimpleRouter):
281+
"""
282+
Router which uses Django 2.x path to build urls
283+
"""
284+
285+
routes = [
286+
# List route.
287+
Route(
288+
url='{prefix}{trailing_slash}',
289+
mapping={
290+
'get': 'list',
291+
'post': 'create'
292+
},
293+
name='{basename}-list',
294+
detail=False,
295+
initkwargs={'suffix': 'List'}
296+
),
297+
# Dynamically generated list routes. Generated using
298+
# @action(detail=False) decorator on methods of the viewset.
299+
DynamicRoute(
300+
url='{prefix}/{url_path}{trailing_slash}',
301+
name='{basename}-{url_name}',
302+
detail=False,
303+
initkwargs={}
304+
),
305+
# Detail route.
306+
Route(
307+
url='{prefix}/{lookup}{trailing_slash}',
308+
mapping={
309+
'get': 'retrieve',
310+
'put': 'update',
311+
'patch': 'partial_update',
312+
'delete': 'destroy'
313+
},
314+
name='{basename}-detail',
315+
detail=True,
316+
initkwargs={'suffix': 'Instance'}
317+
),
318+
# Dynamically generated detail routes. Generated using
319+
# @action(detail=True) decorator on methods of the viewset.
320+
DynamicRoute(
321+
url='{prefix}/{lookup}/{url_path}{trailing_slash}',
322+
name='{basename}-{url_name}',
323+
detail=True,
324+
initkwargs={}
325+
),
326+
]
327+
328+
def get_lookup_path(self, viewset, lookup_prefix=''):
329+
"""
330+
Given a viewset, return the portion of URL path that is used
331+
to match against a single instance.
332+
333+
Note that lookup_prefix is not used directly inside REST rest_framework
334+
itself, but is required in order to nicely support nested router
335+
implementations, such as drf-nested-routers.
336+
337+
https://github.com/alanjds/drf-nested-routers
338+
"""
339+
base_converter = '<{lookup_converter}:{lookup_prefix}{lookup_url_kwarg}>'
340+
# Use `pk` as default field, unset set. Default regex should not
341+
# consume `.json` style suffixes and should break at '/' boundaries.
342+
lookup_field = getattr(viewset, 'lookup_field', 'pk')
343+
lookup_url_kwarg = getattr(viewset, 'lookup_url_kwarg', None) or lookup_field
344+
lookup_converter = getattr(viewset, 'lookup_converter', 'path')
345+
return base_converter.format(
346+
lookup_prefix=lookup_prefix,
347+
lookup_url_kwarg=lookup_url_kwarg,
348+
lookup_converter=lookup_converter
349+
)
350+
351+
def get_urls(self):
352+
"""
353+
Use the registered viewsets to generate a list of URL patterns.
354+
"""
355+
assert path is not None, 'SimplePathRouter requires Django 2.x path'
356+
ret = []
357+
358+
for prefix, viewset, basename in self.registry:
359+
lookup = self.get_lookup_path(viewset)
360+
routes = self.get_routes(viewset)
361+
362+
for route in routes:
363+
364+
# Only actions which actually exist on the viewset will be bound
365+
mapping = self.get_method_map(viewset, route.mapping)
366+
if not mapping:
367+
continue
368+
369+
# Build the url pattern
370+
url_path = route.url.format(
371+
prefix=prefix,
372+
lookup=lookup,
373+
trailing_slash=self.trailing_slash
374+
)
375+
376+
# If there is no prefix, the first part of the url is probably
377+
# controlled by project's urls.py and the router is in an app,
378+
# so a slash in the beginning will (A) cause Django to give
379+
# warnings and (B) generate URLS that will require using '//'.
380+
if not prefix and url_path[0] == '/':
381+
url_path = url_path[1:]
382+
383+
initkwargs = route.initkwargs.copy()
384+
initkwargs.update({
385+
'basename': basename,
386+
'detail': route.detail,
387+
})
388+
389+
view = viewset.as_view(mapping, **initkwargs)
390+
name = route.name.format(basename=basename)
391+
ret.append(path(url_path, view, name=name))
392+
393+
return ret
394+
395+
273396
class APIRootView(views.APIView):
274397
"""
275398
The default basic root view for DefaultRouter

tests/test_routers.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import namedtuple
22

3+
import django
34
import pytest
45
from django.conf.urls import include, url
56
from django.core.exceptions import ImproperlyConfigured
@@ -8,11 +9,15 @@
89
from django.urls import resolve, reverse
910

1011
from rest_framework import permissions, serializers, viewsets
11-
from rest_framework.compat import get_regex_pattern
12+
from rest_framework.compat import get_regex_pattern, path
1213
from rest_framework.decorators import action
1314
from rest_framework.response import Response
14-
from rest_framework.routers import DefaultRouter, SimpleRouter
15-
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
15+
from rest_framework.routers import (
16+
DefaultRouter, SimplePathRouter, SimpleRouter
17+
)
18+
from rest_framework.test import (
19+
APIClient, APIRequestFactory, URLPatternsTestCase
20+
)
1621
from rest_framework.utils import json
1722

1823
factory = APIRequestFactory()
@@ -77,9 +82,25 @@ def regex_url_path_detail(self, request, *args, **kwargs):
7782
return Response({'pk': pk, 'kwarg': kwarg})
7883

7984

85+
class UrlPathViewSet(viewsets.ViewSet):
86+
@action(detail=False, url_path='list/<int:kwarg>')
87+
def url_path_list(self, request, *args, **kwargs):
88+
kwarg = self.kwargs.get('kwarg', '')
89+
return Response({'kwarg': kwarg})
90+
91+
@action(detail=True, url_path='detail/<int:kwarg>')
92+
def url_path_detail(self, request, *args, **kwargs):
93+
pk = self.kwargs.get('pk', '')
94+
kwarg = self.kwargs.get('kwarg', '')
95+
return Response({'pk': pk, 'kwarg': kwarg})
96+
97+
8098
notes_router = SimpleRouter()
8199
notes_router.register(r'notes', NoteViewSet)
82100

101+
notes_path_router = SimplePathRouter()
102+
notes_path_router.register('notes', NoteViewSet)
103+
83104
kwarged_notes_router = SimpleRouter()
84105
kwarged_notes_router.register(r'notes', KWargedNoteViewSet)
85106

@@ -92,6 +113,9 @@ def regex_url_path_detail(self, request, *args, **kwargs):
92113
regex_url_path_router = SimpleRouter()
93114
regex_url_path_router.register(r'', RegexUrlPathViewSet, basename='regex')
94115

116+
url_path_router = SimplePathRouter()
117+
url_path_router.register('', UrlPathViewSet, basename='path')
118+
95119

96120
class BasicViewSet(viewsets.ViewSet):
97121
def list(self, request, *args, **kwargs):
@@ -463,6 +487,69 @@ def test_regex_url_path_detail(self):
463487
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
464488

465489

490+
@pytest.mark.skipif(django.VERSION < (2, 0), reason='Django version < 2.0')
491+
class TestUrlPath(URLPatternsTestCase, TestCase):
492+
client_class = APIClient
493+
urlpatterns = [
494+
path('path/', include(url_path_router.urls)),
495+
path('example/', include(notes_path_router.urls))
496+
] if path else []
497+
498+
def setUp(self):
499+
RouterTestModel.objects.create(uuid='123', text='foo bar')
500+
RouterTestModel.objects.create(uuid='a b', text='baz qux')
501+
502+
def test_create(self):
503+
new_note = {
504+
'uuid': 'foo',
505+
'text': 'example'
506+
}
507+
response = self.client.post('/example/notes/', data=new_note)
508+
assert response.status_code == 201
509+
assert response['location'] == 'http://testserver/example/notes/foo/'
510+
assert response.data == {"url": "http://testserver/example/notes/foo/", "uuid": "foo", "text": "example"}
511+
assert RouterTestModel.objects.filter(uuid='foo').first() is not None
512+
513+
def test_retrieve(self):
514+
response = self.client.get('/example/notes/123/')
515+
assert response.status_code == 200
516+
assert response.data == {"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar"}
517+
518+
def test_list(self):
519+
response = self.client.get('/example/notes/')
520+
assert response.status_code == 200
521+
assert response.data == [
522+
{"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar"},
523+
{"url": "http://testserver/example/notes/a%20b/", "uuid": "a b", "text": "baz qux"},
524+
]
525+
526+
def test_update(self):
527+
updated_note = {
528+
'text': 'foo bar example'
529+
}
530+
response = self.client.patch('/example/notes/123/', data=updated_note)
531+
assert response.status_code == 200
532+
assert response.data == {"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar example"}
533+
534+
def test_delete(self):
535+
response = self.client.delete('/example/notes/123/')
536+
assert response.status_code == 204
537+
assert RouterTestModel.objects.filter(uuid='123').first() is None
538+
539+
def test_list_extra_action(self):
540+
kwarg = 1234
541+
response = self.client.get('/path/list/{}/'.format(kwarg))
542+
assert response.status_code == 200
543+
assert json.loads(response.content.decode()) == {'kwarg': kwarg}
544+
545+
def test_detail_extra_action(self):
546+
pk = '1'
547+
kwarg = 1234
548+
response = self.client.get('/path/{}/detail/{}/'.format(pk, kwarg))
549+
assert response.status_code == 200
550+
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
551+
552+
466553
class TestViewInitkwargs(URLPatternsTestCase, TestCase):
467554
urlpatterns = [
468555
url(r'^example/', include(notes_router.urls)),

0 commit comments

Comments
 (0)