Custom file upload MIME type (#927)

* Support curl-like syntax for custom MIME type for files

In order to specify a custom MIME type for file uploads, a syntax
similar to that used by cURL is used so that

http -F test_file@/path/to/file.bin;type=application/zip https://...

forwards the user-provided file type if provided, otherwise falling
back to the usual guesswork out of the file extension.
This commit is contained in:
Carlo Sciolla 2020-06-08 17:59:41 +02:00 committed by GitHub
parent 492687b0da
commit c4627cc882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 33 additions and 8 deletions

View File

@ -13,6 +13,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
* Added ``--ciphers`` to allow configuring OpenSSL ciphers (`#870`_). * Added ``--ciphers`` to allow configuring OpenSSL ciphers (`#870`_).
* Added support for ``$XDG_CONFIG_HOME`` (`#920`_). * Added support for ``$XDG_CONFIG_HOME`` (`#920`_).
* Fixed built-in plugins-related circular imports (`#925`_). * Fixed built-in plugins-related circular imports (`#925`_).
* Fixed custom content types for each multipart uploaded file (`#668`_).
`2.1.0`_ (2020-04-18) `2.1.0`_ (2020-04-18)
@ -441,3 +442,4 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
.. _#895: https://github.com/jakubroztocil/httpie/issues/895 .. _#895: https://github.com/jakubroztocil/httpie/issues/895
.. _#920: https://github.com/jakubroztocil/httpie/issues/920 .. _#920: https://github.com/jakubroztocil/httpie/issues/920
.. _#925: https://github.com/jakubroztocil/httpie/issues/925 .. _#925: https://github.com/jakubroztocil/httpie/issues/925
.. _#668: https://github.com/jakubroztocil/httpie/issues/668

View File

@ -683,6 +683,13 @@ submitted:
Note that ``@`` is used to simulate a file upload form field, whereas 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
override the inferred content type:
.. code-block:: bash
$ http -f POST httpbin.org/post name='John Smith' cv@'~/files/data.bin;type=application/pdf'
HTTP headers HTTP headers
============ ============

View File

@ -24,6 +24,7 @@ SEPARATOR_PROXY = ':'
SEPARATOR_DATA_STRING = '=' SEPARATOR_DATA_STRING = '='
SEPARATOR_DATA_RAW_JSON = ':=' SEPARATOR_DATA_RAW_JSON = ':='
SEPARATOR_FILE_UPLOAD = '@' SEPARATOR_FILE_UPLOAD = '@'
SEPARATOR_FILE_UPLOAD_TYPE = ';type=' # in already parsed file upload path only
SEPARATOR_DATA_EMBED_FILE_CONTENTS = '=@' SEPARATOR_DATA_EMBED_FILE_CONTENTS = '=@'
SEPARATOR_DATA_EMBED_RAW_JSON_FILE = ':=@' SEPARATOR_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEPARATOR_QUERY_PARAM = '==' SEPARATOR_QUERY_PARAM = '=='

View File

@ -113,6 +113,7 @@ positional.add_argument(
'@' Form file fields (only with --form, -f): '@' Form file fields (only with --form, -f):
cs@~/Documents/CV.pdf cs@~/Documents/CV.pdf
cv@'~/Documents/CV.pdf;type=application/pdf'
'=@' A data field like '=', but takes a file path and embeds its content: '=@' A data field like '=', but takes a file path and embeds its content:

View File

@ -6,7 +6,8 @@ from httpie.cli.argtypes import KeyValueArg
from httpie.cli.constants import ( from httpie.cli.constants import (
SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE, SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD, SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD,
SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY, SEPARATOR_QUERY_PARAM, SEPARATOR_FILE_UPLOAD_TYPE, SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
) )
from httpie.cli.dicts import ( from httpie.cli.dicts import (
RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict, RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
@ -95,7 +96,10 @@ def process_query_param_arg(arg: KeyValueArg) -> str:
def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]: def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
filename = arg.value parts = arg.value.split(SEPARATOR_FILE_UPLOAD_TYPE)
filename = parts[0]
mime_type = parts[1] if len(parts) > 1 else None
try: try:
with open(os.path.expanduser(filename), 'rb') as f: with open(os.path.expanduser(filename), 'rb') as f:
contents = f.read() contents = f.read()
@ -104,7 +108,7 @@ def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
return ( return (
os.path.basename(filename), os.path.basename(filename),
BytesIO(contents), BytesIO(contents),
get_content_type(filename), mime_type or get_content_type(filename),
) )

View File

@ -17,27 +17,37 @@ class TestMultipartFormDataFileUpload:
def test_upload_ok(self, httpbin): def test_upload_ok(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post', r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
'test-file@%s' % FILE_PATH_ARG, 'foo=bar') f'test-file@{FILE_PATH_ARG}', 'foo=bar')
assert HTTP_OK in r assert HTTP_OK in r
assert 'Content-Disposition: form-data; name="foo"' in r assert 'Content-Disposition: form-data; name="foo"' in r
assert 'Content-Disposition: form-data; name="test-file";' \ assert 'Content-Disposition: form-data; name="test-file";' \
' filename="%s"' % os.path.basename(FILE_PATH) in r f' filename="{os.path.basename(FILE_PATH)}"' in r
assert FILE_CONTENT in r assert FILE_CONTENT in r
assert '"foo": "bar"' in r assert '"foo": "bar"' in r
assert 'Content-Type: text/plain' in r assert 'Content-Type: text/plain' in r
def test_upload_multiple_fields_with_the_same_name(self, httpbin): def test_upload_multiple_fields_with_the_same_name(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post', r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
'test-file@%s' % FILE_PATH_ARG, f'test-file@{FILE_PATH_ARG}',
'test-file@%s' % FILE_PATH_ARG) f'test-file@{FILE_PATH_ARG}')
assert HTTP_OK in r assert HTTP_OK in r
assert r.count('Content-Disposition: form-data; name="test-file";' assert r.count('Content-Disposition: form-data; name="test-file";'
' filename="%s"' % os.path.basename(FILE_PATH)) == 2 f' filename="{os.path.basename(FILE_PATH)}"') == 2
# Should be 4, but is 3 because httpbin # Should be 4, but is 3 because httpbin
# doesn't seem to support filed field lists # doesn't seem to support filed field lists
assert r.count(FILE_CONTENT) in [3, 4] assert r.count(FILE_CONTENT) in [3, 4]
assert r.count('Content-Type: text/plain') == 2 assert r.count('Content-Type: text/plain') == 2
def test_upload_custom_content_type(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
f'test-file@{FILE_PATH_ARG};type=image/vnd.microsoft.icon')
assert HTTP_OK in r
# Content type is stripped from the filename
assert 'Content-Disposition: form-data; name="test-file";' \
f' filename="{os.path.basename(FILE_PATH)}"' in r
assert FILE_CONTENT in r
assert 'Content-Type: image/vnd.microsoft.icon' in r
class TestRequestBodyFromFilePath: class TestRequestBodyFromFilePath:
""" """