Skip to content

Commit 96144c8

Browse files
Add --raw-body argument for POST and PUT requests (#794)
1 parent 81cc32d commit 96144c8

File tree

6 files changed

+195
-6
lines changed

6 files changed

+195
-6
lines changed

linodecli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
8080
cli.page = parsed.page
8181
cli.page_size = parsed.page_size
8282
cli.debug_request = parsed.debug
83+
cli.raw_body = parsed.raw_body
8384

8485
if parsed.as_user and not skip_config:
8586
cli.config.set_user(parsed.as_user)

linodecli/api_request.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,21 +348,49 @@ def _build_request_body(
348348
349349
:return: A JSON string representing the request body, or None if not applicable.
350350
"""
351-
if operation.method == "get":
352-
# Get operations don't have a body
351+
if operation.method in ("get", "delete"):
352+
# GET and DELETE operations don't have a body
353+
if ctx.raw_body is not None:
354+
print(
355+
f"--raw-body cannot be specified for actions with method {operation.method}",
356+
file=sys.stderr,
357+
)
358+
sys.exit(ExitCodes.ARGUMENT_ERROR)
359+
353360
return None
354361

362+
param_names = {param.name for param in operation.params}
363+
364+
# Returns whether the given argument should be included in the request body
365+
def __should_include(key: str, value: Any) -> bool:
366+
return value is not None and key not in param_names
367+
368+
# If the user has specified the --raw-body argument,
369+
# return it.
370+
if ctx.raw_body is not None:
371+
specified_keys = [
372+
k for k, v in vars(parsed_args).items() if __should_include(k, v)
373+
]
374+
375+
if len(specified_keys) > 0:
376+
print(
377+
"--raw-body cannot be specified with action arguments: "
378+
+ ", ".join(sorted(f"--{key}" for key in specified_keys)),
379+
file=sys.stderr,
380+
)
381+
sys.exit(ExitCodes.ARGUMENT_ERROR)
382+
383+
return ctx.raw_body
384+
355385
# Merge defaults into body if applicable
356386
if ctx.defaults:
357387
parsed_args = ctx.config.update(parsed_args, operation.allowed_defaults)
358388

359-
param_names = {param.name for param in operation.params}
360-
361389
expanded_json = {}
362390

363391
# Expand dotted keys into nested dictionaries
364392
for k, v in vars(parsed_args).items():
365-
if v is None or k in param_names:
393+
if not __should_include(k, v):
366394
continue
367395

368396
path_segments = get_path_segments(k)

linodecli/arg_helpers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ def register_args(parser: ArgumentParser) -> ArgumentParser:
8181
help="The alias to set or remove.",
8282
)
8383

84+
parser.add_argument(
85+
"--raw-body",
86+
type=str,
87+
help="The raw JSON to use as the request body of an action. "
88+
+ "This argument cannot be used if action-specific arguments are specified. "
89+
+ "Additionally, this argument can only be used with POST and PUT actions.",
90+
)
91+
8492
# Register shared argument groups
8593
register_output_args_shared(parser)
8694
register_pagination_args_shared(parser)

linodecli/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def __init__(self, version, base_url, skip_config=False):
4444
self.base_url = base_url
4545
self.spec_version = "None"
4646
self.suppress_warnings = False
47+
self.raw_body = None
4748

4849
self.output_handler = OutputHandler()
4950
self.config = CLIConfig(self.base_url, skip_config=skip_config)

tests/integration/cli/test_args.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import json
2+
3+
from linodecli.exit_codes import ExitCodes
4+
from tests.integration.helpers import (
5+
exec_failing_test_command,
6+
exec_test_command,
7+
get_random_region_with_caps,
8+
get_random_text,
9+
)
10+
11+
12+
def test_arg_raw_body():
13+
label = get_random_text(12)
14+
region = get_random_region_with_caps(["VPCs"])
15+
16+
res = json.loads(
17+
exec_test_command(
18+
[
19+
"linode-cli",
20+
"vpcs",
21+
"create",
22+
"--json",
23+
"--raw-body",
24+
json.dumps(
25+
{
26+
"label": label,
27+
"region": region,
28+
}
29+
),
30+
],
31+
)
32+
)
33+
34+
exec_test_command(["linode-cli", "vpcs", "delete", str(res[0]["id"])])
35+
36+
assert res[0]["id"] > 0
37+
assert res[0]["label"] == label
38+
assert res[0]["region"] == region
39+
40+
41+
def test_arg_raw_body_conflict():
42+
label = get_random_text(12)
43+
region = get_random_region_with_caps(["VPCs"])
44+
45+
res = exec_failing_test_command(
46+
[
47+
"linode-cli",
48+
"vpcs",
49+
"create",
50+
"--json",
51+
"--label",
52+
label,
53+
"--region",
54+
region,
55+
"--raw-body",
56+
json.dumps(
57+
{
58+
"label": label,
59+
"region": region,
60+
}
61+
),
62+
],
63+
expected_code=ExitCodes.ARGUMENT_ERROR,
64+
)
65+
66+
assert (
67+
"--raw-body cannot be specified with action arguments: --label, --region"
68+
in res
69+
)
70+
71+
72+
def test_arg_raw_body_get():
73+
res = exec_failing_test_command(
74+
[
75+
"linode-cli",
76+
"vpcs",
77+
"list",
78+
"--json",
79+
"--raw-body",
80+
json.dumps({"label": "test"}),
81+
],
82+
expected_code=ExitCodes.ARGUMENT_ERROR,
83+
)
84+
85+
assert "--raw-body cannot be specified for actions with method get" in res

tests/unit/test_api_request.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
import pytest
1212
import requests
13+
from _pytest.capture import CaptureFixture
1314

14-
from linodecli import api_request
15+
from linodecli import ExitCodes, api_request
1516
from linodecli.baked.operation import (
1617
ExplicitEmptyDictValue,
1718
ExplicitEmptyListValue,
@@ -163,6 +164,71 @@ def test_build_request_body_non_null_field(
163164
== result
164165
)
165166

167+
def test_build_request_body_raw(self, mock_cli, create_operation):
168+
body = {"foo": "bar"}
169+
170+
mock_cli.raw_body = json.dumps(body)
171+
172+
result = api_request._build_request_body(
173+
mock_cli,
174+
create_operation,
175+
SimpleNamespace(),
176+
)
177+
assert json.loads(result) == body
178+
179+
def test_build_request_body_raw_with_defaults(
180+
self, mock_cli, create_operation
181+
):
182+
body = {"foo": "bar"}
183+
mock_cli.raw_body = json.dumps(body)
184+
185+
mock_cli.defaults = True
186+
mock_cli.config.get = lambda user, key, **kwargs: {"foo": "baz"}
187+
create_operation.allowed_defaults = ["foo"]
188+
189+
result = api_request._build_request_body(
190+
mock_cli,
191+
create_operation,
192+
SimpleNamespace(),
193+
)
194+
assert json.loads(result) == body
195+
196+
def test_build_request_body_raw_conflict(
197+
self, mock_cli, create_operation, capsys: CaptureFixture
198+
):
199+
mock_cli.raw_body = json.dumps({"foo": "bar"})
200+
201+
with pytest.raises(SystemExit) as err:
202+
api_request._build_request_body(
203+
mock_cli,
204+
create_operation,
205+
SimpleNamespace(foo="bar", bar="foo"),
206+
)
207+
208+
assert err.value.code == ExitCodes.ARGUMENT_ERROR
209+
assert (
210+
"--raw-body cannot be specified with action arguments: --bar, --foo"
211+
in capsys.readouterr().err
212+
)
213+
214+
def test_build_request_body_raw_get(
215+
self, mock_cli, list_operation, capsys: CaptureFixture
216+
):
217+
mock_cli.raw_body = json.dumps({"foo": "bar"})
218+
219+
with pytest.raises(SystemExit) as err:
220+
api_request._build_request_body(
221+
mock_cli,
222+
list_operation,
223+
SimpleNamespace(),
224+
)
225+
226+
assert err.value.code == ExitCodes.ARGUMENT_ERROR
227+
assert (
228+
"--raw-body cannot be specified for actions with method get"
229+
in capsys.readouterr().err
230+
)
231+
166232
def test_build_request_url_get(self, mock_cli, list_operation):
167233
result = api_request._build_request_url(
168234
mock_cli, list_operation, SimpleNamespace()

0 commit comments

Comments
 (0)