Skip to content

Commit d03f1ec

Browse files
authored
Add public Origin API (#1134)
1 parent a966320 commit d03f1ec

7 files changed

Lines changed: 203 additions & 21 deletions

File tree

‎docs/api.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,28 @@ what gets sent over the wire.*
156156
* `.query` - **str**
157157
* `.raw_path` - **str**
158158
* `.fragment` - **str**
159+
* `.origin` - **Origin**
159160
* `.is_ssl` - **bool**
160161
* `.is_absolute_url` - **bool**
161162
* `.is_relative_url` - **bool**
162163
* `def .copy_with([scheme], [authority], [path], [query], [fragment])` - **URL**
163164

165+
## `Origin`
166+
167+
*An immutable, hashable set of normalized scheme, host, and effective port information.*
168+
169+
```pycon
170+
>>> URL('https://example.org').origin == URL('HTTPS://EXAMPLE.ORG:443').origin
171+
True
172+
>>> URL('wss://[::1]/socket').origin
173+
Origin(scheme='wss', host='::1', port=443)
174+
```
175+
176+
* `def __init__(url)`
177+
* `.scheme` - **str**
178+
* `.host` - **str**
179+
* `.port` - **int** or **None**
180+
164181
## `Headers`
165182

166183
*A case-insensitive multi-dict.*

‎src/httpx2/CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

7+
## Unreleased
8+
9+
### Added
10+
11+
* Add the public `Origin` value object and `URL.origin` property for normalized,
12+
hashable origin comparisons. ([#1134](https://github.com/pydantic/httpx2/pull/1134))
13+
714
## 2.10.0 (August 9th, 2026)
815

916
### Added

‎src/httpx2/httpx2/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"NetRCAuth",
5454
"NetworkError",
5555
"options",
56+
"Origin",
5657
"patch",
5758
"PoolTimeout",
5859
"post",

‎src/httpx2/httpx2/_client.py‎

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,30 +72,18 @@ def _is_https_redirect(url: URL, location: URL) -> bool:
7272
"""
7373
Return 'True' if 'location' is a HTTPS upgrade of 'url'
7474
"""
75-
if url.host != location.host:
76-
return False
75+
origin = url.origin
76+
location_origin = location.origin
7777

7878
return (
79-
url.scheme == "http"
80-
and _port_or_default(url) == 80
81-
and location.scheme == "https"
82-
and _port_or_default(location) == 443
79+
origin.host == location_origin.host
80+
and origin.scheme == "http"
81+
and origin.port == 80
82+
and location_origin.scheme == "https"
83+
and location_origin.port == 443
8384
)
8485

8586

86-
def _port_or_default(url: URL) -> int | None:
87-
if url.port is not None:
88-
return url.port
89-
return {"http": 80, "https": 443}.get(url.scheme)
90-
91-
92-
def _same_origin(url: URL, other: URL) -> bool:
93-
"""
94-
Return 'True' if the given URLs share the same origin.
95-
"""
96-
return url.scheme == other.scheme and url.host == other.host and _port_or_default(url) == _port_or_default(other)
97-
98-
9987
class UseClientDefault:
10088
"""
10189
For some parameters such as `auth=...` and `timeout=...` we need to be able
@@ -534,7 +522,7 @@ def _redirect_headers(self, request: Request, url: URL, method: str) -> Headers:
534522
"""
535523
headers = Headers(request.headers)
536524

537-
if not _same_origin(url, request.url):
525+
if url.origin != request.url.origin:
538526
if not _is_https_redirect(request.url, url):
539527
# Strip Authorization headers when responses are redirected
540528
# away from the origin. (Except for direct HTTP to HTTPS redirects.)

‎src/httpx2/httpx2/_urls.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import ipaddress
34
import sys
45
import typing
6+
from dataclasses import dataclass
57
from urllib.parse import parse_qs, unquote, urlencode
68

79
import idna
@@ -16,7 +18,7 @@
1618
from ._urlparse import urlparse
1719
from ._utils import primitive_value_to_str
1820

19-
__all__ = ["URL", "QueryParams"]
21+
__all__ = ["URL", "Origin", "QueryParams"]
2022

2123

2224
class URL:
@@ -328,6 +330,16 @@ def is_relative_url(self) -> bool:
328330
"""
329331
return not self.is_absolute_url
330332

333+
@property
334+
def origin(self) -> Origin:
335+
"""
336+
The URL origin as an immutable, hashable `Origin` value.
337+
338+
The origin consists of the URL scheme, host, and effective port.
339+
User information, path, query, and fragment components are excluded.
340+
"""
341+
return Origin(self)
342+
331343
def copy_with(self, **kwargs: typing.Any) -> URL:
332344
"""
333345
Copy this URL, returning a new URL with some components altered.
@@ -418,6 +430,51 @@ def raw(self) -> tuple[bytes, bytes, int, bytes]: # pragma: no cover
418430
)
419431

420432

433+
_ORIGIN_DEFAULT_PORTS = {
434+
"ftp": 21,
435+
"http": 80,
436+
"https": 443,
437+
"ws": 80,
438+
"wss": 443,
439+
}
440+
441+
442+
@dataclass(frozen=True, slots=True, init=False)
443+
class Origin:
444+
"""
445+
The scheme, host, and effective port of a URL.
446+
447+
Origins are normalized, immutable, comparable, and hashable.
448+
449+
See RFC 9110, Section 4.3.1: https://www.rfc-editor.org/rfc/rfc9110.html#name-uri-origin
450+
"""
451+
452+
scheme: str
453+
host: str
454+
port: int | None
455+
456+
def __init__(self, url: URL | str) -> None:
457+
if not isinstance(url, URL):
458+
url = URL(url)
459+
460+
if url.is_relative_url:
461+
raise ValueError("URL must be absolute to have an origin")
462+
463+
host = url.host
464+
if b":" in url.raw_host:
465+
# IPv6 addresses may have multiple equivalent string forms. Store
466+
# their canonical compressed form so origin equality is numeric.
467+
host = str(ipaddress.IPv6Address(url.raw_host.decode("ascii")))
468+
469+
port = url.port
470+
if port is None:
471+
port = _ORIGIN_DEFAULT_PORTS.get(url.scheme)
472+
473+
object.__setattr__(self, "scheme", url.scheme)
474+
object.__setattr__(self, "host", host)
475+
object.__setattr__(self, "port", port)
476+
477+
421478
class QueryParams(typing.Mapping[str, str]):
422479
"""
423480
URL query parameters, as a multi-dict.

‎tests/httpx2/client/test_headers.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,16 @@ def test_same_origin() -> None:
238238
assert headers["Host"] == request.url.netloc.decode("ascii")
239239

240240

241+
def test_same_origin_with_equivalent_ipv6_addresses() -> None:
242+
origin = httpx2.URL("https://[0:0:0:0:0:0:0:1]/redirected")
243+
request = httpx2.Request("GET", "https://[::1]/", headers={"Authorization": "secret"})
244+
245+
client = httpx2.Client()
246+
headers = client._redirect_headers(request, origin, "GET")
247+
248+
assert headers["Authorization"] == "secret"
249+
250+
241251
def test_not_same_origin() -> None:
242252
origin = httpx2.URL("https://example.com")
243253
request = httpx2.Request("GET", "HTTP://EXAMPLE.COM:80")
@@ -248,6 +258,16 @@ def test_not_same_origin() -> None:
248258
assert headers["Host"] == origin.netloc.decode("ascii")
249259

250260

261+
def test_not_same_origin_with_percent_encoded_ipv6_scope() -> None:
262+
origin = httpx2.URL("https://[2001:db8::10]")
263+
request = httpx2.Request("GET", "https://[2001:db8::1%30]", headers={"Authorization": "secret"})
264+
265+
client = httpx2.Client()
266+
headers = client._redirect_headers(request, origin, "GET")
267+
268+
assert "Authorization" not in headers
269+
270+
251271
def test_is_https_redirect() -> None:
252272
url = httpx2.URL("https://example.com")
253273
request = httpx2.Request("GET", "http://example.com", headers={"Authorization": "empty"})
@@ -258,6 +278,16 @@ def test_is_https_redirect() -> None:
258278
assert "Authorization" in headers
259279

260280

281+
def test_is_https_redirect_with_equivalent_ipv6_addresses() -> None:
282+
url = httpx2.URL("https://[0:0:0:0:0:0:0:1]")
283+
request = httpx2.Request("GET", "http://[::1]", headers={"Authorization": "secret"})
284+
285+
client = httpx2.Client()
286+
headers = client._redirect_headers(request, url, "GET")
287+
288+
assert headers["Authorization"] == "secret"
289+
290+
261291
def test_is_not_https_redirect() -> None:
262292
url = httpx2.URL("https://www.example.com")
263293
request = httpx2.Request("GET", "http://example.com", headers={"Authorization": "empty"})

‎tests/httpx2/models/test_url.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,88 @@ def test_url_no_authority() -> None:
6767
assert url.path == "/"
6868

6969

70+
def test_origin() -> None:
71+
url = httpx2.URL("https://user:password@example.com/path?query#fragment")
72+
73+
assert url.origin == httpx2.Origin("https://example.com")
74+
assert url.origin.scheme == "https"
75+
assert url.origin.host == "example.com"
76+
assert url.origin.port == 443
77+
assert repr(url.origin) == "Origin(scheme='https', host='example.com', port=443)"
78+
79+
80+
def test_origin_is_immutable_and_hashable() -> None:
81+
origin = httpx2.Origin("https://example.com")
82+
83+
with pytest.raises(AttributeError):
84+
origin.host = "other.example.com" # type: ignore[misc]
85+
86+
assert {origin, httpx2.Origin("HTTPS://EXAMPLE.COM:443")} == {origin}
87+
88+
89+
@pytest.mark.parametrize(
90+
"url,scheme,port",
91+
[
92+
("ftp://example.com", "ftp", 21),
93+
("http://example.com", "http", 80),
94+
("https://example.com", "https", 443),
95+
("ws://example.com/socket", "ws", 80),
96+
("wss://example.com/socket", "wss", 443),
97+
("custom://example.com", "custom", None),
98+
("custom://example.com:1234", "custom", 1234),
99+
],
100+
)
101+
def test_origin_effective_port(url: str, scheme: str, port: int | None) -> None:
102+
origin = httpx2.Origin(url)
103+
104+
assert origin.scheme == scheme
105+
assert origin.port == port
106+
107+
108+
@pytest.mark.parametrize(
109+
"left,right",
110+
[
111+
("https://example.com", "HTTPS://EXAMPLE.COM:443"),
112+
("https://中国.icom.museum", "https://xn--fiqs8s.icom.museum:443"),
113+
("ws://example.com/socket", "ws://example.com:80/other"),
114+
("wss://example.com/socket", "wss://example.com:443/other"),
115+
("https://[::1]", "https://[0:0:0:0:0:0:0:1]"),
116+
("https://[2001:db8::1]", "https://[2001:0db8:0:0:0:0:0:1]"),
117+
("https://[::ffff:192.168.0.1]", "https://[::ffff:c0a8:1]"),
118+
("https://[fe80::1%25eth0]", "https://[fe80:0:0:0:0:0:0:1%25eth0]"),
119+
],
120+
)
121+
def test_equivalent_origins(left: str, right: str) -> None:
122+
assert httpx2.URL(left).origin == httpx2.URL(right).origin
123+
124+
125+
@pytest.mark.parametrize(
126+
"left,right",
127+
[
128+
("http://example.com", "https://example.com"),
129+
("http://example.com", "ws://example.com"),
130+
("https://example.com", "wss://example.com"),
131+
("https://example.com", "https://example.com:444"),
132+
("https://[::1]", "https://[::2]"),
133+
("https://[fe80::1%25eth0]", "https://[fe80::1%25eth1]"),
134+
("https://[fe80::1%eth0]", "https://[fe80::1%25eth0]"),
135+
("https://[2001:db8::1%30]", "https://[2001:db8::10]"),
136+
],
137+
)
138+
def test_distinct_origins(left: str, right: str) -> None:
139+
assert httpx2.URL(left).origin != httpx2.URL(right).origin
140+
141+
142+
@pytest.mark.parametrize("url", ["", "/path", "mailto:user@example.com", "file:///tmp/example"])
143+
def test_relative_url_does_not_have_origin(url: str) -> None:
144+
with pytest.raises(ValueError, match="URL must be absolute to have an origin"):
145+
_ = httpx2.URL(url).origin
146+
147+
148+
def test_origin_preserves_percent_escaped_ipv6_scope() -> None:
149+
assert httpx2.Origin("https://[2001:db8::1%2e]").host == "2001:db8::1%2e"
150+
151+
70152
# Tests for percent encoding across path, query, and fragment...
71153

72154

0 commit comments

Comments
 (0)