Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for nested JSON syntax #1169

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Added support for sending multiple HTTP headers with the same name. ([#130](https://github.com/httpie/httpie/issues/130))
- Added support for receving multiple HTTP headers with the same name, individually. ([#1207](https://github.com/httpie/httpie/issues/1207))
- Added support for keeping `://` in the URL argument to allow quick conversions of pasted URLs into HTTPie calls just by adding a space after the protocol name (`$ https ://pie.dev` → `https://pie.dev`). ([#1195](https://github.com/httpie/httpie/issues/1195))
- Added support for nested JSON syntax. ([#1169](https://github.com/httpie/httpie/issues/1169))

## [2.6.0](https://github.com/httpie/httpie/compare/2.5.0...2.6.0) (2021-10-14)

Expand Down
55 changes: 52 additions & 3 deletions docs/README.md
Expand Up @@ -710,6 +710,58 @@ Host: pie.dev
}
```

### Nested JSON fields

Nested JSON fields can be set with both data field (`=`) and raw JSON field(`:=`) separators, which allows you to embed arbitrary JSON data into the resulting JSON object.
HTTPie supports the [JSON form](https://www.w3.org/TR/html-json-forms/) syntax.

```bash
$ http PUT pie.dev/put \
'object=scalar' \ # Object — blank key
'object[0]=array 1' \ # Object — "0" key
'object[key]=key key' \ # Object — "key" key
'array:=1' \ # Array — first item
'array:=2' \ # Array — second item
'array[]:=3' \ # Array — append (third item)
'wow[such][deep][3][much][power][!]=Amaze' # Nested object
```

```http
PUT /person/1 HTTP/1.1
Accept: application/json, */*;q=0.5
Content-Type: application/json
Host: pie.dev

{
"array": [
1,
2,
3
],
"object": {
"": "scalar",
"0": "array 1",
"key": "key key"
},
"wow": {
"such": {
"deep": [
null,
null,
null,
{
"much": {
"power": {
"!": "Amaze"
}
}
}
]
}
}
}
```

### Raw and complex JSON

Please note that with the [request items](#request-items) data field syntax, commands can quickly become unwieldy when sending complex structures.
Expand All @@ -727,9 +779,6 @@ $ http --raw '{"hello": "world"}' POST pie.dev/post
$ http POST pie.dev/post < files/data.json
```

Furthermore, the structure syntax only allows you to send an object as the JSON document, but not an array, etc.
Here, again, the solution is to use [redirected input](#redirected-input).

## Forms

Submitting forms is very similar to sending [JSON](#json) requests.
Expand Down
6 changes: 3 additions & 3 deletions httpie/cli/constants.py
Expand Up @@ -44,10 +44,10 @@
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
})

# Separators for raw JSON items
SEPARATOR_GROUP_RAW_JSON_ITEMS = frozenset([
# Separators for nested JSON items
SEPARATOR_GROUP_NESTED_JSON_ITEMS = frozenset([
SEPARATOR_DATA_STRING,
SEPARATOR_DATA_RAW_JSON,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
])

# Separators allowed in ITEM arguments
Expand Down
2 changes: 1 addition & 1 deletion httpie/cli/definition.py
Expand Up @@ -118,7 +118,7 @@

'=@' A data field like '=', but takes a file path and embeds its content:

essay=@Documents/essay.txt
essay=@Documents/essay.txt

':=@' A raw JSON field like ':=', but takes a file path and embeds its content:

Expand Down
154 changes: 154 additions & 0 deletions httpie/cli/json_form.py
@@ -0,0 +1,154 @@
"""
Routines for JSON form syntax, used to support nested JSON request items.

Highly inspired from the great jarg project <https://github.com/jdp/jarg/blob/master/jarg/jsonform.py>.
"""
import re
from typing import Optional


def step(value: str, is_escaped: bool) -> str:
if is_escaped:
value = value.replace(r'\[', '[').replace(r'\]', ']')
return value


def find_opening_bracket(
value: str,
search=re.compile(r'(?<!\\)\[').search
) -> Optional[int]:
match = search(value)
if not match:
return None
return match.start()


def find_closing_bracket(
value: str,
search=re.compile(r'(?<!\\)\]').search
) -> Optional[int]:
match = search(value)
if not match:
return None
return match.start()


def parse_path(path):
"""
Parse a string as a JSON path.

An implementation of "steps to parse a JSON encoding path".
<https://www.w3.org/TR/html-json-forms/#dfn-steps-to-parse-a-json-encoding-path>

"""
original = path
is_escaped = r'\[' in original

opening_bracket = find_opening_bracket(original)
last_step = [(step(path, is_escaped), {'last': True, 'type': 'object'})]
if opening_bracket is None:
return last_step

steps = [(step(original[:opening_bracket], is_escaped), {'type': 'object'})]
path = original[opening_bracket:]
while path:
if path.startswith('[]'):
steps[-1][1]['append'] = True
path = path[2:]
if path:
return last_step
elif path[0] == '[':
path = path[1:]
closing_bracket = find_closing_bracket(path)
if closing_bracket is None:
return last_step
key = path[:closing_bracket]
path = path[closing_bracket + 1:]
try:
steps.append((int(key), {'type': 'array'}))
except ValueError:
steps.append((key, {'type': 'object'}))
elif path[:2] == r'\[':
key = step(path[1:path.index(r'\]') + 2], is_escaped)
path = path[path.index(r'\]') + 2:]
steps.append((key, {'type': 'object'}))
else:
return last_step

for i in range(len(steps) - 1):
steps[i][1]['type'] = steps[i + 1][1]['type']
steps[-1][1]['last'] = True
return steps


def set_value(context, step, current_value, entry_value):
"""Apply a JSON value to a context object.

An implementation of "steps to set a JSON encoding value".
<https://www.w3.org/TR/html-json-forms/#dfn-steps-to-set-a-json-encoding-value>

"""
key, flags = step
if flags.get('last', False):
if current_value is None:
if flags.get('append', False):
context[key] = [entry_value]
else:
if isinstance(context, list) and len(context) <= key:
context.extend([None] * (key - len(context) + 1))
context[key] = entry_value
elif isinstance(current_value, list):
context[key].append(entry_value)
else:
context[key] = [current_value, entry_value]
return context

if current_value is None:
if flags.get('type') == 'array':
context[key] = []
else:
if isinstance(context, list) and len(context) <= key:
context.extend([None] * (key - len(context) + 1))
context[key] = {}
return context[key]

if isinstance(current_value, dict):
return context[key]

if isinstance(current_value, list):
if flags.get('type') == 'array':
return current_value

obj = {}
for i, item in enumerate(current_value):
if item is not None:
obj[i] = item
else:
context[key] = obj
return obj

obj = {'': current_value}
context[key] = obj
return obj


def interpret_json_form(pairs):
"""The application/json form encoding algorithm.

<https://www.w3.org/TR/html-json-forms/#dfn-application-json-encoding-algorithm>

"""
result = {}
for key, value in pairs:
steps = parse_path(key)
context = result
for step in steps:
try:
current_value = context.get(step[0], None)
except AttributeError:
try:
current_value = context[step[0]]
except IndexError:
current_value = None
context = set_value(context, step, current_value, value)
return result
32 changes: 31 additions & 1 deletion httpie/cli/requestitems.py
Expand Up @@ -4,7 +4,7 @@
from .argtypes import KeyValueArg
from .constants import (
SEPARATORS_GROUP_MULTIPART, SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE, SEPARATOR_GROUP_NESTED_JSON_ITEMS,
SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD,
SEPARATOR_FILE_UPLOAD_TYPE, SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
Expand All @@ -15,6 +15,7 @@
RequestQueryParamsDict,
)
from .exceptions import ParseError
from .json_form import interpret_json_form
from ..utils import get_content_type, load_json_preserve_order_and_dupe_keys


Expand Down Expand Up @@ -60,6 +61,10 @@ def from_args(
process_data_embed_file_contents_arg,
instance.data,
),
SEPARATOR_GROUP_NESTED_JSON_ITEMS: (
process_data_nested_json_embed_args,
instance.data,
),
SEPARATOR_DATA_RAW_JSON: (
process_data_raw_json_embed_arg,
instance.data,
Expand All @@ -70,6 +75,27 @@ def from_args(
),
}

# Nested JSON items must be treated as a whole.
nested_json_items = []
if not as_form:
nested_json_items.extend(
arg for arg in request_item_args
if arg.sep in SEPARATOR_GROUP_NESTED_JSON_ITEMS
)
if nested_json_items:
request_item_args = [
arg for arg in request_item_args
if arg not in nested_json_items
]
pairs = [
(arg.key, rules[arg.sep][0](arg))
for arg in nested_json_items
]
processor_func, target_dict = rules[SEPARATOR_GROUP_NESTED_JSON_ITEMS]
value = processor_func(pairs)
target_dict.update(value)

# Then handle all other items.
for arg in request_item_args:
processor_func, target_dict = rules[arg.sep]
value = processor_func(arg)
Expand Down Expand Up @@ -138,6 +164,10 @@ def process_data_raw_json_embed_arg(arg: KeyValueArg) -> JSONType:
return value


def process_data_nested_json_embed_args(pairs) -> Dict[str, JSONType]:
return interpret_json_form(pairs)


def load_text_file(item: KeyValueArg) -> str:
path = item.value
try:
Expand Down
6 changes: 5 additions & 1 deletion tests/test_cli.py
Expand Up @@ -79,6 +79,8 @@ def test_valid_items(self):
self.key_value_arg('Empty-Header;'),
self.key_value_arg('list:=["a", 1, {}, false]'),
self.key_value_arg('obj:={"a": "b"}'),
self.key_value_arg(r'nested\[2\][a][]=1'),
self.key_value_arg('nested[2][a][]:=1'),
self.key_value_arg('ed='),
self.key_value_arg('bool:=true'),
self.key_value_arg('file@' + FILE_PATH_ARG),
Expand All @@ -104,7 +106,9 @@ def test_valid_items(self):
'ed': '',
'string': 'value',
'bool': True,
'list': ['a', 1, {}, False],
'list': ['a', 1, load_json_preserve_order_and_dupe_keys('{}'), False],
'nested[2]': {'a': ['1']},
'nested': [None, None, {'a': [1]}],
'obj': load_json_preserve_order_and_dupe_keys('{"a": "b"}'),
'string-embed': FILE_CONTENT,
}
Expand Down