Skip to content

Commit c4c09ef

Browse files
committed
Add SimplePathRouter
1 parent 372f4fd commit c4c09ef

File tree

3 files changed

+262
-47
lines changed

3 files changed

+262
-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: 166 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from django.utils.deprecation import RenameMethodsBase
2424

2525
from rest_framework import RemovedInDRF311Warning, views
26+
from rest_framework.compat import path
2627
from rest_framework.response import Response
2728
from rest_framework.reverse import reverse
2829
from rest_framework.schemas import SchemaGenerator
@@ -99,50 +100,10 @@ def urls(self):
99100
return self._urls
100101

101102

102-
class SimpleRouter(BaseRouter):
103-
104-
routes = [
105-
# List route.
106-
Route(
107-
url=r'^{prefix}{trailing_slash}$',
108-
mapping={
109-
'get': 'list',
110-
'post': 'create'
111-
},
112-
name='{basename}-list',
113-
detail=False,
114-
initkwargs={'suffix': 'List'}
115-
),
116-
# Dynamically generated list routes. Generated using
117-
# @action(detail=False) decorator on methods of the viewset.
118-
DynamicRoute(
119-
url=r'^{prefix}/{url_path}{trailing_slash}$',
120-
name='{basename}-{url_name}',
121-
detail=False,
122-
initkwargs={}
123-
),
124-
# Detail route.
125-
Route(
126-
url=r'^{prefix}/{lookup}{trailing_slash}$',
127-
mapping={
128-
'get': 'retrieve',
129-
'put': 'update',
130-
'patch': 'partial_update',
131-
'delete': 'destroy'
132-
},
133-
name='{basename}-detail',
134-
detail=True,
135-
initkwargs={'suffix': 'Instance'}
136-
),
137-
# Dynamically generated detail routes. Generated using
138-
# @action(detail=True) decorator on methods of the viewset.
139-
DynamicRoute(
140-
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
141-
name='{basename}-{url_name}',
142-
detail=True,
143-
initkwargs={}
144-
),
145-
]
103+
class AbstractSimpleRouter(BaseRouter):
104+
"""
105+
Base class for SimpleRouter and SimplePathRouter.
106+
"""
146107

147108
def __init__(self, trailing_slash=True):
148109
self.trailing_slash = '/' if trailing_slash else ''
@@ -223,6 +184,52 @@ def get_method_map(self, viewset, method_map):
223184
bound_methods[method] = action
224185
return bound_methods
225186

187+
188+
class SimpleRouter(AbstractSimpleRouter):
189+
190+
routes = [
191+
# List route.
192+
Route(
193+
url=r'^{prefix}{trailing_slash}$',
194+
mapping={
195+
'get': 'list',
196+
'post': 'create'
197+
},
198+
name='{basename}-list',
199+
detail=False,
200+
initkwargs={'suffix': 'List'}
201+
),
202+
# Dynamically generated list routes. Generated using
203+
# @action(detail=False) decorator on methods of the viewset.
204+
DynamicRoute(
205+
url=r'^{prefix}/{url_path}{trailing_slash}$',
206+
name='{basename}-{url_name}',
207+
detail=False,
208+
initkwargs={}
209+
),
210+
# Detail route.
211+
Route(
212+
url=r'^{prefix}/{lookup}{trailing_slash}$',
213+
mapping={
214+
'get': 'retrieve',
215+
'put': 'update',
216+
'patch': 'partial_update',
217+
'delete': 'destroy'
218+
},
219+
name='{basename}-detail',
220+
detail=True,
221+
initkwargs={'suffix': 'Instance'}
222+
),
223+
# Dynamically generated detail routes. Generated using
224+
# @action(detail=True) decorator on methods of the viewset.
225+
DynamicRoute(
226+
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
227+
name='{basename}-{url_name}',
228+
detail=True,
229+
initkwargs={}
230+
),
231+
]
232+
226233
def get_lookup_regex(self, viewset, lookup_prefix=''):
227234
"""
228235
Given a viewset, return the portion of URL regex that is used
@@ -290,6 +297,121 @@ def get_urls(self):
290297
return ret
291298

292299

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

tests/test_routers.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111
from rest_framework import (
1212
RemovedInDRF311Warning, permissions, serializers, viewsets
1313
)
14-
from rest_framework.compat import get_regex_pattern
14+
from rest_framework.compat import get_regex_pattern, path
1515
from rest_framework.decorators import action
1616
from rest_framework.response import Response
17-
from rest_framework.routers import DefaultRouter, SimpleRouter
18-
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
17+
from rest_framework.routers import (
18+
DefaultRouter, SimplePathRouter, SimpleRouter
19+
)
20+
from rest_framework.test import (
21+
APIClient, APIRequestFactory, URLPatternsTestCase
22+
)
1923
from rest_framework.utils import json
2024

2125
factory = APIRequestFactory()
@@ -80,9 +84,25 @@ def regex_url_path_detail(self, request, *args, **kwargs):
8084
return Response({'pk': pk, 'kwarg': kwarg})
8185

8286

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

103+
notes_path_router = SimplePathRouter()
104+
notes_path_router.register('notes', NoteViewSet)
105+
86106
kwarged_notes_router = SimpleRouter()
87107
kwarged_notes_router.register(r'notes', KWargedNoteViewSet)
88108

@@ -95,6 +115,9 @@ def regex_url_path_detail(self, request, *args, **kwargs):
95115
regex_url_path_router = SimpleRouter()
96116
regex_url_path_router.register(r'', RegexUrlPathViewSet, basename='regex')
97117

118+
url_path_router = SimplePathRouter()
119+
url_path_router.register('', UrlPathViewSet, basename='path')
120+
98121

99122
class BasicViewSet(viewsets.ViewSet):
100123
def list(self, request, *args, **kwargs):
@@ -466,6 +489,68 @@ def test_regex_url_path_detail(self):
466489
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
467490

468491

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

0 commit comments

Comments
 (0)