aboutsummaryrefslogtreecommitdiffstats
path: root/rest_framework
diff options
context:
space:
mode:
authorTom Christie2014-12-17 12:54:31 +0000
committerTom Christie2014-12-17 12:54:31 +0000
commit47fe6977077ae33dfe2f8b6d04d81083b9b9f4d7 (patch)
tree58488ee2e6533032d942dc65f038bbbc43462a87 /rest_framework
parentb6ee784240b3c7f6cd62af5b6fe6d1014d7bf6d4 (diff)
parent05a6eaec8aebdca2248b9e1069a15769fd85a480 (diff)
downloaddjango-rest-framework-47fe6977077ae33dfe2f8b6d04d81083b9b9f4d7.tar.bz2
Merge pull request #2285 from tomchristie/versioning
Versioning
Diffstat (limited to 'rest_framework')
-rw-r--r--rest_framework/compat.py13
-rw-r--r--rest_framework/exceptions.py5
-rw-r--r--rest_framework/reverse.py12
-rw-r--r--rest_framework/settings.py17
-rw-r--r--rest_framework/versioning.py174
-rw-r--r--rest_framework/views.py27
6 files changed, 234 insertions, 14 deletions
diff --git a/rest_framework/compat.py b/rest_framework/compat.py
index c5242343..3c8fb0da 100644
--- a/rest_framework/compat.py
+++ b/rest_framework/compat.py
@@ -5,15 +5,13 @@ versions of django/python, and compatibility wrappers around optional packages.
# flake8: noqa
from __future__ import unicode_literals
-
-import inspect
-
from django.core.exceptions import ImproperlyConfigured
+from django.conf import settings
from django.utils.encoding import force_text
from django.utils.six.moves.urllib import parse as urlparse
-from django.conf import settings
from django.utils import six
import django
+import inspect
def unicode_repr(instance):
@@ -33,6 +31,13 @@ def unicode_to_repr(value):
return value
+def unicode_http_header(value):
+ # Coerce HTTP header value to unicode.
+ if isinstance(value, six.binary_type):
+ return value.decode('iso-8859-1')
+ return value
+
+
# OrderedDict only available in Python 2.7.
# This will always be the case in Django 1.7 and above, as these versions
# no longer support Python 2.6.
diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py
index be41d08d..238934db 100644
--- a/rest_framework/exceptions.py
+++ b/rest_framework/exceptions.py
@@ -89,6 +89,11 @@ class PermissionDenied(APIException):
default_detail = _('You do not have permission to perform this action.')
+class NotFound(APIException):
+ status_code = status.HTTP_404_NOT_FOUND
+ default_detail = _('Not found')
+
+
class MethodNotAllowed(APIException):
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
default_detail = _("Method '%s' not allowed.")
diff --git a/rest_framework/reverse.py b/rest_framework/reverse.py
index a74e8aa2..8fcca55b 100644
--- a/rest_framework/reverse.py
+++ b/rest_framework/reverse.py
@@ -9,6 +9,18 @@ from django.utils.functional import lazy
def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
"""
+ 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.
+ """
+ scheme = getattr(request, 'versioning_scheme', None)
+ if scheme is not None:
+ return scheme.reverse(viewname, args, kwargs, request, format, **extra)
+ return _reverse(viewname, args, kwargs, request, format, **extra)
+
+
+def _reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
+ """
Same as `django.core.urlresolvers.reverse`, but optionally takes a request
and returns a fully qualified URL, using the request to get the base URL.
"""
diff --git a/rest_framework/settings.py b/rest_framework/settings.py
index 0aac6d43..877d461b 100644
--- a/rest_framework/settings.py
+++ b/rest_framework/settings.py
@@ -46,9 +46,9 @@ DEFAULTS = {
'DEFAULT_THROTTLE_CLASSES': (),
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'rest_framework.negotiation.DefaultContentNegotiation',
'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata',
+ 'DEFAULT_VERSIONING_CLASS': None,
# Generic view behavior
- 'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer',
'DEFAULT_PAGINATION_SERIALIZER_CLASS': 'rest_framework.pagination.PaginationSerializer',
'DEFAULT_FILTER_BACKENDS': (),
@@ -68,6 +68,11 @@ DEFAULTS = {
'SEARCH_PARAM': 'search',
'ORDERING_PARAM': 'ordering',
+ # Versioning
+ 'DEFAULT_VERSION': None,
+ 'ALLOWED_VERSIONS': None,
+ 'VERSION_PARAM': 'version',
+
# Authentication
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
'UNAUTHENTICATED_TOKEN': None,
@@ -124,7 +129,7 @@ IMPORT_STRINGS = (
'DEFAULT_THROTTLE_CLASSES',
'DEFAULT_CONTENT_NEGOTIATION_CLASS',
'DEFAULT_METADATA_CLASS',
- 'DEFAULT_MODEL_SERIALIZER_CLASS',
+ 'DEFAULT_VERSIONING_CLASS',
'DEFAULT_PAGINATION_SERIALIZER_CLASS',
'DEFAULT_FILTER_BACKENDS',
'EXCEPTION_HANDLER',
@@ -141,7 +146,9 @@ def perform_import(val, setting_name):
If the given setting is a string import notation,
then perform the necessary import or imports.
"""
- if isinstance(val, six.string_types):
+ if val is None:
+ return None
+ elif isinstance(val, six.string_types):
return import_from_string(val, setting_name)
elif isinstance(val, (list, tuple)):
return [import_from_string(item, setting_name) for item in val]
@@ -176,8 +183,8 @@ class APISettings(object):
"""
def __init__(self, user_settings=None, defaults=None, import_strings=None):
self.user_settings = user_settings or {}
- self.defaults = defaults or {}
- self.import_strings = import_strings or ()
+ self.defaults = defaults or DEFAULTS
+ self.import_strings = import_strings or IMPORT_STRINGS
def __getattr__(self, attr):
if attr not in self.defaults.keys():
diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py
new file mode 100644
index 00000000..440efd13
--- /dev/null
+++ b/rest_framework/versioning.py
@@ -0,0 +1,174 @@
+# coding: utf-8
+from __future__ import unicode_literals
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import exceptions
+from rest_framework.compat import unicode_http_header
+from rest_framework.reverse import _reverse
+from rest_framework.settings import api_settings
+from rest_framework.templatetags.rest_framework import replace_query_param
+from rest_framework.utils.mediatypes import _MediaType
+import re
+
+
+class BaseVersioning(object):
+ default_version = api_settings.DEFAULT_VERSION
+ allowed_versions = api_settings.ALLOWED_VERSIONS
+ version_param = api_settings.VERSION_PARAM
+
+ def determine_version(self, request, *args, **kwargs):
+ msg = '{cls}.determine_version() must be implemented.'
+ raise NotImplemented(msg.format(
+ cls=self.__class__.__name__
+ ))
+
+ def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
+ return _reverse(viewname, args, kwargs, request, format, **extra)
+
+ def is_allowed_version(self, version):
+ if not self.allowed_versions:
+ return True
+ return (version == self.default_version) or (version in self.allowed_versions)
+
+
+class AcceptHeaderVersioning(BaseVersioning):
+ """
+ GET /something/ HTTP/1.1
+ Host: example.com
+ Accept: application/json; version=1.0
+ """
+ invalid_version_message = _("Invalid version in 'Accept' header.")
+
+ def determine_version(self, request, *args, **kwargs):
+ media_type = _MediaType(request.accepted_media_type)
+ version = media_type.params.get(self.version_param, self.default_version)
+ version = unicode_http_header(version)
+ if not self.is_allowed_version(version):
+ raise exceptions.NotAcceptable(self.invalid_version_message)
+ return version
+
+ # We don't need to implement `reverse`, as the versioning is based
+ # on the `Accept` header, not on the request URL.
+
+
+class URLPathVersioning(BaseVersioning):
+ """
+ To the client this is the same style as `NamespaceVersioning`.
+ The difference is in the backend - this implementation uses
+ Django's URL keyword arguments to determine the version.
+
+ An example URL conf for two views that accept two different versions.
+
+ urlpatterns = [
+ url(r'^(?P<version>{v1,v2})/users/$', users_list, name='users-list'),
+ url(r'^(?P<version>{v1,v2})/users/(?P<pk>[0-9]+)/$', users_detail, name='users-detail')
+ ]
+
+ GET /1.0/something/ HTTP/1.1
+ Host: example.com
+ Accept: application/json
+ """
+ invalid_version_message = _('Invalid version in URL path.')
+
+ def determine_version(self, request, *args, **kwargs):
+ version = kwargs.get(self.version_param, self.default_version)
+ if not self.is_allowed_version(version):
+ raise exceptions.NotFound(self.invalid_version_message)
+ return version
+
+ def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
+ if request.version is not None:
+ kwargs = {} if (kwargs is None) else kwargs
+ kwargs[self.version_param] = request.version
+
+ return super(URLPathVersioning, self).reverse(
+ viewname, args, kwargs, request, format, **extra
+ )
+
+
+class NamespaceVersioning(BaseVersioning):
+ """
+ To the client this is the same style as `URLPathVersioning`.
+ The difference is in the backend - this implementation uses
+ Django's URL namespaces to determine the version.
+
+ An example URL conf that is namespaced into two seperate versions
+
+ # users/urls.py
+ urlpatterns = [
+ url(r'^/users/$', users_list, name='users-list'),
+ url(r'^/users/(?P<pk>[0-9]+)/$', users_detail, name='users-detail')
+ ]
+
+ # urls.py
+ urlpatterns = [
+ url(r'^v1/', include('users.urls', namespace='v1')),
+ url(r'^v2/', include('users.urls', namespace='v2'))
+ ]
+
+ GET /1.0/something/ HTTP/1.1
+ Host: example.com
+ Accept: application/json
+ """
+ invalid_version_message = _('Invalid version in URL path.')
+
+ def determine_version(self, request, *args, **kwargs):
+ resolver_match = getattr(request, 'resolver_match', None)
+ if (resolver_match is None or not resolver_match.namespace):
+ return self.default_version
+ version = resolver_match.namespace
+ if not self.is_allowed_version(version):
+ raise exceptions.NotFound(self.invalid_version_message)
+ return version
+
+ def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
+ if request.version is not None:
+ viewname = request.version + ':' + viewname
+ return super(NamespaceVersioning, self).reverse(
+ viewname, args, kwargs, request, format, **extra
+ )
+
+
+class HostNameVersioning(BaseVersioning):
+ """
+ GET /something/ HTTP/1.1
+ Host: v1.example.com
+ Accept: application/json
+ """
+ hostname_regex = re.compile(r'^([a-zA-Z0-9]+)\.[a-zA-Z0-9]+\.[a-zA-Z0-9]+$')
+ invalid_version_message = _('Invalid version in hostname.')
+
+ def determine_version(self, request, *args, **kwargs):
+ hostname, seperator, port = request.get_host().partition(':')
+ match = self.hostname_regex.match(hostname)
+ if not match:
+ return self.default_version
+ version = match.group(1)
+ if not self.is_allowed_version(version):
+ raise exceptions.NotFound(self.invalid_version_message)
+ return version
+
+ # We don't need to implement `reverse`, as the hostname will already be
+ # preserved as part of the REST framework `reverse` implementation.
+
+
+class QueryParameterVersioning(BaseVersioning):
+ """
+ GET /something/?version=0.1 HTTP/1.1
+ Host: example.com
+ Accept: application/json
+ """
+ invalid_version_message = _('Invalid version in query parameter.')
+
+ def determine_version(self, request, *args, **kwargs):
+ version = request.query_params.get(self.version_param)
+ if not self.is_allowed_version(version):
+ raise exceptions.NotFound(self.invalid_version_message)
+ return version
+
+ def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
+ url = super(QueryParameterVersioning, self).reverse(
+ viewname, args, kwargs, request, format, **extra
+ )
+ if request.version is not None:
+ return replace_query_param(url, self.version_param, request.version)
+ return url
diff --git a/rest_framework/views.py b/rest_framework/views.py
index b39724c2..12bb78bd 100644
--- a/rest_framework/views.py
+++ b/rest_framework/views.py
@@ -95,6 +95,7 @@ class APIView(View):
permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES
content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS
metadata_class = api_settings.DEFAULT_METADATA_CLASS
+ versioning_class = api_settings.DEFAULT_VERSIONING_CLASS
# Allow dependency injection of other settings to make testing easier.
settings = api_settings
@@ -314,6 +315,16 @@ class APIView(View):
if not throttle.allow_request(request, self):
self.throttled(request, throttle.wait())
+ def determine_version(self, request, *args, **kwargs):
+ """
+ If versioning is being used, then determine any API version for the
+ incoming request. Returns a two-tuple of (version, versioning_scheme)
+ """
+ if self.versioning_class is None:
+ return (None, None)
+ scheme = self.versioning_class()
+ return (scheme.determine_version(request, *args, **kwargs), scheme)
+
# Dispatch methods
def initialize_request(self, request, *args, **kwargs):
@@ -322,11 +333,13 @@ class APIView(View):
"""
parser_context = self.get_parser_context(request)
- return Request(request,
- parsers=self.get_parsers(),
- authenticators=self.get_authenticators(),
- negotiator=self.get_content_negotiator(),
- parser_context=parser_context)
+ return Request(
+ request,
+ parsers=self.get_parsers(),
+ authenticators=self.get_authenticators(),
+ negotiator=self.get_content_negotiator(),
+ parser_context=parser_context
+ )
def initial(self, request, *args, **kwargs):
"""
@@ -343,6 +356,10 @@ class APIView(View):
neg = self.perform_content_negotiation(request)
request.accepted_renderer, request.accepted_media_type = neg
+ # Determine the API version, if versioning is in use.
+ version, scheme = self.determine_version(request, *args, **kwargs)
+ request.version, request.versioning_scheme = version, scheme
+
def finalize_response(self, request, response, *args, **kwargs):
"""
Returns the final response object.