Regulate top-level arrays (#1292)

* Redesign the starting path

* Do not cast `:=[1,2,3]` to a top-level array
This commit is contained in:
Batuhan Taskaya 2022-02-09 02:18:40 +03:00 committed by GitHub
parent cafa11665b
commit 225dccb218
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 177 additions and 28 deletions

View File

@ -854,6 +854,47 @@ $ http PUT pie.dev/put \
#### Advanced usage #### 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 ##### Escaping behavior
Nested JSON syntax uses the same [escaping rules](#escaping-rules) as Nested JSON syntax uses the same [escaping rules](#escaping-rules) as

View File

@ -127,6 +127,7 @@ class RequestType(enum.Enum):
JSON = enum.auto() JSON = enum.auto()
EMPTY_STRING = ''
OPEN_BRACKET = '[' OPEN_BRACKET = '['
CLOSE_BRACKET = ']' CLOSE_BRACKET = ']'
BACKSLASH = '\\' BACKSLASH = '\\'

View File

@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
class RequestFilesDict(RequestDataDict): class RequestFilesDict(RequestDataDict):
pass pass
class NestedJSONArray(list):
"""Denotes a top-level JSON array."""

View File

@ -9,7 +9,8 @@ from typing import (
Type, Type,
Union, 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): class HTTPieSyntaxError(ValueError):
@ -52,6 +53,7 @@ class TokenKind(Enum):
OPERATORS = {OPEN_BRACKET: TokenKind.LEFT_BRACKET, CLOSE_BRACKET: TokenKind.RIGHT_BRACKET} OPERATORS = {OPEN_BRACKET: TokenKind.LEFT_BRACKET, CLOSE_BRACKET: TokenKind.RIGHT_BRACKET}
SPECIAL_CHARS = OPERATORS.keys() | {BACKSLASH} SPECIAL_CHARS = OPERATORS.keys() | {BACKSLASH}
LITERAL_TOKENS = [TokenKind.TEXT, TokenKind.NUMBER]
class Token(NamedTuple): class Token(NamedTuple):
@ -171,8 +173,8 @@ class Path:
def parse(source: str) -> Iterator[Path]: def parse(source: str) -> Iterator[Path]:
""" """
start: literal? path* start: root_path path*
root_path: (literal | index_path | append_path)
literal: TEXT | NUMBER literal: TEXT | NUMBER
path: path:
@ -215,16 +217,47 @@ def parse(source: str) -> Iterator[Path]:
message = f'Expecting {suffix}' message = f'Expecting {suffix}'
raise HTTPieSyntaxError(source, token, message) raise HTTPieSyntaxError(source, token, message)
root = Path(PathAction.KEY, '', is_root=True) def parse_root():
if can_advance(): tokens = []
token = tokens[cursor] if not can_advance():
if token.kind in {TokenKind.TEXT, TokenKind.NUMBER}: return Path(
token = expect(TokenKind.TEXT, TokenKind.NUMBER) PathAction.KEY,
root.accessor = str(token.value) EMPTY_STRING,
root.tokens.append(token) is_root=True
)
yield root # (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 parse_root()
# path*
while can_advance(): while can_advance():
path_tokens = [] path_tokens = []
path_tokens.append(expect(TokenKind.LEFT_BRACKET)) path_tokens.append(expect(TokenKind.LEFT_BRACKET))
@ -296,6 +329,10 @@ def interpret(context: Any, key: str, value: Any) -> Any:
assert_cant_happen() assert_cant_happen()
for index, (path, next_path) in enumerate(zip(paths, paths[1:])): 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: if path.kind is PathAction.KEY:
type_check(index, path, dict) type_check(index, path, dict)
if next_path.kind is PathAction.SET: if next_path.kind is PathAction.SET:
@ -337,8 +374,19 @@ def interpret(context: Any, key: str, value: Any) -> Any:
return context return context
def interpret_nested_json(pairs): def wrap_with_dict(context):
context = {} if context is None:
for key, value in pairs: return {}
interpret(context, key, value) elif isinstance(context, list):
return {EMPTY_STRING: NestedJSONArray(context)}
else:
assert isinstance(context, dict)
return context return context
def interpret_nested_json(pairs):
context = None
for key, value in pairs:
context = interpret(context, key, value)
return wrap_with_dict(context)

View File

@ -13,7 +13,8 @@ import urllib3
from . import __version__ from . import __version__
from .adapters import HTTPieHTTPAdapter from .adapters import HTTPieHTTPAdapter
from .context import Environment 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 .encoding import UTF8
from .models import RequestsMessage from .models import RequestsMessage
from .plugins.registry import plugin_manager from .plugins.registry import plugin_manager
@ -280,7 +281,8 @@ def json_dict_to_request_body(data: Dict[str, Any]) -> str:
# item in the object, with an en empty key. # item in the object, with an en empty key.
if len(data) == 1: if len(data) == 1:
[(key, value)] = data.items() [(key, value)] = data.items()
if key == '' and isinstance(value, list): if isinstance(value, NestedJSONArray):
assert key == EMPTY_STRING
data = value data = value
if data: if data:

View File

@ -321,7 +321,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
'foo[][key]=value', 'foo[][key]=value',
'foo[2][key 2]=value 2', 'foo[2][key 2]=value 2',
r'foo[2][key \[]=value 3', r'foo[2][key \[]=value 3',
r'[nesting][under][!][empty][?][\\key]:=4', r'bar[nesting][under][!][empty][?][\\key]:=4',
], ],
{ {
'foo': [ 'foo': [
@ -329,7 +329,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
2, 2,
{'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'}, {'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'},
], ],
'': { 'bar': {
'nesting': {'under': {'!': {'empty': {'?': {'\\key': 4}}}}} 'nesting': {'under': {'!': {'empty': {'?': {'\\key': 4}}}}}
}, },
}, },
@ -408,17 +408,47 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
r'a[\\-3\\\\]:=-3', r'a[\\-3\\\\]:=-3',
], ],
{ {
"a": { 'a': {
"0": 0, '0': 0,
r"\1": 1, r'\1': 1,
r"\\2": 2, r'\\2': 2,
r"\\\3": 3, r'\\\3': 3,
"-1\\": -1, '-1\\': -1,
"-2\\\\": -2, '-2\\\\': -2,
"\\-3\\\\": -3, '\\-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): def test_nested_json_syntax(input_json, expected_json, httpbin):
@ -516,13 +546,36 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
['foo[\\1]:=2', 'foo[5]:=3'], ['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 ^^^", "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): def test_nested_json_errors(input_json, expected_error, httpbin):
with pytest.raises(HTTPieSyntaxError) as exc: with pytest.raises(HTTPieSyntaxError) as exc:
http(httpbin + '/post', *input_json) 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): def test_nested_json_sparse_array(httpbin_both):