diff --git a/django/http/request.py b/django/http/request.py index a0bdf49312..790e4546d7 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -20,6 +20,8 @@ from django.utils.functional import cached_property from django.utils.http import is_same_domain, limited_parse_qsl from django.utils.regex_helper import _lazy_re_compile +from .multipartparser import parse_header + RAISE_ERROR = object() host_validation_re = _lazy_re_compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:\d+)?$") @@ -71,6 +73,17 @@ class HttpRequest: def headers(self): return HttpHeaders(self.META) + @cached_property + def accepted_types(self): + """Return a list of MediaType instances.""" + return parse_accept_header(self.headers.get('Accept', '*/*')) + + def accepts(self, media_type): + return any( + accepted_type.match(media_type) + for accepted_type in self.accepted_types + ) + def _set_content_type_params(self, meta): """Set content_type, content_params, and encoding.""" self.content_type, self.content_params = cgi.parse_header(meta.get('CONTENT_TYPE', '')) @@ -557,6 +570,40 @@ class QueryDict(MultiValueDict): return '&'.join(output) +class MediaType: + def __init__(self, media_type_raw_line): + full_type, self.params = parse_header( + media_type_raw_line.encode('ascii') if media_type_raw_line else b'' + ) + self.main_type, _, self.sub_type = full_type.partition('/') + + def __str__(self): + params_str = ''.join( + '; %s=%s' % (k, v.decode('ascii')) + for k, v in self.params.items() + ) + return '%s%s%s' % ( + self.main_type, + ('/%s' % self.sub_type) if self.sub_type else '', + params_str, + ) + + def __repr__(self): + return '<%s: %s>' % (self.__class__.__qualname__, self) + + @property + def is_all_types(self): + return self.main_type == '*' and self.sub_type == '*' + + def match(self, other): + if self.is_all_types: + return True + other = MediaType(other) + if self.main_type == other.main_type and self.sub_type in {'*', other.sub_type}: + return True + return False + + # It's neither necessary nor appropriate to use # django.utils.encoding.force_str() for parsing URLs and form inputs. Thus, # this slightly more restricted function, used by QueryDict. @@ -612,3 +659,7 @@ def validate_host(host, allowed_hosts): Return ``True`` for a valid host, ``False`` otherwise. """ return any(pattern == '*' or is_same_domain(host, pattern) for pattern in allowed_hosts) + + +def parse_accept_header(header): + return [MediaType(token) for token in header.split(',') if token.strip()] diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 370993444b..2f9e78e358 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -406,6 +406,29 @@ Methods Returns ``True`` if the request is secure; that is, if it was made with HTTPS. +.. method:: HttpRequest.accepts(mime_type) + + .. versionadded:: 3.1 + + Returns ``True`` if the request ``Accept`` header matches the ``mime_type`` + argument:: + + >>> request.accepts('text/html') + True + + Most browsers send ``Accept: */*`` by default, so this would return + ``True`` for all content types. Setting an explicit ``Accept`` header in + API requests can be useful for returning a different content type for those + consumers only. See :ref:`content-negotiation-example` of using + ``accepts()`` to return different content to API consumers. + + If a response varies depending on the content of the ``Accept`` header and + you are using some form of caching like Django's :mod:`cache middleware + `, you should decorate the view with + :func:`vary_on_headers('Accept') + ` so that the responses are + properly cached. + .. method:: HttpRequest.is_ajax() Returns ``True`` if the request was made via an ``XMLHttpRequest``, by diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 709c3917bc..3c88ecadd7 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -282,6 +282,9 @@ Requests and Responses now allow using ``samesite='None'`` (string) to explicitly state that the cookie is sent with all same-site and cross-site requests. +* The new :meth:`.HttpRequest.accepts` method returns whether the request + accepts the given MIME type according to the ``Accept`` HTTP header. + Serialization ~~~~~~~~~~~~~ diff --git a/docs/topics/class-based-views/generic-editing.txt b/docs/topics/class-based-views/generic-editing.txt index c1e73fc2fb..7c61592142 100644 --- a/docs/topics/class-based-views/generic-editing.txt +++ b/docs/topics/class-based-views/generic-editing.txt @@ -222,41 +222,43 @@ to edit, and override aren't logged in from accessing the form. If you omit that, you'll need to handle unauthorized users in :meth:`~.ModelFormMixin.form_valid()`. -AJAX example -============ +.. _content-negotiation-example: + +Content negotiation example +=========================== Here is an example showing how you might go about implementing a form that -works for AJAX requests as well as 'normal' form POSTs:: +works with an API-based workflow as well as 'normal' form POSTs:: from django.http import JsonResponse from django.views.generic.edit import CreateView from myapp.models import Author - class AjaxableResponseMixin: + class JsonableResponseMixin: """ - Mixin to add AJAX support to a form. + Mixin to add JSON support to a form. Must be used with an object-based FormView (e.g. CreateView) """ def form_invalid(self, form): response = super().form_invalid(form) - if self.request.is_ajax(): - return JsonResponse(form.errors, status=400) - else: + if self.request.accepts('text/html'): return response + else: + return JsonResponse(form.errors, status=400) def form_valid(self, form): # We make sure to call the parent's form_valid() method because # it might do some processing (in the case of CreateView, it will # call form.save() for example). response = super().form_valid(form) - if self.request.is_ajax(): + if self.request.accepts('text/html'): + return response + else: data = { 'pk': self.object.pk, } return JsonResponse(data) - else: - return response - class AuthorCreate(AjaxableResponseMixin, CreateView): + class AuthorCreate(JsonableResponseMixin, CreateView): model = Author fields = ['name'] diff --git a/tests/requests/test_accept_header.py b/tests/requests/test_accept_header.py new file mode 100644 index 0000000000..76559920af --- /dev/null +++ b/tests/requests/test_accept_header.py @@ -0,0 +1,101 @@ +from unittest import TestCase + +from django.http import HttpRequest +from django.http.request import MediaType + + +class MediaTypeTests(TestCase): + def test_empty(self): + for empty_media_type in (None, ''): + with self.subTest(media_type=empty_media_type): + media_type = MediaType(empty_media_type) + self.assertIs(media_type.is_all_types, False) + self.assertEqual(str(media_type), '') + self.assertEqual(repr(media_type), '') + + def test_str(self): + self.assertEqual(str(MediaType('*/*; q=0.8')), '*/*; q=0.8') + self.assertEqual(str(MediaType('application/xml')), 'application/xml') + + def test_repr(self): + self.assertEqual(repr(MediaType('*/*; q=0.8')), '') + self.assertEqual( + repr(MediaType('application/xml')), + '', + ) + + def test_is_all_types(self): + self.assertIs(MediaType('*/*').is_all_types, True) + self.assertIs(MediaType('*/*; q=0.8').is_all_types, True) + self.assertIs(MediaType('text/*').is_all_types, False) + self.assertIs(MediaType('application/xml').is_all_types, False) + + def test_match(self): + tests = [ + ('*/*; q=0.8', '*/*'), + ('*/*', 'application/json'), + (' */* ', 'application/json'), + ('application/*', 'application/json'), + ('application/xml', 'application/xml'), + (' application/xml ', 'application/xml'), + ('application/xml', ' application/xml '), + ] + for accepted_type, mime_type in tests: + with self.subTest(accepted_type, mime_type=mime_type): + self.assertIs(MediaType(accepted_type).match(mime_type), True) + + def test_no_match(self): + tests = [ + (None, '*/*'), + ('', '*/*'), + ('; q=0.8', '*/*'), + ('application/xml', 'application/html'), + ('application/xml', '*/*'), + ] + for accepted_type, mime_type in tests: + with self.subTest(accepted_type, mime_type=mime_type): + self.assertIs(MediaType(accepted_type).match(mime_type), False) + + +class AcceptHeaderTests(TestCase): + def test_no_headers(self): + """Absence of Accept header defaults to '*/*'.""" + request = HttpRequest() + self.assertEqual( + [str(accepted_type) for accepted_type in request.accepted_types], + ['*/*'], + ) + + def test_accept_headers(self): + request = HttpRequest() + request.META['HTTP_ACCEPT'] = ( + 'text/html, application/xhtml+xml,application/xml ;q=0.9,*/*;q=0.8' + ) + self.assertEqual( + [str(accepted_type) for accepted_type in request.accepted_types], + [ + 'text/html', + 'application/xhtml+xml', + 'application/xml; q=0.9', + '*/*; q=0.8', + ], + ) + + def test_request_accepts_any(self): + request = HttpRequest() + request.META['HTTP_ACCEPT'] = '*/*' + self.assertIs(request.accepts('application/json'), True) + + def test_request_accepts_none(self): + request = HttpRequest() + request.META['HTTP_ACCEPT'] = '' + self.assertIs(request.accepts('application/json'), False) + self.assertEqual(request.accepted_types, []) + + def test_request_accepts_some(self): + request = HttpRequest() + request.META['HTTP_ACCEPT'] = 'text/html,application/xhtml+xml,application/xml;q=0.9' + self.assertIs(request.accepts('text/html'), True) + self.assertIs(request.accepts('application/xhtml+xml'), True) + self.assertIs(request.accepts('application/xml'), True) + self.assertIs(request.accepts('application/json'), False)