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)
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