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
=============
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
form data, files, and URL parameters.
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
their type is distinguished only by the separator used:
``:``, ``=``, ``:=``, ``@``, and ``==``.
``:``, ``=``, ``:=``, ``==``, ``@``, ``=@``, and ``:=@``. The ones with an
``@`` expect a file path as value.
+-----------------------+-----------------------------------------------------+
| Item Type | Description |
@ -266,16 +267,16 @@ their type is distinguished only by the separator used:
| | The ``==`` separator is used |
+-----------------------+-----------------------------------------------------+
| Data Fields | Request data fields to be serialized as a JSON |
| ``field=value`` | object (default), or to be form encoded |
| | (``--form, -f``). |
| ``field=value``, | object (default), or to be form encoded |
| ``field=@file.txt`` | (``--form, -f``). |
+-----------------------+-----------------------------------------------------+
| Raw JSON fields | Useful when sending JSON and one or |
| ``field:=json`` | more fields need to be a ``Boolean``, ``Number``, |
| | nested ``Object``, or an ``Array``, e.g., |
| ``field:=json``, | more fields need to be a ``Boolean``, ``Number``, |
| ``field:=@file.json`` | nested ``Object``, or an ``Array``, e.g., |
| | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` |
| | (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``. |
| | The presence of a file field results |
| | 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
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:
`Redirected input`_ allows for passing arbitrary data to be sent with the
request.
@ -332,11 +335,16 @@ Simple example:
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
$ 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
@ -352,8 +360,12 @@ into the resulting object:
"http",
"pies"
],
"description": "John is a nice guy who likes pies.",
"married": false,
"name": "John"
"name": "John",
"bookmarks": {
"HTTPie": "http://httpie.org",
}
}
@ -383,7 +395,7 @@ Regular Forms
.. 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
@ -391,7 +403,7 @@ Regular Forms
POST /person/1 HTTP/1.1
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" />
</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
@ -1214,6 +1229,8 @@ Changelog
*You can click a version name to see a diff with the previous one.*
* `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)
* Added ``--ignore-stdin``.
* Added support for auth plugins.

View File

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

View File

@ -15,7 +15,7 @@ from .plugins import plugin_manager
from .sessions import DEFAULT_SESSIONS_DIR
from .output import AVAILABLE_STYLES, DEFAULT_STYLE
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_RESP_BODY, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator)
@ -94,7 +94,7 @@ positional.add_argument(
'items',
metavar='REQUEST ITEM',
nargs=ZERO_OR_MORE,
type=KeyValueArgType(*SEP_GROUP_ITEMS),
type=KeyValueArgType(*SEP_GROUP_ALL_ITEMS),
help=r"""
Optional key-value pairs to be included in the request. The separator used
determines the type:
@ -112,13 +112,21 @@ positional.add_argument(
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):
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:

View File

@ -37,22 +37,40 @@ SEP_PROXY = ':'
SEP_DATA = '='
SEP_DATA_RAW_JSON = ':='
SEP_FILES = '@'
SEP_DATA_EMBED_FILE = '=@'
SEP_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEP_QUERY = '=='
# Separators that become request data
SEP_GROUP_DATA_ITEMS = frozenset([
SEP_DATA,
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
SEP_GROUP_ITEMS = frozenset([
SEP_GROUP_ALL_ITEMS = frozenset([
SEP_HEADERS,
SEP_QUERY,
SEP_DATA,
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.
self.args.items.insert(
0,
KeyValueArgType(*SEP_GROUP_ITEMS).__call__(self.args.url)
KeyValueArgType(*SEP_GROUP_ALL_ITEMS).__call__(self.args.url)
)
except ArgumentTypeError as e:
@ -558,9 +576,7 @@ def parse_items(items, data=None, headers=None, files=None, params=None):
params = ParamDict()
for item in items:
value = item.value
key = item.key
if item.sep == SEP_HEADERS:
target = headers
@ -572,21 +588,28 @@ def parse_items(items, data=None, headers=None, files=None, params=None):
value = (os.path.basename(value),
BytesIO(f.read()))
except IOError as e:
raise ParseError(
'Invalid argument "%s": %s' % (item.orig, e))
raise ParseError('"%s": %s' % (item.orig, e))
target = files
elif item.sep in [SEP_DATA, SEP_DATA_RAW_JSON]:
if item.sep == SEP_DATA_RAW_JSON:
elif item.sep in SEP_GROUP_DATA_ITEMS:
if item.sep in SEP_GROUP_DATA_EMBED_ITEMS:
try:
value = json.loads(item.value)
except ValueError:
raise ParseError('"%s" is not valid JSON' % item.orig)
with open(os.path.expanduser(value), 'rb') as f:
value = f.read()
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
else:
raise TypeError(item)
target[key] = value
target[item.key] = value
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')
FILE2_PATH = os.path.join(TESTS_ROOT, 'fixtures', 'file2.txt')
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)
FILE2_PATH_ARG = patharg(FILE2_PATH)
BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH)
JSON_FILE_PATH_ARG = patharg(JSON_FILE_PATH)
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()
with open(BIN_FILE_PATH, 'rb') as f:
BIN_FILE_CONTENT = f.read()
with open(JSON_FILE_PATH, 'rb') as f:
JSON_FILE_CONTENT = f.read()
def httpbin(path):
@ -1172,11 +1179,7 @@ class ItemParsingTest(BaseTestCase):
def setUp(self):
self.key_value_type = input.KeyValueArgType(
input.SEP_HEADERS,
input.SEP_QUERY,
input.SEP_DATA,
input.SEP_DATA_RAW_JSON,
input.SEP_FILES,
*input.SEP_GROUP_ALL_ITEMS
)
def test_invalid_items(self):
@ -1223,26 +1226,41 @@ class ItemParsingTest(BaseTestCase):
self.key_value_type('eh:'),
self.key_value_type('ed='),
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('string-embed=@' + FILE_PATH_ARG),
self.key_value_type('raw-json-embed:=@' + JSON_FILE_PATH_ARG),
])
# Parsed headers
# `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(headers._store.values())
self.assertDictEqual(headers, {
'header': 'value',
'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": "",
"string": "value",
"bool": True,
"list": ["a", 1, {}, False],
"obj": {"a": "b"},
"string-embed": FILE_CONTENT,
})
# Parsed query string parameters
self.assertDictEqual(params, {
'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):