Skip to content

Commit

Permalink
Regulate top-level arrays (#1292)
Browse files Browse the repository at this point in the history
* Redesign the starting path

* Do not cast `:=[1,2,3]` to a top-level array
  • Loading branch information
isidentical committed Feb 8, 2022
1 parent cafa116 commit 225dccb
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 28 deletions.
41 changes: 41 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,47 @@ $ http PUT pie.dev/put \
#### Advanced usage
##### Top level arrays
If you want to send an array instead of a regular object, you can simply
do that by omitting the starting key:
```bash
$ http --offline --print=B pie.dev/post \
[]:=1 \
[]:=2 \
[]:=3
```
```json
[
1,
2,
3
]
```
You can also apply the nesting to the items by referencing their index:
```bash
http --offline --print=B pie.dev/post \
[0][type]=platform [0][name]=terminal \
[1][type]=platform [1][name]=desktop
```
```json
[
{
"type": "platform",
"name": "terminal"
},
{
"type": "platform",
"name": "desktop"
}
]
```
##### Escaping behavior
Nested JSON syntax uses the same [escaping rules](#escaping-rules) as
Expand Down
1 change: 1 addition & 0 deletions httpie/cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class RequestType(enum.Enum):
JSON = enum.auto()


EMPTY_STRING = ''
OPEN_BRACKET = '['
CLOSE_BRACKET = ']'
BACKSLASH = '\\'
Expand Down
4 changes: 4 additions & 0 deletions httpie/cli/dicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):

class RequestFilesDict(RequestDataDict):
pass


class NestedJSONArray(list):
"""Denotes a top-level JSON array."""
76 changes: 62 additions & 14 deletions httpie/cli/nested_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
Type,
Union,
)
from httpie.cli.constants import OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER
from httpie.cli.dicts import NestedJSONArray
from httpie.cli.constants import EMPTY_STRING, OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER


class HTTPieSyntaxError(ValueError):
Expand Down Expand Up @@ -52,6 +53,7 @@ def to_name(self) -> str:

OPERATORS = {OPEN_BRACKET: TokenKind.LEFT_BRACKET, CLOSE_BRACKET: TokenKind.RIGHT_BRACKET}
SPECIAL_CHARS = OPERATORS.keys() | {BACKSLASH}
LITERAL_TOKENS = [TokenKind.TEXT, TokenKind.NUMBER]


class Token(NamedTuple):
Expand Down Expand Up @@ -171,8 +173,8 @@ def reconstruct(self) -> str:

def parse(source: str) -> Iterator[Path]:
"""
start: literal? path*
start: root_path path*
root_path: (literal | index_path | append_path)
literal: TEXT | NUMBER
path:
Expand Down Expand Up @@ -215,16 +217,47 @@ def expect(*kinds):
message = f'Expecting {suffix}'
raise HTTPieSyntaxError(source, token, message)

root = Path(PathAction.KEY, '', is_root=True)
if can_advance():
token = tokens[cursor]
if token.kind in {TokenKind.TEXT, TokenKind.NUMBER}:
token = expect(TokenKind.TEXT, TokenKind.NUMBER)
root.accessor = str(token.value)
root.tokens.append(token)
def parse_root():
tokens = []
if not can_advance():
return Path(
PathAction.KEY,
EMPTY_STRING,
is_root=True
)

# (literal | index_path | append_path)?
token = expect(*LITERAL_TOKENS, TokenKind.LEFT_BRACKET)
tokens.append(token)

if token.kind in LITERAL_TOKENS:
action = PathAction.KEY
value = str(token.value)
elif token.kind is TokenKind.LEFT_BRACKET:
token = expect(TokenKind.NUMBER, TokenKind.RIGHT_BRACKET)
tokens.append(token)
if token.kind is TokenKind.NUMBER:
action = PathAction.INDEX
value = token.value
tokens.append(expect(TokenKind.RIGHT_BRACKET))
elif token.kind is TokenKind.RIGHT_BRACKET:
action = PathAction.APPEND
value = None
else:
assert_cant_happen()
else:
assert_cant_happen()

return Path(
action,
value,
tokens=tokens,
is_root=True
)

yield root
yield parse_root()

# path*
while can_advance():
path_tokens = []
path_tokens.append(expect(TokenKind.LEFT_BRACKET))
Expand Down Expand Up @@ -296,6 +329,10 @@ def object_for(kind: str) -> Any:
assert_cant_happen()

for index, (path, next_path) in enumerate(zip(paths, paths[1:])):
# If there is no context yet, set it.
if cursor is None:
context = cursor = object_for(path.kind)

if path.kind is PathAction.KEY:
type_check(index, path, dict)
if next_path.kind is PathAction.SET:
Expand Down Expand Up @@ -337,8 +374,19 @@ def object_for(kind: str) -> Any:
return context


def wrap_with_dict(context):
if context is None:
return {}
elif isinstance(context, list):
return {EMPTY_STRING: NestedJSONArray(context)}
else:
assert isinstance(context, dict)
return context


def interpret_nested_json(pairs):
context = {}
context = None
for key, value in pairs:
interpret(context, key, value)
return context
context = interpret(context, key, value)

return wrap_with_dict(context)
6 changes: 4 additions & 2 deletions httpie/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
from . import __version__
from .adapters import HTTPieHTTPAdapter
from .context import Environment
from .cli.dicts import HTTPHeadersDict
from .cli.constants import EMPTY_STRING
from .cli.dicts import HTTPHeadersDict, NestedJSONArray
from .encoding import UTF8
from .models import RequestsMessage
from .plugins.registry import plugin_manager
Expand Down Expand Up @@ -280,7 +281,8 @@ def json_dict_to_request_body(data: Dict[str, Any]) -> str:
# item in the object, with an en empty key.
if len(data) == 1:
[(key, value)] = data.items()
if key == '' and isinstance(value, list):
if isinstance(value, NestedJSONArray):
assert key == EMPTY_STRING
data = value

if data:
Expand Down
77 changes: 65 additions & 12 deletions tests/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,15 +321,15 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
'foo[][key]=value',
'foo[2][key 2]=value 2',
r'foo[2][key \[]=value 3',
r'[nesting][under][!][empty][?][\\key]:=4',
r'bar[nesting][under][!][empty][?][\\key]:=4',
],
{
'foo': [
1,
2,
{'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'},
],
'': {
'bar': {
'nesting': {'under': {'!': {'empty': {'?': {'\\key': 4}}}}}
},
},
Expand Down Expand Up @@ -408,17 +408,47 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
r'a[\\-3\\\\]:=-3',
],
{
"a": {
"0": 0,
r"\1": 1,
r"\\2": 2,
r"\\\3": 3,
"-1\\": -1,
"-2\\\\": -2,
"\\-3\\\\": -3,
'a': {
'0': 0,
r'\1': 1,
r'\\2': 2,
r'\\\3': 3,
'-1\\': -1,
'-2\\\\': -2,
'\\-3\\\\': -3,
}
}
},
),
(
['[]:=0', '[]:=1', '[5]:=5', '[]:=6', '[9]:=9'],
[0, 1, None, None, None, 5, 6, None, None, 9],
),
(
['=empty', 'foo=bar', 'bar[baz][quux]=tuut'],
{'': 'empty', 'foo': 'bar', 'bar': {'baz': {'quux': 'tuut'}}},
),
(
[
r'\1=top level int',
r'\\1=escaped top level int',
r'\2[\3][\4]:=5',
],
{
'1': 'top level int',
'\\1': 'escaped top level int',
'2': {'3': {'4': 5}},
},
),
(
[':={"foo": {"bar": "baz"}}', 'top=val'],
{'': {'foo': {'bar': 'baz'}}, 'top': 'val'},
),
(
['[][a][b][]:=1', '[0][a][b][]:=2', '[][]:=2'],
[{'a': {'b': [1, 2]}}, [2]],
),
([':=[1,2,3]'], {'': [1, 2, 3]}),
([':=[1,2,3]', 'foo=bar'], {'': [1, 2, 3], 'foo': 'bar'}),
],
)
def test_nested_json_syntax(input_json, expected_json, httpbin):
Expand Down Expand Up @@ -516,13 +546,36 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
['foo[\\1]:=2', 'foo[5]:=3'],
"HTTPie Type Error: Can't perform 'index' based access on 'foo' which has a type of 'object' but this operation requires a type of 'array'.\nfoo[5]\n ^^^",
),
(
['x=y', '[]:=2'],
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
),
(
['[]:=2', 'x=y'],
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
),
(
[':=[1,2,3]', '[]:=4'],
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
),
(
['[]:=4', ':=[1,2,3]'],
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
),
],
)
def test_nested_json_errors(input_json, expected_error, httpbin):
with pytest.raises(HTTPieSyntaxError) as exc:
http(httpbin + '/post', *input_json)

assert str(exc.value) == expected_error
exc_lines = str(exc.value).splitlines()
expected_lines = expected_error.splitlines()
if len(expected_lines) == 1:
# When the error offsets are not important, we'll just compare the actual
# error message.
exc_lines = exc_lines[:1]

assert expected_lines == exc_lines


def test_nested_json_sparse_array(httpbin_both):
Expand Down

0 comments on commit 225dccb

Please sign in to comment.