Added file upload support

It is now possible to send multipart/form-data requests.

Note that the --file option used previously has been removed
because it didn't allow you specify the field name.

Example:

    http -f POST example.com field-name@/path/to/file
This commit is contained in:
Jakub Roztocil 2012-03-14 12:17:39 +01:00
parent 578acacdf3
commit b7e0473d6c
6 changed files with 95 additions and 45 deletions

View File

@ -4,7 +4,7 @@ python:
- 2.7 - 2.7
# TODO: Python 3 # TODO: Python 3
#- 3.2 #- 3.2
script: python tests.py script: python tests/tests.py
install: install:
- pip install requests pygments - pip install requests pygments
- "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install argparse; fi" - "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install argparse; fi"

View File

@ -40,15 +40,18 @@ Synopsis::
http [flags] METHOD URL [items] http [flags] METHOD URL [items]
There are three types of key-value pair items available: There are four types of key-value pair items available:
Headers Headers
Arbitrary HTTP headers. The ``:`` character is used to separate a header's name from its value, e.g., ``X-API-Token:123``. Arbitrary HTTP headers. The ``:`` character is used to separate a header's name from its value, e.g., ``X-API-Token:123``.
Simple data items Simple data fields
Data items are included in the request body. Depending on the ``Content-Type``, they are automatically serialized as a JSON ``Object`` (default) or ``application/x-www-form-urlencoded`` (the ``-f`` flag). Data items use ``=`` as the separator, e.g., ``hello=world``. Data items are included in the request body. Depending on the ``Content-Type``, they are automatically serialized as a JSON ``Object`` (default) or ``application/x-www-form-urlencoded`` (the ``-f`` flag). Data items use ``=`` as the separator, e.g., ``hello=world``.
Raw JSON items File fields
Only available with ``-f`` / ``--form``. Use ``@`` as the separator, e.g., ``screenshot@/path/to/file.png``.
Raw JSON fields
This item type is needed when ``Content-Type`` is JSON and a field's value is a ``Boolean``, ``Number``, nested ``Object`` or an ``Array``, because simple data items are always serialized as ``String``. E.g. ``pies:=[1,2,3]``. This item type is needed when ``Content-Type`` is JSON and a field's value is a ``Boolean``, ``Number``, nested ``Object`` or an ``Array``, because simple data items are always serialized as ``String``. E.g. ``pies:=[1,2,3]``.
Examples Examples
@ -77,6 +80,17 @@ It can easily be changed to a 'form' request using the ``-f`` (or ``--form``) fl
age=29&name=John&email=john%40example.org age=29&name=John&email=john%40example.org
It is also possible to send ``multipart/form-data`` requests, i.e., to simulate a file upload form submission. It is done using the ``--form`` / ``-f`` flag and passing one or more file fields::
http -f POST example.com/job-application email=John cv@~/Documents/cv.pdf
The above will send a request equivalent to submitting the following form::
<form enctype="multipart/form-data" method="post" action="http://example.com/job-application" >
<input type="text" name="email" />
<input type="file" name="cv" />
</form>
A whole request body can be passed in via ``stdin`` instead:: A whole request body can be passed in via ``stdin`` instead::
echo '{"name": "John"}' | http PATCH example.com/person/1 X-API-Token:123 echo '{"name": "John"}' | http PATCH example.com/person/1 X-API-Token:123
@ -88,14 +102,6 @@ Flags
^^^^^ ^^^^^
Most of the flags mirror the arguments understood by ``requests.request``. See ``http -h`` for more details:: Most of the flags mirror the arguments understood by ``requests.request``. See ``http -h`` for more details::
usage: http [-h] [--version] [--json | --form] [--traceback]
[--pretty | --ugly]
[--print OUTPUT_OPTIONS | --headers | --body]
[--style STYLE] [--auth AUTH] [--verify VERIFY]
[--proxy PROXY] [--allow-redirects] [--file PATH]
[--timeout TIMEOUT]
METHOD URL [items [items ...]]
HTTPie - cURL for humans. HTTPie - cURL for humans.
positional arguments: positional arguments:
@ -103,17 +109,20 @@ Most of the flags mirror the arguments understood by ``requests.request``. See `
PUT, DELETE, PATCH, ...). PUT, DELETE, PATCH, ...).
URL Protocol defaults to http:// if the URL does not URL Protocol defaults to http:// if the URL does not
include it. include it.
items HTTP header (key:value), data field (key=value) or raw items HTTP header (header:value), data field (field=value),
JSON field (field:=value). raw JSON field (field:=value) or file field
(field@/path/to/file).
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
--version show program's version number and exit --version show program's version number and exit
--json, -j Serialize data items as a JSON object and set Content- --json, -j Serialize data items as a JSON object and set Content-
Type to application/json, if not specified. Type to application/json, if not specified.
--form, -f Serialize data items as form values and set Content- --form, -f Serialize fields as form values. The Content-Type is
Type to application/x-www-form-urlencoded, if not set to application/x-www-form-urlencoded. The presence
specified. of any file fields results into a multipart/form-data
request. Note that Content-Type is not automatically
set if explicitely specified.
--traceback Print exception traceback should one occur. --traceback Print exception traceback should one occur.
--pretty If stdout is a terminal, the response is prettified by --pretty If stdout is a terminal, the response is prettified by
default (colorized and indented if it is JSON). This default (colorized and indented if it is JSON). This
@ -126,10 +135,11 @@ Most of the flags mirror the arguments understood by ``requests.request``. See `
"h" stands for response headers and "b" for response "h" stands for response headers and "b" for response
body. Defaults to "hb" which means that the whole body. Defaults to "hb" which means that the whole
response (headers and body) is printed. response (headers and body) is printed.
--headers, -t Print only the response headers. It's a shortcut for --verbose, -v Print the whole request as well as response. Shortcut
for --print=HBhb.
--headers, -t Print only the response headers. Shortcut for
--print=h. --print=h.
--body, -b Print only the response body. It's a shortcut for --body, -b Print only the response body. Shortcut for --print=b.
--print=b.
--style STYLE, -s STYLE --style STYLE, -s STYLE
Output coloring style, one of autumn, borland, bw, Output coloring style, one of autumn, borland, bw,
colorful, default, emacs, friendly, fruity, manni, colorful, default, emacs, friendly, fruity, manni,
@ -144,11 +154,9 @@ Most of the flags mirror the arguments understood by ``requests.request``. See `
http:foo.bar:3128). http:foo.bar:3128).
--allow-redirects Set this flag if full redirects are allowed (e.g. re- --allow-redirects Set this flag if full redirects are allowed (e.g. re-
POST-ing of data at new ``Location``) POST-ing of data at new ``Location``)
--file PATH File to multipart upload
--timeout TIMEOUT Float describes the timeout of the request (Use --timeout TIMEOUT Float describes the timeout of the request (Use
socket.setdefaulttimeout() as fallback). socket.setdefaulttimeout() as fallback).
Contributors Contributors
------------ ------------

View File

@ -100,26 +100,37 @@ def main(args=None,
headers = CaseInsensitiveDict() headers = CaseInsensitiveDict()
headers['User-Agent'] = DEFAULT_UA headers['User-Agent'] = DEFAULT_UA
data = OrderedDict() data = OrderedDict()
files = OrderedDict()
try: try:
cli.parse_items(items=args.items, headers=headers, data=data) cli.parse_items(items=args.items, headers=headers,
data=data, files=files)
except cli.ParseError as e: except cli.ParseError as e:
if args.traceback: if args.traceback:
raise raise
parser.error(e.message) parser.error(e.message)
if files and not args.form:
# We could just switch to --form automatically here,
# but I think it's better to make it explicit.
parser.error(
' You need to set the --form / -f flag to'
' to issue a multipart request. File fields: %s'
% ','.join(files.keys()))
if not stdin_isatty: if not stdin_isatty:
if data: if data:
parser.error('Request body (stdin) and request ' parser.error('Request body (stdin) and request '
'data (key=value) cannot be mixed.') 'data (key=value) cannot be mixed.')
data = stdin.read() data = stdin.read()
# JSON/Form content type. # JSON/Form content type.
if args.json or (not args.form and data): if args.json or (not args.form and data):
if stdin_isatty: if stdin_isatty:
data = json.dumps(data) data = json.dumps(data)
if 'Content-Type' not in headers and (data or args.json): if not files and ('Content-Type' not in headers and (data or args.json)):
headers['Content-Type'] = TYPE_JSON headers['Content-Type'] = TYPE_JSON
elif 'Content-Type' not in headers: elif not files and 'Content-Type' not in headers:
headers['Content-Type'] = TYPE_FORM headers['Content-Type'] = TYPE_FORM
# Fire the request. # Fire the request.
@ -133,7 +144,7 @@ def main(args=None,
timeout=args.timeout, timeout=args.timeout,
auth=(args.auth.key, args.auth.value) if args.auth else None, auth=(args.auth.key, args.auth.value) if args.auth else None,
proxies=dict((p.key, p.value) for p in args.proxy), proxies=dict((p.key, p.value) for p in args.proxy),
files=dict((os.path.basename(f.name), f) for f in args.file), files=files,
allow_redirects=args.allow_redirects, allow_redirects=args.allow_redirects,
) )
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):

View File

@ -1,3 +1,4 @@
import os
import json import json
import argparse import argparse
from collections import namedtuple from collections import namedtuple
@ -10,6 +11,7 @@ SEP_COMMON = ':'
SEP_HEADERS = SEP_COMMON SEP_HEADERS = SEP_COMMON
SEP_DATA = '=' SEP_DATA = '='
SEP_DATA_RAW_JSON = ':=' SEP_DATA_RAW_JSON = ':='
SEP_FILES = '@'
PRETTIFY_STDOUT_TTY_ONLY = object() PRETTIFY_STDOUT_TTY_ONLY = object()
OUT_REQUEST_HEADERS = 'H' OUT_REQUEST_HEADERS = 'H'
@ -49,16 +51,28 @@ class KeyValueType(object):
return KeyValue(key=key, value=value, sep=sep, orig=string) return KeyValue(key=key, value=value, sep=sep, orig=string)
def parse_items(items, data=None, headers=None): def parse_items(items, data=None, headers=None, files=None):
"""Parse `KeyValueType` `items` into `data` and `headers`.""" """Parse `KeyValueType` `items` into `data`, `headers` and `files`."""
if headers is None: if headers is None:
headers = {} headers = {}
if data is None: if data is None:
data = {} data = {}
if files is None:
files = {}
for item in items: for item in items:
value = item.value value = item.value
key = item.key
if item.sep == SEP_HEADERS: if item.sep == SEP_HEADERS:
target = headers target = headers
elif item.sep == SEP_FILES:
try:
value = open(os.path.expanduser(item.value), 'r')
except IOError as e:
raise ParseError(
'Invalid argument %r. %s' % (item.orig, e))
if not key:
key = os.path.basename(value.name)
target = files
elif item.sep in [SEP_DATA, SEP_DATA_RAW_JSON]: elif item.sep in [SEP_DATA, SEP_DATA_RAW_JSON]:
if item.sep == SEP_DATA_RAW_JSON: if item.sep == SEP_DATA_RAW_JSON:
try: try:
@ -69,12 +83,12 @@ def parse_items(items, data=None, headers=None):
else: else:
raise ParseError('%s is not valid item' % item.orig) raise ParseError('%s is not valid item' % item.orig)
if item.key in target: if key in target:
ParseError('duplicate item %s (%s)' % (item.key, item.orig)) ParseError('duplicate item %s (%s)' % (item.key, item.orig))
target[item.key] = value target[key] = value
return headers, data return headers, data, files
def _(text): def _(text):
@ -111,9 +125,9 @@ group_type.add_argument(
group_type.add_argument( group_type.add_argument(
'--form', '-f', action='store_true', '--form', '-f', action='store_true',
help=_(''' help=_('''
Serialize data items as form values and set Serialize fields as form values. The Content-Type is set to application/x-www-form-urlencoded.
Content-Type to application/x-www-form-urlencoded, The presence of any file fields results into a multipart/form-data request.
if not specified. Note that Content-Type is not automatically set if explicitely specified.
''') ''')
) )
@ -225,11 +239,6 @@ parser.add_argument(
(e.g. re-POST-ing of data at new ``Location``) (e.g. re-POST-ing of data at new ``Location``)
''') ''')
) )
parser.add_argument(
'--file', metavar='PATH', type=argparse.FileType(),
default=[], action='append',
help='File to multipart upload'
)
parser.add_argument( parser.add_argument(
'--timeout', type=float, '--timeout', type=float,
help=_(''' help=_('''
@ -258,9 +267,10 @@ parser.add_argument(
) )
parser.add_argument( parser.add_argument(
'items', nargs='*', 'items', nargs='*',
type=KeyValueType(SEP_COMMON, SEP_DATA, SEP_DATA_RAW_JSON), type=KeyValueType(SEP_COMMON, SEP_DATA, SEP_DATA_RAW_JSON, SEP_FILES),
help=_(''' help=_('''
HTTP header (key:value), data field (key=value) HTTP header (header:value), data field (field=value),
or raw JSON field (field:=value). raw JSON field (field:=value)
or file field (field@/path/to/file).
''') ''')
) )

1
tests/file.txt Normal file
View File

@ -0,0 +1 @@
__test_file_content__

View File

@ -1,7 +1,13 @@
# coding:utf8
import os
import sys import sys
import unittest import unittest
import argparse import argparse
from StringIO import StringIO from StringIO import StringIO
TESTS_ROOT = os.path.dirname(__file__)
sys.path.insert(0, os.path.abspath(os.path.join(TESTS_ROOT, '..')))
from httpie import __main__ from httpie import __main__
from httpie import cli from httpie import cli
@ -82,7 +88,7 @@ class TestHTTPie(BaseTest):
self.assertIn('"foo": "bar"', response) self.assertIn('"foo": "bar"', response)
def test_form(self): def test_form(self):
response = http('POST', '--form', 'http://httpbin.org/post', 'foo=bar') response = http('--form', 'POST', 'http://httpbin.org/post', 'foo=bar')
self.assertIn('"foo": "bar"', response) self.assertIn('"foo": "bar"', response)
def test_headers(self): def test_headers(self):
@ -103,13 +109,27 @@ class TestPrettyFlag(BaseTest):
self.assertNotIn(TERMINAL_COLOR_CHECK, r) self.assertNotIn(TERMINAL_COLOR_CHECK, r)
def test_force_pretty(self): def test_force_pretty(self):
r = http('GET', '--pretty', 'http://httpbin.org/get', stdout_isatty=False) r = http('--pretty', 'GET', 'http://httpbin.org/get', stdout_isatty=False)
self.assertIn(TERMINAL_COLOR_CHECK, r) self.assertIn(TERMINAL_COLOR_CHECK, r)
def test_force_ugly(self): def test_force_ugly(self):
r = http('GET', '--ugly', 'http://httpbin.org/get', stdout_isatty=True) r = http('--ugly', 'GET', 'http://httpbin.org/get', stdout_isatty=True)
self.assertNotIn(TERMINAL_COLOR_CHECK, r) self.assertNotIn(TERMINAL_COLOR_CHECK, r)
class TestFileUpload(BaseTest):
def test_non_existent_file_raises_parse_error(self):
self.assertRaises(cli.ParseError, http,
'--form', '--traceback',
'POST', 'http://httpbin.org/post',
'foo@/__does_not_exist__')
def test_upload_ok(self):
r = http('--form', 'POST', 'http://httpbin.org/post',
'test-file@%s' % os.path.join(TESTS_ROOT, 'file.txt'))
self.assertIn('"test-file": "__test_file_content__', r)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()