Skip to content

Commit 2d19f23

Browse files
sevdogauvipymerwok
authored
Add SimplePathRouter (#6789)
* Allow usage of Django 2.x path in SimpleRouter * Use path in Default router * Update docs/api-guide/routers.md Co-authored-by: Éric <[email protected]> * Update docs/api-guide/routers.md Co-authored-by: Éric <[email protected]> * Add tests also for default router with path * Use a more relevant attribute for lookup when using path converters Co-authored-by: Asif Saif Uddin <[email protected]> Co-authored-by: Éric <[email protected]>
1 parent 2b34aa4 commit 2d19f23

File tree

3 files changed

+153
-11
lines changed

3 files changed

+153
-11
lines changed

docs/api-guide/routers.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,23 @@ This behavior can be modified by setting the `trailing_slash` argument to `False
167167

168168
Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style.
169169

170-
The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_value_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs:
170+
By default the URLs created by `SimpleRouter` use regular expressions. This behavior can be modified by setting the `use_regex_path` argument to `False` when instantiating the router, in this case [path converters][path-converters-topic-reference] are used. For example:
171+
172+
router = SimpleRouter(use_regex_path=False)
173+
174+
**Note**: `use_regex_path=False` only works with Django 2.x or above, since this feature was introduced in 2.0.0. See [release note][simplified-routing-release-note]
175+
176+
177+
The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_value_regex` attribute on the viewset or `lookup_value_converter` if using path converters. For example, you can limit the lookup to valid UUIDs:
171178

172179
class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
173180
lookup_field = 'my_model_id'
174181
lookup_value_regex = '[0-9a-f]{32}'
175182

183+
class MyPathModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
184+
lookup_field = 'my_model_uuid'
185+
lookup_value_converter = 'uuid'
186+
176187
## DefaultRouter
177188

178189
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 +351,5 @@ The [`DRF-extensions` package][drf-extensions] provides [routers][drf-extensions
340351
[drf-extensions-customizable-endpoint-names]: https://chibisov.github.io/drf-extensions/docs/#controller-endpoint-name
341352
[url-namespace-docs]: https://docs.djangoproject.com/en/4.0/topics/http/urls/#url-namespaces
342353
[include-api-reference]: https://docs.djangoproject.com/en/4.0/ref/urls/#include
354+
[simplified-routing-release-note]: https://docs.djangoproject.com/en/2.0/releases/2.0/#simplified-url-routing-syntax
355+
[path-converters-topic-reference]: https://docs.djangoproject.com/en/2.0/topics/http/urls/#path-converters

rest_framework/routers.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from collections import OrderedDict, namedtuple
1818

1919
from django.core.exceptions import ImproperlyConfigured
20-
from django.urls import NoReverseMatch, re_path
20+
from django.urls import NoReverseMatch, path, re_path
2121

2222
from rest_framework import views
2323
from rest_framework.response import Response
@@ -135,8 +135,29 @@ class SimpleRouter(BaseRouter):
135135
),
136136
]
137137

138-
def __init__(self, trailing_slash=True):
138+
def __init__(self, trailing_slash=True, use_regex_path=True):
139139
self.trailing_slash = '/' if trailing_slash else ''
140+
self._use_regex = use_regex_path
141+
if use_regex_path:
142+
self._base_pattern = '(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})'
143+
self._default_value_pattern = '[^/.]+'
144+
self._url_conf = re_path
145+
else:
146+
self._base_pattern = '<{lookup_value}:{lookup_prefix}{lookup_url_kwarg}>'
147+
self._default_value_pattern = 'path'
148+
self._url_conf = path
149+
# remove regex characters from routes
150+
_routes = []
151+
for route in self.routes:
152+
url_param = route.url
153+
if url_param[0] == '^':
154+
url_param = url_param[1:]
155+
if url_param[-1] == '$':
156+
url_param = url_param[:-1]
157+
158+
_routes.append(route._replace(url=url_param))
159+
self.routes = _routes
160+
140161
super().__init__()
141162

142163
def get_default_basename(self, viewset):
@@ -225,13 +246,18 @@ def get_lookup_regex(self, viewset, lookup_prefix=''):
225246
226247
https://github.com/alanjds/drf-nested-routers
227248
"""
228-
base_regex = '(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})'
229249
# Use `pk` as default field, unset set. Default regex should not
230250
# consume `.json` style suffixes and should break at '/' boundaries.
231251
lookup_field = getattr(viewset, 'lookup_field', 'pk')
232252
lookup_url_kwarg = getattr(viewset, 'lookup_url_kwarg', None) or lookup_field
233-
lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+')
234-
return base_regex.format(
253+
lookup_value = None
254+
if not self._use_regex:
255+
# try to get a more appropriate attribute when not using regex
256+
lookup_value = getattr(viewset, 'lookup_value_converter', None)
257+
if lookup_value is None:
258+
# fallback to legacy
259+
lookup_value = getattr(viewset, 'lookup_value_regex', self._default_value_pattern)
260+
return self._base_pattern.format(
235261
lookup_prefix=lookup_prefix,
236262
lookup_url_kwarg=lookup_url_kwarg,
237263
lookup_value=lookup_value
@@ -265,8 +291,12 @@ def get_urls(self):
265291
# controlled by project's urls.py and the router is in an app,
266292
# so a slash in the beginning will (A) cause Django to give
267293
# warnings and (B) generate URLS that will require using '//'.
268-
if not prefix and regex[:2] == '^/':
269-
regex = '^' + regex[2:]
294+
if not prefix:
295+
if self._url_conf is path:
296+
if regex[0] == '/':
297+
regex = regex[1:]
298+
elif regex[:2] == '^/':
299+
regex = '^' + regex[2:]
270300

271301
initkwargs = route.initkwargs.copy()
272302
initkwargs.update({
@@ -276,7 +306,7 @@ def get_urls(self):
276306

277307
view = viewset.as_view(mapping, **initkwargs)
278308
name = route.name.format(basename=basename)
279-
ret.append(re_path(regex, view, name=name))
309+
ret.append(self._url_conf(regex, view, name=name))
280310

281311
return ret
282312

@@ -351,7 +381,7 @@ def get_urls(self):
351381

352382
if self.include_root_view:
353383
view = self.get_api_root_view(api_urls=urls)
354-
root_url = re_path(r'^$', view, name=self.root_view_name)
384+
root_url = path('', view, name=self.root_view_name)
355385
urls.append(root_url)
356386

357387
if self.include_format_suffixes:

tests/test_routers.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from rest_framework.decorators import action
1111
from rest_framework.response import Response
1212
from rest_framework.routers import DefaultRouter, SimpleRouter
13-
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
13+
from rest_framework.test import (
14+
APIClient, APIRequestFactory, URLPatternsTestCase
15+
)
1416
from rest_framework.utils import json
1517

1618
factory = APIRequestFactory()
@@ -85,9 +87,28 @@ def regex_url_path_detail(self, request, *args, **kwargs):
8587
return Response({'pk': pk, 'kwarg': kwarg})
8688

8789

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

106+
notes_path_router = SimpleRouter(use_regex_path=False)
107+
notes_path_router.register('notes', NoteViewSet)
108+
109+
notes_path_default_router = DefaultRouter(use_regex_path=False)
110+
notes_path_default_router.register('notes', NoteViewSet)
111+
91112
kwarged_notes_router = SimpleRouter()
92113
kwarged_notes_router.register(r'notes', KWargedNoteViewSet)
93114

@@ -100,6 +121,9 @@ def regex_url_path_detail(self, request, *args, **kwargs):
100121
regex_url_path_router = SimpleRouter()
101122
regex_url_path_router.register(r'', RegexUrlPathViewSet, basename='regex')
102123

124+
url_path_router = SimpleRouter(use_regex_path=False)
125+
url_path_router.register('', UrlPathViewSet, basename='path')
126+
103127

104128
class BasicViewSet(viewsets.ViewSet):
105129
def list(self, request, *args, **kwargs):
@@ -469,6 +493,81 @@ def test_regex_url_path_detail(self):
469493
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
470494

471495

496+
class TestUrlPath(URLPatternsTestCase, TestCase):
497+
client_class = APIClient
498+
urlpatterns = [
499+
path('path/', include(url_path_router.urls)),
500+
path('default/', include(notes_path_default_router.urls)),
501+
path('example/', include(notes_path_router.urls)),
502+
]
503+
504+
def setUp(self):
505+
RouterTestModel.objects.create(uuid='123', text='foo bar')
506+
RouterTestModel.objects.create(uuid='a b', text='baz qux')
507+
508+
def test_create(self):
509+
new_note = {
510+
'uuid': 'foo',
511+
'text': 'example'
512+
}
513+
response = self.client.post('/example/notes/', data=new_note)
514+
assert response.status_code == 201
515+
assert response['location'] == 'http://testserver/example/notes/foo/'
516+
assert response.data == {"url": "http://testserver/example/notes/foo/", "uuid": "foo", "text": "example"}
517+
assert RouterTestModel.objects.filter(uuid='foo').exists()
518+
519+
def test_retrieve(self):
520+
for url in ('/example/notes/123/', '/default/notes/123/'):
521+
with self.subTest(url=url):
522+
response = self.client.get(url)
523+
assert response.status_code == 200
524+
# only gets example path since was the last to be registered
525+
assert response.data == {"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar"}
526+
527+
def test_list(self):
528+
for url in ('/example/notes/', '/default/notes/'):
529+
with self.subTest(url=url):
530+
response = self.client.get(url)
531+
assert response.status_code == 200
532+
# only gets example path since was the last to be registered
533+
assert response.data == [
534+
{"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar"},
535+
{"url": "http://testserver/example/notes/a%20b/", "uuid": "a b", "text": "baz qux"},
536+
]
537+
538+
def test_update(self):
539+
updated_note = {
540+
'text': 'foo bar example'
541+
}
542+
response = self.client.patch('/example/notes/123/', data=updated_note)
543+
assert response.status_code == 200
544+
assert response.data == {"url": "http://testserver/example/notes/123/", "uuid": "123", "text": "foo bar example"}
545+
546+
def test_delete(self):
547+
response = self.client.delete('/example/notes/123/')
548+
assert response.status_code == 204
549+
assert not RouterTestModel.objects.filter(uuid='123').exists()
550+
551+
def test_list_extra_action(self):
552+
kwarg = 1234
553+
response = self.client.get('/path/list/{}/'.format(kwarg))
554+
assert response.status_code == 200
555+
assert json.loads(response.content.decode()) == {'kwarg': kwarg}
556+
557+
def test_detail_extra_action(self):
558+
pk = '1'
559+
kwarg = 1234
560+
response = self.client.get('/path/{}/detail/{}/'.format(pk, kwarg))
561+
assert response.status_code == 200
562+
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
563+
564+
def test_defaultrouter_root(self):
565+
response = self.client.get('/default/')
566+
assert response.status_code == 200
567+
# only gets example path since was the last to be registered
568+
assert response.data == {"notes": "http://testserver/example/notes/"}
569+
570+
472571
class TestViewInitkwargs(URLPatternsTestCase, TestCase):
473572
urlpatterns = [
474573
path('example/', include(notes_router.urls)),

0 commit comments

Comments
 (0)