django-rest-framework/rest_framework/routers.py

391 lines
14 KiB
Python
Raw Permalink Normal View History

2013-04-25 01:40:24 +04:00
"""
Routers provide a convenient and consistent way of automatically
determining the URL conf for your API.
They are used by simply instantiating a Router class, and then registering
all the required ViewSets with that router.
For example, you might have a `urls.py` that looks something like this:
router = routers.DefaultRouter()
router.register('users', UserViewSet, 'user')
router.register('accounts', AccountViewSet, 'account')
urlpatterns = router.urls
"""
import itertools
2015-09-22 17:35:38 +03:00
from collections import OrderedDict, namedtuple
from django.core.exceptions import ImproperlyConfigured
from django.urls import NoReverseMatch, path, re_path
from rest_framework import views
from rest_framework.response import Response
2015-06-25 23:55:51 +03:00
from rest_framework.reverse import reverse
from rest_framework.schemas import SchemaGenerator
from rest_framework.schemas.views import SchemaView
from rest_framework.settings import api_settings
2013-04-25 01:40:24 +04:00
from rest_framework.urlpatterns import format_suffix_patterns
2013-04-04 23:00:44 +04:00
Route = namedtuple('Route', ['url', 'mapping', 'name', 'detail', 'initkwargs'])
DynamicRoute = namedtuple('DynamicRoute', ['url', 'name', 'detail', 'initkwargs'])
2017-05-29 14:55:06 +03:00
def escape_curly_brackets(url_path):
"""
Double brackets in regex of url_path for escape string formatting
"""
return url_path.replace('{', '{{').replace('}', '}}')
def flatten(list_of_lists):
"""
Takes an iterable of iterables, returns a single iterable containing all items
"""
return itertools.chain(*list_of_lists)
class BaseRouter:
2013-04-04 23:00:44 +04:00
def __init__(self):
self.registry = []
def register(self, prefix, viewset, basename=None):
if basename is None:
basename = self.get_default_basename(viewset)
if self.is_already_registered(basename):
msg = (f'Router with basename "{basename}" is already registered. '
f'Please provide a unique basename for viewset "{viewset}"')
raise ImproperlyConfigured(msg)
self.registry.append((prefix, viewset, basename))
# invalidate the urls cache
if hasattr(self, '_urls'):
del self._urls
def is_already_registered(self, new_basename):
"""
Check if `basename` is already registered
"""
return any(basename == new_basename for _prefix, _viewset, basename in self.registry)
def get_default_basename(self, viewset):
"""
If `basename` is not specified, attempt to automatically determine
it from the viewset.
"""
raise NotImplementedError('get_default_basename must be overridden')
2013-04-04 23:00:44 +04:00
2013-04-25 01:40:24 +04:00
def get_urls(self):
"""
Return a list of URL patterns, given the registered viewsets.
"""
2015-01-19 17:48:13 +03:00
raise NotImplementedError('get_urls must be overridden')
2013-04-04 23:00:44 +04:00
@property
2013-04-25 01:40:24 +04:00
def urls(self):
if not hasattr(self, '_urls'):
self._urls = self.get_urls()
2013-04-25 01:40:24 +04:00
return self._urls
class SimpleRouter(BaseRouter):
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
2013-04-25 01:40:24 +04:00
routes = [
# List route.
Route(
url=r'^{prefix}{trailing_slash}$',
mapping={
2013-04-25 01:40:24 +04:00
'get': 'list',
'post': 'create'
},
name='{basename}-list',
detail=False,
initkwargs={'suffix': 'List'}
2013-04-25 01:40:24 +04:00
),
# Dynamically generated list routes. Generated using
# @action(detail=False) decorator on methods of the viewset.
DynamicRoute(
url=r'^{prefix}/{url_path}{trailing_slash}$',
name='{basename}-{url_name}',
detail=False,
initkwargs={}
),
2013-04-25 01:40:24 +04:00
# Detail route.
Route(
url=r'^{prefix}/{lookup}{trailing_slash}$',
mapping={
2013-04-25 01:40:24 +04:00
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
},
name='{basename}-detail',
detail=True,
initkwargs={'suffix': 'Instance'}
2013-04-25 01:40:24 +04:00
),
# Dynamically generated detail routes. Generated using
# @action(detail=True) decorator on methods of the viewset.
DynamicRoute(
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
name='{basename}-{url_name}',
detail=True,
initkwargs={}
2013-04-25 01:40:24 +04:00
),
2013-04-04 23:00:44 +04:00
]
def __init__(self, trailing_slash=True, use_regex_path=True):
self.trailing_slash = '/' if trailing_slash else ''
self._use_regex = use_regex_path
if use_regex_path:
self._base_pattern = '(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})'
self._default_value_pattern = '[^/.]+'
self._url_conf = re_path
else:
self._base_pattern = '<{lookup_value}:{lookup_prefix}{lookup_url_kwarg}>'
self._default_value_pattern = 'path'
self._url_conf = path
# remove regex characters from routes
_routes = []
for route in self.routes:
url_param = route.url
if url_param[0] == '^':
url_param = url_param[1:]
if url_param[-1] == '$':
url_param = url_param[:-1]
_routes.append(route._replace(url=url_param))
self.routes = _routes
super().__init__()
def get_default_basename(self, viewset):
"""
If `basename` is not specified, attempt to automatically determine
it from the viewset.
"""
queryset = getattr(viewset, 'queryset', None)
assert queryset is not None, '`basename` argument not specified, and could ' \
'not automatically determine the name from the viewset, as ' \
'it does not have a `.queryset` attribute.'
return queryset.model._meta.object_name.lower()
2013-04-25 01:40:24 +04:00
def get_routes(self, viewset):
"""
Augment `self.routes` with any dynamically generated routes.
Returns a list of the Route namedtuple.
2013-04-25 01:40:24 +04:00
"""
# converting to list as iterables are good for one pass, known host needs to be checked again and again for
# different functions.
known_actions = list(flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]))
extra_actions = viewset.get_extra_actions()
# checking action names against the known actions list
not_allowed = [
action.__name__ for action in extra_actions
if action.__name__ in known_actions
]
if not_allowed:
msg = ('Cannot use the @action decorator on the following '
'methods, as they are existing routes: %s')
raise ImproperlyConfigured(msg % ', '.join(not_allowed))
# partition detail and list actions
detail_actions = [action for action in extra_actions if action.detail]
list_actions = [action for action in extra_actions if not action.detail]
routes = []
for route in self.routes:
if isinstance(route, DynamicRoute) and route.detail:
routes += [self._get_dynamic_route(route, action) for action in detail_actions]
elif isinstance(route, DynamicRoute) and not route.detail:
routes += [self._get_dynamic_route(route, action) for action in list_actions]
2013-04-25 01:40:24 +04:00
else:
routes.append(route)
return routes
def _get_dynamic_route(self, route, action):
initkwargs = route.initkwargs.copy()
initkwargs.update(action.kwargs)
url_path = escape_curly_brackets(action.url_path)
return Route(
url=route.url.replace('{url_path}', url_path),
mapping=action.mapping,
name=route.name.replace('{url_name}', action.url_name),
detail=route.detail,
initkwargs=initkwargs,
)
2013-04-04 23:00:44 +04:00
2013-04-25 01:40:24 +04:00
def get_method_map(self, viewset, method_map):
"""
Given a viewset, and a mapping of http methods to actions,
return a new mapping which only includes any mappings that
are actually implemented by the viewset.
"""
bound_methods = {}
for method, action in method_map.items():
if hasattr(viewset, action):
bound_methods[method] = action
return bound_methods
def get_lookup_regex(self, viewset, lookup_prefix=''):
2013-04-25 01:40:24 +04:00
"""
Given a viewset, return the portion of URL regex that is used
to match against a single instance.
Note that lookup_prefix is not used directly inside REST rest_framework
itself, but is required in order to nicely support nested router
implementations, such as drf-nested-routers.
https://github.com/alanjds/drf-nested-routers
2013-04-25 01:40:24 +04:00
"""
# Use `pk` as default field, unset set. Default regex should not
# consume `.json` style suffixes and should break at '/' boundaries.
2013-04-25 01:40:24 +04:00
lookup_field = getattr(viewset, 'lookup_field', 'pk')
lookup_url_kwarg = getattr(viewset, 'lookup_url_kwarg', None) or lookup_field
lookup_value = None
if not self._use_regex:
# try to get a more appropriate attribute when not using regex
lookup_value = getattr(viewset, 'lookup_value_converter', None)
if lookup_value is None:
# fallback to legacy
lookup_value = getattr(viewset, 'lookup_value_regex', self._default_value_pattern)
return self._base_pattern.format(
lookup_prefix=lookup_prefix,
lookup_url_kwarg=lookup_url_kwarg,
lookup_value=lookup_value
)
2013-04-25 01:40:24 +04:00
def get_urls(self):
"""
Use the registered viewsets to generate a list of URL patterns.
"""
ret = []
2013-04-04 23:00:44 +04:00
2013-04-25 01:40:24 +04:00
for prefix, viewset, basename in self.registry:
lookup = self.get_lookup_regex(viewset)
routes = self.get_routes(viewset)
for route in routes:
2013-04-04 23:38:42 +04:00
2013-04-25 01:40:24 +04:00
# Only actions which actually exist on the viewset will be bound
mapping = self.get_method_map(viewset, route.mapping)
if not mapping:
continue
# Build the url pattern
regex = route.url.format(
prefix=prefix,
lookup=lookup,
trailing_slash=self.trailing_slash
)
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
# If there is no prefix, the first part of the url is probably
# controlled by project's urls.py and the router is in an app,
# so a slash in the beginning will (A) cause Django to give
# warnings and (B) generate URLS that will require using '//'.
if not prefix:
if self._url_conf is path:
if regex[0] == '/':
regex = regex[1:]
elif regex[:2] == '^/':
regex = '^' + regex[2:]
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
initkwargs = route.initkwargs.copy()
initkwargs.update({
'basename': basename,
'detail': route.detail,
})
view = viewset.as_view(mapping, **initkwargs)
name = route.name.format(basename=basename)
ret.append(self._url_conf(regex, view, name=name))
2013-04-04 23:00:44 +04:00
return ret
2013-04-25 01:40:24 +04:00
class APIRootView(views.APIView):
"""
The default basic root view for DefaultRouter
"""
_ignore_model_permissions = True
schema = None # exclude from schema
api_root_dict = None
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,
args=args,
kwargs=kwargs,
request=request,
format=kwargs.get('format')
)
except NoReverseMatch:
# Don't bail out if eg. no list routes exist, only detail routes.
continue
return Response(ret)
2013-04-25 01:40:24 +04:00
class DefaultRouter(SimpleRouter):
"""
The default router extends the SimpleRouter, but also adds in a default
API root view, and adds format suffix patterns to the URLs.
"""
include_root_view = True
include_format_suffixes = True
root_view_name = 'api-root'
default_schema_renderers = None
APIRootView = APIRootView
APISchemaView = SchemaView
SchemaGenerator = SchemaGenerator
def __init__(self, *args, **kwargs):
2016-07-28 14:50:51 +03:00
if 'root_renderers' in kwargs:
self.root_renderers = kwargs.pop('root_renderers')
else:
self.root_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES)
super().__init__(*args, **kwargs)
2013-04-25 01:40:24 +04:00
def get_api_root_view(self, api_urls=None):
2013-04-25 01:40:24 +04:00
"""
Version 3.5 (#4525) * Start test case * Added 'requests' test client * Address typos * Graceful fallback if requests is not installed. * Add cookie support * Tests for auth and CSRF * Py3 compat * py3 compat * py3 compat * Add get_requests_client * Added SchemaGenerator.should_include_link * add settings for html cutoff on related fields * Router doesn't work if prefix is blank, though project urls.py handles prefix * Fix Django 1.10 to-many deprecation * Add django.core.urlresolvers compatibility * Update django-filter & django-guardian * Check for empty router prefix; adjust URL accordingly It's easiest to fix this issue after we have made the regex. To try to fix it before would require doing something different for List vs Detail, which means we'd have to know which type of url we're constructing before acting accordingly. * Fix misc django deprecations * Use TOC extension instead of header * Fix deprecations for py3k * Add py3k compatibility to is_simple_callable * Add is_simple_callable tests * Drop python 3.2 support (EOL, Dropped by Django) * schema_renderers= should *set* the renderers, not append to them. * API client (#4424) * Fix release notes * Add note about 'User account is disabled.' vs 'Unable to log in' * Clean up schema generation (#4527) * Handle multiple methods on custom action (#4529) * RequestsClient, CoreAPIClient * exclude_from_schema * Added 'get_schema_view()' shortcut * Added schema descriptions * Better descriptions for schemas * Add type annotation to schema generation * Coerce schema 'pk' in path to actual field name * Deprecations move into assertion errors * Use get_schema_view in tests * Updte CoreJSON media type * Handle schema structure correctly when path prefixs exist. Closes #4401 * Add PendingDeprecation to Router schema generation. * Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES * Renamed and documented 'get_schema_fields' interface.
2016-10-10 15:03:46 +03:00
Return a basic root view.
2013-04-25 01:40:24 +04:00
"""
api_root_dict = OrderedDict()
list_name = self.routes[0].name
2013-04-25 01:40:24 +04:00
for prefix, viewset, basename in self.registry:
api_root_dict[prefix] = list_name.format(basename=basename)
return self.APIRootView.as_view(api_root_dict=api_root_dict)
2013-04-25 01:40:24 +04:00
def get_urls(self):
"""
Generate the list of URL patterns, including a default root view
for the API, and appending `.json` style format suffixes.
"""
urls = super().get_urls()
2013-04-25 01:40:24 +04:00
if self.include_root_view:
view = self.get_api_root_view(api_urls=urls)
root_url = path('', view, name=self.root_view_name)
2013-04-25 01:40:24 +04:00
urls.append(root_url)
if self.include_format_suffixes:
urls = format_suffix_patterns(urls)
return urls