From e161bd4657177f0e723a14a6e414884363b31a5d Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Fri, 26 Jul 2024 12:34:42 +0100 Subject: [PATCH] Fixed #35631 -- Added HttpRequest.get_preferred_type(). --- django/http/request.py | 83 ++++++++++++++++--- docs/ref/request-response.txt | 55 +++++++++--- docs/releases/5.2.txt | 6 +- .../class-based-views/generic-editing.txt | 53 ++++++++++++ tests/requests_tests/test_accept_header.py | 76 ++++++++++++++++- 5 files changed, 247 insertions(+), 26 deletions(-) diff --git a/django/http/request.py b/django/http/request.py index 4c27d576ba..986d4eee89 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -1,5 +1,6 @@ import codecs import copy +import operator from io import BytesIO from itertools import chain from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit @@ -89,13 +90,47 @@ class HttpRequest: @cached_property def accepted_types(self): - """Return a list of MediaType instances.""" - return parse_accept_header(self.headers.get("Accept", "*/*")) + """Return a list of MediaType instances, in order of preference.""" + header_value = self.headers.get("Accept", "*/*") + return sorted( + (MediaType(token) for token in header_value.split(",") if token.strip()), + key=operator.attrgetter("quality", "specificity"), + reverse=True, + ) + + def accepted_type(self, media_type): + """ + Return the preferred MediaType instance which matches the given media type. + """ + return next( + ( + accepted_type + for accepted_type in self.accepted_types + if accepted_type.match(media_type) + ), + None, + ) + + def get_preferred_type(self, media_types): + """Select the preferred media type from the provided options.""" + if not media_types or not self.accepted_types: + return None + + desired_types = [ + (accepted_type, media_type) + for media_type in media_types + if (accepted_type := self.accepted_type(media_type)) is not None + ] + + if not desired_types: + return None + + # Of the desired media types, select the one which is most desirable. + return min(desired_types, key=lambda t: self.accepted_types.index(t[0]))[1] def accepts(self, media_type): - return any( - accepted_type.match(media_type) for accepted_type in self.accepted_types - ) + """Does the client accept a response in the given media type?""" + return self.accepted_type(media_type) is not None def _set_content_type_params(self, meta): """Set content_type, content_params, and encoding.""" @@ -678,9 +713,37 @@ class MediaType: 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 + return self.main_type == other.main_type and self.sub_type in { + "*", + other.sub_type, + } + + @cached_property + def quality(self): + try: + quality = float(self.params.get("q", 1)) + except ValueError: + # Discard invalid values. + return 1 + + # Valid quality values must be between 0 and 1. + if quality < 0 or quality > 1: + return 1 + + return round(quality, 3) + + @property + def specificity(self): + """ + Return a value from 0-3 for how specific the media type is. + """ + if self.main_type == "*": + return 0 + elif self.sub_type == "*": + return 1 + elif self.quality == 1: + return 2 + return 3 # It's neither necessary nor appropriate to use @@ -732,7 +795,3 @@ def validate_host(host, allowed_hosts): 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 20c04279b2..31111a435a 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -425,10 +425,48 @@ Methods Returns ``True`` if the request is secure; that is, if it was made with HTTPS. +.. method:: HttpRequest.get_preferred_type(media_types) + + .. versionadded:: 5.2 + + Returns the preferred mime type from ``media_types``, based on the + ``Accept`` header, or ``None`` if the client does not accept any of the + provided types. + + Assuming the client sends an ``Accept`` header of + ``text/html,application/json;q=0.8``: + + .. code-block:: pycon + + >>> request.get_preferred_type(["text/html", "application/json"]) + "text/html" + >>> request.get_preferred_type(["application/json", "text/plain"]) + "application/json" + >>> request.get_preferred_type(["application/xml", "text/plain"]) + None + + Most browsers send ``Accept: */*`` by default, meaning they don't have a + preference, in which case the first item in ``media_types`` would be + returned. + + 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` for an example of returning + different content based on the ``Accept`` header. + + .. note:: + + 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.accepts(mime_type) - Returns ``True`` if the request ``Accept`` header matches the ``mime_type`` - argument: + Returns ``True`` if the request's ``Accept`` header matches the + ``mime_type`` argument: .. code-block:: pycon @@ -436,17 +474,10 @@ Methods 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. + ``True`` for all content types. - 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. + See :ref:`content-negotiation-example` for an example of using + ``accepts()`` to return different content based on the ``Accept`` header. .. method:: HttpRequest.read(size=None) .. method:: HttpRequest.readline() diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index add5d9506a..3dd7b00b29 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -226,7 +226,8 @@ Models Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The new :meth:`.HttpRequest.get_preferred_type` method can be used to query + the preferred media type the client accepts. Security ~~~~~~~~ @@ -309,6 +310,9 @@ Miscellaneous * The minimum supported version of ``gettext`` is increased from 0.15 to 0.19. +* ``HttpRequest.accepted_types`` is now sorted by the client's preference, based + on the request's ``Accept`` header. + .. _deprecated-features-5.2: Features deprecated in 5.2 diff --git a/docs/topics/class-based-views/generic-editing.txt b/docs/topics/class-based-views/generic-editing.txt index 5841c703f6..4310ae9dcc 100644 --- a/docs/topics/class-based-views/generic-editing.txt +++ b/docs/topics/class-based-views/generic-editing.txt @@ -273,3 +273,56 @@ works with an API-based workflow as well as 'normal' form POSTs:: class AuthorCreateView(JsonableResponseMixin, CreateView): model = Author fields = ["name"] + +The above example assumes that if the client supports ``text/html``, that they +would prefer it. However, this may not always be true. When requesting a +``.css`` file, many browsers will send the header +``Accept: text/css,*/*;q=0.1``, indicating that they would prefer CSS, but +anything else is fine. This means ``request.accepts("text/html") will be +``True``. + +To determine the correct format, taking into consideration the client's +preference, use :func:`django.http.HttpRequest.get_preferred_type`:: + + class JsonableResponseMixin: + """ + Mixin to add JSON support to a form. + Must be used with an object-based FormView (e.g. CreateView). + """ + + accepted_media_types = ["text/html", "application/json"] + + def dispatch(self, request, *args, **kwargs): + if request.get_preferred_type(self.accepted_media_types) is None: + # No format in common. + return HttpResponse( + status_code=406, headers={"Accept": ",".join(self.accepted_media_types)} + ) + + return super().dispatch(request, *args, **kwargs) + + def form_invalid(self, form): + response = super().form_invalid(form) + accepted_type = request.get_preferred_type(self.accepted_media_types) + if accepted_type == "text/html": + return response + elif accepted_type == "application/json": + 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) + accepted_type = request.get_preferred_type(self.accepted_media_types) + if accepted_type == "text/html": + return response + elif accepted_type == "application/json": + data = { + "pk": self.object.pk, + } + return JsonResponse(data) + +.. versionchanged:: 5.2 + + The :meth:`.HttpRequest.get_preferred_type` method was added. diff --git a/tests/requests_tests/test_accept_header.py b/tests/requests_tests/test_accept_header.py index 5afb9e9993..6585fec678 100644 --- a/tests/requests_tests/test_accept_header.py +++ b/tests/requests_tests/test_accept_header.py @@ -56,6 +56,35 @@ class MediaTypeTests(TestCase): with self.subTest(accepted_type, mime_type=mime_type): self.assertIs(MediaType(accepted_type).match(mime_type), False) + def test_quality(self): + tests = [ + ("*/*; q=0.8", 0.8), + ("*/*; q=0.0001", 0), + ("*/*; q=0.12345", 0.123), + ("*/*; q=0.1", 0.1), + ("*/*; q=-1", 1), + ("*/*; q=2", 1), + ("*/*; q=h", 1), + ("*/*", 1), + ] + for accepted_type, quality in tests: + with self.subTest(accepted_type, quality=quality): + self.assertEqual(MediaType(accepted_type).quality, quality) + + def test_specificity(self): + tests = [ + ("*/*", 0), + ("*/*;q=0.5", 0), + ("text/*", 1), + ("text/*;q=0.5", 1), + ("text/html", 2), + ("text/html;q=1", 2), + ("text/html;q=0.5", 3), + ] + for accepted_type, specificity in tests: + with self.subTest(accepted_type, specificity=specificity): + self.assertEqual(MediaType(accepted_type).specificity, specificity) + class AcceptHeaderTests(TestCase): def test_no_headers(self): @@ -69,13 +98,14 @@ class AcceptHeaderTests(TestCase): def test_accept_headers(self): request = HttpRequest() request.META["HTTP_ACCEPT"] = ( - "text/html, application/xhtml+xml,application/xml ;q=0.9,*/*;q=0.8" + "text/*,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", + "text/*", "application/xml; q=0.9", "*/*; q=0.8", ], @@ -85,12 +115,20 @@ class AcceptHeaderTests(TestCase): request = HttpRequest() request.META["HTTP_ACCEPT"] = "*/*" self.assertIs(request.accepts("application/json"), True) + self.assertIsNone(request.get_preferred_type([])) + self.assertEqual( + request.get_preferred_type(["application/json", "text/plain"]), + "application/json", + ) def test_request_accepts_none(self): request = HttpRequest() request.META["HTTP_ACCEPT"] = "" self.assertIs(request.accepts("application/json"), False) self.assertEqual(request.accepted_types, []) + self.assertIsNone( + request.get_preferred_type(["application/json", "text/plain"]) + ) def test_request_accepts_some(self): request = HttpRequest() @@ -101,3 +139,39 @@ class AcceptHeaderTests(TestCase): self.assertIs(request.accepts("application/xhtml+xml"), True) self.assertIs(request.accepts("application/xml"), True) self.assertIs(request.accepts("application/json"), False) + + def test_accept_header_priority(self): + request = HttpRequest() + request.META["HTTP_ACCEPT"] = ( + "text/html,application/xml;q=0.9,*/*;q=0.1,text/*;q=0.5" + ) + + tests = [ + (["text/html", "application/xml"], "text/html"), + (["application/xml", "application/json"], "application/xml"), + (["application/json"], "application/json"), + (["application/json", "text/plain"], "text/plain"), + ] + for types, preferred_type in tests: + with self.subTest(types, preferred_type=preferred_type): + self.assertEqual(str(request.get_preferred_type(types)), preferred_type) + + def test_accept_header_priority_overlapping_mime(self): + request = HttpRequest() + request.META["HTTP_ACCEPT"] = "text/*;q=0.8,text/html;q=0.8" + + self.assertEqual( + [str(accepted_type) for accepted_type in request.accepted_types], + [ + "text/html; q=0.8", + "text/*; q=0.8", + ], + ) + + def test_no_matching_accepted_type(self): + request = HttpRequest() + request.META["HTTP_ACCEPT"] = "text/html" + + self.assertIsNone( + request.get_preferred_type(["application/json", "text/plain"]) + )