Skip to content

Commit 4696b19

Browse files
committed
Allow usage of Django 2.x path in SimpleRouter
1 parent de497a9 commit 4696b19

File tree

3 files changed

+127
-9
lines changed

3 files changed

+127
-9
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+
By default the URLs created by `SimpleRouter` uses _regexs_ to build urls. This behavior can be modified by setting the `use_regex_path` argument to `False` when instantiating the router, in this case [path converters][path-convertes-topic-reference] are used. For example:
177+
178+
router = SimpleRouter(use_regex_path=False)
179+
180+
**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]
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: 32 additions & 7 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
@@ -124,8 +125,28 @@ class SimpleRouter(BaseRouter):
124125
),
125126
]
126127

127-
def __init__(self, trailing_slash=True):
128+
def __init__(self, trailing_slash=True, use_regex_path=True):
128129
self.trailing_slash = '/' if trailing_slash else ''
130+
if use_regex_path:
131+
self._base_regex = '(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})'
132+
self._default_regex = '[^/.]+'
133+
self._url_conf = url
134+
else:
135+
self._base_regex = '<{lookup_value}:{lookup_prefix}{lookup_url_kwarg}>'
136+
self._default_regex = 'path'
137+
self._url_conf = path
138+
# remove regex characters from routes
139+
_routes = []
140+
for route in self.routes:
141+
url_param = route.url
142+
if url_param[0] == '^':
143+
url_param = url_param[1:]
144+
if url_param[-1] == '$':
145+
url_param = url_param[:-1]
146+
147+
_routes.append(route._replace(url=url_param))
148+
self.routes = _routes
149+
129150
super().__init__()
130151

131152
def get_default_basename(self, viewset):
@@ -214,13 +235,12 @@ def get_lookup_regex(self, viewset, lookup_prefix=''):
214235
215236
https://github.com/alanjds/drf-nested-routers
216237
"""
217-
base_regex = '(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})'
218238
# Use `pk` as default field, unset set. Default regex should not
219239
# consume `.json` style suffixes and should break at '/' boundaries.
220240
lookup_field = getattr(viewset, 'lookup_field', 'pk')
221241
lookup_url_kwarg = getattr(viewset, 'lookup_url_kwarg', None) or lookup_field
222-
lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+')
223-
return base_regex.format(
242+
lookup_value = getattr(viewset, 'lookup_value_regex', self._default_regex)
243+
return self._base_regex.format(
224244
lookup_prefix=lookup_prefix,
225245
lookup_url_kwarg=lookup_url_kwarg,
226246
lookup_value=lookup_value
@@ -230,6 +250,7 @@ def get_urls(self):
230250
"""
231251
Use the registered viewsets to generate a list of URL patterns.
232252
"""
253+
assert self._url_conf is not None, 'SimpleRouter requires Django 2.x when using path'
233254
ret = []
234255

235256
for prefix, viewset, basename in self.registry:
@@ -254,8 +275,12 @@ def get_urls(self):
254275
# controlled by project's urls.py and the router is in an app,
255276
# so a slash in the beginning will (A) cause Django to give
256277
# warnings and (B) generate URLS that will require using '//'.
257-
if not prefix and regex[:2] == '^/':
258-
regex = '^' + regex[2:]
278+
if not prefix:
279+
if self._url_conf is path:
280+
if regex[0] == '/':
281+
regex = regex[1:]
282+
elif regex[:2] == '^/':
283+
regex = '^' + regex[2:]
259284

260285
initkwargs = route.initkwargs.copy()
261286
initkwargs.update({
@@ -265,7 +290,7 @@ def get_urls(self):
265290

266291
view = viewset.as_view(mapping, **initkwargs)
267292
name = route.name.format(basename=basename)
268-
ret.append(url(regex, view, name=name))
293+
ret.append(self._url_conf(regex, view, name=name))
269294

270295
return ret
271296

tests/test_routers.py

Lines changed: 87 additions & 2 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,13 @@
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
1415
from rest_framework.routers import DefaultRouter, SimpleRouter
15-
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
16+
from rest_framework.test import (
17+
APIClient, APIRequestFactory, URLPatternsTestCase
18+
)
1619
from rest_framework.utils import json
1720

1821
factory = APIRequestFactory()
@@ -77,9 +80,25 @@ def regex_url_path_detail(self, request, *args, **kwargs):
7780
return Response({'pk': pk, 'kwarg': kwarg})
7881

7982

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

99+
notes_path_router = SimpleRouter(use_regex_path=False)
100+
notes_path_router.register('notes', NoteViewSet)
101+
83102
kwarged_notes_router = SimpleRouter()
84103
kwarged_notes_router.register(r'notes', KWargedNoteViewSet)
85104

@@ -92,6 +111,9 @@ def regex_url_path_detail(self, request, *args, **kwargs):
92111
regex_url_path_router = SimpleRouter()
93112
regex_url_path_router.register(r'', RegexUrlPathViewSet, basename='regex')
94113

114+
url_path_router = SimpleRouter(use_regex_path=False)
115+
url_path_router.register('', UrlPathViewSet, basename='path')
116+
95117

96118
class BasicViewSet(viewsets.ViewSet):
97119
def list(self, request, *args, **kwargs):
@@ -463,6 +485,69 @@ def test_regex_url_path_detail(self):
463485
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
464486

465487

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

0 commit comments

Comments
 (0)