mirror of
https://github.com/httpie/cli.git
synced 2025-01-07 14:19:30 +01:00
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:
parent
cafa11665b
commit
225dccb218
@ -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
|
||||
|
@ -127,6 +127,7 @@ class RequestType(enum.Enum):
|
||||
JSON = enum.auto()
|
||||
|
||||
|
||||
EMPTY_STRING = ''
|
||||
OPEN_BRACKET = '['
|
||||
CLOSE_BRACKET = ']'
|
||||
BACKSLASH = '\\'
|
||||
|
@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
|
||||
|
||||
class RequestFilesDict(RequestDataDict):
|
||||
pass
|
||||
|
||||
|
||||
class NestedJSONArray(list):
|
||||
"""Denotes a top-level JSON array."""
|
||||
|
@ -9,7 +9,8 @@ from typing import (
|
||||
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):
|
||||
@ -52,6 +53,7 @@ class TokenKind(Enum):
|
||||
|
||||
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):
|
||||
@ -171,8 +173,8 @@ class Path:
|
||||
|
||||
def parse(source: str) -> Iterator[Path]:
|
||||
"""
|
||||
start: literal? path*
|
||||
|
||||
start: root_path path*
|
||||
root_path: (literal | index_path | append_path)
|
||||
literal: TEXT | NUMBER
|
||||
|
||||
path:
|
||||
@ -215,16 +217,47 @@ def parse(source: str) -> Iterator[Path]:
|
||||
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
|
||||
)
|
||||
|
||||
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():
|
||||
path_tokens = []
|
||||
path_tokens.append(expect(TokenKind.LEFT_BRACKET))
|
||||
@ -296,6 +329,10 @@ def interpret(context: Any, key: str, value: Any) -> 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:
|
||||
@ -337,8 +374,19 @@ def interpret(context: Any, key: str, value: Any) -> 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)
|
||||
|
@ -13,7 +13,8 @@ import urllib3
|
||||
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
|
||||
@ -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:
|
||||
|
@ -321,7 +321,7 @@ 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': [
|
||||
@ -329,7 +329,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
|
||||
2,
|
||||
{'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'},
|
||||
],
|
||||
'': {
|
||||
'bar': {
|
||||
'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',
|
||||
],
|
||||
{
|
||||
"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):
|
||||
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user