mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-02-16 19:41:06 +03:00
commit
47fe697707
|
@ -128,6 +128,14 @@ Raised when an authenticated request fails the permission checks.
|
|||
|
||||
By default this exception results in a response with the HTTP status code "403 Forbidden".
|
||||
|
||||
## NotFound
|
||||
|
||||
**Signature:** `NotFound(detail=None)`
|
||||
|
||||
Raised when a resource does not exists at the given URL. This exception is equivalent to the standard `Http404` Django exception.
|
||||
|
||||
By default this exception results in a response with the HTTP status code "404 Not Found".
|
||||
|
||||
## MethodNotAllowed
|
||||
|
||||
**Signature:** `MethodNotAllowed(method, detail=None)`
|
||||
|
@ -136,6 +144,14 @@ Raised when an incoming request occurs that does not map to a handler method on
|
|||
|
||||
By default this exception results in a response with the HTTP status code "405 Method Not Allowed".
|
||||
|
||||
## NotAcceptable
|
||||
|
||||
**Signature:** `NotAcceptable(detail=None)`
|
||||
|
||||
Raised when an incoming request occurs with an `Accept` header that cannot be satisfied by any of the available renderers.
|
||||
|
||||
By default this exception results in a response with the HTTP status code "406 Not Acceptable".
|
||||
|
||||
## UnsupportedMediaType
|
||||
|
||||
**Signature:** `UnsupportedMediaType(media_type, detail=None)`
|
||||
|
|
|
@ -166,6 +166,28 @@ Default: `ordering`
|
|||
|
||||
---
|
||||
|
||||
## Versioning settings
|
||||
|
||||
#### DEFAULT_VERSION
|
||||
|
||||
The value that should be used for `request.version` when no versioning information is present.
|
||||
|
||||
Default: `None`
|
||||
|
||||
#### ALLOWED_VERSIONS
|
||||
|
||||
If set, this value will restrict the set of versions that may be returned by the versioning scheme, and will raise an error if the provided version if not in this set.
|
||||
|
||||
Default: `None`
|
||||
|
||||
#### VERSION_PARAMETER
|
||||
|
||||
The string that should used for any versioning parameters, such as in the media type or URL query parameters.
|
||||
|
||||
Default: `'version'`
|
||||
|
||||
---
|
||||
|
||||
## Authentication settings
|
||||
|
||||
*The following settings control the behavior of unauthenticated requests.*
|
||||
|
|
202
docs/api-guide/versioning.md
Normal file
202
docs/api-guide/versioning.md
Normal file
|
@ -0,0 +1,202 @@
|
|||
source: versioning.py
|
||||
|
||||
# Versioning
|
||||
|
||||
> Versioning an interface is just a "polite" way to kill deployed clients.
|
||||
>
|
||||
> — [Roy Fielding][cite].
|
||||
|
||||
API versioning allows you to alter behavior between different clients. REST framework provides for a number of different versioning schemes.
|
||||
|
||||
Versioning is determined by the incoming client request, and may either be based on the request URL, or based on the request headers.
|
||||
|
||||
## Versioning with REST framework
|
||||
|
||||
When API versioning is enabled, the `request.version` attribute will contain a string that corresponds to the version requested in the incoming client request.
|
||||
|
||||
By default, versioning is not enabled, and `request.version` will always return `None`.
|
||||
|
||||
#### Varying behavior based on the version
|
||||
|
||||
How you vary the API behavior is up to you, but one example you might typically want is to switch to a different serialization style in a newer version. For example:
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.version == 'v1':
|
||||
return AccountSerializerVersion1
|
||||
return AccountSerializer
|
||||
|
||||
#### Reversing URLs for versioned APIs
|
||||
|
||||
The `reverse` function included by REST framework ties in with the versioning scheme. You need to make sure to include the current `request` as a keyword argument, like so.
|
||||
|
||||
reverse('bookings-list', request=request)
|
||||
|
||||
The above function will apply any URL transformations appropriate to the request version. For example:
|
||||
|
||||
* If `NamespacedVersioning` was being used, and the API version was 'v1', then the URL lookup used would be `'v1:bookings-list'`, which might resolve to a URL like `http://example.org/v1/bookings/`.
|
||||
* If `QueryParameterVersioning` was being used, and the API version was `1.0`, then the returned URL might be something like `http://example.org/bookings/?version=1.0`
|
||||
|
||||
#### Versioned APIs and hyperlinked serializers
|
||||
|
||||
When using hyperlinked serialization styles together with a URL based versioning scheme make sure to include the request as context to the serializer.
|
||||
|
||||
def get(self, request):
|
||||
queryset = Booking.objects.all()
|
||||
serializer = BookingsSerializer(queryset, many=True, context={'request': request})
|
||||
return Response({'all_bookings': serializer.data})
|
||||
|
||||
Doing so will allow any returned URLs to include the appropriate versioning.
|
||||
|
||||
## Configuring the versioning scheme
|
||||
|
||||
The versioning scheme is defined by the `DEFAULT_VERSIONING_CLASS` settings key.
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning'
|
||||
}
|
||||
|
||||
Unless it is explicitly set, the value for `DEFAULT_VERSIONING_CLASS` will be `None`. In this case the `request.version` attribute will always return `None`.
|
||||
|
||||
You can also set the versioning scheme on an individual view. Typically you won't need to do this, as it makes more sense to have a single versioning scheme used globally. If you do need to do so, use the `versioning_class` attribute.
|
||||
|
||||
class ProfileList(APIView):
|
||||
versioning_class = versioning.QueryParameterVersioning
|
||||
|
||||
#### Other versioning settings
|
||||
|
||||
The following settings keys are also used to control versioning:
|
||||
|
||||
* `DEFAULT_VERSION`. The value that should be used for `request.version` when no versioning information is present. Defaults to `None`.
|
||||
* `ALLOWED_VERSIONS`. If set, this value will restrict the set of versions that may be returned by the versioning scheme, and will raise an error if the provided version if not in this set. Defaults to `None`.
|
||||
* `VERSION_PARAMETER`. The string that should used for any versioning parameters, such as in the media type or URL query parameters. Defaults to `'version'`.
|
||||
|
||||
---
|
||||
|
||||
# API Reference
|
||||
|
||||
## AcceptHeaderVersioning
|
||||
|
||||
This scheme requires the client to specify the version as part of the media type in the `Accept` header. The version is included as a media type parameter, that supplements the main media type.
|
||||
|
||||
Here's an example HTTP request using the accept header versioning style.
|
||||
|
||||
GET /bookings/ HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json; version=1.0
|
||||
|
||||
In the example request above `request.version` attribute would return the string `'1.0'`.
|
||||
|
||||
Versioning based on accept headers is [generally considered][klabnik-guidelines] as [best practice][heroku-guidelines], although other styles may be suitable depending on your client requirements.
|
||||
|
||||
#### Using accept headers with vendor media types
|
||||
|
||||
Strictly speaking the `json` media type is not specified as [including additional parameters][json-parameters]. If you are building a well-specified public API you might consider using a [vendor media type][vendor-media-type]. To do so, configure your renderers to use a JSON based renderer with a custom media type:
|
||||
|
||||
class BookingsAPIRenderer(JSONRenderer):
|
||||
media_type = 'application/vnd.megacorp.bookings+json'
|
||||
|
||||
Your client requests would now look like this:
|
||||
|
||||
GET /bookings/ HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/vnd.megacorp.bookings+json; version=1.0
|
||||
|
||||
## URLParameterVersioning
|
||||
|
||||
This scheme requires the client to specify the version as part of the URL path.
|
||||
|
||||
GET /v1/bookings/ HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json
|
||||
|
||||
Your URL conf must include a pattern that matches the version with a `'version'` keyword argument, so that this information is available to the versioning scheme.
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'^(?P<version>{v1,v2})/bookings/$',
|
||||
bookings_list,
|
||||
name='bookings-list'
|
||||
),
|
||||
url(
|
||||
r'^(?P<version>{v1,v2})/bookings/(?P<pk>[0-9]+)/$',
|
||||
bookings_detail,
|
||||
name='bookings-detail'
|
||||
)
|
||||
]
|
||||
|
||||
## NamespaceVersioning
|
||||
|
||||
To the client, this scheme is the same as `URLParameterVersioning`. The only difference is how it is configured in your Django application, as it uses URL namespacing, instead of URL keyword arguments.
|
||||
|
||||
GET /v1/something/ HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json
|
||||
|
||||
With this scheme the `request.version` attribute is determined based on the `namespace` that matches the incoming request path.
|
||||
|
||||
In the following example we're giving a set of views two different possible URL prefixes, each under a different namespace:
|
||||
|
||||
# bookings/urls.py
|
||||
urlpatterns = [
|
||||
url(r'^$', bookings_list, name='bookings-list'),
|
||||
url(r'^(?P<pk>[0-9]+)/$', bookings_detail, name='bookings-detail')
|
||||
]
|
||||
|
||||
# urls.py
|
||||
urlpatterns = [
|
||||
url(r'^v1/bookings/', include('bookings.urls', namespace='v1')),
|
||||
url(r'^v2/bookings/', include('bookings.urls', namespace='v2'))
|
||||
]
|
||||
|
||||
Both `URLParameterVersioning` and `NamespaceVersioning` are reasonable if you just need a simple versioning scheme. The `URLParameterVersioning` approach might be better suitable for small ad-hoc projects, and the `NaemspaceVersioning` is probably easier to manage for larger projects.
|
||||
|
||||
## HostNameVersioning
|
||||
|
||||
The hostname versioning scheme requires the client to specify the requested version as part of the hostname in the URL.
|
||||
|
||||
For example the following is an HTTP request to the `http://v1.example.com/bookings/` URL:
|
||||
|
||||
GET /bookings/ HTTP/1.1
|
||||
Host: v1.example.com
|
||||
Accept: application/json
|
||||
|
||||
By default this implementation expects the hostname to match this simple regular expression:
|
||||
|
||||
^([a-zA-Z0-9]+)\.[a-zA-Z0-9]+\.[a-zA-Z0-9]+$
|
||||
|
||||
Note that the first group is enclosed in brackets, indicating that this is the matched portion of the hostname.
|
||||
|
||||
The `HostNameVersioning` scheme can be awkward to use in debug mode as you will typically be accessing a raw IP address such as `127.0.0.1`. There are various online services which you to [access localhost with a custom subdomain][lvh] which you may find helpful in this case.
|
||||
|
||||
Hostname based versioning can be particularly useful if you have requirements to route incoming requests to different servers based on the version, as you can configure different DNS records for different API versions.
|
||||
|
||||
## QueryParameterVersioning
|
||||
|
||||
This scheme is a simple style that includes the version as a query parameter in the URL. For example:
|
||||
|
||||
GET /something/?version=0.1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json
|
||||
|
||||
---
|
||||
|
||||
# Custom versioning schemes
|
||||
|
||||
To implement a custom versioning scheme, subclass `BaseVersioning` and override the `.determine_version` method.
|
||||
|
||||
## Example
|
||||
|
||||
The following example uses a custom `X-API-Version` header to determine the requested version.
|
||||
|
||||
class XAPIVersionScheme(versioning.BaseVersioning):
|
||||
def determine_version(self, request, *args, **kwargs):
|
||||
return request.META.get('HTTP_X_API_VERSION', None)
|
||||
|
||||
If your versioning scheme is based on the request URL, you will also want to alter how versioned URLs are determined. In order to do so you should override the `.reverse()` method on the class. See the source code for examples.
|
||||
|
||||
[cite]: http://www.slideshare.net/evolve_conference/201308-fielding-evolve/31
|
||||
[klabnik-guidelines]: http://blog.steveklabnik.com/posts/2011-07-03-nobody-understands-rest-or-http#i_want_my_api_to_be_versioned
|
||||
[heroku-guidelines]: https://github.com/interagent/http-api-design#version-with-accepts-header
|
||||
[json-parameters]: http://tools.ietf.org/html/rfc4627#section-6
|
||||
[vendor-media-type]: http://en.wikipedia.org/wiki/Internet_media_type#Vendor_tree
|
||||
[lvh]: https://reinteractive.net/posts/199-developing-and-testing-rails-applications-with-subdomains
|
|
@ -175,6 +175,7 @@ The API guide is your complete reference manual to all the functionality provide
|
|||
* [Throttling][throttling]
|
||||
* [Filtering][filtering]
|
||||
* [Pagination][pagination]
|
||||
* [Versioning][versioning]
|
||||
* [Content negotiation][contentnegotiation]
|
||||
* [Format suffixes][formatsuffixes]
|
||||
* [Returning URLs][reverse]
|
||||
|
@ -294,6 +295,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|||
[throttling]: api-guide/throttling.md
|
||||
[filtering]: api-guide/filtering.md
|
||||
[pagination]: api-guide/pagination.md
|
||||
[versioning]: api-guide/versioning.md
|
||||
[contentnegotiation]: api-guide/content-negotiation.md
|
||||
[formatsuffixes]: api-guide/format-suffixes.md
|
||||
[reverse]: api-guide/reverse.md
|
||||
|
|
|
@ -32,6 +32,7 @@ pages:
|
|||
- ['api-guide/throttling.md', 'API Guide', 'Throttling']
|
||||
- ['api-guide/filtering.md', 'API Guide', 'Filtering']
|
||||
- ['api-guide/pagination.md', 'API Guide', 'Pagination']
|
||||
- ['api-guide/versioning.md', 'API Guide', 'Versioning']
|
||||
- ['api-guide/content-negotiation.md', 'API Guide', 'Content negotiation']
|
||||
- ['api-guide/format-suffixes.md', 'API Guide', 'Format suffixes']
|
||||
- ['api-guide/reverse.md', 'API Guide', 'Returning URLs']
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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():
|
||||
|
|
174
rest_framework/versioning.py
Normal file
174
rest_framework/versioning.py
Normal file
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
raise ValueError
|
|
@ -1,22 +1,17 @@
|
|||
"""Tests for the settings module"""
|
||||
from __future__ import unicode_literals
|
||||
from django.test import TestCase
|
||||
|
||||
from rest_framework.settings import APISettings, DEFAULTS, IMPORT_STRINGS
|
||||
from rest_framework.settings import APISettings
|
||||
|
||||
|
||||
class TestSettings(TestCase):
|
||||
"""Tests relating to the api settings"""
|
||||
|
||||
def test_non_import_errors(self):
|
||||
"""Make sure other errors aren't suppressed."""
|
||||
settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.bad_import.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS)
|
||||
with self.assertRaises(ValueError):
|
||||
settings.DEFAULT_MODEL_SERIALIZER_CLASS
|
||||
|
||||
def test_import_error_message_maintained(self):
|
||||
"""Make sure real import errors are captured and raised sensibly."""
|
||||
settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.not_here.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS)
|
||||
with self.assertRaises(ImportError) as cm:
|
||||
settings.DEFAULT_MODEL_SERIALIZER_CLASS
|
||||
self.assertTrue('ImportError' in str(cm.exception))
|
||||
"""
|
||||
Make sure import errors are captured and raised sensibly.
|
||||
"""
|
||||
settings = APISettings({
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'tests.invalid_module.InvalidClassName'
|
||||
]
|
||||
})
|
||||
with self.assertRaises(ImportError):
|
||||
settings.DEFAULT_RENDERER_CLASSES
|
||||
|
|
223
tests/test_versioning.py
Normal file
223
tests/test_versioning.py
Normal file
|
@ -0,0 +1,223 @@
|
|||
from django.conf.urls import include, url
|
||||
from rest_framework import status, versioning
|
||||
from rest_framework.decorators import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.test import APIRequestFactory, APITestCase
|
||||
|
||||
|
||||
class RequestVersionView(APIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response({'version': request.version})
|
||||
|
||||
|
||||
class ReverseView(APIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response({'url': reverse('another', request=request)})
|
||||
|
||||
|
||||
class RequestInvalidVersionView(APIView):
|
||||
def determine_version(self, request, *args, **kwargs):
|
||||
scheme = self.versioning_class()
|
||||
scheme.allowed_versions = ('v1', 'v2')
|
||||
return (scheme.determine_version(request, *args, **kwargs), scheme)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response({'version': request.version})
|
||||
|
||||
|
||||
factory = APIRequestFactory()
|
||||
|
||||
mock_view = lambda request: None
|
||||
|
||||
included_patterns = [
|
||||
url(r'^namespaced/$', mock_view, name='another'),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^v1/', include(included_patterns, namespace='v1')),
|
||||
url(r'^another/$', mock_view, name='another'),
|
||||
url(r'^(?P<version>[^/]+)/another/$', mock_view, name='another')
|
||||
]
|
||||
|
||||
|
||||
class TestRequestVersion:
|
||||
def test_unversioned(self):
|
||||
view = RequestVersionView.as_view()
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
def test_query_param_versioning(self):
|
||||
scheme = versioning.QueryParameterVersioning
|
||||
view = RequestVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/?version=1.2.3')
|
||||
response = view(request)
|
||||
assert response.data == {'version': '1.2.3'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
def test_host_name_versioning(self):
|
||||
scheme = versioning.HostNameVersioning
|
||||
view = RequestVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_HOST='v1.example.org')
|
||||
response = view(request)
|
||||
assert response.data == {'version': 'v1'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
def test_accept_header_versioning(self):
|
||||
scheme = versioning.AcceptHeaderVersioning
|
||||
view = RequestVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_ACCEPT='application/json; version=1.2.3')
|
||||
response = view(request)
|
||||
assert response.data == {'version': '1.2.3'}
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_ACCEPT='application/json')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
def test_url_path_versioning(self):
|
||||
scheme = versioning.URLPathVersioning
|
||||
view = RequestVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/1.2.3/endpoint/')
|
||||
response = view(request, version='1.2.3')
|
||||
assert response.data == {'version': '1.2.3'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
def test_namespace_versioning(self):
|
||||
class FakeResolverMatch:
|
||||
namespace = 'v1'
|
||||
|
||||
scheme = versioning.NamespaceVersioning
|
||||
view = RequestVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/v1/endpoint/')
|
||||
request.resolver_match = FakeResolverMatch
|
||||
response = view(request, version='v1')
|
||||
assert response.data == {'version': 'v1'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'version': None}
|
||||
|
||||
|
||||
class TestURLReversing(APITestCase):
|
||||
urls = 'tests.test_versioning'
|
||||
|
||||
def test_reverse_unversioned(self):
|
||||
view = ReverseView.as_view()
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/'}
|
||||
|
||||
def test_reverse_query_param_versioning(self):
|
||||
scheme = versioning.QueryParameterVersioning
|
||||
view = ReverseView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/?version=v1')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/?version=v1'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/'}
|
||||
|
||||
def test_reverse_host_name_versioning(self):
|
||||
scheme = versioning.HostNameVersioning
|
||||
view = ReverseView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_HOST='v1.example.org')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://v1.example.org/another/'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/'}
|
||||
|
||||
def test_reverse_url_path_versioning(self):
|
||||
scheme = versioning.URLPathVersioning
|
||||
view = ReverseView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/v1/endpoint/')
|
||||
response = view(request, version='v1')
|
||||
assert response.data == {'url': 'http://testserver/v1/another/'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/'}
|
||||
|
||||
def test_reverse_namespace_versioning(self):
|
||||
class FakeResolverMatch:
|
||||
namespace = 'v1'
|
||||
|
||||
scheme = versioning.NamespaceVersioning
|
||||
view = ReverseView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/v1/endpoint/')
|
||||
request.resolver_match = FakeResolverMatch
|
||||
response = view(request, version='v1')
|
||||
assert response.data == {'url': 'http://testserver/v1/namespaced/'}
|
||||
|
||||
request = factory.get('/endpoint/')
|
||||
response = view(request)
|
||||
assert response.data == {'url': 'http://testserver/another/'}
|
||||
|
||||
|
||||
class TestInvalidVersion:
|
||||
def test_invalid_query_param_versioning(self):
|
||||
scheme = versioning.QueryParameterVersioning
|
||||
view = RequestInvalidVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/?version=v3')
|
||||
response = view(request)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_invalid_host_name_versioning(self):
|
||||
scheme = versioning.HostNameVersioning
|
||||
view = RequestInvalidVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_HOST='v3.example.org')
|
||||
response = view(request)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_invalid_accept_header_versioning(self):
|
||||
scheme = versioning.AcceptHeaderVersioning
|
||||
view = RequestInvalidVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/endpoint/', HTTP_ACCEPT='application/json; version=v3')
|
||||
response = view(request)
|
||||
assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE
|
||||
|
||||
def test_invalid_url_path_versioning(self):
|
||||
scheme = versioning.URLPathVersioning
|
||||
view = RequestInvalidVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/v3/endpoint/')
|
||||
response = view(request, version='v3')
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_invalid_namespace_versioning(self):
|
||||
class FakeResolverMatch:
|
||||
namespace = 'v3'
|
||||
|
||||
scheme = versioning.NamespaceVersioning
|
||||
view = RequestInvalidVersionView.as_view(versioning_class=scheme)
|
||||
|
||||
request = factory.get('/v3/endpoint/')
|
||||
request.resolver_match = FakeResolverMatch
|
||||
response = view(request, version='v3')
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
Loading…
Reference in New Issue
Block a user