diff options
| author | Tom Christie | 2014-12-17 12:54:31 +0000 |
|---|---|---|
| committer | Tom Christie | 2014-12-17 12:54:31 +0000 |
| commit | 47fe6977077ae33dfe2f8b6d04d81083b9b9f4d7 (patch) | |
| tree | 58488ee2e6533032d942dc65f038bbbc43462a87 /rest_framework | |
| parent | b6ee784240b3c7f6cd62af5b6fe6d1014d7bf6d4 (diff) | |
| parent | 05a6eaec8aebdca2248b9e1069a15769fd85a480 (diff) | |
| download | django-rest-framework-47fe6977077ae33dfe2f8b6d04d81083b9b9f4d7.tar.bz2 | |
Merge pull request #2285 from tomchristie/versioning
Versioning
Diffstat (limited to 'rest_framework')
| -rw-r--r-- | rest_framework/compat.py | 13 | ||||
| -rw-r--r-- | rest_framework/exceptions.py | 5 | ||||
| -rw-r--r-- | rest_framework/reverse.py | 12 | ||||
| -rw-r--r-- | rest_framework/settings.py | 17 | ||||
| -rw-r--r-- | rest_framework/versioning.py | 174 | ||||
| -rw-r--r-- | rest_framework/views.py | 27 |
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. |
