diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 06a10ec05..f08d2424d 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -80,6 +80,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements cli.page = parsed.page cli.page_size = parsed.page_size cli.debug_request = parsed.debug + cli.raw_body = parsed.raw_body if parsed.as_user and not skip_config: cli.config.set_user(parsed.as_user) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index 1d66207ad..aa04a924b 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -348,21 +348,49 @@ def _build_request_body( :return: A JSON string representing the request body, or None if not applicable. """ - if operation.method == "get": - # Get operations don't have a body + if operation.method in ("get", "delete"): + # GET and DELETE operations don't have a body + if ctx.raw_body is not None: + print( + f"--raw-body cannot be specified for actions with method {operation.method}", + file=sys.stderr, + ) + sys.exit(ExitCodes.ARGUMENT_ERROR) + return None + param_names = {param.name for param in operation.params} + + # Returns whether the given argument should be included in the request body + def __should_include(key: str, value: Any) -> bool: + return value is not None and key not in param_names + + # If the user has specified the --raw-body argument, + # return it. + if ctx.raw_body is not None: + specified_keys = [ + k for k, v in vars(parsed_args).items() if __should_include(k, v) + ] + + if len(specified_keys) > 0: + print( + "--raw-body cannot be specified with action arguments: " + + ", ".join(sorted(f"--{key}" for key in specified_keys)), + file=sys.stderr, + ) + sys.exit(ExitCodes.ARGUMENT_ERROR) + + return ctx.raw_body + # Merge defaults into body if applicable if ctx.defaults: parsed_args = ctx.config.update(parsed_args, operation.allowed_defaults) - param_names = {param.name for param in operation.params} - expanded_json = {} # Expand dotted keys into nested dictionaries for k, v in vars(parsed_args).items(): - if v is None or k in param_names: + if not __should_include(k, v): continue path_segments = get_path_segments(k) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index ad8850001..2f5f1b70c 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -81,6 +81,14 @@ def register_args(parser: ArgumentParser) -> ArgumentParser: help="The alias to set or remove.", ) + parser.add_argument( + "--raw-body", + type=str, + help="The raw JSON to use as the request body of an action. " + + "This argument cannot be used if action-specific arguments are specified. " + + "Additionally, this argument can only be used with POST and PUT actions.", + ) + # Register shared argument groups register_output_args_shared(parser) register_pagination_args_shared(parser) diff --git a/linodecli/cli.py b/linodecli/cli.py index 07babb929..5fcd48e8f 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -44,6 +44,7 @@ def __init__(self, version, base_url, skip_config=False): self.base_url = base_url self.spec_version = "None" self.suppress_warnings = False + self.raw_body = None self.output_handler = OutputHandler() self.config = CLIConfig(self.base_url, skip_config=skip_config) diff --git a/tests/integration/cli/test_args.py b/tests/integration/cli/test_args.py new file mode 100644 index 000000000..6d4ecc969 --- /dev/null +++ b/tests/integration/cli/test_args.py @@ -0,0 +1,85 @@ +import json + +from linodecli.exit_codes import ExitCodes +from tests.integration.helpers import ( + exec_failing_test_command, + exec_test_command, + get_random_region_with_caps, + get_random_text, +) + + +def test_arg_raw_body(): + label = get_random_text(12) + region = get_random_region_with_caps(["VPCs"]) + + res = json.loads( + exec_test_command( + [ + "linode-cli", + "vpcs", + "create", + "--json", + "--raw-body", + json.dumps( + { + "label": label, + "region": region, + } + ), + ], + ) + ) + + exec_test_command(["linode-cli", "vpcs", "delete", str(res[0]["id"])]) + + assert res[0]["id"] > 0 + assert res[0]["label"] == label + assert res[0]["region"] == region + + +def test_arg_raw_body_conflict(): + label = get_random_text(12) + region = get_random_region_with_caps(["VPCs"]) + + res = exec_failing_test_command( + [ + "linode-cli", + "vpcs", + "create", + "--json", + "--label", + label, + "--region", + region, + "--raw-body", + json.dumps( + { + "label": label, + "region": region, + } + ), + ], + expected_code=ExitCodes.ARGUMENT_ERROR, + ) + + assert ( + "--raw-body cannot be specified with action arguments: --label, --region" + in res + ) + + +def test_arg_raw_body_get(): + res = exec_failing_test_command( + [ + "linode-cli", + "vpcs", + "list", + "--json", + "--raw-body", + json.dumps({"label": "test"}), + ], + expected_code=ExitCodes.ARGUMENT_ERROR, + ) + + assert "--raw-body cannot be specified for actions with method get" in res diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index bd6b13e2b..253f0385b 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -10,8 +10,9 @@ import pytest import requests +from _pytest.capture import CaptureFixture -from linodecli import api_request +from linodecli import ExitCodes, api_request from linodecli.baked.operation import ( ExplicitEmptyDictValue, ExplicitEmptyListValue, @@ -163,6 +164,71 @@ def test_build_request_body_non_null_field( == result ) + def test_build_request_body_raw(self, mock_cli, create_operation): + body = {"foo": "bar"} + + mock_cli.raw_body = json.dumps(body) + + result = api_request._build_request_body( + mock_cli, + create_operation, + SimpleNamespace(), + ) + assert json.loads(result) == body + + def test_build_request_body_raw_with_defaults( + self, mock_cli, create_operation + ): + body = {"foo": "bar"} + mock_cli.raw_body = json.dumps(body) + + mock_cli.defaults = True + mock_cli.config.get = lambda user, key, **kwargs: {"foo": "baz"} + create_operation.allowed_defaults = ["foo"] + + result = api_request._build_request_body( + mock_cli, + create_operation, + SimpleNamespace(), + ) + assert json.loads(result) == body + + def test_build_request_body_raw_conflict( + self, mock_cli, create_operation, capsys: CaptureFixture + ): + mock_cli.raw_body = json.dumps({"foo": "bar"}) + + with pytest.raises(SystemExit) as err: + api_request._build_request_body( + mock_cli, + create_operation, + SimpleNamespace(foo="bar", bar="foo"), + ) + + assert err.value.code == ExitCodes.ARGUMENT_ERROR + assert ( + "--raw-body cannot be specified with action arguments: --bar, --foo" + in capsys.readouterr().err + ) + + def test_build_request_body_raw_get( + self, mock_cli, list_operation, capsys: CaptureFixture + ): + mock_cli.raw_body = json.dumps({"foo": "bar"}) + + with pytest.raises(SystemExit) as err: + api_request._build_request_body( + mock_cli, + list_operation, + SimpleNamespace(), + ) + + assert err.value.code == ExitCodes.ARGUMENT_ERROR + assert ( + "--raw-body cannot be specified for actions with method get" + in capsys.readouterr().err + ) + def test_build_request_url_get(self, mock_cli, list_operation): result = api_request._build_request_url( mock_cli, list_operation, SimpleNamespace()