Support multiple headers sharing the same name (#1190)

* Support multiple headers sharing the same name

* Apply suggestions

* Don't normalize HTTP header names

* apply visual suggestions

Co-authored-by: Jakub Roztocil <jakub@roztocil.co>

* bump down multidict to 4.7.0

Co-authored-by: Jakub Roztocil <jakub@roztocil.co>
This commit is contained in:
Batuhan Taskaya 2021-10-31 15:04:39 +01:00 committed by GitHub
parent d40f06687f
commit 7cdd74fece
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 221 additions and 13 deletions

View File

@ -5,6 +5,8 @@ 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))
## [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)
[Whats new in HTTPie 2.6.0 →](https://httpie.io/blog/httpie-2.6.0) [Whats new in HTTPie 2.6.0 →](https://httpie.io/blog/httpie-2.6.0)

View File

@ -869,6 +869,39 @@ To send a header with an empty value, use `Header;`, with a semicolon:
$ http pie.dev/headers 'Header;' $ http pie.dev/headers 'Header;'
``` ```
Please note that some internal headers, such as `Content-Length`, can't be unset if
they are automatically added by the client itself.
### Multiple header values with the same name
If the request is sent with multiple headers that are sharing the same name, then
the HTTPie will send them individually.
```bash
http --offline example.org Cookie:one Cookie:two
```
```http
GET / HTTP/1.1
Cookie: one
Cookie: two
```
It is also possible to pass a single header value pair, where the value is a comma
separated list of header values. Then the client will send it as a single header.
```bash
http --offline example.org Numbers:one,two
```
```http
GET / HTTP/1.1
Numbers: one,two
```
Also be aware that if the current session contains any headers they will get overwriten
by individual commands when sending a request instead of being joined together.
### Limiting response headers ### Limiting response headers
The `--max-headers=n` options allows you to control the number of headers HTTPie reads before giving up (the default `0`, i.e., theres no limit). The `--max-headers=n` options allows you to control the number of headers HTTPie reads before giving up (the default `0`, i.e., theres no limit).

View File

@ -1,15 +1,41 @@
from collections import OrderedDict from collections import OrderedDict
from requests.structures import CaseInsensitiveDict from multidict import MultiDict, CIMultiDict
class RequestHeadersDict(CaseInsensitiveDict): class BaseMultiDict(MultiDict):
""" """
Headers are case-insensitive and multiple values are currently not supported. Base class for all MultiDicts.
""" """
class RequestHeadersDict(CIMultiDict, BaseMultiDict):
"""
Headers are case-insensitive and multiple values are supported
through the `add()` API.
"""
def add(self, key, value):
"""
Add or update a new header.
If the given `value` is `None`, then all the previous
values will be overwritten and the value will be set
to `None`.
"""
if value is None:
self[key] = value
return None
# If the previous value for the given header is `None`
# then discard it since we are explicitly giving a new
# value for it.
if key in self and self.getone(key) is None:
self.popone(key)
super().add(key, value)
class RequestJSONDataDict(OrderedDict): class RequestJSONDataDict(OrderedDict):
pass pass

View File

@ -10,8 +10,8 @@ from .constants import (
SEPARATOR_QUERY_PARAM, SEPARATOR_QUERY_PARAM,
) )
from .dicts import ( from .dicts import (
MultipartRequestDataDict, RequestDataDict, RequestFilesDict, BaseMultiDict, MultipartRequestDataDict, RequestDataDict,
RequestHeadersDict, RequestJSONDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
RequestQueryParamsDict, RequestQueryParamsDict,
) )
from .exceptions import ParseError from .exceptions import ParseError
@ -73,11 +73,15 @@ class RequestItems:
for arg in request_item_args: for arg in request_item_args:
processor_func, target_dict = rules[arg.sep] processor_func, target_dict = rules[arg.sep]
value = processor_func(arg) value = processor_func(arg)
target_dict[arg.key] = value
if arg.sep in SEPARATORS_GROUP_MULTIPART: if arg.sep in SEPARATORS_GROUP_MULTIPART:
instance.multipart_data[arg.key] = value instance.multipart_data[arg.key] = value
if isinstance(target_dict, BaseMultiDict):
target_dict.add(arg.key, value)
else:
target_dict[arg.key] = value
return instance return instance

View File

@ -79,6 +79,7 @@ def collect_messages(
request = requests.Request(**request_kwargs) request = requests.Request(**request_kwargs)
prepared_request = requests_session.prepare_request(request) prepared_request = requests_session.prepare_request(request)
apply_missing_repeated_headers(prepared_request, request.headers)
if args.path_as_is: if args.path_as_is:
prepared_request.url = ensure_path_as_is( prepared_request.url = ensure_path_as_is(
orig_url=args.url, orig_url=args.url,
@ -190,10 +191,40 @@ def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict:
if isinstance(value, str): if isinstance(value, str):
# See <https://github.com/httpie/httpie/issues/212> # See <https://github.com/httpie/httpie/issues/212>
value = value.encode() value = value.encode()
final_headers[name] = value final_headers.add(name, value)
return final_headers return final_headers
def apply_missing_repeated_headers(
prepared_request: requests.PreparedRequest,
original_headers: RequestHeadersDict
) -> 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)
for prepared_name, prepared_value in prepared_request.headers.items():
if prepared_name not in original_headers:
continue
original_keys, original_values = zip(*filter(
lambda item: item[0].casefold() == prepared_name.casefold(),
original_headers.items()
))
if prepared_value not in original_values:
# If the current value is not among the initial values
# set for this field, then it means that this field got
# overridden on the way, and we should preserve it.
continue
new_headers.popone(prepared_name)
new_headers.update(zip(original_keys, original_values))
prepared_request.headers = new_headers
def make_default_headers(args: argparse.Namespace) -> RequestHeadersDict: def make_default_headers(args: argparse.Namespace) -> RequestHeadersDict:
default_headers = RequestHeadersDict({ default_headers = RequestHeadersDict({
'User-Agent': DEFAULT_UA 'User-Agent': DEFAULT_UA

View File

@ -96,7 +96,7 @@ class HTTPRequest(HTTPMessage):
query=f'?{url.query}' if url.query else '' query=f'?{url.query}' if url.query else ''
) )
headers = dict(self._orig.headers) headers = self._orig.headers.copy()
if 'Host' not in self._orig.headers: if 'Host' not in self._orig.headers:
headers['Host'] = url.netloc.split('@')[-1] headers['Host'] = url.netloc.split('@')[-1]

View File

@ -33,6 +33,7 @@ install_requires = [
'requests[socks]>=2.22.0', 'requests[socks]>=2.22.0',
'Pygments>=2.5.2', 'Pygments>=2.5.2',
'requests-toolbelt>=0.9.1', 'requests-toolbelt>=0.9.1',
'multidict>=4.7.0',
'setuptools', 'setuptools',
] ]
install_requires_win_only = [ install_requires_win_only = [

View File

@ -39,8 +39,8 @@ class TestItemParsing:
# files # files
self.key_value_arg(fr'bar\@baz@{FILE_PATH_ARG}'), self.key_value_arg(fr'bar\@baz@{FILE_PATH_ARG}'),
]) ])
# `requests.structures.CaseInsensitiveDict` => `dict` # `RequestHeadersDict` => `dict`
headers = dict(items.headers._store.values()) headers = dict(items.headers)
assert headers == { assert headers == {
'foo:bar': 'baz', 'foo:bar': 'baz',
@ -88,8 +88,8 @@ class TestItemParsing:
]) ])
# Parsed headers # Parsed headers
# `requests.structures.CaseInsensitiveDict` => `dict` # `RequestHeadersDict` => `dict`
headers = dict(items.headers._store.values()) headers = dict(items.headers)
assert headers == { assert headers == {
'Header': 'value', 'Header': 'value',
'Unset-Header': None, 'Unset-Header': None,

View File

@ -209,6 +209,88 @@ def test_headers_empty_value_with_value_gives_error(httpbin):
http('GET', httpbin + '/headers', 'Accept;SYNTAX_ERROR') http('GET', httpbin + '/headers', 'Accept;SYNTAX_ERROR')
def test_headers_omit(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Accept:')
assert 'Accept' not in r.json['headers']
def test_headers_multiple_omit(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Bar:baz',
'Foo:', 'Baz:quux')
assert 'Foo' not in r.json['headers']
assert r.json['headers']['Bar'] == 'baz'
assert r.json['headers']['Baz'] == 'quux'
def test_headers_same_after_omit(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Foo:',
'Foo:quux')
assert r.json['headers']['Foo'] == 'quux'
def test_headers_fully_omit(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Foo:baz',
'Foo:')
assert 'Foo' not in r.json['headers']
def test_headers_multiple_values(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Foo:baz')
assert r.json['headers']['Foo'] == 'bar,baz'
def test_headers_multiple_values_repeated(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Foo:baz',
'Foo:bar')
assert r.json['headers']['Foo'] == 'bar,baz,bar'
@pytest.mark.parametrize("headers, expected", [
(
["Foo;", "Foo:bar"],
",bar"
),
(
["Foo:bar", "Foo;"],
"bar,"
),
(
["Foo:bar", "Foo;", "Foo:baz"],
"bar,,baz"
),
])
def test_headers_multiple_values_with_empty(httpbin_both, headers, expected):
r = http('GET', httpbin_both + '/headers', *headers)
assert r.json['headers']['Foo'] == expected
def test_headers_multiple_values_mixed(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar', 'Vary:XXX',
'Foo:baz', 'Vary:YYY', 'Foo:quux')
assert r.json['headers']['Vary'] == 'XXX,YYY'
assert r.json['headers']['Foo'] == 'bar,baz,quux'
def test_headers_preserve_prepared_headers(httpbin_both):
r = http('POST', httpbin_both + '/post', 'Content-Length:0',
'--raw', 'foo')
assert r.json['headers']['Content-Length'] == '3'
@pytest.mark.parametrize('pretty', ['format', 'none'])
def test_headers_multiple_headers_representation(httpbin_both, pretty):
r = http('--offline', '--pretty', pretty, 'example.org',
'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

@ -73,12 +73,17 @@ def test_follow_redirect_with_repost(httpbin, status_code):
r = http( r = http(
'--follow', '--follow',
httpbin.url + '/redirect-to', httpbin.url + '/redirect-to',
'A:A',
'A:B',
'B:B',
f'url=={httpbin.url}/post', f'url=={httpbin.url}/post',
f'status_code=={status_code}', f'status_code=={status_code}',
'@' + FILE_PATH_ARG, '@' + FILE_PATH_ARG,
) )
assert HTTP_OK in r assert HTTP_OK in r
assert FILE_CONTENT in r assert FILE_CONTENT in r
assert r.json['headers']['A'] == 'A,B'
assert r.json['headers']['B'] == 'B'
@pytest.mark.skipif(is_windows, reason='occasionally fails w/ ConnectionError for no apparent reason') @pytest.mark.skipif(is_windows, reason='occasionally fails w/ ConnectionError for no apparent reason')
@ -88,11 +93,17 @@ def test_verbose_follow_redirect_with_repost(httpbin, status_code):
'--follow', '--follow',
'--verbose', '--verbose',
httpbin.url + '/redirect-to', httpbin.url + '/redirect-to',
'A:A',
'A:B',
'B:B',
f'url=={httpbin.url}/post', f'url=={httpbin.url}/post',
f'status_code=={status_code}', f'status_code=={status_code}',
'@' + FILE_PATH_ARG, '@' + FILE_PATH_ARG,
) )
assert f'HTTP/1.1 {status_code}' in r assert f'HTTP/1.1 {status_code}' in r
assert 'A: A' in r
assert 'A: B' in r
assert 'B: B' in r
assert r.count('POST /redirect-to') == 1 assert r.count('POST /redirect-to') == 1
assert r.count('POST /post') == 1 assert r.count('POST /post') == 1
assert r.count(FILE_CONTENT) == 3 # two requests + final response contain it assert r.count(FILE_CONTENT) == 3 # two requests + final response contain it

View File

@ -143,6 +143,24 @@ class TestSessionFlow(SessionTestBase):
# Should be the same as before r3. # Should be the same as before r3.
assert r2.json == r4.json assert r2.json == r4.json
def test_session_overwrite_header(self, httpbin):
self.start_session(httpbin)
r2 = http('--session=test', 'GET', httpbin.url + '/get',
'Hello:World2', env=self.env())
assert HTTP_OK in r2
assert r2.json['headers']['Hello'] == 'World2'
r3 = http('--session=test', 'GET', httpbin.url + '/get',
'Hello:World2', 'Hello:World3', env=self.env())
assert HTTP_OK in r3
assert r3.json['headers']['Hello'] == 'World2,World3'
r3 = http('--session=test', 'GET', httpbin.url + '/get',
'Hello:', 'Hello:World3', env=self.env())
assert HTTP_OK in r3
assert 'Hello' not in r3.json['headers']['Hello']
class TestSession(SessionTestBase): class TestSession(SessionTestBase):
"""Stand-alone session tests.""" """Stand-alone session tests."""