diff --git a/CHANGELOG.md b/CHANGELOG.md index 0497ac3508..b63a52a9e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index 9bf09b3b73..78698c85f5 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -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, diff --git a/httpie/downloads.py b/httpie/downloads.py index 9c4b895e6f..90a733cafc 100644 --- a/httpie/downloads.py +++ b/httpie/downloads.py @@ -216,11 +216,21 @@ def start( """ assert not self.status.time_started - # FIXME: some servers still might sent Content-Encoding: gzip - # - 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 . + 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: diff --git a/tests/test_downloads.py b/tests/test_downloads.py index b646a0e6a5..0337761329 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -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". + # + 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) diff --git a/tests/test_json.py b/tests/test_json.py index e758ebe7f4..38e4060b3d 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -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'