mirror of
https://github.com/httpie/cli.git
synced 2025-06-25 12:01:41 +02: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
|
#### 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
|
||||||
|
@ -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 = '\\'
|
||||||
|
@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
|
|||||||
|
|
||||||
class RequestFilesDict(RequestDataDict):
|
class RequestFilesDict(RequestDataDict):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NestedJSONArray(list):
|
||||||
|
"""Denotes a top-level JSON array."""
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user