Make request header lookups case-insensitive

Closes #1861
This commit is contained in:
Simon Willison 2026-09-16 10:27:35 -07:00 committed by GitHub
commit df4c0fde0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 63 additions and 4 deletions

View file

@ -83,6 +83,19 @@ SAMESITE_VALUES = ("strict", "lax", "none")
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
class _RequestHeaders(dict):
"""Incoming headers with lowercase keys and case-insensitive lookups."""
def __getitem__(self, key):
return super().__getitem__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def __contains__(self, key):
return super().__contains__(key.lower())
class Request:
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
self.scope = scope
@ -112,10 +125,10 @@ class Request:
@property
def headers(self):
return {
k.decode("latin-1").lower(): v.decode("latin-1")
return _RequestHeaders(
(k.decode("latin-1").lower(), v.decode("latin-1"))
for k, v in self.scope.get("headers") or []
}
)
@property
def host(self):

View file

@ -26,6 +26,7 @@ Datasette plugins can now use **background tasks** to run code independent of th
Bug fixes
~~~~~~~~~
- :ref:`request.headers <internals_request>` now supports case-insensitive header lookups, so ``request.headers.get("Content-Type")`` works as well as ``request.headers.get("content-type")``. (:issue:`1861`)
- CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`)
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`)
- Numeric comparison filters now correctly handle decimal values, negative numbers and scientific notation when filtering computed columns and SQL views. Thanks, `Rami Abdelrazzaq <https://github.com/RamiNoodle733>`__. (:issue:`1681`, :pr:`2876`)

View file

@ -26,7 +26,7 @@ The request object is passed to various plugin hooks. It represents an incoming
The request scheme - usually ``https`` or ``http``.
``.headers`` - dictionary (str -> str)
A dictionary of incoming HTTP request headers. Header names have been converted to lowercase.
A dictionary of incoming HTTP request headers. Header lookups using ``request.headers["Content-Type"]``, ``request.headers.get("Content-Type")`` and ``"Content-Type" in request.headers`` are case-insensitive. Header names are lowercase when iterating over the dictionary.
``.cookies`` - dictionary (str -> str)
A dictionary of incoming cookies

View file

@ -35,6 +35,51 @@ def _receive_chunks(chunks):
return receive
@pytest.mark.parametrize(
"header_name", [b"content-type", b"Content-Type", b"CONTENT-TYPE"]
)
@pytest.mark.parametrize("lookup", ["content-type", "Content-Type", "CONTENT-TYPE"])
def test_request_headers_case_insensitive(header_name, lookup):
request = Request({"headers": [(header_name, b"application/json")]}, None)
assert request.headers.get(lookup) == "application/json"
assert request.headers[lookup] == "application/json"
assert lookup in request.headers
def test_request_headers_mapping():
request = Request(
{
"headers": [
(b"Content-Type", b"application/json"),
(b"X-Title", "café".encode("latin-1")),
(b"CONTENT-TYPE", b"text/plain"),
]
},
None,
)
headers = request.headers
expected = {"content-type": "text/plain", "x-title": "café"}
assert headers == expected
assert dict(headers) == expected
assert list(headers) == list(expected)
assert list(headers.keys()) == list(expected.keys())
assert list(headers.items()) == list(expected.items())
assert json.loads(json.dumps(headers)) == expected
assert headers["Content-Type"] == "text/plain"
assert headers["X-Title"] == "café"
@pytest.mark.parametrize("scope", [{}, {"headers": None}, {"headers": []}])
def test_request_headers_missing(scope):
headers = Request(scope, None).headers
assert headers == {}
assert headers.get("Content-Type") is None
assert headers.get("Content-Type", "default") == "default"
assert "Content-Type" not in headers
with pytest.raises(KeyError):
headers["Content-Type"]
@pytest.mark.asyncio
async def test_request_post_vars():
scope = {