Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preserve individual headers with the same name on responses #1208

Merged
merged 5 commits into from
Nov 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [2.7.0.dev0](https://github.com/httpie/httpie/compare/2.6.0...master) (unreleased)

- Added support for sending multiple HTTP headers with the same name. ([#130](https://github.com/httpie/httpie/issues/130))
- Added support for receving multiple HTTP headers with the same name, individually. ([#1207](https://github.com/httpie/httpie/issues/1207))
- Added support for keeping `://` in the URL argument to allow quick conversions of pasted URLs into HTTPie calls just by adding a space after the protocol name (`$ https ://pie.dev` → `https://pie.dev`). ([#1195](https://github.com/httpie/httpie/issues/1195))

## [2.6.0](https://github.com/httpie/httpie/compare/2.5.0...2.6.0) (2021-10-14)
Expand Down
13 changes: 13 additions & 0 deletions httpie/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from httpie.cli.dicts import HTTPHeadersDict
from requests.adapters import HTTPAdapter


class HTTPieHTTPAdapter(HTTPAdapter):

def build_response(self, req, resp):
"""Wrap the original headers with the `HTTPHeadersDict`
to preserve multiple headers that have the same name"""

response = super().build_response(req, resp)
response.headers = HTTPHeadersDict(getattr(resp, 'headers', {}))
return response
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking about moving httpie/ssl.py into httpie/adapters.py, since it does make sense for all the adapters to be in the same place. WDYT @jakubroztocil?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good 👍🏻

2 changes: 1 addition & 1 deletion httpie/cli/dicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class BaseMultiDict(MultiDict):
"""


class RequestHeadersDict(CIMultiDict, BaseMultiDict):
class HTTPHeadersDict(CIMultiDict, BaseMultiDict):
"""
Headers are case-insensitive and multiple values are supported
through the `add()` API.
Expand Down
4 changes: 2 additions & 2 deletions httpie/cli/requestitems.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
)
from .dicts import (
BaseMultiDict, MultipartRequestDataDict, RequestDataDict,
RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
RequestFilesDict, HTTPHeadersDict, RequestJSONDataDict,
RequestQueryParamsDict,
)
from .exceptions import ParseError
Expand All @@ -21,7 +21,7 @@
class RequestItems:

def __init__(self, as_form=False):
self.headers = RequestHeadersDict()
self.headers = HTTPHeadersDict()
self.data = RequestDataDict() if as_form else RequestJSONDataDict()
self.files = RequestFilesDict()
self.params = RequestQueryParamsDict()
Expand Down
19 changes: 11 additions & 8 deletions httpie/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# noinspection PyPackageRequirements
import urllib3
from . import __version__
from .cli.dicts import RequestHeadersDict
from .adapters import HTTPieHTTPAdapter
from .cli.dicts import HTTPHeadersDict
from .encoding import UTF8
from .plugins.registry import plugin_manager
from .sessions import get_httpie_session
Expand Down Expand Up @@ -153,6 +154,7 @@ def build_requests_session(
requests_session = requests.Session()

# Install our adapter.
http_adapter = HTTPieHTTPAdapter()
https_adapter = HTTPieHTTPSAdapter(
ciphers=ciphers,
verify=verify,
Expand All @@ -161,6 +163,7 @@ def build_requests_session(
if ssl_version else None
),
)
requests_session.mount('http://', http_adapter)
requests_session.mount('https://', https_adapter)

# Install adapters from plugins.
Expand All @@ -179,8 +182,8 @@ def dump_request(kwargs: dict):
f'\n>>> requests.request(**{repr_dict(kwargs)})\n\n')


def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict:
final_headers = RequestHeadersDict()
def finalize_headers(headers: HTTPHeadersDict) -> HTTPHeadersDict:
final_headers = HTTPHeadersDict()
for name, value in headers.items():
if value is not None:
# “leading or trailing LWS MAY be removed without
Expand All @@ -197,13 +200,13 @@ def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict:

def apply_missing_repeated_headers(
prepared_request: requests.PreparedRequest,
original_headers: RequestHeadersDict
original_headers: HTTPHeadersDict
) -> None:
"""Update the given `prepared_request`'s headers with the original
ones. This allows the requests to be prepared as usual, and then later
merged with headers that are specified multiple times."""

new_headers = RequestHeadersDict(prepared_request.headers)
new_headers = HTTPHeadersDict(prepared_request.headers)
for prepared_name, prepared_value in prepared_request.headers.items():
if prepared_name not in original_headers:
continue
Expand All @@ -225,8 +228,8 @@ def apply_missing_repeated_headers(
prepared_request.headers = new_headers


def make_default_headers(args: argparse.Namespace) -> RequestHeadersDict:
default_headers = RequestHeadersDict({
def make_default_headers(args: argparse.Namespace) -> HTTPHeadersDict:
default_headers = HTTPHeadersDict({
'User-Agent': DEFAULT_UA
})

Expand Down Expand Up @@ -271,7 +274,7 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:

def make_request_kwargs(
args: argparse.Namespace,
base_headers: RequestHeadersDict = None,
base_headers: HTTPHeadersDict = None,
request_body_read_callback=lambda chunk: chunk
) -> dict:
"""
Expand Down
4 changes: 3 additions & 1 deletion httpie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ def headers(self):
)
headers.extend(
f'Set-Cookie: {cookie}'
for cookie in split_cookies(original.headers.get('Set-Cookie'))
for header, value in original.headers.items()
for cookie in split_cookies(value)
if header == 'Set-Cookie'
)
return '\r\n'.join(headers)

Expand Down
8 changes: 4 additions & 4 deletions httpie/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from requests.auth import AuthBase
from requests.cookies import RequestsCookieJar, create_cookie

from .cli.dicts import RequestHeadersDict
from .cli.dicts import HTTPHeadersDict
from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
from .plugins.registry import plugin_manager

Expand Down Expand Up @@ -65,7 +65,7 @@ def __init__(self, path: Union[str, Path]):
'password': None
}

def update_headers(self, request_headers: RequestHeadersDict):
def update_headers(self, request_headers: HTTPHeadersDict):
"""
Update the session headers with the request ones while ignoring
certain name prefixes.
Expand Down Expand Up @@ -98,8 +98,8 @@ def update_headers(self, request_headers: RequestHeadersDict):
self['headers'] = dict(headers)

@property
def headers(self) -> RequestHeadersDict:
return RequestHeadersDict(self['headers'])
def headers(self) -> HTTPHeadersDict:
return HTTPHeadersDict(self['headers'])

@property
def cookies(self) -> RequestsCookieJar:
Expand Down
2 changes: 1 addition & 1 deletion httpie/ssl.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ssl

from requests.adapters import HTTPAdapter
from httpie.adapters import HTTPAdapter
# noinspection PyPackageRequirements
from urllib3.util.ssl_ import (
DEFAULT_CIPHERS, create_urllib3_context,
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pytest_httpbin import certs

from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT
from .utils.http_server import http_server # noqa


@pytest.fixture(scope='function', autouse=True)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_escape_separator(self):
# files
self.key_value_arg(fr'bar\@baz@{FILE_PATH_ARG}'),
])
# `RequestHeadersDict` => `dict`
# `HTTPHeadersDict` => `dict`
headers = dict(items.headers)

assert headers == {
Expand Down Expand Up @@ -88,7 +88,7 @@ def test_valid_items(self):
])

# Parsed headers
# `RequestHeadersDict` => `dict`
# `HTTPHeadersDict` => `dict`
headers = dict(items.headers)
assert headers == {
'Header': 'value',
Expand Down
27 changes: 27 additions & 0 deletions tests/test_httpie.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,33 @@ def test_headers_multiple_headers_representation(httpbin_both, pretty):
assert 'c: c' in r


def test_response_headers_multiple(http_server):
r = http('GET', http_server + '/headers', 'Foo:bar', 'Foo:baz')
assert 'Foo: bar' in r
assert 'Foo: baz' in r


def test_response_headers_multiple_repeated(http_server):
r = http('GET', http_server + '/headers', 'Foo:bar', 'Foo:baz',
'Foo:bar')
assert r.count('Foo: bar') == 2
assert 'Foo: baz' in r


@pytest.mark.parametrize('pretty', ['format', 'none'])
def test_response_headers_multiple_representation(http_server, pretty):
r = http('--pretty', pretty, http_server + '/headers',
'A:A', 'A:B', 'A:C', 'B:A', 'B:B', 'C:C', 'C:c')

assert 'A: A' in r
assert 'A: B' in r
assert 'A: C' in r
assert 'B: A' in r
assert 'B: B' in r
assert 'C: C' in r
assert 'C: c' in r


def test_json_input_preserve_order(httpbin_both):
r = http('PATCH', httpbin_both + '/patch',
'order:={"map":{"1":"first","2":"second"}}')
Expand Down
50 changes: 50 additions & 0 deletions tests/utils/http_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import threading

from collections import defaultdict
from http import HTTPStatus
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse

import pytest


class TestHandler(BaseHTTPRequestHandler):
handlers = defaultdict(dict)

@classmethod
def handler(cls, method, path):
def inner(func):
cls.handlers[method][path] = func
return func
return inner

def do_GET(self):
parse_result = urlparse(self.path)
func = self.handlers['GET'].get(parse_result.path)
if func is None:
return self.send_error(HTTPStatus.NOT_FOUND)

return func(self)


@TestHandler.handler('GET', '/headers')
def get_headers(handler):
handler.send_response(200)
for key, value in handler.headers.items():
handler.send_header(key, value)
handler.send_header('Content-Length', 0)
handler.end_headers()


@pytest.fixture(scope="function")
def http_server():
"""A custom HTTP server implementation for our tests, that is
built on top of the http.server module. Handy when we need to
deal with details which httpbin can not capture."""

server = HTTPServer(('localhost', 0), TestHandler)
thread = threading.Thread(target=server.serve_forever)
thread.start()
yield '{}:{}'.format(*server.socket.getsockname())
server.shutdown()
thread.join(timeout=0.5)