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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy