Skip to content

Commit 225dccb

Browse files
authored
Regulate top-level arrays (#1292)
* Redesign the starting path * Do not cast `:=[1,2,3]` to a top-level array
1 parent cafa116 commit 225dccb

File tree

6 files changed

+177
-28
lines changed

6 files changed

+177
-28
lines changed

docs/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,47 @@ $ http PUT pie.dev/put \
854854
855855
#### Advanced usage
856856
857+
##### Top level arrays
858+
859+
If you want to send an array instead of a regular object, you can simply
860+
do that by omitting the starting key:
861+
862+
```bash
863+
$ http --offline --print=B pie.dev/post \
864+
[]:=1 \
865+
[]:=2 \
866+
[]:=3
867+
```
868+
869+
```json
870+
[
871+
1,
872+
2,
873+
3
874+
]
875+
```
876+
877+
You can also apply the nesting to the items by referencing their index:
878+
879+
```bash
880+
http --offline --print=B pie.dev/post \
881+
[0][type]=platform [0][name]=terminal \
882+
[1][type]=platform [1][name]=desktop
883+
```
884+
885+
```json
886+
[
887+
{
888+
"type": "platform",
889+
"name": "terminal"
890+
},
891+
{
892+
"type": "platform",
893+
"name": "desktop"
894+
}
895+
]
896+
```
897+
857898
##### Escaping behavior
858899
859900
Nested JSON syntax uses the same [escaping rules](#escaping-rules) as

httpie/cli/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ class RequestType(enum.Enum):
127127
JSON = enum.auto()
128128

129129

130+
EMPTY_STRING = ''
130131
OPEN_BRACKET = '['
131132
CLOSE_BRACKET = ']'
132133
BACKSLASH = '\\'

httpie/cli/dicts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
8282

8383
class RequestFilesDict(RequestDataDict):
8484
pass
85+
86+
87+
class NestedJSONArray(list):
88+
"""Denotes a top-level JSON array."""

httpie/cli/nested_json.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
Type,
1010
Union,
1111
)
12-
from httpie.cli.constants import OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER
12+
from httpie.cli.dicts import NestedJSONArray
13+
from httpie.cli.constants import EMPTY_STRING, OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER
1314

1415

1516
class HTTPieSyntaxError(ValueError):
@@ -52,6 +53,7 @@ def to_name(self) -> str:
5253

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

5658

5759
class Token(NamedTuple):
@@ -171,8 +173,8 @@ def reconstruct(self) -> str:
171173

172174
def parse(source: str) -> Iterator[Path]:
173175
"""
174-
start: literal? path*
175-
176+
start: root_path path*
177+
root_path: (literal | index_path | append_path)
176178
literal: TEXT | NUMBER
177179
178180
path:
@@ -215,16 +217,47 @@ def expect(*kinds):
215217
message = f'Expecting {suffix}'
216218
raise HTTPieSyntaxError(source, token, message)
217219

218-
root = Path(PathAction.KEY, '', is_root=True)
219-
if can_advance():
220-
token = tokens[cursor]
221-
if token.kind in {TokenKind.TEXT, TokenKind.NUMBER}:
222-
token = expect(TokenKind.TEXT, TokenKind.NUMBER)
223-
root.accessor = str(token.value)
224-
root.tokens.append(token)
220+
def parse_root():
221+
tokens = []
222+
if not can_advance():
223+
return Path(
224+
PathAction.KEY,
225+
EMPTY_STRING,
226+
is_root=True
227+
)
228+
229+
# (literal | index_path | append_path)?
230+
token = expect(*LITERAL_TOKENS, TokenKind.LEFT_BRACKET)
231+
tokens.append(token)
232+
233+
if token.kind in LITERAL_TOKENS:
234+
action = PathAction.KEY
235+
value = str(token.value)
236+
elif token.kind is TokenKind.LEFT_BRACKET:
237+
token = expect(TokenKind.NUMBER, TokenKind.RIGHT_BRACKET)
238+
tokens.append(token)
239+
if token.kind is TokenKind.NUMBER:
240+
action = PathAction.INDEX
241+
value = token.value
242+
tokens.append(expect(TokenKind.RIGHT_BRACKET))
243+
elif token.kind is TokenKind.RIGHT_BRACKET:
244+
action = PathAction.APPEND
245+
value = None
246+
else:
247+
assert_cant_happen()
248+
else:
249+
assert_cant_happen()
250+
251+
return Path(
252+
action,
253+
value,
254+
tokens=tokens,
255+
is_root=True
256+
)
225257

226-
yield root
258+
yield parse_root()
227259

260+
# path*
228261
while can_advance():
229262
path_tokens = []
230263
path_tokens.append(expect(TokenKind.LEFT_BRACKET))
@@ -296,6 +329,10 @@ def object_for(kind: str) -> Any:
296329
assert_cant_happen()
297330

298331
for index, (path, next_path) in enumerate(zip(paths, paths[1:])):
332+
# If there is no context yet, set it.
333+
if cursor is None:
334+
context = cursor = object_for(path.kind)
335+
299336
if path.kind is PathAction.KEY:
300337
type_check(index, path, dict)
301338
if next_path.kind is PathAction.SET:
@@ -337,8 +374,19 @@ def object_for(kind: str) -> Any:
337374
return context
338375

339376

377+
def wrap_with_dict(context):
378+
if context is None:
379+
return {}
380+
elif isinstance(context, list):
381+
return {EMPTY_STRING: NestedJSONArray(context)}
382+
else:
383+
assert isinstance(context, dict)
384+
return context
385+
386+
340387
def interpret_nested_json(pairs):
341-
context = {}
388+
context = None
342389
for key, value in pairs:
343-
interpret(context, key, value)
344-
return context
390+
context = interpret(context, key, value)
391+
392+
return wrap_with_dict(context)

httpie/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from . import __version__
1414
from .adapters import HTTPieHTTPAdapter
1515
from .context import Environment
16-
from .cli.dicts import HTTPHeadersDict
16+
from .cli.constants import EMPTY_STRING
17+
from .cli.dicts import HTTPHeadersDict, NestedJSONArray
1718
from .encoding import UTF8
1819
from .models import RequestsMessage
1920
from .plugins.registry import plugin_manager
@@ -280,7 +281,8 @@ def json_dict_to_request_body(data: Dict[str, Any]) -> str:
280281
# item in the object, with an en empty key.
281282
if len(data) == 1:
282283
[(key, value)] = data.items()
283-
if key == '' and isinstance(value, list):
284+
if isinstance(value, NestedJSONArray):
285+
assert key == EMPTY_STRING
284286
data = value
285287

286288
if data:

tests/test_json.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -321,15 +321,15 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
321321
'foo[][key]=value',
322322
'foo[2][key 2]=value 2',
323323
r'foo[2][key \[]=value 3',
324-
r'[nesting][under][!][empty][?][\\key]:=4',
324+
r'bar[nesting][under][!][empty][?][\\key]:=4',
325325
],
326326
{
327327
'foo': [
328328
1,
329329
2,
330330
{'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'},
331331
],
332-
'': {
332+
'bar': {
333333
'nesting': {'under': {'!': {'empty': {'?': {'\\key': 4}}}}}
334334
},
335335
},
@@ -408,17 +408,47 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
408408
r'a[\\-3\\\\]:=-3',
409409
],
410410
{
411-
"a": {
412-
"0": 0,
413-
r"\1": 1,
414-
r"\\2": 2,
415-
r"\\\3": 3,
416-
"-1\\": -1,
417-
"-2\\\\": -2,
418-
"\\-3\\\\": -3,
411+
'a': {
412+
'0': 0,
413+
r'\1': 1,
414+
r'\\2': 2,
415+
r'\\\3': 3,
416+
'-1\\': -1,
417+
'-2\\\\': -2,
418+
'\\-3\\\\': -3,
419419
}
420-
}
420+
},
421+
),
422+
(
423+
['[]:=0', '[]:=1', '[5]:=5', '[]:=6', '[9]:=9'],
424+
[0, 1, None, None, None, 5, 6, None, None, 9],
425+
),
426+
(
427+
['=empty', 'foo=bar', 'bar[baz][quux]=tuut'],
428+
{'': 'empty', 'foo': 'bar', 'bar': {'baz': {'quux': 'tuut'}}},
429+
),
430+
(
431+
[
432+
r'\1=top level int',
433+
r'\\1=escaped top level int',
434+
r'\2[\3][\4]:=5',
435+
],
436+
{
437+
'1': 'top level int',
438+
'\\1': 'escaped top level int',
439+
'2': {'3': {'4': 5}},
440+
},
441+
),
442+
(
443+
[':={"foo": {"bar": "baz"}}', 'top=val'],
444+
{'': {'foo': {'bar': 'baz'}}, 'top': 'val'},
445+
),
446+
(
447+
['[][a][b][]:=1', '[0][a][b][]:=2', '[][]:=2'],
448+
[{'a': {'b': [1, 2]}}, [2]],
421449
),
450+
([':=[1,2,3]'], {'': [1, 2, 3]}),
451+
([':=[1,2,3]', 'foo=bar'], {'': [1, 2, 3], 'foo': 'bar'}),
422452
],
423453
)
424454
def test_nested_json_syntax(input_json, expected_json, httpbin):
@@ -516,13 +546,36 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
516546
['foo[\\1]:=2', 'foo[5]:=3'],
517547
"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 ^^^",
518548
),
549+
(
550+
['x=y', '[]:=2'],
551+
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
552+
),
553+
(
554+
['[]:=2', 'x=y'],
555+
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
556+
),
557+
(
558+
[':=[1,2,3]', '[]:=4'],
559+
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
560+
),
561+
(
562+
['[]:=4', ':=[1,2,3]'],
563+
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
564+
),
519565
],
520566
)
521567
def test_nested_json_errors(input_json, expected_error, httpbin):
522568
with pytest.raises(HTTPieSyntaxError) as exc:
523569
http(httpbin + '/post', *input_json)
524570

525-
assert str(exc.value) == expected_error
571+
exc_lines = str(exc.value).splitlines()
572+
expected_lines = expected_error.splitlines()
573+
if len(expected_lines) == 1:
574+
# When the error offsets are not important, we'll just compare the actual
575+
# error message.
576+
exc_lines = exc_lines[:1]
577+
578+
assert expected_lines == exc_lines
526579

527580

528581
def test_nested_json_sparse_array(httpbin_both):

0 commit comments

Comments
 (0)