Skip to content

Commit a4e68d6

Browse files
committed
Add SimplePathRouter
1 parent 372f4fd commit a4e68d6

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
@@ -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,122 @@ 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+
assert path is not None, 'SimplePathRouter requires Django 2.x path'
376+
ret = []
377+
378+
for prefix, viewset, basename in self.registry:
379+
lookup = self.get_lookup_path(viewset)
380+
routes = self.get_routes(viewset)
381+
382+
for route in routes:
383+
384+
# Only actions which actually exist on the viewset will be bound
385+
mapping = self.get_method_map(viewset, route.mapping)
386+
if not mapping:
387+
continue
388+
389+
# Build the url pattern
390+
url_path = route.url.format(
391+
prefix=prefix,
392+
lookup=lookup,
393+
trailing_slash=self.trailing_slash
394+
)
395+
396+
# If there is no prefix, the first part of the url is probably
397+
# controlled by project's urls.py and the router is in an app,
398+
# so a slash in the beginning will (A) cause Django to give
399+
# warnings and (B) generate URLS that will require using '//'.
400+
if not prefix and url_path[0] == '/':
401+
url_path = url_path[1:]
402+
403+
initkwargs = route.initkwargs.copy()
404+
initkwargs.update({
405+
'basename': basename,
406+
'detail': route.detail,
407+
})
408+
409+
view = viewset.as_view(mapping, **initkwargs)
410+
name = route.name.format(basename=basename)
411+
ret.append(path(url_path, view, name=name))
412+
413+
return ret
414+
415+
293416
class APIRootView(views.APIView):
294417
"""
295418
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
@@ -2,6 +2,7 @@
22
from collections import namedtuple
33

44
import pytest
5+
import django
56
from django.conf.urls import include, url
67
from django.core.exceptions import ImproperlyConfigured
78
from django.db import models
@@ -11,11 +12,15 @@
1112
from rest_framework import (
1213
RemovedInDRF311Warning, permissions, serializers, viewsets
1314
)
14-
from rest_framework.compat import get_regex_pattern
15+
from rest_framework.compat import get_regex_pattern, path
1516
from rest_framework.decorators import action
1617
from rest_framework.response import Response
17-
from rest_framework.routers import DefaultRouter, SimpleRouter
18-
from rest_framework.test import APIRequestFactory, URLPatternsTestCase
18+
from rest_framework.routers import (
19+
DefaultRouter, SimplePathRouter, SimpleRouter
20+
)
21+
from rest_framework.test import (
22+
APIClient, APIRequestFactory, URLPatternsTestCase
23+
)
1924
from rest_framework.utils import json
2025

2126
factory = APIRequestFactory()
@@ -80,9 +85,25 @@ def regex_url_path_detail(self, request, *args, **kwargs):
8085
return Response({'pk': pk, 'kwarg': kwarg})
8186

8287

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

104+
notes_path_router = SimplePathRouter()
105+
notes_path_router.register('notes', NoteViewSet)
106+
86107
kwarged_notes_router = SimpleRouter()
87108
kwarged_notes_router.register(r'notes', KWargedNoteViewSet)
88109

@@ -95,6 +116,9 @@ def regex_url_path_detail(self, request, *args, **kwargs):
95116
regex_url_path_router = SimpleRouter()
96117
regex_url_path_router.register(r'', RegexUrlPathViewSet, basename='regex')
97118

119+
url_path_router = SimplePathRouter()
120+
url_path_router.register('', UrlPathViewSet, basename='path')
121+
98122

99123
class BasicViewSet(viewsets.ViewSet):
100124
def list(self, request, *args, **kwargs):
@@ -466,6 +490,69 @@ def test_regex_url_path_detail(self):
466490
assert json.loads(response.content.decode()) == {'pk': pk, 'kwarg': kwarg}
467491

468492

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

0 commit comments

Comments
 (0)