Add --multipart and --boundary

This commit is contained in:
Jakub Roztocil 2020-08-19 10:22:42 +02:00
parent a23b0e39e5
commit 1813cf6156
7 changed files with 186 additions and 16 deletions

View File

@ -12,6 +12,8 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
* Added support for combining cookies specified on the CLI and in a session file (`#932`_).
* Added out of the box SOCKS support with no extra installation (`#904`_).
* Added ``--quiet, -q`` flag to enforce silent behaviour.
* Added ``--multipart`` to allow ``multipart/form-data`` encoding for non-file ``--form`` requests as well.
* Added ``--boundary`` to allow a custom boundary string for ``multipart/form-data`` requests.
* Removed Tox testing entirely (`#943`_).

View File

@ -698,6 +698,66 @@ Larger multipart uploads (i.e., ``--form`` requests with at least one ``file@pat
are always streamed to avoid memory issues. Additionally, the display of the
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
$ http --form --multipart --offline example.org/ hello=world
.. code-block:: http
POST / HTTP/1.1
Content-Length: 129
Content-Type: multipart/form-data; boundary=c31279ab254f40aeb06df32b433cbccb
Host: example.org
--c31279ab254f40aeb06df32b433cbccb
Content-Disposition: form-data; name="hello"
world
--c31279ab254f40aeb06df32b433cbccb--
By default, HTTPie uses a random unique string as the boundary. You may use
the ``--boundary`` option to specify a custom boundary string instead:
.. code-block:: bash
$ http --form --multipart --boundary=XOXO --offline example.org/ hello=world
.. code-block:: http
POST / HTTP/1.1
Content-Length: 129
Content-Type: multipart/form-data; boundary=XOXO
Host: example.org
--XOXO
Content-Disposition: form-data; name="hello"
world
--XOXO--
If you specify a custom ``Content-Type`` header without including the boundary
bit, HTTPie will add the boundary value (specified or generated) to the header
automatically:
.. code-block:: bash
http --form --multipart --boundary=XOXO --offline example.org/ hello=world Content-Type:love/letter
.. code-block:: http
POST / HTTP/1.1
Content-Length: 129
Content-Type: love/letter; boundary=XOXO
Host: example.org
--XOXO
Content-Disposition: form-data; name="hello"
world
--XOXO--
HTTP headers
============

View File

@ -75,16 +75,13 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
) -> argparse.Namespace:
self.env = env
self.args, no_options = super().parse_known_args(args, namespace)
if self.args.debug:
self.args.traceback = True
self.has_stdin_data = (
self.env.stdin
and not self.args.ignore_stdin
and not self.env.stdin_isatty
)
# Arguments processing and environment setup.
self._apply_no_options(no_options)
self._process_download_options()
@ -94,11 +91,15 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self._process_format_options()
self._guess_method()
self._parse_items()
if self.has_stdin_data:
self._body_from_file(self.env.stdin)
self._process_url()
self._process_auth()
return self.args
def _process_url(self):
if not URL_SCHEME_RE.match(self.args.url):
if os.path.basename(env.program_name) == 'https':
if os.path.basename(self.env.program_name) == 'https':
scheme = 'https://'
else:
scheme = self.args.default_scheme + "://"
@ -114,9 +115,6 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self.args.url += rest
else:
self.args.url = scheme + self.args.url
self._process_auth()
return self.args
# noinspection PyShadowingBuiltins
def _print_message(self, message, file=None):

View File

@ -161,6 +161,25 @@ content_type.add_argument(
'''
)
content_type.add_argument(
'--multipart',
default=False,
action='store_true',
help='''
Force the request to be encoded as multipart/form-data even without
any file fields. Only has effect only together with --form.
'''
)
content_type.add_argument(
'--boundary',
help='''
Specify a custom boundary string for multipart/form-data requests.
Only has effect only together with --form.
'''
)
#######################################################################
# Content processing.

View File

@ -16,7 +16,7 @@ from httpie.cli.dicts import RequestHeadersDict
from httpie.plugins.registry import plugin_manager
from httpie.sessions import get_httpie_session
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
from httpie.uploads import get_multipart_data
from httpie.uploads import get_multipart_data_and_content_type
from httpie.utils import get_expired_cookies, repr_dict
@ -276,8 +276,13 @@ def make_request_kwargs(
headers.update(args.headers)
headers = finalize_headers(headers)
if args.form and files:
data, headers['Content-Type'] = get_multipart_data(data, files)
if args.form and (files or args.multipart):
data, headers['Content-Type'] = get_multipart_data_and_content_type(
data=data,
files=files,
boundary=args.boundary,
content_type=args.headers.get('Content-Type'),
)
files = None
kwargs = {

View File

@ -1,20 +1,32 @@
from typing import Tuple, Union
from httpie.cli.dicts import RequestDataDict, RequestFilesDict
from requests_toolbelt import MultipartEncoder
from httpie.cli.dicts import RequestDataDict, RequestFilesDict
# Multipart uploads smaller than this size gets buffered (otherwise streamed).
# NOTE: Unbuffered upload requests cannot be displayed on the terminal.
UPLOAD_BUFFER = 1024 * 100
def get_multipart_data(
def get_multipart_data_and_content_type(
data: RequestDataDict,
files: RequestFilesDict
files: RequestFilesDict,
boundary: str = None,
content_type: str = None,
) -> Tuple[Union[MultipartEncoder, bytes], str]:
fields = list(data.items()) + list(files.items())
encoder = MultipartEncoder(fields=fields)
encoder = MultipartEncoder(
fields=fields,
boundary=boundary,
)
if content_type:
content_type = content_type.strip() # maybe auto-strip all headers somewhere
if 'boundary=' not in content_type:
content_type = f'{content_type}; boundary={encoder.boundary_value}'
else:
content_type = encoder.content_type
data = encoder.to_string() if encoder.len < UPLOAD_BUFFER else encoder
return data, content_type

View File

@ -4,6 +4,7 @@ from unittest import mock
import pytest
from httpie.cli.exceptions import ParseError
from httpie.client import FORM_CONTENT_TYPE
from httpie.output.streams import LARGE_UPLOAD_SUPPRESSED_NOTICE
from httpie.status import ExitStatus
from utils import MockEnvironment, http, HTTP_OK
@ -67,6 +68,79 @@ class TestMultipartFormDataFileUpload:
assert r.count(FILE_CONTENT) == 1
assert LARGE_UPLOAD_SUPPRESSED_NOTICE.decode() in r
def test_form_no_files_urlencoded(self, httpbin):
r = http(
'--form',
'--verbose',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert HTTP_OK in r
assert FORM_CONTENT_TYPE in r
def test_form_no_files_multipart(self, httpbin):
r = http(
'--form',
'--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_form_multipart_custom_boundary(self, httpbin):
boundary = 'HTTPIE_FTW'
r = http(
'--print=HB',
'--check-status',
'--form',
'--multipart',
f'--boundary={boundary}',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert f'multipart/form-data; boundary={boundary}' in r
assert r.count(boundary) == 4
def test_multipart_custom_content_type_boundary_added(self, httpbin):
boundary = 'HTTPIE_FTW'
r = http(
'--print=HB',
'--check-status',
'--form',
'--multipart',
f'--boundary={boundary}',
httpbin.url + '/post',
'Content-Type: multipart/magic',
'AAAA=AAA',
'BBB=BBB',
)
assert f'multipart/magic; boundary={boundary}' in r
assert r.count(boundary) == 4
def test_multipart_custom_content_type_boundary_preserved(self, httpbin):
# Allow explicit nonsense requests.
boundary_in_header = 'HEADER_BOUNDARY'
boundary_in_body = 'BODY_BOUNDARY'
r = http(
'--form',
'--print=HB',
'--check-status',
'--multipart',
f'--boundary={boundary_in_body}',
httpbin.url + '/post',
f'Content-Type: multipart/magic; boundary={boundary_in_header}',
'AAAA=AAA',
'BBB=BBB',
)
assert f'multipart/magic; boundary={boundary_in_header}' in r
assert r.count(boundary_in_body) == 3
class TestRequestBodyFromFilePath:
"""