Allow embeding text (=@) and JSON (:=@) files content into request data fields.

This commit is contained in:
Jakub Roztocil 2013-09-24 23:41:18 +02:00
parent 54c5c3d82b
commit d5bc564e4f
6 changed files with 108 additions and 39 deletions

View File

@ -246,14 +246,15 @@ command:
Request Items Request Items
============= =============
There are five different *request item* types that provide a There are a few different *request item* types that provide a
convenient mechanism for specifying HTTP headers, simple JSON and convenient mechanism for specifying HTTP headers, simple JSON and
form data, files, and URL parameters. form data, files, and URL parameters.
They are key/value pairs specified after the URL. All have in They are key/value pairs specified after the URL. All have in
common that they become part of the actual request that is sent and that common that they become part of the actual request that is sent and that
their type is distinguished only by the separator used: their type is distinguished only by the separator used:
``:``, ``=``, ``:=``, ``@``, and ``==``. ``:``, ``=``, ``:=``, ``==``, ``@``, ``=@``, and ``:=@``. The ones with an
``@`` expect a file path as value.
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Item Type | Description | | Item Type | Description |
@ -266,16 +267,16 @@ their type is distinguished only by the separator used:
| | The ``==`` separator is used | | | The ``==`` separator is used |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Data Fields | Request data fields to be serialized as a JSON | | Data Fields | Request data fields to be serialized as a JSON |
| ``field=value`` | object (default), or to be form encoded | | ``field=value``, | object (default), or to be form encoded |
| | (``--form, -f``). | | ``field=@file.txt`` | (``--form, -f``). |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Raw JSON fields | Useful when sending JSON and one or | | Raw JSON fields | Useful when sending JSON and one or |
| ``field:=json`` | more fields need to be a ``Boolean``, ``Number``, | | ``field:=json``, | more fields need to be a ``Boolean``, ``Number``, |
| | nested ``Object``, or an ``Array``, e.g., | | ``field:=@file.json`` | nested ``Object``, or an ``Array``, e.g., |
| | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` | | | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` |
| | (note the quotes). | | | (note the quotes). |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Files | Only available with ``--form, -f``. | | Form File Fields | Only available with ``--form, -f``. |
| ``field@/dir/file`` | For example ``screenshot@~/Pictures/img.png``. | | ``field@/dir/file`` | For example ``screenshot@~/Pictures/img.png``. |
| | The presence of a file field results | | | The presence of a file field results |
| | in a ``multipart/form-data`` request. | | | in a ``multipart/form-data`` request. |
@ -285,6 +286,8 @@ You can use ``\`` to escape characters that shouldn't be used as separators
(or parts thereof). For instance, ``foo\==bar`` will become a data key/value (or parts thereof). For instance, ``foo\==bar`` will become a data key/value
pair (``foo=`` and ``bar``) instead of a URL parameter. pair (``foo=`` and ``bar``) instead of a URL parameter.
You can also quote values, e.g. ``foo="bar baz"``.
Note that data fields aren't the only way to specify request data: Note that data fields aren't the only way to specify request data:
`Redirected input`_ allows for passing arbitrary data to be sent with the `Redirected input`_ allows for passing arbitrary data to be sent with the
request. request.
@ -332,11 +335,16 @@ Simple example:
Non-string fields use the ``:=`` separator, which allows you to embed raw JSON Non-string fields use the ``:=`` separator, which allows you to embed raw JSON
into the resulting object: into the resulting object. Text and raw JSON files can also be embedded into
fields using ``=@`` and ``:=@``:
.. code-block:: bash .. code-block:: bash
$ http PUT api.example.com/person/1 name=John age:=29 married:=false hobbies:='["http", "pies"]' $ http PUT api.example.com/person/1 \
name=John \
age:=29 married:=false hobbies:='["http", "pies"]' \ # Raw JSON
description=@about-john.txt \ # Embed text file
bookmarks:=@bookmarks.json # Embed JSON file
.. code-block:: http .. code-block:: http
@ -352,8 +360,12 @@ into the resulting object:
"http", "http",
"pies" "pies"
], ],
"description": "John is a nice guy who likes pies.",
"married": false, "married": false,
"name": "John" "name": "John",
"bookmarks": {
"HTTPie": "http://httpie.org",
}
} }
@ -383,7 +395,7 @@ Regular Forms
.. code-block:: bash .. code-block:: bash
$ http --form POST api.example.org/person/1 name='John Smith' email=john@example.org $ http --form POST api.example.org/person/1 name='John Smith' email=john@example.org cv=@~/Documents/cv.txt
.. code-block:: http .. code-block:: http
@ -391,7 +403,7 @@ Regular Forms
POST /person/1 HTTP/1.1 POST /person/1 HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=utf-8 Content-Type: application/x-www-form-urlencoded; charset=utf-8
name=John+Smith&email=john%40example.org name=John+Smith&email=john%40example.org&cv=John's+CV+...
----------------- -----------------
@ -416,6 +428,9 @@ 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
``=@`` just embeds the file content as a regular text field value.
============ ============
HTTP Headers HTTP Headers
@ -1214,6 +1229,8 @@ Changelog
*You can click a version name to see a diff with the previous one.* *You can click a version name to see a diff with the previous one.*
* `0.8.0-dev`_ * `0.8.0-dev`_
* Added ``field=@file.txt`` and ``field:=@file.json`` for embedding
the contents of text and JSON files into request data.
* `0.7.1`_ (2013-09-24) * `0.7.1`_ (2013-09-24)
* Added ``--ignore-stdin``. * Added ``--ignore-stdin``.
* Added support for auth plugins. * Added support for auth plugins.

View File

@ -3,7 +3,7 @@ HTTPie - a CLI, cURL-like tool for humans.
""" """
__author__ = 'Jakub Roztocil' __author__ = 'Jakub Roztocil'
__version__ = '0.7.1' __version__ = '0.8.0-dev'
__licence__ = 'BSD' __licence__ = 'BSD'

View File

@ -15,7 +15,7 @@ from .plugins import plugin_manager
from .sessions import DEFAULT_SESSIONS_DIR from .sessions import DEFAULT_SESSIONS_DIR
from .output import AVAILABLE_STYLES, DEFAULT_STYLE from .output import AVAILABLE_STYLES, DEFAULT_STYLE
from .input import (Parser, AuthCredentialsArgType, KeyValueArgType, from .input import (Parser, AuthCredentialsArgType, KeyValueArgType,
SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ITEMS, SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ALL_ITEMS,
OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD, OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
OUT_RESP_BODY, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, OUT_RESP_BODY, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator) PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator)
@ -94,7 +94,7 @@ positional.add_argument(
'items', 'items',
metavar='REQUEST ITEM', metavar='REQUEST ITEM',
nargs=ZERO_OR_MORE, nargs=ZERO_OR_MORE,
type=KeyValueArgType(*SEP_GROUP_ITEMS), type=KeyValueArgType(*SEP_GROUP_ALL_ITEMS),
help=r""" help=r"""
Optional key-value pairs to be included in the request. The separator used Optional key-value pairs to be included in the request. The separator used
determines the type: determines the type:
@ -112,13 +112,21 @@ positional.add_argument(
name=HTTPie language=Python description='CLI HTTP client' name=HTTPie language=Python description='CLI HTTP client'
':=' Non-string JSON data fields (only with --json, -j):
awesome:=true amount:=42 colors:='["red", "green", "blue"]'
'@' Form file fields (only with --form, -f): '@' Form file fields (only with --form, -f):
cs@~/Documents/CV.pdf cs@~/Documents/CV.pdf
':=' Non-string JSON data fields (only with --json, -j): '=@' A data field like '=', but takes a file path and embeds its content:
awesome:=true amount:=42 colors:='["red", "green", "blue"]' essay=@Documents/essay.txt
':=@' A raw JSON field like ':=', but takes a file path and embeds its content:
package:=@./package.json
You can use a backslash to escape a colliding separator in the field name: You can use a backslash to escape a colliding separator in the field name:

View File

@ -37,22 +37,40 @@ SEP_PROXY = ':'
SEP_DATA = '=' SEP_DATA = '='
SEP_DATA_RAW_JSON = ':=' SEP_DATA_RAW_JSON = ':='
SEP_FILES = '@' SEP_FILES = '@'
SEP_DATA_EMBED_FILE = '=@'
SEP_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEP_QUERY = '==' SEP_QUERY = '=='
# Separators that become request data # Separators that become request data
SEP_GROUP_DATA_ITEMS = frozenset([ SEP_GROUP_DATA_ITEMS = frozenset([
SEP_DATA, SEP_DATA,
SEP_DATA_RAW_JSON, SEP_DATA_RAW_JSON,
SEP_FILES SEP_FILES,
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE
])
# Separators for items whose value is a filename to be embedded
SEP_GROUP_DATA_EMBED_ITEMS = frozenset([
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE,
])
# Separators for raw JSON items
SEP_GROUP_RAW_JSON_ITEMS = frozenset([
SEP_DATA_RAW_JSON,
SEP_DATA_EMBED_RAW_JSON_FILE,
]) ])
# Separators allowed in ITEM arguments # Separators allowed in ITEM arguments
SEP_GROUP_ITEMS = frozenset([ SEP_GROUP_ALL_ITEMS = frozenset([
SEP_HEADERS, SEP_HEADERS,
SEP_QUERY, SEP_QUERY,
SEP_DATA, SEP_DATA,
SEP_DATA_RAW_JSON, SEP_DATA_RAW_JSON,
SEP_FILES SEP_FILES,
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE,
]) ])
@ -257,7 +275,7 @@ class Parser(ArgumentParser):
# Parse the URL as an ITEM and store it as the first ITEM arg. # Parse the URL as an ITEM and store it as the first ITEM arg.
self.args.items.insert( self.args.items.insert(
0, 0,
KeyValueArgType(*SEP_GROUP_ITEMS).__call__(self.args.url) KeyValueArgType(*SEP_GROUP_ALL_ITEMS).__call__(self.args.url)
) )
except ArgumentTypeError as e: except ArgumentTypeError as e:
@ -558,9 +576,7 @@ def parse_items(items, data=None, headers=None, files=None, params=None):
params = ParamDict() params = ParamDict()
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
@ -572,21 +588,28 @@ def parse_items(items, data=None, headers=None, files=None, params=None):
value = (os.path.basename(value), value = (os.path.basename(value),
BytesIO(f.read())) BytesIO(f.read()))
except IOError as e: except IOError as e:
raise ParseError( raise ParseError('"%s": %s' % (item.orig, e))
'Invalid argument "%s": %s' % (item.orig, e))
target = files target = files
elif item.sep in [SEP_DATA, SEP_DATA_RAW_JSON]: elif item.sep in SEP_GROUP_DATA_ITEMS:
if item.sep == SEP_DATA_RAW_JSON:
if item.sep in SEP_GROUP_DATA_EMBED_ITEMS:
try: try:
value = json.loads(item.value) with open(os.path.expanduser(value), 'rb') as f:
except ValueError: value = f.read()
raise ParseError('"%s" is not valid JSON' % item.orig) except IOError as e:
raise ParseError('%s": %s' % (item.orig, e))
if item.sep in SEP_GROUP_RAW_JSON_ITEMS:
try:
value = json.loads(value)
except ValueError as e:
raise ParseError('"%s": %s' % (item.orig, e))
target = data target = data
else: else:
raise TypeError(item) raise TypeError(item)
target[key] = value target[item.key] = value
return headers, data, files, params return headers, data, files, params

3
tests/fixtures/test.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"hello": "world"
}

View File

@ -101,15 +101,22 @@ def patharg(path):
FILE_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file.txt') FILE_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file.txt')
FILE2_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file2.txt') FILE2_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file2.txt')
BIN_FILE_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file.bin') BIN_FILE_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file.bin')
JSON_FILE_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'test.json')
FILE_PATH_ARG = patharg(FILE_PATH) FILE_PATH_ARG = patharg(FILE_PATH)
FILE2_PATH_ARG = patharg(FILE2_PATH) FILE2_PATH_ARG = patharg(FILE2_PATH)
BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH) BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH)
JSON_FILE_PATH_ARG = patharg(JSON_FILE_PATH)
with open(FILE_PATH) as f: with open(FILE_PATH) as f:
# Strip because we don't want new lines in the data so that we can
# easily count occurrences also when embedded in JSON (where the new
# line would be escaped).
FILE_CONTENT = f.read().strip() FILE_CONTENT = f.read().strip()
with open(BIN_FILE_PATH, 'rb') as f: with open(BIN_FILE_PATH, 'rb') as f:
BIN_FILE_CONTENT = f.read() BIN_FILE_CONTENT = f.read()
with open(JSON_FILE_PATH, 'rb') as f:
JSON_FILE_CONTENT = f.read()
def httpbin(path): def httpbin(path):
@ -1172,11 +1179,7 @@ class ItemParsingTest(BaseTestCase):
def setUp(self): def setUp(self):
self.key_value_type = input.KeyValueArgType( self.key_value_type = input.KeyValueArgType(
input.SEP_HEADERS, *input.SEP_GROUP_ALL_ITEMS
input.SEP_QUERY,
input.SEP_DATA,
input.SEP_DATA_RAW_JSON,
input.SEP_FILES,
) )
def test_invalid_items(self): def test_invalid_items(self):
@ -1223,26 +1226,41 @@ class ItemParsingTest(BaseTestCase):
self.key_value_type('eh:'), self.key_value_type('eh:'),
self.key_value_type('ed='), self.key_value_type('ed='),
self.key_value_type('bool:=true'), self.key_value_type('bool:=true'),
self.key_value_type('test-file@%s' % FILE_PATH_ARG), self.key_value_type('file@' + FILE_PATH_ARG),
self.key_value_type('query==value'), self.key_value_type('query==value'),
self.key_value_type('string-embed=@' + FILE_PATH_ARG),
self.key_value_type('raw-json-embed:=@' + JSON_FILE_PATH_ARG),
]) ])
# Parsed headers
# `requests.structures.CaseInsensitiveDict` => `dict` # `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(headers._store.values()) headers = dict(headers._store.values())
self.assertDictEqual(headers, { self.assertDictEqual(headers, {
'header': 'value', 'header': 'value',
'eh': '' 'eh': ''
}) })
self.assertDictEqual(data, {
# Parsed data
raw_json_embed = data.pop('raw-json-embed')
self.assertDictEqual(raw_json_embed, json.loads(JSON_FILE_CONTENT))
data['string-embed'] = data['string-embed'].strip()
self.assertDictEqual(dict(data), {
"ed": "", "ed": "",
"string": "value", "string": "value",
"bool": True, "bool": True,
"list": ["a", 1, {}, False], "list": ["a", 1, {}, False],
"obj": {"a": "b"}, "obj": {"a": "b"},
"string-embed": FILE_CONTENT,
}) })
# Parsed query string parameters
self.assertDictEqual(params, { self.assertDictEqual(params, {
'query': 'value', 'query': 'value',
}) })
self.assertIn('test-file', files)
# Parsed file fields
self.assertIn('file', files)
self.assertEqual(files['file'][1].read().strip(), FILE_CONTENT)
class ArgumentParserTestCase(unittest.TestCase): class ArgumentParserTestCase(unittest.TestCase):