From 225dccb2186f14f871695b6c4e0bfbcdb2e3aa28 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Wed, 9 Feb 2022 02:18:40 +0300 Subject: [PATCH] Regulate top-level arrays (#1292) * Redesign the starting path * Do not cast `:=[1,2,3]` to a top-level array --- docs/README.md | 41 +++++++++++++++++++++ httpie/cli/constants.py | 1 + httpie/cli/dicts.py | 4 ++ httpie/cli/nested_json.py | 76 +++++++++++++++++++++++++++++++------- httpie/client.py | 6 ++- tests/test_json.py | 77 +++++++++++++++++++++++++++++++++------ 6 files changed, 177 insertions(+), 28 deletions(-) diff --git a/docs/README.md b/docs/README.md index 5b4f6493da..bc7f0e7af7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/httpie/cli/constants.py b/httpie/cli/constants.py index 897e806b4d..067aaabdf7 100644 --- a/httpie/cli/constants.py +++ b/httpie/cli/constants.py @@ -127,6 +127,7 @@ class RequestType(enum.Enum): JSON = enum.auto() +EMPTY_STRING = '' OPEN_BRACKET = '[' CLOSE_BRACKET = ']' BACKSLASH = '\\' diff --git a/httpie/cli/dicts.py b/httpie/cli/dicts.py index 3d0cab5a45..434a396672 100644 --- a/httpie/cli/dicts.py +++ b/httpie/cli/dicts.py @@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict): class RequestFilesDict(RequestDataDict): pass + + +class NestedJSONArray(list): + """Denotes a top-level JSON array.""" diff --git a/httpie/cli/nested_json.py b/httpie/cli/nested_json.py index beb5205843..c75798929d 100644 --- a/httpie/cli/nested_json.py +++ b/httpie/cli/nested_json.py @@ -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): @@ -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): @@ -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: @@ -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)) @@ -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: @@ -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) diff --git a/httpie/client.py b/httpie/client.py index c2563cbc3e..06235d249b 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -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 @@ -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: diff --git a/tests/test_json.py b/tests/test_json.py index 7b4dff41e8..2ba603a680 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -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):