1
0
mirror of https://github.com/httpie/cli.git synced 2025-01-22 03:08:59 +02:00

Request content type

This commit is contained in:
Jakub Roztocil 2020-09-25 14:44:22 +02:00
parent c431ed7728
commit e4e40e5b06
9 changed files with 85 additions and 32 deletions

View File

@ -684,7 +684,7 @@ submitted:
<input type="file" name="cv" /> <input type="file" name="cv" />
</form> </form>
Note that ``@`` is used to simulate a file upload form field, whereas Please note that ``@`` is used to simulate a file upload form field, whereas
``=@`` just embeds the file content as a regular text field value. ``=@`` just embeds the file content as a regular text field value.
When uploading files, their content type is inferred from the file name. You can manually When uploading files, their content type is inferred from the file name. You can manually
@ -694,16 +694,12 @@ override the inferred content type:
$ http -f POST httpbin.org/post name='John Smith' cv@'~/files/data.bin;type=application/pdf' $ http -f POST httpbin.org/post name='John Smith' cv@'~/files/data.bin;type=application/pdf'
Larger multipart uploads (i.e., ``--form`` requests with at least one ``file@path``) To perform a ``multipart/form-data`` request even without any files, use
are always streamed to avoid memory issues. Additionally, the display of the ``--multipart`` instead of ``--form``:
request body on the terminal is suppressed.
You can explicitly use ``--multipart`` to enforce ``multipart/form-data`` even
for form requests without any files:
.. code-block:: bash .. code-block:: bash
$ http --form --multipart --offline example.org hello=world $ http --multipart --offline example.org hello=world
.. code-block:: http .. code-block:: http
@ -718,6 +714,10 @@ for form requests without any files:
world world
--c31279ab254f40aeb06df32b433cbccb-- --c31279ab254f40aeb06df32b433cbccb--
Larger multipart uploads are always streamed to avoid memory issues.
Additionally, the display of the request body on the terminal is suppressed
for larger uploads.
By default, HTTPie uses a random unique string as the boundary but you can use By default, HTTPie uses a random unique string as the boundary but you can use
``--boundary`` to specify a custom string instead: ``--boundary`` to specify a custom string instead:

View File

@ -18,7 +18,8 @@ from httpie.cli.constants import (
DEFAULT_FORMAT_OPTIONS, HTTP_GET, HTTP_POST, OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS, HTTP_GET, HTTP_POST, OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT, OUTPUT_OPTIONS_DEFAULT,
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED, OUT_RESP_BODY, PRETTY_MAP, OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED, OUT_RESP_BODY, PRETTY_MAP,
PRETTY_STDOUT_TTY_ONLY, SEPARATOR_CREDENTIALS, SEPARATOR_GROUP_ALL_ITEMS, PRETTY_STDOUT_TTY_ONLY, RequestContentType,
SEPARATOR_CREDENTIALS, SEPARATOR_GROUP_ALL_ITEMS,
SEPARATOR_GROUP_DATA_ITEMS, URL_SCHEME_RE, SEPARATOR_GROUP_DATA_ITEMS, URL_SCHEME_RE,
OUTPUT_OPTIONS_DEFAULT_OFFLINE, OUTPUT_OPTIONS_DEFAULT_OFFLINE,
) )
@ -84,6 +85,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
) )
# Arguments processing and environment setup. # Arguments processing and environment setup.
self._apply_no_options(no_options) self._apply_no_options(no_options)
self._process_request_content_type()
self._process_download_options() self._process_download_options()
self._setup_standard_streams() self._setup_standard_streams()
self._process_output_options() self._process_output_options()
@ -97,12 +99,21 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self._process_auth() self._process_auth()
return self.args return self.args
def _process_request_content_type(self):
rct = self.args.request_content_type
self.args.json = rct is RequestContentType.JSON
self.args.multipart = rct is RequestContentType.MULTIPART
self.args.form = rct in {
RequestContentType.FORM,
RequestContentType.MULTIPART,
}
def _process_url(self): def _process_url(self):
if not URL_SCHEME_RE.match(self.args.url): if not URL_SCHEME_RE.match(self.args.url):
if os.path.basename(self.env.program_name) == 'https': if os.path.basename(self.env.program_name) == 'https':
scheme = 'https://' scheme = 'https://'
else: else:
scheme = self.args.default_scheme + "://" scheme = self.args.default_scheme + '://'
# See if we're using curl style shorthand for localhost (:3000/foo) # See if we're using curl style shorthand for localhost (:3000/foo)
shorthand = re.match(r'^:(?!:)(\d*)(/?.*)$', self.args.url) shorthand = re.match(r'^:(?!:)(\d*)(/?.*)$', self.args.url)

View File

@ -1,6 +1,7 @@
"""Parsing and processing of CLI input (args, auth credentials, files, stdin). """Parsing and processing of CLI input (args, auth credentials, files, stdin).
""" """
import enum
import re import re
@ -10,6 +11,9 @@ import re
# ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) # ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
# <https://tools.ietf.org/html/rfc3986#section-3.1> # <https://tools.ietf.org/html/rfc3986#section-3.1>
from enum import Enum
URL_SCHEME_RE = re.compile(r'^[a-z][a-z0-9.+-]*://', re.IGNORECASE) URL_SCHEME_RE = re.compile(r'^[a-z][a-z0-9.+-]*://', re.IGNORECASE)
HTTP_POST = 'POST' HTTP_POST = 'POST'
@ -102,3 +106,9 @@ UNSORTED_FORMAT_OPTIONS_STRING = ','.join(
OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY
class RequestContentType(enum.Enum):
FORM = enum.auto()
MULTIPART = enum.auto()
JSON = enum.auto()

View File

@ -15,7 +15,8 @@ from httpie.cli.constants import (
DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD, OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD,
OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY,
SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, SORTED_FORMAT_OPTIONS_STRING, RequestContentType, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING, UNSORTED_FORMAT_OPTIONS_STRING,
) )
from httpie.output.formatters.colors import ( from httpie.output.formatters.colors import (
@ -141,7 +142,9 @@ content_type = parser.add_argument_group(
content_type.add_argument( content_type.add_argument(
'--json', '-j', '--json', '-j',
action='store_true', action='store_const',
const=RequestContentType.JSON,
dest='request_content_type',
help=''' help='''
(default) Data items from the command line are serialized as a JSON object. (default) Data items from the command line are serialized as a JSON object.
The Content-Type and Accept headers are set to application/json The Content-Type and Accept headers are set to application/json
@ -151,7 +154,9 @@ content_type.add_argument(
) )
content_type.add_argument( content_type.add_argument(
'--form', '-f', '--form', '-f',
action='store_true', action='store_const',
const=RequestContentType.FORM,
dest='request_content_type',
help=''' help='''
Data items from the command line are serialized as form fields. Data items from the command line are serialized as form fields.
@ -163,11 +168,12 @@ content_type.add_argument(
) )
content_type.add_argument( content_type.add_argument(
'--multipart', '--multipart',
default=False, action='store_const',
action='store_true', const=RequestContentType.MULTIPART,
dest='request_content_type',
help=''' help='''
Force the request to be encoded as multipart/form-data even without Similar to --form, but always sends a multipart/form-data
any file fields. Only has effect only together with --form. request (i.e., even without files).
''' '''
) )

View File

@ -139,11 +139,12 @@ def max_headers(limit):
def compress_body(request: requests.PreparedRequest, always: bool): def compress_body(request: requests.PreparedRequest, always: bool):
deflater = zlib.compressobj() deflater = zlib.compressobj()
body_bytes = ( if isinstance(request.body, str):
request.body body_bytes = request.body.encode()
if isinstance(request.body, bytes) elif hasattr(request.body, 'read'):
else request.body.encode() body_bytes = request.body.read()
) else:
body_bytes = request.body
deflated_data = deflater.compress(body_bytes) deflated_data = deflater.compress(body_bytes)
deflated_data += deflater.flush() deflated_data += deflater.flush()
is_economical = len(deflated_data) < len(body_bytes) is_economical = len(deflated_data) < len(body_bytes)
@ -276,7 +277,7 @@ def make_request_kwargs(
headers.update(args.headers) headers.update(args.headers)
headers = finalize_headers(headers) headers = finalize_headers(headers)
if args.form and (files or args.multipart): if (args.form and files) or args.multipart:
data, headers['Content-Type'] = get_multipart_data_and_content_type( data, headers['Content-Type'] = get_multipart_data_and_content_type(
data=data, data=data,
files=files, files=files,

View File

@ -125,6 +125,8 @@ class EncodedStream(BaseStream):
def iter_body(self) -> Iterable[bytes]: def iter_body(self) -> Iterable[bytes]:
for line, lf in self.msg.iter_lines(self.CHUNK_SIZE): for line, lf in self.msg.iter_lines(self.CHUNK_SIZE):
if isinstance(line, MultipartEncoder):
raise LargeUploadSuppressedError()
if b'\0' in line: if b'\0' in line:
raise BinarySuppressedError() raise BinarySuppressedError()
yield line.decode(self.msg.encoding) \ yield line.decode(self.msg.encoding) \

View File

@ -52,7 +52,7 @@ class HTTPieHTTPSAdapter(HTTPAdapter):
verify: bool, verify: bool,
ssl_version: str = None, ssl_version: str = None,
ciphers: str = None, ciphers: str = None,
) -> ssl.SSLContext: ) -> 'ssl.SSLContext':
return create_urllib3_context( return create_urllib3_context(
ciphers=ciphers, ciphers=ciphers,
ssl_version=resolve_ssl_version(ssl_version), ssl_version=resolve_ssl_version(ssl_version),

View File

@ -7,7 +7,7 @@ from httpie.cli.dicts import RequestDataDict, RequestFilesDict
# Multipart uploads smaller than this size gets buffered (otherwise streamed). # Multipart uploads smaller than this size gets buffered (otherwise streamed).
# NOTE: Unbuffered upload requests cannot be displayed on the terminal. # NOTE: Unbuffered upload requests cannot be displayed on the terminal.
UPLOAD_BUFFER = 1024 * 100 MULTIPART_UPLOAD_BUFFER = 1024 * 1000
def get_multipart_data_and_content_type( def get_multipart_data_and_content_type(
@ -28,5 +28,5 @@ def get_multipart_data_and_content_type(
else: else:
content_type = encoder.content_type content_type = encoder.content_type
data = encoder.to_string() if encoder.len < UPLOAD_BUFFER else encoder data = encoder.to_string() if encoder.len < MULTIPART_UPLOAD_BUFFER else encoder
return data, content_type return data, content_type

View File

@ -55,7 +55,7 @@ class TestMultipartFormDataFileUpload:
assert r.count(FILE_CONTENT) == 2 assert r.count(FILE_CONTENT) == 2
assert 'Content-Type: image/vnd.microsoft.icon' in r assert 'Content-Type: image/vnd.microsoft.icon' in r
@mock.patch('httpie.uploads.UPLOAD_BUFFER', 0) @mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0)
def test_large_upload_display_suppressed(self, httpbin): def test_large_upload_display_suppressed(self, httpbin):
r = http( r = http(
'--form', '--form',
@ -79,9 +79,8 @@ class TestMultipartFormDataFileUpload:
assert HTTP_OK in r assert HTTP_OK in r
assert FORM_CONTENT_TYPE in r assert FORM_CONTENT_TYPE in r
def test_form_no_files_multipart(self, httpbin): def test_multipart(self, httpbin):
r = http( r = http(
'--form',
'--verbose', '--verbose',
'--multipart', '--multipart',
httpbin.url + '/post', httpbin.url + '/post',
@ -92,12 +91,38 @@ class TestMultipartFormDataFileUpload:
assert FORM_CONTENT_TYPE not in r assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r assert 'multipart/form-data' in r
def test_multipart_too_large_for_terminal(self, httpbin):
with mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0):
r = http(
'--verbose',
'--multipart',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert HTTP_OK in r
assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r
def test_multipart_too_large_for_terminal_non_pretty(self, httpbin):
with mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0):
r = http(
'--verbose',
'--multipart',
'--pretty=none',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert HTTP_OK in r
assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r
def test_form_multipart_custom_boundary(self, httpbin): def test_form_multipart_custom_boundary(self, httpbin):
boundary = 'HTTPIE_FTW' boundary = 'HTTPIE_FTW'
r = http( r = http(
'--print=HB', '--print=HB',
'--check-status', '--check-status',
'--form',
'--multipart', '--multipart',
f'--boundary={boundary}', f'--boundary={boundary}',
httpbin.url + '/post', httpbin.url + '/post',
@ -112,7 +137,6 @@ class TestMultipartFormDataFileUpload:
r = http( r = http(
'--print=HB', '--print=HB',
'--check-status', '--check-status',
'--form',
'--multipart', '--multipart',
f'--boundary={boundary}', f'--boundary={boundary}',
httpbin.url + '/post', httpbin.url + '/post',
@ -128,7 +152,6 @@ class TestMultipartFormDataFileUpload:
boundary_in_header = 'HEADER_BOUNDARY' boundary_in_header = 'HEADER_BOUNDARY'
boundary_in_body = 'BODY_BOUNDARY' boundary_in_body = 'BODY_BOUNDARY'
r = http( r = http(
'--form',
'--print=HB', '--print=HB',
'--check-status', '--check-status',
'--multipart', '--multipart',