Skip to content

Commit 84f2894

Browse files
authored
Add opt-in case-insensitive header value matcher (#524)
1 parent c12273c commit 84f2894

7 files changed

Lines changed: 241 additions & 14 deletions

File tree

‎doc/api.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ HeaderValueMatcher
6969
.. autoclass:: HeaderValueMatcher
7070
:members:
7171

72+
CaseInsensitiveHeaderValueMatcher
73+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
74+
75+
.. autoclass:: CaseInsensitiveHeaderValueMatcher
76+
:members:
77+
7278
URIPattern
7379
~~~~~~~~~~
7480

‎doc/howto.rst‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,47 @@ In case you don't want to change the defaults, you can provide the
202202
.. literalinclude :: ../tests/examples/test_howto_header_value_matcher.py
203203
:language: python
204204
205+
Case-insensitive comparator names
206+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
207+
208+
``HeaderValueMatcher`` selects comparators by exact header name and remains the
209+
default. To select them regardless of header-name casing, explicitly pass a
210+
``CaseInsensitiveHeaderValueMatcher`` to an expectation or use ``bake()``:
211+
212+
.. code:: python
213+
214+
from pytest_httpserver import CaseInsensitiveHeaderValueMatcher
215+
216+
server = httpserver.bake(header_value_matcher=CaseInsensitiveHeaderValueMatcher())
217+
server.expect_request(
218+
"/", headers={"authorization": 'Digest a="1", b="2"'}
219+
).respond_with_data("OK")
220+
221+
This parses ``Authorization`` values for any casing of that header name. Other
222+
header values still use exact, case-sensitive equality. Only expectations
223+
registered through ``server`` use this matcher; the original ``httpserver``
224+
retains its defaults.
225+
226+
Custom comparator keys are also case-insensitive:
227+
228+
.. code:: python
229+
230+
def compare_values(actual: str | None, expected: str) -> bool:
231+
return actual is not None and actual.lower() == expected.lower()
232+
233+
234+
matcher = CaseInsensitiveHeaderValueMatcher({"x-CUSTOM": compare_values})
235+
httpserver.expect_request(
236+
"/custom", headers={"X-Custom": "value"}, header_value_matcher=matcher
237+
).respond_with_data("OK")
238+
239+
The supplied mapping replaces the built-in comparators and is copied when the
240+
matcher is created. If keys differ only in case, the last entry wins. A plain
241+
mapping raises ``NoMethodFoundForMatchingHeaderValueError`` for unregistered
242+
names; a ``defaultdict`` can supply a fallback comparator without caching those
243+
names. Create a new matcher to use a changed mapping. Instances do not read or
244+
modify ``HeaderValueMatcher.DEFAULT_MATCHERS`` and do not share a registry.
245+
205246
Using custom request handler
206247
----------------------------
207248
In the case the response is not static, for example it depends on the request,

‎pytest_httpserver/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"BakedHTTPServer",
1010
"BlockingHTTPServer",
1111
"BlockingRequestHandler",
12+
"CaseInsensitiveHeaderValueMatcher",
1213
"Error",
1314
"HTTPServer",
1415
"HTTPServerError",
@@ -26,6 +27,7 @@
2627
from .blocking_httpserver import BlockingRequestHandler
2728
from .httpserver import METHOD_ALL
2829
from .httpserver import URI_DEFAULT
30+
from .httpserver import CaseInsensitiveHeaderValueMatcher
2931
from .httpserver import Error
3032
from .httpserver import HeaderValueMatcher
3133
from .httpserver import HTTPServer

‎pytest_httpserver/blocking_httpserver.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from typing import TYPE_CHECKING
66
from typing import Any
77

8+
from pytest_httpserver.httpserver import HVMATCHER_T
89
from pytest_httpserver.httpserver import METHOD_ALL
910
from pytest_httpserver.httpserver import UNDEFINED
10-
from pytest_httpserver.httpserver import HeaderValueMatcher
1111
from pytest_httpserver.httpserver import HTTPServerBase
1212
from pytest_httpserver.httpserver import QueryMatcher
1313
from pytest_httpserver.httpserver import RequestHandlerBase
@@ -77,7 +77,7 @@ def assert_request(
7777
data_encoding: str = "utf-8",
7878
headers: Mapping[str, str] | None = None,
7979
query_string: QueryMatcher | str | bytes | Mapping[str, str] | None = None,
80-
header_value_matcher: HeaderValueMatcher | None = None,
80+
header_value_matcher: HVMATCHER_T | None = None,
8181
json: Any = UNDEFINED,
8282
timeout: int = 30,
8383
) -> BlockingRequestHandler:
@@ -104,7 +104,7 @@ def assert_request(
104104
specified in the request. If multiple values specified for a given key, the first
105105
value will be used. If multiple values needed to be handled, use ``MultiDict``
106106
object from werkzeug.
107-
:param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers.
107+
:param header_value_matcher: callable that matches values of headers.
108108
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
109109
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
110110
If that's desired, add it to the headers parameter.

‎pytest_httpserver/httpserver.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,43 @@ def __call__(self, header_name: str, actual: str | None, expected: str) -> bool:
177177
)
178178

179179

180+
class CaseInsensitiveHeaderValueMatcher:
181+
"""
182+
Match header values using case-insensitive comparator names.
183+
184+
This matcher must be passed explicitly as ``header_value_matcher``. By
185+
default, it parses Authorization values and compares other values exactly,
186+
independently of ``HeaderValueMatcher.DEFAULT_MATCHERS``.
187+
188+
:param matchers: optional mapping of header names to comparator functions,
189+
replacing the built-in comparators. Keys are copied and lowercased at
190+
construction; the last entry wins if names differ only in case. Later
191+
changes to the input mapping do not affect this instance. A defaultdict's
192+
factory supplies comparators for missing names without caching them;
193+
otherwise missing names raise NoMethodFoundForMatchingHeaderValueError.
194+
"""
195+
196+
def __init__(self, matchers: Mapping[str, Callable[[str | None, str], bool]] | None = None) -> None:
197+
if matchers is None:
198+
matchers = defaultdict(
199+
lambda: HeaderValueMatcher.default_header_value_matcher,
200+
{"Authorization": HeaderValueMatcher.authorization_header_value_matcher},
201+
)
202+
self._matchers = {name.lower(): matcher for name, matcher in matchers.items()}
203+
self._default_factory = matchers.default_factory if isinstance(matchers, defaultdict) else None
204+
205+
def __call__(self, header_name: str, actual: str | None, expected: str) -> bool:
206+
try:
207+
matcher = self._matchers[header_name.lower()]
208+
except KeyError:
209+
if self._default_factory is None:
210+
raise NoMethodFoundForMatchingHeaderValueError(
211+
"No method found for matching header value: {}".format(header_name)
212+
)
213+
matcher = self._default_factory()
214+
return matcher(actual, expected)
215+
216+
180217
class QueryMatcher(abc.ABC):
181218
"""
182219
Abstract class for QueryMatchers

‎tests/test_blocking_httpserver.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import requests
88

99
from pytest_httpserver import BlockingHTTPServer
10+
from pytest_httpserver import CaseInsensitiveHeaderValueMatcher
1011

1112

1213
@contextmanager
@@ -68,6 +69,22 @@ def test_behave_workflow(httpserver: BlockingHTTPServer):
6869
then_the_response_is_got_from(server_connection, response)
6970

7071

72+
def test_case_insensitive_header_matcher(httpserver: BlockingHTTPServer):
73+
request = dict(
74+
method="GET",
75+
url=httpserver.url_for("/"),
76+
headers={"Authorization": 'Digest a="1", b="2"'},
77+
)
78+
with when_a_request_is_being_sent_to_the_server(request) as server_connection:
79+
client_connection = httpserver.assert_request(
80+
uri="/",
81+
headers={"authorization": 'Digest b="2", a="1"'},
82+
header_value_matcher=CaseInsensitiveHeaderValueMatcher(),
83+
)
84+
client_connection.respond_with_data("OK")
85+
assert server_connection.get(timeout=9).text == "OK"
86+
87+
7188
def test_raises_assertion_error_when_request_does_not_match(httpserver: BlockingHTTPServer):
7289
request = dict(
7390
method="GET",

‎tests/test_headers.py‎

Lines changed: 135 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,126 @@
11
import http.client
2+
from collections import defaultdict
23

4+
import pytest
35
import requests
46
from werkzeug.datastructures import Headers
57
from werkzeug.http import parse_dict_header
68

9+
from pytest_httpserver import CaseInsensitiveHeaderValueMatcher
710
from pytest_httpserver import HTTPServer
11+
from pytest_httpserver import RequestMatcher
812
from pytest_httpserver.httpserver import HeaderValueMatcher
13+
from pytest_httpserver.httpserver import NoMethodFoundForMatchingHeaderValueError
914

1015

11-
def test_custom_headers(httpserver: HTTPServer):
12-
headers_with_values_in_direct_order = {"Custom": 'Scheme key0="value0", key1="value1"'}
16+
def test_case_insensitive_header_matcher_fallback_does_not_cache_header_names():
17+
comparators = iter(
18+
[HeaderValueMatcher.default_header_value_matcher, lambda actual, expected: actual == expected.upper()]
19+
)
20+
matchers = defaultdict(lambda: next(comparators))
21+
matcher = CaseInsensitiveHeaderValueMatcher(matchers)
22+
23+
assert matcher("x-custom", "value", "value")
24+
assert matcher("x-custom", "VALUE", "value")
25+
assert not matchers
26+
27+
28+
@pytest.mark.parametrize("mapping_class", [dict, defaultdict])
29+
def test_case_insensitive_header_matcher_without_default(mapping_class):
30+
matchers = mapping_class()
31+
matchers["x-custom"] = HeaderValueMatcher.default_header_value_matcher
32+
matcher = CaseInsensitiveHeaderValueMatcher(matchers)
33+
34+
assert matcher("X-CUSTOM", "value", "value")
35+
assert not matcher("X-CUSTOM", "VALUE", "value")
36+
with pytest.raises(NoMethodFoundForMatchingHeaderValueError, match="X-Missing"):
37+
matcher("X-Missing", "value", "value")
38+
39+
40+
def test_case_insensitive_header_matcher_copies_comparators():
41+
matchers = {"X-Custom": HeaderValueMatcher.default_header_value_matcher}
42+
first = CaseInsensitiveHeaderValueMatcher(matchers)
43+
matchers["X-Custom"] = lambda actual, expected: actual == expected.upper()
44+
second = CaseInsensitiveHeaderValueMatcher(matchers)
45+
46+
assert first("X-CUSTOM", "value", "value")
47+
assert not first("X-CUSTOM", "VALUE", "value")
48+
assert second("x-custom", "VALUE", "value")
49+
assert not second("x-custom", "value", "value")
50+
51+
52+
def test_case_insensitive_header_matcher_duplicate_names():
53+
matcher = CaseInsensitiveHeaderValueMatcher(
54+
{
55+
"X-Custom": HeaderValueMatcher.default_header_value_matcher,
56+
"x-CUSTOM": lambda actual, expected: actual == expected.upper(),
57+
}
58+
)
59+
60+
assert matcher("x-custom", "VALUE", "value")
61+
assert not matcher("X-Custom", "value", "value")
62+
63+
64+
def test_case_insensitive_header_matcher_independent_defaults(monkeypatch):
65+
legacy_matchers = {"X-Custom": lambda actual, expected: actual == expected.upper()}
66+
monkeypatch.setattr(HeaderValueMatcher, "DEFAULT_MATCHERS", legacy_matchers)
67+
customized = CaseInsensitiveHeaderValueMatcher(legacy_matchers)
68+
69+
assert customized("x-custom", "VALUE", "value")
70+
for matcher in (CaseInsensitiveHeaderValueMatcher(), CaseInsensitiveHeaderValueMatcher()):
71+
assert matcher("x-custom", "value", "value")
72+
assert not matcher("x-custom", "VALUE", "value")
73+
assert not matcher("x-custom", None, "value")
74+
assert matcher("authorization", 'Digest a="1", b="2"', 'Digest b="2", a="1"')
75+
assert not matcher("AUTHORIZATION", None, 'Digest a="1"')
76+
77+
assert list(legacy_matchers) == ["X-Custom"]
78+
default_matcher = RequestMatcher("/").header_value_matcher
79+
assert isinstance(default_matcher, HeaderValueMatcher)
80+
assert default_matcher.matchers is legacy_matchers
81+
82+
83+
def test_legacy_header_matcher_preserves_case_sensitive_lookup():
84+
matcher = HeaderValueMatcher({"X-Custom": HeaderValueMatcher.default_header_value_matcher})
85+
86+
assert matcher("X-Custom", "value", "value")
87+
with pytest.raises(NoMethodFoundForMatchingHeaderValueError, match="x-custom"):
88+
matcher("x-custom", "value", "value")
89+
90+
91+
def test_legacy_header_matcher_preserves_defaultdict_lookup():
92+
matchers = defaultdict(lambda: HeaderValueMatcher.default_header_value_matcher)
93+
matcher = HeaderValueMatcher(matchers)
94+
95+
assert matcher("x-custom", "value", "value")
96+
assert matchers["x-custom"] is HeaderValueMatcher.default_header_value_matcher
97+
matchers["X-Custom"] = lambda actual, expected: actual == expected.upper()
98+
assert not matcher("x-custom", "VALUE", "value")
99+
100+
101+
def test_case_insensitive_header_matcher_bake_is_opt_in(httpserver: HTTPServer):
102+
matcher = CaseInsensitiveHeaderValueMatcher(
103+
{"x-CUSTOM": lambda actual, expected: actual is not None and actual.lower() == expected.lower()}
104+
)
105+
server = httpserver.bake(header_value_matcher=matcher)
106+
server.expect_request("/opt-in", headers={"X-Custom": "value"}).respond_with_data("OK")
107+
httpserver.expect_request("/default", headers={"X-Custom": "value"}).respond_with_data("OK")
108+
109+
assert requests.get(server.url_for("/opt-in"), headers={"X-Custom": "VALUE"}).status_code == 200
110+
assert requests.get(httpserver.url_for("/default"), headers={"X-Custom": "VALUE"}).status_code == 500
111+
112+
113+
@pytest.mark.parametrize(
114+
("matcher_class", "comparator_name", "header_name"),
115+
[
116+
(HeaderValueMatcher, "Custom", "Custom"),
117+
(CaseInsensitiveHeaderValueMatcher, "Custom", "Custom"),
118+
(CaseInsensitiveHeaderValueMatcher, "CuStOm", "custom"),
119+
(CaseInsensitiveHeaderValueMatcher, "custom", "CUSTOM"),
120+
],
121+
)
122+
def test_custom_headers(httpserver: HTTPServer, matcher_class, comparator_name: str, header_name: str):
123+
headers_with_values_in_direct_order = {header_name: 'Scheme key0="value0", key1="value1"'}
13124
httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK")
14125
response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order)
15126
assert response.status_code == 200
@@ -21,16 +132,16 @@ def test_custom_headers(httpserver: HTTPServer):
21132
assert response.status_code == 500
22133

23134
# Define header_value_matcher that ignores the order of items in header value dict
24-
def custom_header_value_matcher(actual: str, expected: str) -> bool:
135+
def custom_header_value_matcher(actual: str | None, expected: str) -> bool:
136+
if actual is None:
137+
return False
25138
actual_scheme, _, actual_dict_str = actual.partition(" ")
26139
expected_scheme, _, expected_dict_str = expected.partition(" ")
27140
actual_dict = parse_dict_header(actual_dict_str)
28141
expected_dict = parse_dict_header(expected_dict_str)
29142
return actual_scheme == expected_scheme and actual_dict == expected_dict
30143

31-
matchers = HeaderValueMatcher.DEFAULT_MATCHERS.copy() # type: ignore
32-
matchers["Custom"] = custom_header_value_matcher
33-
header_value_matcher = HeaderValueMatcher(matchers)
144+
header_value_matcher = matcher_class({comparator_name: custom_header_value_matcher})
34145

35146
httpserver.handlers.clear()
36147
httpserver.expect_request(
@@ -42,9 +153,22 @@ def custom_header_value_matcher(actual: str, expected: str) -> bool:
42153

43154

44155
# See https://en.wikipedia.org/wiki/Digest_access_authentication
45-
def test_authorization_headers(httpserver: HTTPServer):
156+
@pytest.mark.parametrize(
157+
("header_name", "case_insensitive"),
158+
[
159+
("Authorization", False),
160+
("Authorization", True),
161+
("authorization", True),
162+
("AUTHORIZATION", True),
163+
("aUtHoRiZaTiOn", True),
164+
],
165+
)
166+
def test_authorization_headers(httpserver: HTTPServer, header_name: str, *, case_insensitive: bool):
167+
server = (
168+
httpserver.bake(header_value_matcher=CaseInsensitiveHeaderValueMatcher()) if case_insensitive else httpserver
169+
)
46170
headers_with_values_in_direct_order = {
47-
"Authorization": (
171+
header_name: (
48172
'Digest username="Mufasa",'
49173
'realm="testrealm@host.com",'
50174
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",'
@@ -56,8 +180,8 @@ def test_authorization_headers(httpserver: HTTPServer):
56180
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
57181
)
58182
}
59-
httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK")
60-
response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order)
183+
server.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK")
184+
response = requests.get(server.url_for("/"), headers=headers_with_values_in_direct_order)
61185
assert response.status_code == 200
62186
assert response.text == "OK"
63187

@@ -74,7 +198,7 @@ def test_authorization_headers(httpserver: HTTPServer):
74198
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
75199
)
76200
}
77-
response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_modified_order)
201+
response = requests.get(server.url_for("/"), headers=headers_with_values_in_modified_order)
78202
assert response.status_code == 200
79203
assert response.text == "OK"
80204

0 commit comments

Comments
 (0)