Skip to content

Commit

Permalink
Add support for nested JSON syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
BoboTiG committed Oct 20, 2021
1 parent 3abc76f commit 9868e5d
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 9 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [2.7.0.dev0](https://github.com/httpie/httpie/compare/2.6.0...master) (unreleased)

- 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)

- Added support for formatting & coloring of JSON bodies preceded by non-JSON data (e.g., an XXSI prefix). ([#1130](https://github.com/httpie/httpie/issues/1130))
Expand Down
55 changes: 52 additions & 3 deletions docs/README.md
Expand Up @@ -698,6 +698,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 @@ -715,9 +767,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 @@ -134,6 +160,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

0 comments on commit 9868e5d

Please sign in to comment.