This commit is contained in:
Ryan P Kilby 2018-02-22 21:22:50 +00:00 committed by GitHub
commit d4c89fec6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 390 additions and 49 deletions

View File

@ -339,7 +339,8 @@ class HyperlinkedRelatedField(RelatedField):
self.view_name, request
)
except AttributeError:
expected_viewname = self.view_name
# by default, expect the 'rest_framework' namespace
expected_viewname = 'rest_framework:' + self.view_name
if match.view_name != expected_viewname:
self.fail('incorrect_match')

View File

@ -34,10 +34,29 @@ def preserve_builtin_query_params(url, request=None):
def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
"""
Extends `django.urls.reverse` with behavior specific to rest framework.
The `viewname` will be prepended with the 'rest_framework' application
namespace if no namspace is included in the `viewname` argument. The
framework fundamentally assumes that the router urls will be included with
the 'rest_framework' namespace, so ensure that your root url patterns are
configured accordingly. Assuming you use the default router, you can check
this with:
from django.urls import reverse
reverse('rest_framework:api-root')
If versioning is being used then we pass any `reverse` calls through
to the versioning scheme instance, so that the resulting URL
can be modified if needed.
Optionally takes a `request` object (see `_reverse` for details).
"""
# prepend the 'rest_framework' application namespace
if ':' not in viewname:
viewname = 'rest_framework:' + viewname
scheme = getattr(request, 'versioning_scheme', None)
if scheme is not None:
try:
@ -56,14 +75,33 @@ def _reverse(viewname, args=None, kwargs=None, request=None, format=None, **extr
"""
Same as `django.urls.reverse`, but optionally takes a request
and returns a fully qualified URL, using the request to get the base URL.
Additionally, the request is used to determine the `current_app` instance.
"""
if format is not None:
kwargs = kwargs or {}
kwargs['format'] = format
if request:
extra.setdefault('current_app', current_app(request))
url = django_reverse(viewname, args=args, kwargs=kwargs, **extra)
if request:
return request.build_absolute_uri(url)
return url
def current_app(request):
"""
Get the current app for the request.
This code is copied from the URL tag.
"""
try:
return request.current_app
except AttributeError:
try:
return request.resolver_match.namespace
except AttributeError:
return None
reverse_lazy = lazy(reverse, six.text_type)

View File

@ -101,6 +101,10 @@ class BaseRouter(object):
self._urls = self.get_urls()
return self._urls
@property
def urlpatterns(self):
return (self.urls, 'rest_framework')
class SimpleRouter(BaseRouter):
@ -305,10 +309,7 @@ class APIRootView(views.APIView):
def get(self, request, *args, **kwargs):
# Return a plain {"name": "hyperlink"} response.
ret = OrderedDict()
namespace = request.resolver_match.namespace
for key, url_name in self.api_root_dict.items():
if namespace:
url_name = namespace + ':' + url_name
try:
ret[key] = reverse(
url_name,

View File

@ -96,7 +96,7 @@ def optional_login(request):
Include a login snippet if REST framework's login view is in the URLconf.
"""
try:
login_url = reverse('rest_framework:login')
login_url = reverse('rest_framework_auth:login')
except NoReverseMatch:
return ''
@ -112,7 +112,7 @@ def optional_docs_login(request):
Include a login snippet if REST framework's login view is in the URLconf.
"""
try:
login_url = reverse('rest_framework:login')
login_url = reverse('rest_framework_auth:login')
except NoReverseMatch:
return 'log in'
@ -128,7 +128,7 @@ def optional_logout(request, user):
Include a logout snippet if REST framework's logout view is in the URLconf.
"""
try:
logout_url = reverse('rest_framework:logout')
logout_url = reverse('rest_framework_auth:logout')
except NoReverseMatch:
snippet = format_html('<li class="navbar-text">{user}</li>', user=escape(user))
return mark_safe(snippet)

View File

@ -27,7 +27,7 @@ else:
logout = views.LogoutView.as_view()
app_name = 'rest_framework'
app_name = 'rest_framework_auth'
urlpatterns = [
url(r'^login/$', login, login_kwargs, name='login'),
url(r'^logout/$', logout, name='logout'),

View File

@ -135,6 +135,13 @@ class NamespaceVersioning(BaseVersioning):
)
def get_versioned_viewname(self, viewname, request):
"""
The incoming `viewname` should be prefixed with the 'rest_framework'
application namespace. We want to replace this with the version
instance namespace.
"""
if viewname.startswith('rest_framework:'):
viewname = viewname[len('rest_framework:'):]
return request.version + ':' + viewname

View File

@ -1,4 +1,4 @@
from django.conf.urls import url
from django.conf.urls import include, url
from django.db import models
from django.test import TestCase, override_settings
@ -28,10 +28,14 @@ def dummy_view(request):
pass
urlpatterns = [
patterns = [
url(r'^example/(?P<pk>[0-9]+)/$', dummy_view, name='example-detail'),
]
urlpatterns = [
url(r'^', include((patterns, 'rest_framework')))
]
@override_settings(ROOT_URLCONF='tests.test_lazy_hyperlinks')
class TestLazyHyperlinkNames(TestCase):

View File

@ -2,7 +2,7 @@ import uuid
import pytest
from _pytest.monkeypatch import MonkeyPatch
from django.conf.urls import url
from django.conf.urls import include, url
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings
from django.utils.datastructures import MultiValueDict
@ -146,7 +146,9 @@ class TestProxiedPrimaryKeyRelatedField(APISimpleTestCase):
@override_settings(ROOT_URLCONF=[
url(r'^example/(?P<name>.+)/$', lambda: None, name='example'),
url(r'^', include(
([url(r'^example/(?P<name>.+)/$', lambda: None, name='example')], 'rest_framework')
)),
])
class TestHyperlinkedRelatedField(APISimpleTestCase):
def setUp(self):

View File

@ -1,6 +1,6 @@
from __future__ import unicode_literals
from django.conf.urls import url
from django.conf.urls import include, url
from django.test import TestCase, override_settings
from rest_framework import serializers
@ -18,7 +18,7 @@ def dummy_view(request, pk):
pass
urlpatterns = [
patterns = [
url(r'^dummyurl/(?P<pk>[0-9]+)/$', dummy_view, name='dummy-url'),
url(r'^manytomanysource/(?P<pk>[0-9]+)/$', dummy_view, name='manytomanysource-detail'),
url(r'^manytomanytarget/(?P<pk>[0-9]+)/$', dummy_view, name='manytomanytarget-detail'),
@ -29,6 +29,10 @@ urlpatterns = [
url(r'^nullableonetoonesource/(?P<pk>[0-9]+)/$', dummy_view, name='nullableonetoonesource-detail'),
]
urlpatterns = [
url(r'^', include((patterns, 'rest_framework')))
]
# ManyToMany
class ManyToManyTargetSerializer(serializers.HyperlinkedModelSerializer):
@ -449,3 +453,39 @@ class HyperlinkedNullableOneToOneTests(TestCase):
{'url': 'http://testserver/onetoonetarget/2/', 'name': 'target-2', 'nullable_source': None},
]
assert serializer.data == expected
class HyperlinkedNamespaceTests(TestCase):
def setUp(self):
target = ForeignKeyTarget(name='target-1')
target.save()
new_target = ForeignKeyTarget(name='target-2')
new_target.save()
for idx in range(1, 4):
source = ForeignKeySource(name='source-%d' % idx, target=target)
source.save()
def results(self):
queryset = ForeignKeySource.objects.all()
serializer = ForeignKeySourceSerializer(queryset, many=True, context={'request': request})
expected = [
{'url': 'http://testserver/foreignkeysource/1/', 'name': 'source-1', 'target': 'http://testserver/foreignkeytarget/1/'},
{'url': 'http://testserver/foreignkeysource/2/', 'name': 'source-2', 'target': 'http://testserver/foreignkeytarget/1/'},
{'url': 'http://testserver/foreignkeysource/3/', 'name': 'source-3', 'target': 'http://testserver/foreignkeytarget/1/'}
]
with self.assertNumQueries(1):
assert serializer.data == expected
def test_foreign_key_retrieve_no_namespace(self):
url_conf = [
url(r'^', include((patterns, 'rest_framework'), namespace=None))
]
with override_settings(ROOT_URLCONF=url_conf):
self.results()
def test_foreign_key_retrieve_namespace(self):
url_conf = [
url(r'^', include((patterns, 'rest_framework'), namespace='api'))
]
with override_settings(ROOT_URLCONF=url_conf):
self.results()

View File

@ -1,21 +1,34 @@
from __future__ import unicode_literals
from django.conf.urls import url
from django.conf.urls import include, url
from django.http import HttpResponse
from django.test import TestCase, override_settings
from django.urls import NoReverseMatch
from rest_framework.reverse import reverse
from rest_framework.reverse import current_app, reverse
from rest_framework.test import APIRequestFactory
factory = APIRequestFactory()
def null_view(request):
pass
def mock_view(request):
return HttpResponse('')
apppatterns = ([
url(r'^home$', mock_view, name='home'),
], 'app')
apipatterns = ([
url(r'^root$', mock_view, name='root'),
], 'rest_framework')
urlpatterns = [
url(r'^view$', null_view, name='view'),
url(r'^view$', mock_view, name='view'),
url(r'^app2/', include(apppatterns, namespace='app2')),
url(r'^app1/', include(apppatterns, namespace='app1')),
url(r'^api/', include(apipatterns)),
]
@ -28,7 +41,7 @@ class MockVersioningScheme(object):
if self.raise_error:
raise NoReverseMatch()
return 'http://scheme-reversed/view'
return 'http://scheme-reversed/api/root'
@override_settings(ROOT_URLCONF='tests.test_reverse')
@ -36,21 +49,116 @@ class ReverseTests(TestCase):
"""
Tests for fully qualified URLs when using `reverse`.
"""
def test_reverse_non_drf_view(self):
request = factory.get('/api/root')
# DRF reverse should not match non-DRF views
with self.assertRaises(NoReverseMatch):
reverse('view', request=request)
def test_reversed_urls_are_fully_qualified(self):
request = factory.get('/view')
url = reverse('view', request=request)
assert url == 'http://testserver/view'
request = factory.get('/api/root')
url = reverse('root', request=request)
assert url == 'http://testserver/api/root'
def test_reverse_with_versioning_scheme(self):
request = factory.get('/view')
request = factory.get('/api/root')
request.versioning_scheme = MockVersioningScheme()
url = reverse('view', request=request)
assert url == 'http://scheme-reversed/view'
url = reverse('root', request=request)
assert url == 'http://scheme-reversed/api/root'
def test_reverse_with_versioning_scheme_fallback_to_default_on_error(self):
request = factory.get('/view')
request = factory.get('/api/root')
request.versioning_scheme = MockVersioningScheme(raise_error=True)
url = reverse('view', request=request)
assert url == 'http://testserver/view'
url = reverse('root', request=request)
assert url == 'http://testserver/api/root'
@override_settings(ROOT_URLCONF='tests.test_reverse')
class NamespaceTests(TestCase):
"""
Ensure reverse can handle namespaces.
Note: It's necessary to use self.client() here, as the
RequestFactory does not setup the resolver_match.
"""
def request(self, url):
return self.client.get(url).wsgi_request
def test_default_namespace(self):
url = reverse('root')
assert url == '/api/root'
def test_application_namespace(self):
url = reverse('app:home')
assert url == '/app1/home'
# instance namespace provided by current_app
url = reverse('app:home', current_app='app2')
assert url == '/app2/home'
def test_instance_namespace(self):
url = reverse('app1:home')
assert url == '/app1/home'
url = reverse('app2:home')
assert url == '/app2/home'
def test_application_namespace_with_request(self):
# request's current app affects result
request1 = self.request('/app1/home')
request2 = self.request('/app2/home')
# sanity check
assert current_app(request1) == 'app1'
assert current_app(request2) == 'app2'
assert reverse('app:home', request=request1) == 'http://testserver/app1/home'
assert reverse('app:home', request=request2) == 'http://testserver/app2/home'
def test_instance_namespace_with_request(self):
# request's current app is not relevant
request1 = self.request('/app1/home')
request2 = self.request('/app2/home')
# sanity check
assert current_app(request1) == 'app1'
assert current_app(request2) == 'app2'
assert reverse('app1:home', request=request1) == 'http://testserver/app1/home'
assert reverse('app2:home', request=request1) == 'http://testserver/app2/home'
assert reverse('app1:home', request=request2) == 'http://testserver/app1/home'
assert reverse('app2:home', request=request2) == 'http://testserver/app2/home'
@override_settings(ROOT_URLCONF='tests.test_reverse')
class CurrentAppTests(TestCase):
"""
Test current_app() function.
Note: It's necessary to use self.client() here, as the
RequestFactory does not setup the resolver_match.
"""
def request(self, url):
return self.client.get(url).wsgi_request
def test_no_namespace(self):
request = self.request('/view')
assert current_app(request) == ''
def test_namespace(self):
request = self.request('/app1/home')
assert current_app(request) == 'app1'
request = self.request('/app2/home')
assert current_app(request) == 'app2'
def test_factory_incompatibility(self):
request = factory.get('/app1/home')
assert current_app(request) is None

View File

@ -145,13 +145,17 @@ class TestSimpleRouter(TestCase):
class TestRootView(URLPatternsTestCase, TestCase):
urlpatterns = [
url(r'^non-namespaced/', include(namespaced_router.urls)),
url(r'^namespaced/', include((namespaced_router.urls, 'namespaced'), namespace='namespaced')),
url(r'^non-namespaced/', include(namespaced_router.urlpatterns)),
url(r'^namespaced1/', include(namespaced_router.urlpatterns, namespace='namespaced1')),
url(r'^namespaced2/', include(namespaced_router.urlpatterns, namespace='namespaced2')),
]
def test_retrieve_namespaced_root(self):
response = self.client.get('/namespaced/')
assert response.data == {"example": "http://testserver/namespaced/example/"}
response = self.client.get('/namespaced1/')
assert response.data == {"example": "http://testserver/namespaced1/example/"}
response = self.client.get('/namespaced2/')
assert response.data == {"example": "http://testserver/namespaced2/example/"}
def test_retrieve_non_namespaced_root(self):
response = self.client.get('/non-namespaced/')
@ -163,8 +167,8 @@ class TestCustomLookupFields(URLPatternsTestCase, TestCase):
Ensure that custom lookup fields are correctly routed.
"""
urlpatterns = [
url(r'^example/', include(notes_router.urls)),
url(r'^example2/', include(kwarged_notes_router.urls)),
url(r'^example/', include(notes_router.urlpatterns)),
url(r'^example2/', include(kwarged_notes_router.urlpatterns)),
]
def setUp(self):
@ -221,8 +225,8 @@ class TestLookupUrlKwargs(URLPatternsTestCase, TestCase):
Setup a deep lookup_field, but map it to a simple URL kwarg.
"""
urlpatterns = [
url(r'^example/', include(notes_router.urls)),
url(r'^example2/', include(kwarged_notes_router.urls)),
url(r'^example/', include(notes_router.urlpatterns)),
url(r'^example2/', include(kwarged_notes_router.urlpatterns)),
]
def setUp(self):
@ -410,7 +414,7 @@ class TestDynamicListAndDetailRouter(TestCase):
class TestEmptyPrefix(URLPatternsTestCase, TestCase):
urlpatterns = [
url(r'^empty-prefix/', include(empty_prefix_router.urls)),
url(r'^empty-prefix/', include(empty_prefix_router.urlpatterns)),
]
def test_empty_prefix_list(self):
@ -427,7 +431,7 @@ class TestEmptyPrefix(URLPatternsTestCase, TestCase):
class TestRegexUrlPath(URLPatternsTestCase, TestCase):
urlpatterns = [
url(r'^regex/', include(regex_url_path_router.urls)),
url(r'^regex/', include(regex_url_path_router.urlpatterns)),
]
def test_regex_url_path_list(self):

127
tests/test_urlconf.py Normal file
View File

@ -0,0 +1,127 @@
"""
Test rest framework assumptions about the configuration of the root urlconf.
"""
from django.conf.urls import include, url
from rest_framework import routers, viewsets
from rest_framework.reverse import NoReverseMatch, reverse
from rest_framework.test import APITestCase, URLPatternsTestCase
class MockViewSet(viewsets.ViewSet):
def list(self, request, *args, **kwargs):
pass
router = routers.DefaultRouter(trailing_slash=False)
router.register(r'example', MockViewSet, base_name='example')
class TestUrlsAppNameRequired(APITestCase, URLPatternsTestCase):
urlpatterns = [
url(r'^api/', include(router.urls)),
]
def test_reverse(self):
"""
The 'rest_framework' namespace must be present.
"""
with self.assertRaises(NoReverseMatch):
reverse('example-list')
class TestUrlpatternsAppName(APITestCase, URLPatternsTestCase):
urlpatterns = [
url(r'^api/', include(router.urlpatterns)),
]
def test_reverse(self):
self.assertEqual(reverse('example-list'), '/api/example')
def test_app_name_reverse(self):
self.assertEqual(reverse('rest_framework:example-list'), '/api/example')
class TestUrlpatternsNamespace(APITestCase, URLPatternsTestCase):
urlpatterns = [
url(r'^api/v1/', include(router.urlpatterns, namespace='v1')),
url(r'^api/v2/', include(router.urlpatterns, namespace='v2')),
]
def test_reverse(self):
self.assertEqual(reverse('example-list'), '/api/v2/example')
def test_app_name_reverse(self):
self.assertEqual(reverse('rest_framework:example-list'), '/api/v2/example')
def test_namespace_reverse(self):
self.assertEqual(reverse('v1:example-list'), '/api/v1/example')
self.assertEqual(reverse('v2:example-list'), '/api/v2/example')
class TestAppUrlpatternsAppName(APITestCase, URLPatternsTestCase):
apppatterns = ([
url(r'^api/', include(router.urlpatterns)),
], 'api')
urlpatterns = [
url(r'^', include(apppatterns)),
]
def test_reverse(self):
"""
Nesting the router.urlpatterns in an app with an app_name will
break url resolution.
"""
with self.assertRaises(NoReverseMatch):
reverse('example-list')
class TestAppUrlpatterns(APITestCase, URLPatternsTestCase):
apppatterns = ([
url(r'^api/', include(router.urlpatterns)),
], None)
urlpatterns = [
url(r'^', include(apppatterns)),
]
def test_reverse(self):
self.assertEqual(reverse('example-list'), '/api/example')
class TestAppUrlsAppName(APITestCase, URLPatternsTestCase):
apppatterns = ([
url(r'^api/', include(router.urls)),
], 'rest_framework')
urlpatterns = [
url(r'^', include(apppatterns)),
]
def test_reverse(self):
self.assertEqual(reverse('example-list'), '/api/example')
def test_app_name_reverse(self):
self.assertEqual(reverse('rest_framework:example-list'), '/api/example')
class TestAppUrlsNamespace(APITestCase, URLPatternsTestCase):
apppatterns = ([
url(r'^', include(router.urls)),
], 'rest_framework')
urlpatterns = [
url(r'^api/v1/', include(apppatterns, namespace='v1')),
url(r'^api/v2/', include(apppatterns, namespace='v2')),
]
def test_reverse(self):
self.assertEqual(reverse('example-list'), '/api/v2/example')
def test_app_name_reverse(self):
self.assertEqual(reverse('rest_framework:example-list'), '/api/v2/example')
def test_namespace_reverse(self):
self.assertEqual(reverse('v1:example-list'), '/api/v1/example')
self.assertEqual(reverse('v2:example-list'), '/api/v2/example')

View File

@ -143,17 +143,21 @@ class TestRequestVersion:
class TestURLReversing(URLPatternsTestCase, APITestCase):
included = [
versioned = [
url(r'^namespaced/$', dummy_view, name='another'),
url(r'^example/(?P<pk>\d+)/$', dummy_pk_view, name='example-detail')
]
urlpatterns = [
url(r'^v1/', include((included, 'v1'), namespace='v1')),
included = [
url(r'^another/$', dummy_view, name='another'),
url(r'^(?P<version>[v1|v2]+)/another/$', dummy_view, name='another'),
]
urlpatterns = [
url(r'^v1/', include((versioned, 'rest_framework'), namespace='v1')),
url(r'^', include((included, 'rest_framework'))),
]
def test_reverse_unversioned(self):
view = ReverseView.as_view()
@ -314,8 +318,8 @@ class TestHyperlinkedRelatedField(URLPatternsTestCase, APITestCase):
]
urlpatterns = [
url(r'^v1/', include((included, 'v1'), namespace='v1')),
url(r'^v2/', include((included, 'v2'), namespace='v2'))
url(r'^v1/', include((included, 'rest_framework'), namespace='v1')),
url(r'^v2/', include((included, 'rest_framework'), namespace='v2'))
]
def setUp(self):
@ -344,17 +348,21 @@ class TestNamespaceVersioningHyperlinkedRelatedFieldScheme(URLPatternsTestCase,
nested = [
url(r'^namespaced/(?P<pk>\d+)/$', dummy_pk_view, name='nested'),
]
included = [
versioned = [
url(r'^namespaced/(?P<pk>\d+)/$', dummy_pk_view, name='namespaced'),
url(r'^nested/', include((nested, 'nested-namespace'), namespace='nested-namespace'))
]
urlpatterns = [
url(r'^v1/', include((included, 'restframeworkv1'), namespace='v1')),
url(r'^v2/', include((included, 'restframeworkv2'), namespace='v2')),
included = [
url(r'^non-api/(?P<pk>\d+)/$', dummy_pk_view, name='non-api-view')
]
urlpatterns = [
url(r'^v1/', include((versioned, 'rest_framework'), namespace='v1')),
url(r'^v2/', include((versioned, 'rest_framework'), namespace='v2')),
url(r'^', include((included, 'rest_framework'))),
]
def _create_field(self, view_name, version):
request = factory.get("/")
request.versioning_scheme = NamespaceVersioning()
@ -381,6 +389,7 @@ class TestNamespaceVersioningHyperlinkedRelatedFieldScheme(URLPatternsTestCase,
def test_non_api_url_is_properly_reversed_regardless_of_the_version(self):
"""
Regression test for #2711
Note: non-api-views still need to be included in the 'rest_framework' namespace
"""
field = self._create_field('non-api-view', 'v1')
assert field.to_representation(PKOnlyObject(10)) == 'http://testserver/non-api/10/'

View File

@ -62,7 +62,7 @@ router.register(r'actions-alt', ActionViewSet, base_name='actions-alt')
urlpatterns = [
url(r'^api/', include(router.urls)),
url(r'^api/', include(router.urlpatterns)),
]