Preserve individual headers with the same name on responses (#1208)

* Preserve individual headers with the same name on responses

* Rename RequestHeadersDict to HTTPHeadersDict

* Update tests/utils/http_server.py

* Update tests/utils/http_server.py

* Update httpie/adapters.py

Co-authored-by: Jakub Roztocil <jakub@roztocil.co>
This commit is contained in:
Batuhan Taskaya 2021-11-25 02:41:37 +03:00 committed by GitHub
parent cfcd7413d1
commit c000886546
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 116 additions and 19 deletions

View File

@ -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) ## [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 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)) - 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) ## [2.6.0](https://github.com/httpie/httpie/compare/2.5.0...2.6.0) (2021-10-14)

13
httpie/adapters.py Normal file
View File

@ -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

View File

@ -9,7 +9,7 @@ class BaseMultiDict(MultiDict):
""" """
class RequestHeadersDict(CIMultiDict, BaseMultiDict): class HTTPHeadersDict(CIMultiDict, BaseMultiDict):
""" """
Headers are case-insensitive and multiple values are supported Headers are case-insensitive and multiple values are supported
through the `add()` API. through the `add()` API.

View File

@ -11,7 +11,7 @@ from .constants import (
) )
from .dicts import ( from .dicts import (
BaseMultiDict, MultipartRequestDataDict, RequestDataDict, BaseMultiDict, MultipartRequestDataDict, RequestDataDict,
RequestFilesDict, RequestHeadersDict, RequestJSONDataDict, RequestFilesDict, HTTPHeadersDict, RequestJSONDataDict,
RequestQueryParamsDict, RequestQueryParamsDict,
) )
from .exceptions import ParseError from .exceptions import ParseError
@ -21,7 +21,7 @@ from ..utils import get_content_type, load_json_preserve_order_and_dupe_keys
class RequestItems: class RequestItems:
def __init__(self, as_form=False): def __init__(self, as_form=False):
self.headers = RequestHeadersDict() self.headers = HTTPHeadersDict()
self.data = RequestDataDict() if as_form else RequestJSONDataDict() self.data = RequestDataDict() if as_form else RequestJSONDataDict()
self.files = RequestFilesDict() self.files = RequestFilesDict()
self.params = RequestQueryParamsDict() self.params = RequestQueryParamsDict()

View File

@ -11,7 +11,8 @@ import requests
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import urllib3 import urllib3
from . import __version__ from . import __version__
from .cli.dicts import RequestHeadersDict from .adapters import HTTPieHTTPAdapter
from .cli.dicts import HTTPHeadersDict
from .encoding import UTF8 from .encoding import UTF8
from .plugins.registry import plugin_manager from .plugins.registry import plugin_manager
from .sessions import get_httpie_session from .sessions import get_httpie_session
@ -153,6 +154,7 @@ def build_requests_session(
requests_session = requests.Session() requests_session = requests.Session()
# Install our adapter. # Install our adapter.
http_adapter = HTTPieHTTPAdapter()
https_adapter = HTTPieHTTPSAdapter( https_adapter = HTTPieHTTPSAdapter(
ciphers=ciphers, ciphers=ciphers,
verify=verify, verify=verify,
@ -161,6 +163,7 @@ def build_requests_session(
if ssl_version else None if ssl_version else None
), ),
) )
requests_session.mount('http://', http_adapter)
requests_session.mount('https://', https_adapter) requests_session.mount('https://', https_adapter)
# Install adapters from plugins. # Install adapters from plugins.
@ -179,8 +182,8 @@ def dump_request(kwargs: dict):
f'\n>>> requests.request(**{repr_dict(kwargs)})\n\n') f'\n>>> requests.request(**{repr_dict(kwargs)})\n\n')
def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict: def finalize_headers(headers: HTTPHeadersDict) -> HTTPHeadersDict:
final_headers = RequestHeadersDict() final_headers = HTTPHeadersDict()
for name, value in headers.items(): for name, value in headers.items():
if value is not None: if value is not None:
# “leading or trailing LWS MAY be removed without # “leading or trailing LWS MAY be removed without
@ -197,13 +200,13 @@ def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict:
def apply_missing_repeated_headers( def apply_missing_repeated_headers(
prepared_request: requests.PreparedRequest, prepared_request: requests.PreparedRequest,
original_headers: RequestHeadersDict original_headers: HTTPHeadersDict
) -> None: ) -> None:
"""Update the given `prepared_request`'s headers with the original """Update the given `prepared_request`'s headers with the original
ones. This allows the requests to be prepared as usual, and then later ones. This allows the requests to be prepared as usual, and then later
merged with headers that are specified multiple times.""" 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(): for prepared_name, prepared_value in prepared_request.headers.items():
if prepared_name not in original_headers: if prepared_name not in original_headers:
continue continue
@ -225,8 +228,8 @@ def apply_missing_repeated_headers(
prepared_request.headers = new_headers prepared_request.headers = new_headers
def make_default_headers(args: argparse.Namespace) -> RequestHeadersDict: def make_default_headers(args: argparse.Namespace) -> HTTPHeadersDict:
default_headers = RequestHeadersDict({ default_headers = HTTPHeadersDict({
'User-Agent': DEFAULT_UA 'User-Agent': DEFAULT_UA
}) })
@ -271,7 +274,7 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:
def make_request_kwargs( def make_request_kwargs(
args: argparse.Namespace, args: argparse.Namespace,
base_headers: RequestHeadersDict = None, base_headers: HTTPHeadersDict = None,
request_body_read_callback=lambda chunk: chunk request_body_read_callback=lambda chunk: chunk
) -> dict: ) -> dict:
""" """

View File

@ -72,7 +72,9 @@ class HTTPResponse(HTTPMessage):
) )
headers.extend( headers.extend(
f'Set-Cookie: {cookie}' 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) return '\r\n'.join(headers)

View File

@ -13,7 +13,7 @@ from urllib.parse import urlsplit
from requests.auth import AuthBase from requests.auth import AuthBase
from requests.cookies import RequestsCookieJar, create_cookie 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 .config import BaseConfigDict, DEFAULT_CONFIG_DIR
from .plugins.registry import plugin_manager from .plugins.registry import plugin_manager
@ -65,7 +65,7 @@ class Session(BaseConfigDict):
'password': None '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 Update the session headers with the request ones while ignoring
certain name prefixes. certain name prefixes.
@ -98,8 +98,8 @@ class Session(BaseConfigDict):
self['headers'] = dict(headers) self['headers'] = dict(headers)
@property @property
def headers(self) -> RequestHeadersDict: def headers(self) -> HTTPHeadersDict:
return RequestHeadersDict(self['headers']) return HTTPHeadersDict(self['headers'])
@property @property
def cookies(self) -> RequestsCookieJar: def cookies(self) -> RequestsCookieJar:

View File

@ -1,6 +1,6 @@
import ssl import ssl
from requests.adapters import HTTPAdapter from httpie.adapters import HTTPAdapter
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
from urllib3.util.ssl_ import ( from urllib3.util.ssl_ import (
DEFAULT_CIPHERS, create_urllib3_context, DEFAULT_CIPHERS, create_urllib3_context,

View File

@ -5,6 +5,7 @@ import pytest
from pytest_httpbin import certs from pytest_httpbin import certs
from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT 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) @pytest.fixture(scope='function', autouse=True)

View File

@ -39,7 +39,7 @@ class TestItemParsing:
# files # files
self.key_value_arg(fr'bar\@baz@{FILE_PATH_ARG}'), self.key_value_arg(fr'bar\@baz@{FILE_PATH_ARG}'),
]) ])
# `RequestHeadersDict` => `dict` # `HTTPHeadersDict` => `dict`
headers = dict(items.headers) headers = dict(items.headers)
assert headers == { assert headers == {
@ -88,7 +88,7 @@ class TestItemParsing:
]) ])
# Parsed headers # Parsed headers
# `RequestHeadersDict` => `dict` # `HTTPHeadersDict` => `dict`
headers = dict(items.headers) headers = dict(items.headers)
assert headers == { assert headers == {
'Header': 'value', 'Header': 'value',

View File

@ -291,6 +291,33 @@ def test_headers_multiple_headers_representation(httpbin_both, pretty):
assert 'c: c' in r 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): def test_json_input_preserve_order(httpbin_both):
r = http('PATCH', httpbin_both + '/patch', r = http('PATCH', httpbin_both + '/patch',
'order:={"map":{"1":"first","2":"second"}}') 'order:={"map":{"1":"first","2":"second"}}')

View File

@ -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)