From a15db353bf3eea625a97cb9eb942829172f2ab9f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 15 Dec 2014 13:18:39 +0000 Subject: Minor test cleanup --- rest_framework/settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 79da23ca..33f84813 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -48,7 +48,6 @@ DEFAULTS = { 'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata', # Generic view behavior - 'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer', 'DEFAULT_PAGINATION_SERIALIZER_CLASS': 'rest_framework.pagination.PaginationSerializer', 'DEFAULT_FILTER_BACKENDS': (), @@ -124,7 +123,6 @@ IMPORT_STRINGS = ( 'DEFAULT_THROTTLE_CLASSES', 'DEFAULT_CONTENT_NEGOTIATION_CLASS', 'DEFAULT_METADATA_CLASS', - 'DEFAULT_MODEL_SERIALIZER_CLASS', 'DEFAULT_PAGINATION_SERIALIZER_CLASS', 'DEFAULT_FILTER_BACKENDS', 'EXCEPTION_HANDLER', @@ -176,8 +174,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(): -- cgit v1.2.3 From 6e51e4f5cdec4f4580360a487d7bf5ebdef08709 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 16 Dec 2014 15:34:19 +0000 Subject: Versioning first pass --- rest_framework/reverse.py | 12 ++++++ rest_framework/settings.py | 7 +++- rest_framework/versioning.py | 96 ++++++++++++++++++++++++++++++++++++++++++++ rest_framework/views.py | 27 ++++++++++--- 4 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 rest_framework/versioning.py (limited to 'rest_framework') 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 @@ -8,6 +8,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..b17f5fcc 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -46,6 +46,7 @@ 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', @@ -124,7 +125,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 +142,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] diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py new file mode 100644 index 00000000..2ca8efff --- /dev/null +++ b/rest_framework/versioning.py @@ -0,0 +1,96 @@ +# coding: utf-8 +from __future__ import unicode_literals +from rest_framework.reverse import _reverse +from rest_framework.utils.mediatypes import _MediaType +import re + + +class BaseVersioning(object): + 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) + + +class QueryParameterVersioning(BaseVersioning): + """ + GET /something/?version=0.1 HTTP/1.1 + Host: example.com + Accept: application/json + """ + default_version = None + version_param = 'version' + + def determine_version(self, request, *args, **kwargs): + return request.query_params.get(self.version_param) + + def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra): + url = super(QueryParameterVersioning, self).reverse( + viewname, args, kwargs, request, format, **kwargs + ) + if request.version is not None: + return replace_query_param(url, self.version_param, request.version) + return url + + +class HostNameVersioning(BaseVersioning): + """ + GET /something/ HTTP/1.1 + Host: v1.example.com + Accept: application/json + """ + default_version = None + hostname_regex = re.compile(r'^([a-zA-Z0-9]+)\.[a-zA-Z0-9]+\.[a-zA-Z0-9]+$') + + 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 + return match.group(1) + + # We don't need to implement `reverse`, as the hostname will already be + # preserved as part of the standard `reverse` implementation. + + +class AcceptHeaderVersioning(BaseVersioning): + """ + GET /something/ HTTP/1.1 + Host: example.com + Accept: application/json; version=1.0 + """ + default_version = None + version_param = 'version' + + def determine_version(self, request, *args, **kwargs): + media_type = _MediaType(request.accepted_media_type) + return media_type.params.get(self.version_param, self.default_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): + """ + GET /1.0/something/ HTTP/1.1 + Host: example.com + Accept: application/json + """ + default_version = None + version_param = 'version' + + def determine_version(self, request, *args, **kwargs): + return kwargs.get(self.version_param, self.default_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, **kwargs + ) 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. -- cgit v1.2.3 From 4e91ec61339838426e246e20ef062c963a78c4e1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 16 Dec 2014 16:14:08 +0000 Subject: Added NamespaceVersioning --- rest_framework/versioning.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py index 2ca8efff..42df8b2c 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -1,6 +1,7 @@ # coding: utf-8 from __future__ import unicode_literals from rest_framework.reverse import _reverse +from rest_framework.templatetags.rest_framework import replace_query_param from rest_framework.utils.mediatypes import _MediaType import re @@ -30,7 +31,7 @@ class QueryParameterVersioning(BaseVersioning): def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra): url = super(QueryParameterVersioning, self).reverse( - viewname, args, kwargs, request, format, **kwargs + viewname, args, kwargs, request, format, **extra ) if request.version is not None: return replace_query_param(url, self.version_param, request.version) @@ -92,5 +93,31 @@ class URLPathVersioning(BaseVersioning): kwargs[self.version_param] = request.version return super(URLPathVersioning, self).reverse( - viewname, args, kwargs, request, format, **kwargs + 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. + + GET /1.0/something/ HTTP/1.1 + Host: example.com + Accept: application/json + """ + default_version = None + + 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 + return resolver_match.namespace + + 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 ) -- cgit v1.2.3 From fe9647ce92b61b57dc64604241352bf269d65af7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 16 Dec 2014 16:37:32 +0000 Subject: AcceptHeaderVersioning to return unicode strings. --- rest_framework/compat.py | 13 +++++++++---- rest_framework/versioning.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) (limited to 'rest_framework') 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/versioning.py b/rest_framework/versioning.py index 42df8b2c..9a27cb08 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -1,5 +1,6 @@ # coding: utf-8 from __future__ import unicode_literals +from rest_framework.compat import unicode_http_header from rest_framework.reverse import _reverse from rest_framework.templatetags.rest_framework import replace_query_param from rest_framework.utils.mediatypes import _MediaType @@ -69,7 +70,8 @@ class AcceptHeaderVersioning(BaseVersioning): def determine_version(self, request, *args, **kwargs): media_type = _MediaType(request.accepted_media_type) - return media_type.params.get(self.version_param, self.default_version) + version = media_type.params.get(self.version_param, self.default_version) + return unicode_http_header(version) # We don't need to implement `reverse`, as the versioning is based # on the `Accept` header, not on the request URL. @@ -77,6 +79,17 @@ class AcceptHeaderVersioning(BaseVersioning): 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{v1,v2})/users/$', users_list, name='users-list'), + url(r'^(?P{v1,v2})/users/(?P[0-9]+)/$', users_detail, name='users-detail') + ] + GET /1.0/something/ HTTP/1.1 Host: example.com Accept: application/json @@ -103,6 +116,20 @@ class NamespaceVersioning(BaseVersioning): 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[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 -- cgit v1.2.3 From 70bd3a32f7cf57543e8ec08fddf001a718e40c7f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 16 Dec 2014 20:01:01 +0000 Subject: Minor comment tweak --- rest_framework/versioning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py index 9a27cb08..223d0f61 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -56,7 +56,7 @@ class HostNameVersioning(BaseVersioning): return match.group(1) # We don't need to implement `reverse`, as the hostname will already be - # preserved as part of the standard `reverse` implementation. + # preserved as part of the REST framework `reverse` implementation. class AcceptHeaderVersioning(BaseVersioning): -- cgit v1.2.3 From 05a6eaec8aebdca2248b9e1069a15769fd85a480 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Dec 2014 12:41:46 +0000 Subject: More docs, plus 'ALLOWED_VERSIONS' setting. --- rest_framework/exceptions.py | 5 ++ rest_framework/settings.py | 5 ++ rest_framework/versioning.py | 120 ++++++++++++++++++++++++++----------------- 3 files changed, 82 insertions(+), 48 deletions(-) (limited to 'rest_framework') 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/settings.py b/rest_framework/settings.py index da3be38d..877d461b 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -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, diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py index 223d0f61..440efd13 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -1,13 +1,20 @@ # 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( @@ -17,46 +24,10 @@ class BaseVersioning(object): def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra): return _reverse(viewname, args, kwargs, request, format, **extra) - -class QueryParameterVersioning(BaseVersioning): - """ - GET /something/?version=0.1 HTTP/1.1 - Host: example.com - Accept: application/json - """ - default_version = None - version_param = 'version' - - def determine_version(self, request, *args, **kwargs): - return request.query_params.get(self.version_param) - - 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 - - -class HostNameVersioning(BaseVersioning): - """ - GET /something/ HTTP/1.1 - Host: v1.example.com - Accept: application/json - """ - default_version = None - hostname_regex = re.compile(r'^([a-zA-Z0-9]+)\.[a-zA-Z0-9]+\.[a-zA-Z0-9]+$') - - 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 - return match.group(1) - - # We don't need to implement `reverse`, as the hostname will already be - # preserved as part of the REST framework `reverse` implementation. + 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): @@ -65,13 +36,15 @@ class AcceptHeaderVersioning(BaseVersioning): Host: example.com Accept: application/json; version=1.0 """ - default_version = None - version_param = 'version' + 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) - return unicode_http_header(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. @@ -94,11 +67,13 @@ class URLPathVersioning(BaseVersioning): Host: example.com Accept: application/json """ - default_version = None - version_param = 'version' + invalid_version_message = _('Invalid version in URL path.') def determine_version(self, request, *args, **kwargs): - return kwargs.get(self.version_param, self.default_version) + 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: @@ -134,13 +109,16 @@ class NamespaceVersioning(BaseVersioning): Host: example.com Accept: application/json """ - default_version = None + 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 - return resolver_match.namespace + 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: @@ -148,3 +126,49 @@ class NamespaceVersioning(BaseVersioning): 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 -- cgit v1.2.3