Skip to content

Commit da88180

Browse files
Centralize header parsing and normalization utilities
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent cf08d9a commit da88180

File tree

5 files changed

+108
-76
lines changed

5 files changed

+108
-76
lines changed

hyperbrowser/config.py

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from dataclasses import dataclass
2-
import json
32
from urllib.parse import urlparse
43
from typing import Dict, Mapping, Optional
54
import os
65

76
from .exceptions import HyperbrowserError
7+
from .header_utils import normalize_headers, parse_headers_env_json
88

99

1010
@dataclass
@@ -36,25 +36,10 @@ def __post_init__(self) -> None:
3636
raise HyperbrowserError(
3737
"base_url must start with 'https://' or 'http://' and include a host"
3838
)
39-
if self.headers is not None:
40-
normalized_headers: Dict[str, str] = {}
41-
for key, value in self.headers.items():
42-
if not isinstance(key, str) or not isinstance(value, str):
43-
raise HyperbrowserError("headers must be a mapping of string pairs")
44-
normalized_key = key.strip()
45-
if not normalized_key:
46-
raise HyperbrowserError("header names must not be empty")
47-
if (
48-
"\n" in normalized_key
49-
or "\r" in normalized_key
50-
or "\n" in value
51-
or "\r" in value
52-
):
53-
raise HyperbrowserError(
54-
"headers must not contain newline characters"
55-
)
56-
normalized_headers[normalized_key] = value
57-
self.headers = normalized_headers
39+
self.headers = normalize_headers(
40+
self.headers,
41+
mapping_error_message="headers must be a mapping of string pairs",
42+
)
5843

5944
@classmethod
6045
def from_env(cls) -> "ClientConfig":
@@ -72,23 +57,4 @@ def from_env(cls) -> "ClientConfig":
7257

7358
@staticmethod
7459
def parse_headers_from_env(raw_headers: Optional[str]) -> Optional[Dict[str, str]]:
75-
if raw_headers is None or not raw_headers.strip():
76-
return None
77-
try:
78-
parsed_headers = json.loads(raw_headers)
79-
except json.JSONDecodeError as exc:
80-
raise HyperbrowserError(
81-
"HYPERBROWSER_HEADERS must be valid JSON object"
82-
) from exc
83-
if not isinstance(parsed_headers, dict):
84-
raise HyperbrowserError(
85-
"HYPERBROWSER_HEADERS must be a JSON object of string pairs"
86-
)
87-
if any(
88-
not isinstance(key, str) or not isinstance(value, str)
89-
for key, value in parsed_headers.items()
90-
):
91-
raise HyperbrowserError(
92-
"HYPERBROWSER_HEADERS must be a JSON object of string pairs"
93-
)
94-
return parsed_headers
60+
return parse_headers_env_json(raw_headers)

hyperbrowser/header_utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import json
2+
from typing import Dict, Mapping, Optional
3+
4+
from .exceptions import HyperbrowserError
5+
6+
7+
def normalize_headers(
8+
headers: Optional[Mapping[str, str]],
9+
*,
10+
mapping_error_message: str,
11+
pair_error_message: Optional[str] = None,
12+
) -> Optional[Dict[str, str]]:
13+
if headers is None:
14+
return None
15+
if not isinstance(headers, Mapping):
16+
raise HyperbrowserError(mapping_error_message)
17+
18+
effective_pair_error_message = pair_error_message or mapping_error_message
19+
normalized_headers: Dict[str, str] = {}
20+
for key, value in headers.items():
21+
if not isinstance(key, str) or not isinstance(value, str):
22+
raise HyperbrowserError(effective_pair_error_message)
23+
normalized_key = key.strip()
24+
if not normalized_key:
25+
raise HyperbrowserError("header names must not be empty")
26+
if (
27+
"\n" in normalized_key
28+
or "\r" in normalized_key
29+
or "\n" in value
30+
or "\r" in value
31+
):
32+
raise HyperbrowserError("headers must not contain newline characters")
33+
normalized_headers[normalized_key] = value
34+
return normalized_headers
35+
36+
37+
def parse_headers_env_json(raw_headers: Optional[str]) -> Optional[Dict[str, str]]:
38+
if raw_headers is None or not raw_headers.strip():
39+
return None
40+
try:
41+
parsed_headers = json.loads(raw_headers)
42+
except json.JSONDecodeError as exc:
43+
raise HyperbrowserError(
44+
"HYPERBROWSER_HEADERS must be valid JSON object"
45+
) from exc
46+
return normalize_headers(
47+
parsed_headers, # type: ignore[arg-type]
48+
mapping_error_message="HYPERBROWSER_HEADERS must be a JSON object of string pairs",
49+
pair_error_message="HYPERBROWSER_HEADERS must be a JSON object of string pairs",
50+
)

hyperbrowser/transport/async_transport.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Mapping, Optional
44

55
from hyperbrowser.exceptions import HyperbrowserError
6+
from hyperbrowser.header_utils import normalize_headers
67
from hyperbrowser.version import __version__
78
from .base import APIResponse, AsyncTransportStrategy
89
from .error_utils import extract_error_message
@@ -16,24 +17,11 @@ def __init__(self, api_key: str, headers: Optional[Mapping[str, str]] = None):
1617
"x-api-key": api_key,
1718
"User-Agent": f"hyperbrowser-python-sdk/{__version__}",
1819
}
19-
if headers:
20-
normalized_headers = {}
21-
for key, value in headers.items():
22-
if not isinstance(key, str) or not isinstance(value, str):
23-
raise HyperbrowserError("headers must be a mapping of string pairs")
24-
normalized_key = key.strip()
25-
if not normalized_key:
26-
raise HyperbrowserError("header names must not be empty")
27-
if (
28-
"\n" in normalized_key
29-
or "\r" in normalized_key
30-
or "\n" in value
31-
or "\r" in value
32-
):
33-
raise HyperbrowserError(
34-
"headers must not contain newline characters"
35-
)
36-
normalized_headers[normalized_key] = value
20+
normalized_headers = normalize_headers(
21+
headers,
22+
mapping_error_message="headers must be a mapping of string pairs",
23+
)
24+
if normalized_headers:
3725
merged_headers.update(normalized_headers)
3826
self.client = httpx.AsyncClient(headers=merged_headers)
3927
self._closed = False

hyperbrowser/transport/sync.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Mapping, Optional
44

55
from hyperbrowser.exceptions import HyperbrowserError
6+
from hyperbrowser.header_utils import normalize_headers
67
from hyperbrowser.version import __version__
78
from .base import APIResponse, SyncTransportStrategy
89
from .error_utils import extract_error_message
@@ -16,24 +17,11 @@ def __init__(self, api_key: str, headers: Optional[Mapping[str, str]] = None):
1617
"x-api-key": api_key,
1718
"User-Agent": f"hyperbrowser-python-sdk/{__version__}",
1819
}
19-
if headers:
20-
normalized_headers = {}
21-
for key, value in headers.items():
22-
if not isinstance(key, str) or not isinstance(value, str):
23-
raise HyperbrowserError("headers must be a mapping of string pairs")
24-
normalized_key = key.strip()
25-
if not normalized_key:
26-
raise HyperbrowserError("header names must not be empty")
27-
if (
28-
"\n" in normalized_key
29-
or "\r" in normalized_key
30-
or "\n" in value
31-
or "\r" in value
32-
):
33-
raise HyperbrowserError(
34-
"headers must not contain newline characters"
35-
)
36-
normalized_headers[normalized_key] = value
20+
normalized_headers = normalize_headers(
21+
headers,
22+
mapping_error_message="headers must be a mapping of string pairs",
23+
)
24+
if normalized_headers:
3725
merged_headers.update(normalized_headers)
3826
self.client = httpx.Client(headers=merged_headers)
3927

tests/test_header_utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
3+
from hyperbrowser.exceptions import HyperbrowserError
4+
from hyperbrowser.header_utils import normalize_headers, parse_headers_env_json
5+
6+
7+
def test_normalize_headers_trims_header_names():
8+
headers = normalize_headers(
9+
{" X-Correlation-Id ": "abc123"},
10+
mapping_error_message="headers must be a mapping of string pairs",
11+
)
12+
13+
assert headers == {"X-Correlation-Id": "abc123"}
14+
15+
16+
def test_normalize_headers_rejects_empty_header_name():
17+
with pytest.raises(HyperbrowserError, match="header names must not be empty"):
18+
normalize_headers(
19+
{" ": "value"},
20+
mapping_error_message="headers must be a mapping of string pairs",
21+
)
22+
23+
24+
def test_parse_headers_env_json_ignores_blank_values():
25+
assert parse_headers_env_json(" ") is None
26+
27+
28+
def test_parse_headers_env_json_rejects_invalid_json():
29+
with pytest.raises(
30+
HyperbrowserError, match="HYPERBROWSER_HEADERS must be valid JSON object"
31+
):
32+
parse_headers_env_json("{invalid")
33+
34+
35+
def test_parse_headers_env_json_rejects_non_mapping_payload():
36+
with pytest.raises(
37+
HyperbrowserError,
38+
match="HYPERBROWSER_HEADERS must be a JSON object of string pairs",
39+
):
40+
parse_headers_env_json('["bad"]')

0 commit comments

Comments
 (0)