Browse Source

Fixed #35631 -- Added HttpRequest.get_preferred_type().

Jake Howard 8 months ago
parent
commit
e161bd4657

+ 71 - 12
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,14 +90,48 @@ 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 accepts(self, media_type):
-        return any(
-            accepted_type.match(media_type) for accepted_type in self.accepted_types
+    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):
+        """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."""
         self.content_type, self.content_params = parse_header_parameters(
@@ -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()]

+ 44 - 13
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 <django.middleware.cache>`, you should decorate
+        the view with :func:`vary_on_headers('Accept')
+        <django.views.decorators.vary.vary_on_headers>` 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.
-
-    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
-    <django.middleware.cache>`, you should decorate the view with
-    :func:`vary_on_headers('Accept')
-    <django.views.decorators.vary.vary_on_headers>` so that the responses are
-    properly cached.
+    ``True`` for all content types.
+
+    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()

+ 5 - 1
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

+ 53 - 0
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.

+ 75 - 1
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"])
+        )