mirror of
https://github.com/httpie/cli.git
synced 2024-11-25 09:13:25 +01:00
Add --multipart
and --boundary
This commit is contained in:
parent
a23b0e39e5
commit
1813cf6156
@ -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 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 out of the box SOCKS support with no extra installation (`#904`_).
|
||||||
* Added ``--quiet, -q`` flag to enforce silent behaviour.
|
* 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`_).
|
* Removed Tox testing entirely (`#943`_).
|
||||||
|
|
||||||
|
|
||||||
|
60
README.rst
60
README.rst
@ -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
|
are always streamed to avoid memory issues. Additionally, the display of the
|
||||||
request body on the terminal is suppressed.
|
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
|
HTTP headers
|
||||||
============
|
============
|
||||||
|
@ -75,16 +75,13 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
|
|||||||
) -> argparse.Namespace:
|
) -> argparse.Namespace:
|
||||||
self.env = env
|
self.env = env
|
||||||
self.args, no_options = super().parse_known_args(args, namespace)
|
self.args, no_options = super().parse_known_args(args, namespace)
|
||||||
|
|
||||||
if self.args.debug:
|
if self.args.debug:
|
||||||
self.args.traceback = True
|
self.args.traceback = True
|
||||||
|
|
||||||
self.has_stdin_data = (
|
self.has_stdin_data = (
|
||||||
self.env.stdin
|
self.env.stdin
|
||||||
and not self.args.ignore_stdin
|
and not self.args.ignore_stdin
|
||||||
and not self.env.stdin_isatty
|
and not self.env.stdin_isatty
|
||||||
)
|
)
|
||||||
|
|
||||||
# Arguments processing and environment setup.
|
# Arguments processing and environment setup.
|
||||||
self._apply_no_options(no_options)
|
self._apply_no_options(no_options)
|
||||||
self._process_download_options()
|
self._process_download_options()
|
||||||
@ -94,11 +91,15 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
|
|||||||
self._process_format_options()
|
self._process_format_options()
|
||||||
self._guess_method()
|
self._guess_method()
|
||||||
self._parse_items()
|
self._parse_items()
|
||||||
|
|
||||||
if self.has_stdin_data:
|
if self.has_stdin_data:
|
||||||
self._body_from_file(self.env.stdin)
|
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 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://'
|
scheme = 'https://'
|
||||||
else:
|
else:
|
||||||
scheme = self.args.default_scheme + "://"
|
scheme = self.args.default_scheme + "://"
|
||||||
@ -114,9 +115,6 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
|
|||||||
self.args.url += rest
|
self.args.url += rest
|
||||||
else:
|
else:
|
||||||
self.args.url = scheme + self.args.url
|
self.args.url = scheme + self.args.url
|
||||||
self._process_auth()
|
|
||||||
|
|
||||||
return self.args
|
|
||||||
|
|
||||||
# noinspection PyShadowingBuiltins
|
# noinspection PyShadowingBuiltins
|
||||||
def _print_message(self, message, file=None):
|
def _print_message(self, message, file=None):
|
||||||
|
@ -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.
|
# Content processing.
|
||||||
|
@ -16,7 +16,7 @@ from httpie.cli.dicts import RequestHeadersDict
|
|||||||
from httpie.plugins.registry import plugin_manager
|
from httpie.plugins.registry import plugin_manager
|
||||||
from httpie.sessions import get_httpie_session
|
from httpie.sessions import get_httpie_session
|
||||||
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
|
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
|
from httpie.utils import get_expired_cookies, repr_dict
|
||||||
|
|
||||||
|
|
||||||
@ -276,8 +276,13 @@ 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:
|
if args.form and (files or args.multipart):
|
||||||
data, headers['Content-Type'] = get_multipart_data(data, files)
|
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
|
files = None
|
||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
|
@ -1,20 +1,32 @@
|
|||||||
from typing import Tuple, Union
|
from typing import Tuple, Union
|
||||||
|
|
||||||
from httpie.cli.dicts import RequestDataDict, RequestFilesDict
|
|
||||||
from requests_toolbelt import MultipartEncoder
|
from requests_toolbelt import MultipartEncoder
|
||||||
|
|
||||||
|
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
|
UPLOAD_BUFFER = 1024 * 100
|
||||||
|
|
||||||
|
|
||||||
def get_multipart_data(
|
def get_multipart_data_and_content_type(
|
||||||
data: RequestDataDict,
|
data: RequestDataDict,
|
||||||
files: RequestFilesDict
|
files: RequestFilesDict,
|
||||||
|
boundary: str = None,
|
||||||
|
content_type: str = None,
|
||||||
) -> Tuple[Union[MultipartEncoder, bytes], str]:
|
) -> Tuple[Union[MultipartEncoder, bytes], str]:
|
||||||
fields = list(data.items()) + list(files.items())
|
fields = list(data.items()) + list(files.items())
|
||||||
encoder = MultipartEncoder(fields=fields)
|
encoder = MultipartEncoder(
|
||||||
content_type = encoder.content_type
|
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
|
data = encoder.to_string() if encoder.len < UPLOAD_BUFFER else encoder
|
||||||
return data, content_type
|
return data, content_type
|
||||||
|
@ -4,6 +4,7 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from httpie.cli.exceptions import ParseError
|
from httpie.cli.exceptions import ParseError
|
||||||
|
from httpie.client import FORM_CONTENT_TYPE
|
||||||
from httpie.output.streams import LARGE_UPLOAD_SUPPRESSED_NOTICE
|
from httpie.output.streams import LARGE_UPLOAD_SUPPRESSED_NOTICE
|
||||||
from httpie.status import ExitStatus
|
from httpie.status import ExitStatus
|
||||||
from utils import MockEnvironment, http, HTTP_OK
|
from utils import MockEnvironment, http, HTTP_OK
|
||||||
@ -67,6 +68,79 @@ class TestMultipartFormDataFileUpload:
|
|||||||
assert r.count(FILE_CONTENT) == 1
|
assert r.count(FILE_CONTENT) == 1
|
||||||
assert LARGE_UPLOAD_SUPPRESSED_NOTICE.decode() in r
|
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:
|
class TestRequestBodyFromFilePath:
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user