Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
This document records all notable changes to [HTTPie](https://httpie.io).
This project adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

## [3.2.4](https://github.com/httpie/cli/compare/3.2.3...3.2.4) (2024-11-01)

- Fix default certs loading and unpin `requests`. ([#1596](https://github.com/httpie/cli/issues/1596))
Expand Down
13 changes: 12 additions & 1 deletion httpie/cli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,19 @@ def parse_args(
return self.args

def _process_request_type(self):
# JSON is the implicit default request type: when the user passes no
# --json/--form/--multipart flag (so request_type is None), we still
# want the data item parser to treat key=value pairs as JSON, the
# request serializer to send a JSON body, and the default Content-Type
# to be application/json. RequestItems already encodes that in
# `is_json` (request_type is None or RequestType.JSON); mirror it on
# the args namespace so client.make_default_headers / make_request_kwargs
# can take the same code path as an explicit --json. Without this,
# `https URL 'h: v' x=1` would set Content-Type only because of the
# data item, not because of the implicit JSON mode, and a request with
# only headers would silently lose the Content-Type/Accept defaults.
request_type = self.args.request_type
self.args.json = request_type is RequestType.JSON
self.args.json = request_type is None or request_type is RequestType.JSON
self.args.multipart = request_type is RequestType.MULTIPART
self.args.form = request_type in {
RequestType.FORM,
Expand Down
20 changes: 15 additions & 5 deletions httpie/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,21 @@ def start(
"""
assert not self.status.time_started

# FIXME: some servers still might sent Content-Encoding: gzip
# <https://github.com/httpie/cli/issues/423>
try:
total_size = int(final_response.headers['Content-Length'])
except (KeyError, ValueError, TypeError):
# If the server applied a content coding (e.g. ``gzip``), then
# ``requests`` auto-decompresses the body in ``iter_content`` while
# ``Content-Length`` still reflects the *encoded* size per
# RFC 9110 §8.6. Comparing those two numbers would always mark the
# download as incomplete, so skip the size tracking in that case.
# See <https://github.com/httpie/cli/issues/423>.
content_encoding = (
final_response.headers.get('Content-Encoding') or 'identity'
).strip().lower()
if content_encoding == 'identity':
try:
total_size = int(final_response.headers['Content-Length'])
except (KeyError, ValueError, TypeError):
total_size = None
else:
total_size = None

if not self._output_file:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,53 @@ def test_download_with_Content_Length(self, mock_env, httpbin_both):
downloader.finish()
assert not downloader.interrupted

def test_download_with_Content_Encoding_skips_size_check(self, mock_env, httpbin_both):
# When the server applies a content coding, ``requests`` auto-decompresses
# the body but Content-Length still reflects the encoded size per
# RFC 9110 §8.6. The downloader must skip the size comparison in that
# case so a fully-received, encoded payload isn't reported as an
# "Incomplete download".
# <https://github.com/httpie/cli/issues/423>
with open(os.devnull, 'w') as devnull:
for content_encoding in ('gzip', 'br', 'deflate'):
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
initial_url='/',
final_response=Response(
url=httpbin_both.url + '/',
headers={
'Content-Length': 10,
'Content-Encoding': content_encoding,
},
),
)
# Decompressed stream ends up much larger than the encoded size.
downloader.chunk_downloaded(b'1234567890' * 1000)
downloader.finish()
assert not downloader.interrupted, (
f'Content-Encoding={content_encoding!r} should bypass '
f'the size comparison; got interrupted=True.'
)

def test_download_with_Content_Encoding_uppercase_or_padded(self, mock_env, httpbin_both):
# Header values come back with arbitrary casing and surrounding whitespace;
# the downloader must recognise those as content codings too.
with open(os.devnull, 'w') as devnull:
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
initial_url='/',
final_response=Response(
url=httpbin_both.url + '/',
headers={
'Content-Length': 10,
'Content-Encoding': ' GZIP ',
},
),
)
downloader.chunk_downloaded(b'1234567890' * 1000)
downloader.finish()
assert not downloader.interrupted

def test_download_no_Content_Length(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull:
downloader = Downloader(mock_env, output_file=devnull)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,37 @@ def test_nested_json_errors(input_json, expected_error, httpbin):
def test_nested_json_sparse_array(httpbin_both):
r = http(httpbin_both + '/post', 'test[0]:=1', 'test[100]:=1')
assert len(r.json['json']['test']) == 101

class TestImplicitJsonDefaults:
"""Regression tests for issue #1834: implicit --json should set the
Content-Type and Accept defaults even when the request has no data
items. The implicit mode is JSON (key=value becomes a JSON object),
so the contract should match what the user sees from explicit --json,
including the default headers."""

def test_content_type_set_with_only_header(self, httpbin):
# Use --check-status so an error status still yields r.json with
# headers, and target /get (GET) so the request does have a body
# echo. X-Test:abc is the only request item (a header). The
# implicit JSON mode should still set Content-Type and Accept.
r = http('--check-status', httpbin + '/get', 'X-Test:abc')
assert r.json['headers'].get('Content-Type') == 'application/json'
assert r.json['headers'].get('Accept') == 'application/json, */*;q=0.5'

def test_content_type_set_with_no_items(self, httpbin):
r = http(httpbin + '/get')
assert r.json['headers'].get('Content-Type') == 'application/json'
assert r.json['headers'].get('Accept') == 'application/json, */*;q=0.5'

def test_form_explicit_still_overrides(self, httpbin):
r = http('--form', httpbin + '/post', 'a=b')
assert r.json['headers'].get('Content-Type') == 'application/x-www-form-urlencoded; charset=utf-8'
# httpie does not set the JSON Accept for --form, so the value should
# not be the JSON_ACCEPT default. urllib3/requests may still attach
# its own Accept header on the wire, but it won't be the JSON one.
assert r.json['headers'].get('Accept') != 'application/json, */*;q=0.5'

def test_explicit_json_unchanged(self, httpbin):
r = http('--json', httpbin + '/get')
assert r.json['headers'].get('Content-Type') == 'application/json'
assert r.json['headers'].get('Accept') == 'application/json, */*;q=0.5'
Loading