Repository: falconry/falcon
Pull request #2063 (2022-05-12 03:03:17)

feat(Request): add Request.headers_lower to provide a normalized interface between WSGI and ASGI
# Summary of Changes

This patch adds a `headers_lower` property to provide a unified way to get all headers lowercased for middleware, etc. that needs to be compatible with both WSGI and ASGI. This was done in lieu of introducing a breaking change to the WSGI `headers` property.

# Related Issues

N/A

# Pull Request Checklist

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](../CONTRIBUTING.md) at least once; it will save you a few review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing to do.

- [ ] Applied changes to both WSGI and ASGI code paths and interfaces (where applicable).
- [ ] Added **tests** for changed code.
- [ ] Prefixed code comments with GitHub nick and an appropriate prefix.
- [ ] Coding style is consistent with the rest of the framework.
- [ ] Updated **documentation** for changed code.
    - [ ] Added docstrings for any new classes, functions, or modules.
    - [ ] Updated docstrings for any modifications to existing code.
    - [ ] Updated both WSGI and ASGI docs (where applicable).
    - [ ] Added references to new classes, functions, or modules to the relevant RST file under `docs/`.
    - [ ] Updated all relevant supporting documentation files under `docs/`.
    - [ ] A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/usage/restructuredtext/directives.html?highlight=versionadded#directive-versionadded).
- [ ] Changes (and possible deprecations) have [towncrier](https://towncrier.readthedocs.io/en/actual-freaking-docs/index.html) news fragments under `docs/_newsfragments/`, with the file name format `{issue_number}.{fragment_type}.rst`. (Run `towncrier --draft` to ensure it renders correctly.)

If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing!

*PR template inspired by the attrs project.*


codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) Report
> Merging [#2063](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) (db0408c) into [master](https://codecov.io/gh/falconry/falcon/commit/47e3e162bf5bd322ec5c2b9a7722e038d635f69f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) (47e3e16) will **decrease** coverage by `11.80%`.
> The diff coverage is `100.00%`.

```diff
@@             Coverage Diff              @@
##            master    #2063       +/-   ##
============================================
- Coverage   100.00%   88.19%   -11.81%     
============================================
  Files           63       54        -9     
  Lines         6715     5218     -1497     
  Branches      1242      796      -446     
============================================
- Hits          6715     4602     -2113     
- Misses           0      601      +601     
- Partials         0       15       +15     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) | Coverage Δ | |
|---|---|---|
| [falcon/request.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3JlcXVlc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [falcon/asgi/\_request\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FzZ2kvX3JlcXVlc3RfaGVscGVycy5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/\_\_init\_\_.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FzZ2kvX19pbml0X18ucHk=) | `27.27% <0.00%> (-72.73%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `38.05% <0.00%> (-61.95%)` | :arrow_down: |
| [falcon/responders.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Jlc3BvbmRlcnMucHk=) | `64.70% <0.00%> (-35.30%)` | :arrow_down: |
| [falcon/testing/client.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Rlc3RpbmcvY2xpZW50LnB5) | `67.01% <0.00%> (-32.99%)` | :arrow_down: |
| [falcon/app\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FwcF9oZWxwZXJzLnB5) | `68.13% <0.00%> (-31.87%)` | :arrow_down: |
| [falcon/media/validators/jsonschema.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL21lZGlhL3ZhbGlkYXRvcnMvanNvbnNjaGVtYS5weQ==) | `69.04% <0.00%> (-30.96%)` | :arrow_down: |
| [falcon/util/\_\_init\_\_.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3V0aWwvX19pbml0X18ucHk=) | `76.08% <0.00%> (-23.92%)` | :arrow_down: |
| [falcon/util/sync.py](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3V0aWwvc3luYy5weQ==) | `76.74% <0.00%> (-23.26%)` | :arrow_down: |
| ... and [35 more](https://codecov.io/gh/falconry/falcon/pull/2063/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry). Last update [47e3e16...db0408c](https://codecov.io/gh/falconry/falcon/pull/2063?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).

CaselIT: @kgriffs you forgot to commit the news fragment

Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index c9a8fe3..ca7aa2a 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -24,7 +24,7 @@ If an item doesn't apply to your pull request, **check it anyway** to make it ap
     - [ ] Updated all relevant supporting documentation files under `docs/`.
     - [ ] A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
     - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/usage/restructuredtext/directives.html?highlight=versionadded#directive-versionadded).
-- [ ] Changes (and possible deprecations) have [towncrier](https://towncrier.readthedocs.io/en/actual-freaking-docs/index.html) news fragments under `docs/_newsfragments/`, with the file name format `{issue_number}.{fragment_type}.rst`. (Run `towncrier --draft` to ensure it renders correctly.)
+- [ ] Changes (and possible deprecations) have [towncrier](https://towncrier.readthedocs.io/en/latest/quickstart.html#creating-news-fragments) news fragments under `docs/_newsfragments/`, with the file name format `{issue_number}.{fragment_type}.rst`. (Run `towncrier --draft` to ensure it renders correctly.)
 
 If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing!
 
diff --git a/docs/_newsfragments/2063.newandimproved.rst b/docs/_newsfragments/2063.newandimproved.rst
new file mode 100644
index 0000000..95dbed0
--- /dev/null
+++ b/docs/_newsfragments/2063.newandimproved.rst
@@ -0,0 +1,7 @@
+A new property, :attr:`~falcon.Request.headers_lower`, was added to provide a
+unified, self-documenting way to get a copy of all request headers with
+lowercase names to facilitate case-insensitive matching. This is especially
+useful for middleware components that need to be compatible with both WSGI and
+ASGI. :attr:`~falcon.Request.headers_lower` was added in lieu of introducing a
+breaking change to the WSGI :attr:`~falcon.Request.headers` property that
+returns uppercase header names from the WSGI ``environ`` dictionary.
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index db70522..3bb5f5c 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -321,14 +321,27 @@ class Request(request.Request):
         if_range (str): Value of the If-Range header, or ``None`` if the
             header is missing.
 
-        headers (dict): Raw HTTP headers from the request with
-            canonical dash-separated names. Parsing all the headers
-            to create this dict is done the first time this attribute
-            is accessed, and the returned object should be treated as
-            read-only. Note that this parsing can be costly, so unless you
-            need all the headers in this format, you should instead use the
-            ``get_header()`` method or one of the convenience attributes
-            to get a value for a specific header.
+        headers (dict): Raw HTTP headers from the request with dash-separated
+            names normalized to lowercase.
+
+            Note:
+                This property differs from the WSGI version of ``Request.headers``
+                in that the latter returns *uppercase* names for historical
+                reasons. Middleware, such as tracing and logging components, that
+                need to be compatible with both WSGI and ASGI apps should
+                use :attr:`headers_lower` instead.
+
+            Warning:
+                Parsing all the headers to create this dict is done the first
+                time this attribute is accessed, and the returned object should
+                be treated as read-only. Note that this parsing can be costly,
+                so unless you need all the headers in this format, you should
+                instead use the ``get_header()`` method or one of the
+                convenience attributes to get a value for a specific header.
+
+        headers_lower(dict): Alias for :attr:`headers` provided to expose
+            a uniform way to get lowercased headers for both WSGI and ASGI
+            apps.
 
         params (dict): The mapping of request query parameter names to their
             values.  Where the parameter appears multiple times in the query
@@ -874,6 +887,8 @@ class Request(request.Request):
 
         return self._cached_headers
 
+    headers_lower = headers
+
     # ------------------------------------------------------------------------
     # Public Methods
     # ------------------------------------------------------------------------
diff --git a/falcon/request.py b/falcon/request.py
index 088501c..cab6c1b 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -390,14 +390,25 @@ class Request:
         if_range (str): Value of the If-Range header, or ``None`` if the
             header is missing.
 
-        headers (dict): Raw HTTP headers from the request with
-            canonical dash-separated names. Parsing all the headers
-            to create this dict is done the first time this attribute
-            is accessed, and the returned object should be treated as
-            read-only. Note that this parsing can be costly, so unless you
-            need all the headers in this format, you should instead use the
-            ``get_header()`` method or one of the convenience attributes
-            to get a value for a specific header.
+        headers (dict): Raw HTTP headers from the request with dash-separated
+            names normalized to uppercase.
+
+            Note:
+                This property differs from the ASGI version of ``Request.headers``
+                in that the latter returns *lowercase* names. Middleware, such
+                as tracing and logging components, that need to be compatible with
+                both WSGI and ASGI apps should use :attr:`headers_lower` instead.
+
+            Warning:
+                Parsing all the headers to create this dict is done the first
+                time this attribute is accessed, and the returned object should
+                be treated as read-only. Note that this parsing can be costly,
+                so unless you need all the headers in this format, you should
+                instead use the ``get_header()`` method or one of the
+                convenience attributes to get a value for a specific header.
+
+        headers_lower (dict): Same as :attr:`headers` except header names
+            are normalized to lowercase.
 
         params (dict): The mapping of request query parameter names to their
             values.  Where the parameter appears multiple times in the query
@@ -415,6 +426,7 @@ class Request:
         '_cached_forwarded_prefix',
         '_cached_forwarded_uri',
         '_cached_headers',
+        '_cached_headers_lower',
         '_cached_prefix',
         '_cached_relative_uri',
         '_cached_uri',
@@ -509,6 +521,7 @@ class Request:
         self._cached_forwarded_prefix = None
         self._cached_forwarded_uri = None
         self._cached_headers = None
+        self._cached_headers_lower = None
         self._cached_prefix = None
         self._cached_relative_uri = None
         self._cached_uri = None
@@ -875,8 +888,6 @@ class Request:
 
     @property
     def headers(self):
-        # NOTE(kgriffs: First time here will cache the dict so all we
-        # have to do is clone it in the future.
         if self._cached_headers is None:
             headers = self._cached_headers = {}
 
@@ -893,6 +904,15 @@ class Request:
 
         return self._cached_headers
 
+    @property
+    def headers_lower(self):
+        if self._cached_headers_lower is None:
+            self._cached_headers_lower = {
+                key.lower(): value for key, value in self.headers.items()
+            }
+
+        return self._cached_headers_lower
+
     @property
     def params(self):
         return self._params


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
index fcc8073..dc1cb83 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -312,7 +312,10 @@ class CustomHeadersResource:
 
 class HeadersDebugResource:
     def on_get(self, req, resp):
-        resp.media = {key.lower(): value for key, value in req.headers.items()}
+        resp.media = {
+            'raw': req.headers,
+            'lower': req.headers_lower,
+        }
 
     def on_get_header(self, req, resp, header):
         resp.media = {header.lower(): req.get_header(header)}
@@ -448,6 +451,7 @@ class TestHeaders:
 
         for name, value in headers:
             assert (name.upper(), value) in req.headers.items()
+            assert (name.lower(), value) in req.headers_lower.items()
 
         # Functional test
         client.app.add_route('/', testing.SimpleTestResource(headers=headers))
@@ -624,13 +628,25 @@ class TestHeaders:
         }
         resp = client.simulate_get('/headers', headers=headers)
         assert resp.status_code == 200
-        assert resp.json == {
+
+        headers_lower = {
             'host': 'falconframework.org',
             'user-agent': 'Mosaic/0.9',
             'x-latin1-header': 'Förmånsrätt',
             'x-size': 'groß',
         }
 
+        headers_upper = {key.upper(): value for key, value in headers_lower.items()}
+
+        headers_received = resp.json
+
+        if client.app._ASGI:
+            assert resp.json['raw'] == headers_lower
+        else:
+            assert resp.json['raw'] == headers_upper
+
+        assert headers_received['lower'] == headers_lower
+
         resp = client.simulate_get('/headers/X-Latin1-Header', headers=headers)
         assert resp.json == {'x-latin1-header': 'Förmånsrätt'}
         resp = client.simulate_get('/headers/X-Size', headers=headers)