Skip to content

Commit 6fc4ef9

Browse files
00200200csernazs
authored andcommitted
feat: add opt-in case-insensitive header value matcher
1 parent a6bc6d1 commit 6fc4ef9

7 files changed

Lines changed: 225 additions & 31 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: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,8 @@ def default_header_value_matcher(actual: str | None, expected: str) -> bool:
162162
return actual == expected
163163

164164
def __call__(self, header_name: str, actual: str | None, expected: str) -> bool:
165-
for name, matcher in self.matchers.items():
166-
if name.lower() == header_name.lower():
167-
return matcher(actual, expected)
168-
169165
try:
170-
if isinstance(self.matchers, defaultdict) and self.matchers.default_factory is not None:
171-
matcher = self.matchers.default_factory()
172-
else:
173-
matcher = self.matchers[header_name]
166+
matcher = self.matchers[header_name]
174167
except KeyError:
175168
raise NoMethodFoundForMatchingHeaderValueError(
176169
"No method found for matching header value: {}".format(header_name)
@@ -184,6 +177,43 @@ def __call__(self, header_name: str, actual: str | None, expected: str) -> bool:
184177
)
185178

186179

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+
187217
class QueryMatcher(abc.ABC):
188218
"""
189219
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: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,120 @@
66
from werkzeug.datastructures import Headers
77
from werkzeug.http import parse_dict_header
88

9+
from pytest_httpserver import CaseInsensitiveHeaderValueMatcher
910
from pytest_httpserver import HTTPServer
11+
from pytest_httpserver import RequestMatcher
1012
from pytest_httpserver.httpserver import HeaderValueMatcher
1113
from pytest_httpserver.httpserver import NoMethodFoundForMatchingHeaderValueError
1214

1315

14-
def test_header_matcher_fallback_does_not_cache_header_names():
15-
matchers = defaultdict(lambda: HeaderValueMatcher.default_header_value_matcher)
16-
matcher = HeaderValueMatcher(matchers)
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)
1722

1823
assert matcher("x-custom", "value", "value")
19-
assert not matcher("x-custom", "VALUE", "value")
20-
assert not matchers
21-
22-
matchers["X-Custom"] = lambda actual, expected: actual == expected.upper()
2324
assert matcher("x-custom", "VALUE", "value")
25+
assert not matchers
2426

2527

26-
def test_header_matcher_without_default():
27-
matcher = HeaderValueMatcher({"x-custom": HeaderValueMatcher.default_header_value_matcher})
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)
2833

2934
assert matcher("X-CUSTOM", "value", "value")
3035
assert not matcher("X-CUSTOM", "VALUE", "value")
3136
with pytest.raises(NoMethodFoundForMatchingHeaderValueError, match="X-Missing"):
3237
matcher("X-Missing", "value", "value")
3338

3439

35-
@pytest.mark.parametrize("header_name", ["Custom", "custom", "CUSTOM"])
36-
def test_custom_headers(httpserver: HTTPServer, header_name: str):
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):
37123
headers_with_values_in_direct_order = {header_name: 'Scheme key0="value0", key1="value1"'}
38124
httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK")
39125
response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order)
@@ -46,16 +132,16 @@ def test_custom_headers(httpserver: HTTPServer, header_name: str):
46132
assert response.status_code == 500
47133

48134
# Define header_value_matcher that ignores the order of items in header value dict
49-
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
50138
actual_scheme, _, actual_dict_str = actual.partition(" ")
51139
expected_scheme, _, expected_dict_str = expected.partition(" ")
52140
actual_dict = parse_dict_header(actual_dict_str)
53141
expected_dict = parse_dict_header(expected_dict_str)
54142
return actual_scheme == expected_scheme and actual_dict == expected_dict
55143

56-
matchers = HeaderValueMatcher.DEFAULT_MATCHERS.copy() # type: ignore
57-
matchers["Custom"] = custom_header_value_matcher
58-
header_value_matcher = HeaderValueMatcher(matchers)
144+
header_value_matcher = matcher_class({comparator_name: custom_header_value_matcher})
59145

60146
httpserver.handlers.clear()
61147
httpserver.expect_request(
@@ -67,8 +153,20 @@ def custom_header_value_matcher(actual: str, expected: str) -> bool:
67153

68154

69155
# See https://en.wikipedia.org/wiki/Digest_access_authentication
70-
@pytest.mark.parametrize("header_name", ["Authorization", "authorization", "AUTHORIZATION", "aUtHoRiZaTiOn"])
71-
def test_authorization_headers(httpserver: HTTPServer, header_name: str):
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+
)
72170
headers_with_values_in_direct_order = {
73171
header_name: (
74172
'Digest username="Mufasa",'
@@ -82,8 +180,8 @@ def test_authorization_headers(httpserver: HTTPServer, header_name: str):
82180
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
83181
)
84182
}
85-
httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK")
86-
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)
87185
assert response.status_code == 200
88186
assert response.text == "OK"
89187

@@ -100,7 +198,7 @@ def test_authorization_headers(httpserver: HTTPServer, header_name: str):
100198
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
101199
)
102200
}
103-
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)
104202
assert response.status_code == 200
105203
assert response.text == "OK"
106204

0 commit comments

Comments
 (0)