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

This commit is contained in:
Jake Howard 2024-07-26 12:34:42 +01:00 committed by Sarah Boyce
parent 826ef00668
commit e161bd4657
5 changed files with 247 additions and 26 deletions

View File

@ -1,5 +1,6 @@
import codecs import codecs
import copy import copy
import operator
from io import BytesIO from io import BytesIO
from itertools import chain from itertools import chain
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
@ -89,13 +90,47 @@ class HttpRequest:
@cached_property @cached_property
def accepted_types(self): def accepted_types(self):
"""Return a list of MediaType instances.""" """Return a list of MediaType instances, in order of preference."""
return parse_accept_header(self.headers.get("Accept", "*/*")) 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): def accepts(self, media_type):
return any( """Does the client accept a response in the given media type?"""
accepted_type.match(media_type) for accepted_type in self.accepted_types return self.accepted_type(media_type) is not None
)
def _set_content_type_params(self, meta): def _set_content_type_params(self, meta):
"""Set content_type, content_params, and encoding.""" """Set content_type, content_params, and encoding."""
@ -678,9 +713,37 @@ class MediaType:
if self.is_all_types: if self.is_all_types:
return True return True
other = MediaType(other) other = MediaType(other)
if self.main_type == other.main_type and self.sub_type in {"*", other.sub_type}: return self.main_type == other.main_type and self.sub_type in {
return True "*",
return False 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 # It's neither necessary nor appropriate to use
@ -732,7 +795,3 @@ def validate_host(host, allowed_hosts):
return any( return any(
pattern == "*" or is_same_domain(host, pattern) for pattern in allowed_hosts 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()]

View File

@ -425,10 +425,48 @@ Methods
Returns ``True`` if the request is secure; that is, if it was made with Returns ``True`` if the request is secure; that is, if it was made with
HTTPS. 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) .. method:: HttpRequest.accepts(mime_type)
Returns ``True`` if the request ``Accept`` header matches the ``mime_type`` Returns ``True`` if the request's ``Accept`` header matches the
argument: ``mime_type`` argument:
.. code-block:: pycon .. code-block:: pycon
@ -436,17 +474,10 @@ Methods
True True
Most browsers send ``Accept: */*`` by default, so this would return Most browsers send ``Accept: */*`` by default, so this would return
``True`` for all content types. Setting an explicit ``Accept`` header in ``True`` for all content types.
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 See :ref:`content-negotiation-example` for an example of using
you are using some form of caching like Django's :mod:`cache middleware ``accepts()`` to return different content based on the ``Accept`` header.
<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.read(size=None) .. method:: HttpRequest.read(size=None)
.. method:: HttpRequest.readline() .. method:: HttpRequest.readline()

View File

@ -226,7 +226,8 @@ Models
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
* ... * The new :meth:`.HttpRequest.get_preferred_type` method can be used to query
the preferred media type the client accepts.
Security Security
~~~~~~~~ ~~~~~~~~
@ -309,6 +310,9 @@ Miscellaneous
* The minimum supported version of ``gettext`` is increased from 0.15 to 0.19. * 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: .. _deprecated-features-5.2:
Features deprecated in 5.2 Features deprecated in 5.2

View File

@ -273,3 +273,56 @@ works with an API-based workflow as well as 'normal' form POSTs::
class AuthorCreateView(JsonableResponseMixin, CreateView): class AuthorCreateView(JsonableResponseMixin, CreateView):
model = Author model = Author
fields = ["name"] 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.

View File

@ -56,6 +56,35 @@ class MediaTypeTests(TestCase):
with self.subTest(accepted_type, mime_type=mime_type): with self.subTest(accepted_type, mime_type=mime_type):
self.assertIs(MediaType(accepted_type).match(mime_type), False) 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): class AcceptHeaderTests(TestCase):
def test_no_headers(self): def test_no_headers(self):
@ -69,13 +98,14 @@ class AcceptHeaderTests(TestCase):
def test_accept_headers(self): def test_accept_headers(self):
request = HttpRequest() request = HttpRequest()
request.META["HTTP_ACCEPT"] = ( 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( self.assertEqual(
[str(accepted_type) for accepted_type in request.accepted_types], [str(accepted_type) for accepted_type in request.accepted_types],
[ [
"text/html", "text/html",
"application/xhtml+xml", "application/xhtml+xml",
"text/*",
"application/xml; q=0.9", "application/xml; q=0.9",
"*/*; q=0.8", "*/*; q=0.8",
], ],
@ -85,12 +115,20 @@ class AcceptHeaderTests(TestCase):
request = HttpRequest() request = HttpRequest()
request.META["HTTP_ACCEPT"] = "*/*" request.META["HTTP_ACCEPT"] = "*/*"
self.assertIs(request.accepts("application/json"), True) 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): def test_request_accepts_none(self):
request = HttpRequest() request = HttpRequest()
request.META["HTTP_ACCEPT"] = "" request.META["HTTP_ACCEPT"] = ""
self.assertIs(request.accepts("application/json"), False) self.assertIs(request.accepts("application/json"), False)
self.assertEqual(request.accepted_types, []) self.assertEqual(request.accepted_types, [])
self.assertIsNone(
request.get_preferred_type(["application/json", "text/plain"])
)
def test_request_accepts_some(self): def test_request_accepts_some(self):
request = HttpRequest() request = HttpRequest()
@ -101,3 +139,39 @@ class AcceptHeaderTests(TestCase):
self.assertIs(request.accepts("application/xhtml+xml"), True) self.assertIs(request.accepts("application/xhtml+xml"), True)
self.assertIs(request.accepts("application/xml"), True) self.assertIs(request.accepts("application/xml"), True)
self.assertIs(request.accepts("application/json"), False) 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"])
)