Skip to content

Commit

Permalink
Fixes #20147 -- Added Request.headers
Browse files Browse the repository at this point in the history
Provides an alternative to request.META to access HTTP headers,
implemented using an immutable case-insensitive dictionary.
Targeting Django release 2.2.
  • Loading branch information
santiagobasulto committed Jul 10, 2018
1 parent 263e039 commit 8ce2c2e
Show file tree
Hide file tree
Showing 7 changed files with 493 additions and 5 deletions.
8 changes: 7 additions & 1 deletion django/http/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
)
from django.core.files import uploadhandler
from django.http.multipartparser import MultiPartParser, MultiPartParserError
from django.utils.datastructures import ImmutableList, MultiValueDict
from django.utils.datastructures import (
EnvironHeaders, ImmutableList, MultiValueDict,
)
from django.utils.deprecation import RemovedInDjango30Warning
from django.utils.encoding import escape_uri_path, iri_to_uri
from django.utils.functional import cached_property
Expand Down Expand Up @@ -65,6 +67,10 @@ def __repr__(self):
return '<%s>' % self.__class__.__name__
return '<%s: %s %r>' % (self.__class__.__name__, self.method, self.get_full_path())

@cached_property
def headers(self):
return EnvironHeaders(self.META)

def _get_raw_host(self):
"""
Return the HTTP host using the environment or request headers. Skip
Expand Down
82 changes: 82 additions & 0 deletions django/utils/datastructures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
from collections import OrderedDict
from collections.abc import Mapping


class OrderedSet:
Expand Down Expand Up @@ -280,3 +281,84 @@ def __getitem__(self, key):
if use_func:
return self.func(value)
return value


def _destruct_iterable_mapping_values(data):
for i, elem in enumerate(data):
if len(elem) != 2:
raise ValueError("dictionary update sequence element #{} has "
"length {}; 2 is required".format(i, len(elem)))

if not isinstance(elem[0], str):
raise ValueError('Element key invalid, only strings are allowed')

yield tuple(elem)


class ImmutableCaseInsensitiveDict(Mapping):
"""An immutable case-insensitive dictionary that still preserves
the case of the original keys used to create it."""

def __init__(self, data):
if not isinstance(data, Mapping):
data = {k: v for k, v in _destruct_iterable_mapping_values(data)}
self._store = {k.lower(): (k, v) for k, v in data.items()}

def __getitem__(self, key):
return self._store[key.lower()][1]

def __len__(self):
return len(self._store)

def __eq__(self, other):
if not isinstance(other, Mapping):
return NotImplemented
return {
k.lower(): v for k, v in self.items()
} == {
k.lower(): v for k, v in other.items()
}

def __iter__(self):
return (original_key for original_key, value in self._store.values())

def __repr__(self):
return repr({key: value for key, value in self._store.values()})

def copy(self):
return self


class EnvironHeaders(ImmutableCaseInsensitiveDict):
IGNORE_EXCEPTIONS = {'HTTP_CONTENT_TYPE', 'HTTP_CONTENT_LENGTH'}
SPECIAL_UNCHANGED_HEADERS = {'CONTENT_TYPE', 'CONTENT_LENGTH'}
HTTP_PREFIX = 'HTTP_'

def __init__(self, environ):
header_name_generator = ((
self.parse_cgi_header_name(header_name), value
) for header_name, value in environ.items())
headers = {
header: value for header, value in header_name_generator if header
}

super().__init__(headers)

@classmethod
def _style_header_name(cls, header_name):
return header_name.title()

@classmethod
def parse_cgi_header_name(cls, cgi_header):
ignore_header = (
cgi_header in cls.IGNORE_EXCEPTIONS or
not cgi_header.startswith(cls.HTTP_PREFIX) and
cgi_header not in cls.SPECIAL_UNCHANGED_HEADERS)

if ignore_header:
return None

if cgi_header not in cls.SPECIAL_UNCHANGED_HEADERS:
cgi_header = cgi_header[len(cls.HTTP_PREFIX):]

return cls._style_header_name(cgi_header.replace('_', '-'))
29 changes: 28 additions & 1 deletion docs/ref/request-response.txt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ All attributes should be considered read-only, unless stated otherwise.

.. attribute:: HttpRequest.META

A dictionary containing all available HTTP headers. Available headers
A dictionary containing the `WSGI environ \
<https://www.python.org/dev/peps/pep-0333/#environ-variables>`_, including all available HTTP headers.
Available request headers are prefixed with the string ``HTTP_`` and
depend on the client and server, but here are some examples:

* ``CONTENT_LENGTH`` -- The length of the request body (as a string).
Expand All @@ -160,13 +162,38 @@ All attributes should be considered read-only, unless stated otherwise.
underscores and adding an ``HTTP_`` prefix to the name. So, for example, a
header called ``X-Bender`` would be mapped to the ``META`` key
``HTTP_X_BENDER``.
See :attr:`~HttpRequest.headers` for more convenient header access (``request.headers['User-Agent']``).

Note that :djadmin:`runserver` strips all headers with underscores in the
name, so you won't see them in ``META``. This prevents header-spoofing
based on ambiguity between underscores and dashes both being normalizing to
underscores in WSGI environment variables. It matches the behavior of
Web servers like Nginx and Apache 2.4+.

.. _headers:
.. attribute:: HttpRequest.headers

.. versionadded:: 2.2

A case-insensitive dictionary containing the headers passed by the client.
The name of each header is Title-Cased (``User-Agent``) when
headers are printed or inspected (``str``, ``repr``).

When accessing elements with ``headers.get``, ``headers[]`` or
checking membership (``in``), case is ignored. Examples::

'User-Agent' in request.headers # True
'user-agent' in request.headers # True

# getitem:
request.headers['User-Agent'] # Returns the value of User-Agent
request.headers['user-agent'] # Returns the value of User-Agent

# .get():
request.headers.get('User-Agent') # Returns the value of User-Agent
request.headers.get('user-agent') # Returns the value of User-Agent


.. attribute:: HttpRequest.resolver_match

An instance of :class:`~django.urls.ResolverMatch` representing the
Expand Down
11 changes: 11 additions & 0 deletions docs/releases/2.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ Requests and Responses
sets the ``Content-Disposition`` header to make the browser ask if the user
wants to download the file. ``FileResponse`` also tries to set the
``Content-Type`` and ``Content-Length`` headers where appropriate.
* Added :attr:`.HttpRequest.headers`.

Serialization
~~~~~~~~~~~~~

* ...

Signals
~~~~~~~

* ...

Templates
~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion docs/releases/2.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Models
Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~

* ...
* Added :attr:`.HttpRequest.headers`.

Serialization
~~~~~~~~~~~~~
Expand Down
118 changes: 118 additions & 0 deletions tests/requests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,3 +806,121 @@ def test_request_path_begins_with_two_slashes(self):
for location, expected_url in tests:
with self.subTest(location=location):
self.assertEqual(request.build_absolute_uri(location=location), expected_url)


class CaseInsensitiveHeadersTestCase(SimpleTestCase):
TESTING_ENVIRON = {
'PATH_INFO': '/somepath/',
'REQUEST_METHOD': 'get',
'wsgi.input': BytesIO(b''),
'SERVER_NAME': 'internal.com',
'SERVER_PORT': 80,

# Special Headers
'CONTENT_TYPE': 'text/html',
'CONTENT_LENGTH': '100',

# Invalid Headers
'HTTP_CONTENT_TYPE': 'text/html',
'HTTP_CONTENT_LENGTH': '100',

# Regular headers:
'HTTP_ACCEPT': '*',
'HTTP_HOST': 'example.com',
'HTTP_USER_AGENT': 'python-requests/1.2.0',
'HTTP_REFERER': 'https://docs.djangoproject.com',
'HTTP_IF_MATCH': 'py7h0n',
'HTTP_IF_NONE_MATCH': 'dj4n60',
'HTTP_IF_MODIFIED_SINCE': 'Sat, 12 Feb 2011 17:38:44 GMT',
'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br',
'HTTP_CONNECTION': 'keep-alive',
'HTTP_PRAGMA': 'no-cache',
'HTTP_CACHE_CONTROL': 'no-cache',
'HTTP_UPGRADE_INSECURE_REQUESTS': '1',
'HTTP_ACCEPT_LANGUAGE': 'es-419,es;q=0.9,en;q=0.8,en-US;q=0.7',
'HTTP_COOKIE': '%7B%22hello%22%3A%22world%22%7D;another=value',

# Special headers used by AWS and other services
'HTTP_X_PROTO': 'https',
'HTTP_X_FORWARDED_HOST': 'forward.com',
'HTTP_X_FORWARDED_PORT': '80',
'HTTP_X_FORWARDED_PROTOCOL': 'https',

# Custom headers
'HTTP_X_CUSTOM_HEADER_1': 'custom_header_1',
'HTTP_X_CUSTOM_HEADER_2': 'custom_header_2',
}

def test_base_request_headers(self):
request = HttpRequest()
request.META = self.TESTING_ENVIRON

self.assertEqual(dict(request.headers), {
'Content-Type': 'text/html',
'Content-Length': '100',
'Accept': '*', 'Host': 'example.com',
'User-Agent': 'python-requests/1.2.0',
'Referer': 'https://docs.djangoproject.com',
'If-Match': 'py7h0n',
'If-None-Match': 'dj4n60',
'If-Modified-Since': 'Sat, 12 Feb 2011 17:38:44 GMT',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Accept-Language': 'es-419,es;q=0.9,en;q=0.8,en-US;q=0.7',
'Cookie': '%7B%22hello%22%3A%22world%22%7D;another=value',
'X-Proto': 'https',
'X-Forwarded-Host': 'forward.com',
'X-Forwarded-Port': '80',
'X-Forwarded-Protocol': 'https',
'X-Custom-Header-1': 'custom_header_1',
'X-Custom-Header-2': 'custom_header_2'})

def test_wsgi_request_headers(self):
request = WSGIRequest(self.TESTING_ENVIRON)

self.assertEqual(dict(request.headers), {
'Content-Type': 'text/html',
'Content-Length': '100',
'Accept': '*', 'Host': 'example.com',
'User-Agent': 'python-requests/1.2.0',
'Referer': 'https://docs.djangoproject.com',
'If-Match': 'py7h0n',
'If-None-Match': 'dj4n60',
'If-Modified-Since': 'Sat, 12 Feb 2011 17:38:44 GMT',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Accept-Language': 'es-419,es;q=0.9,en;q=0.8,en-US;q=0.7',
'Cookie': '%7B%22hello%22%3A%22world%22%7D;another=value',
'X-Proto': 'https',
'X-Forwarded-Host': 'forward.com',
'X-Forwarded-Port': '80',
'X-Forwarded-Protocol': 'https',
'X-Custom-Header-1': 'custom_header_1',
'X-Custom-Header-2': 'custom_header_2'})

def test_wsgi_request_headers_getitem(self):
request = WSGIRequest(self.TESTING_ENVIRON)

self.assertEqual(request.headers['User-Agent'], 'python-requests/1.2.0')
self.assertEqual(
request.headers['X-Custom-Header-1'], 'custom_header_1')
self.assertEqual(request.headers['Pragma'], 'no-cache')
self.assertEqual(request.headers['Content-Type'], 'text/html')
self.assertEqual(request.headers['Content-Length'], '100')

def test_wsgi_request_headers_get(self):
request = WSGIRequest(self.TESTING_ENVIRON)

self.assertEqual(
request.headers.get('User-Agent'), 'python-requests/1.2.0')
self.assertEqual(
request.headers.get('X-Custom-Header-1'), 'custom_header_1')
self.assertEqual(request.headers.get('Pragma'), 'no-cache')
self.assertEqual(request.headers.get('Content-Type'), 'text/html')
self.assertEqual(request.headers.get('Content-Length'), '100')
Loading

0 comments on commit 8ce2c2e

Please sign in to comment.