diff --git a/httpie/models.py b/httpie/models.py index a0a68c8ddc..e9957b974a 100644 --- a/httpie/models.py +++ b/httpie/models.py @@ -19,6 +19,14 @@ ELAPSED_TIME_LABEL = 'Elapsed time' +# Default ports stripped from the displayed `Host` header to match what +# `http.client` actually sends on the wire (it omits the port when it +# matches the scheme's default). +DEFAULT_PORTS_BY_SCHEME = { + 'http': 80, + 'https': 443, +} + class HTTPMessage: """Abstract class for HTTP messages.""" @@ -148,7 +156,7 @@ def headers(self): headers = self._orig.headers.copy() if 'Host' not in self._orig.headers: - headers['Host'] = url.netloc.split('@')[-1] + headers['Host'] = _build_host_header(url) headers = [ f'{name}: {value if isinstance(value, str) else value.decode()}' @@ -169,6 +177,30 @@ def body(self): return body or b'' +def _build_host_header(url) -> str: + """Return the displayed `Host` header for a URL. + + Mirrors `http.client`'s behavior of omitting the port when it equals the + scheme's default, so `--print hH` matches the bytes actually sent on + the wire. + """ + netloc = url.netloc.split('@')[-1] + default_port = DEFAULT_PORTS_BY_SCHEME.get(url.scheme) + if default_port is not None: + try: + port = url.port + except ValueError: + # Malformed port — leave the netloc as-is. + port = None + if port == default_port: + # Strip the trailing `:` while preserving any + # IPv6 brackets and host casing in `netloc`. + suffix = f':{default_port}' + if netloc.endswith(suffix): + netloc = netloc[: -len(suffix)] + return netloc + + RequestsMessage = Union[requests.PreparedRequest, requests.Response] diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 622d03d7ce..66c3f8f6ea 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -19,6 +19,40 @@ def test_Host_header_overwrite(httpbin): assert f'host: {host}' in r +@pytest.mark.parametrize( + 'url, expected_host', + [ + # Default ports must be stripped from the displayed Host + # header to match what `http.client` actually sends on the + # wire (it omits the port when it equals the scheme default). + ('http://localhost:80/', 'localhost'), + ('https://example.com:443/', 'example.com'), + # Userinfo should not appear in Host either way. + ('http://user:pass@localhost:80/', 'localhost'), + # Non-default ports must be preserved. + ('http://localhost:8080/', 'localhost:8080'), + ('https://example.com:80/', 'example.com:80'), + ('https://example.com:8443/', 'example.com:8443'), + # No explicit port -> no port in Host. + ('https://example.com/', 'example.com'), + # IPv6 hosts keep their brackets. + ('http://[::1]:80/', '[::1]'), + ('http://[::1]:8080/', '[::1]:8080'), + ], +) +def test_Host_header_default_port_stripped(url, expected_host): + """ + https://github.com/httpie/cli/issues/1034 + """ + r = http('--offline', '--print=H', url) + host_lines = [ + line for line in r.splitlines() + if line.lower().startswith('host:') + ] + assert len(host_lines) == 1, r + assert host_lines[0] == f'Host: {expected_host}', r + + @pytest.mark.skipif(is_windows, reason='Unix-only') def test_output_devnull(httpbin): """