diff --git a/.gitignore b/.gitignore index 7ccf80b..793ad5d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ config/swagger.json /tmp/* .make_creds.sh remove +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b78cc84..4777cf3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v5.0.0 hooks: - id: check-toml - id: end-of-file-fixer diff --git a/README.md b/README.md index 173778a..abc1c06 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Response: { } ``` > Note: All responses are currently stored locally in results.json if the -q flag is not passed, for longer responses - stdout will point to the file, rather than print them in the terminal. +> Endpoints & Workflows that are passed as arguments will be converted to camelCase automatically, however - leading underscores will be preserved, so be sure to use the correct spelling. ## Usage - Custom Workflows Existing workflows are defined in helpers.supported_workflows.* diff --git a/ferry_cli/__main__.py b/ferry_cli/__main__.py index 142fd4a..a400fff 100755 --- a/ferry_cli/__main__.py +++ b/ferry_cli/__main__.py @@ -3,6 +3,7 @@ import json import os import pathlib +import re import sys import textwrap from typing import Any, Dict, Optional, List, Type @@ -226,6 +227,7 @@ def __call__( # type: ignore def get_endpoint_params_action(self): # type: ignore safeguards = self.safeguards + ferrycli = self ferrycli_get_endpoint_params = self.get_endpoint_params class _GetEndpointParams(argparse.Action): @@ -233,8 +235,9 @@ def __call__( # type: ignore self: "_GetEndpointParams", parser, args, values, option_string=None ) -> None: # Prevent DCS from running this endpoint if necessary, and print proper steps to take instead. - safeguards.verify(values) - ferrycli_get_endpoint_params(values) + ep = normalize_endpoint(ferrycli.endpoints, values) + safeguards.verify(ep) + ferrycli_get_endpoint_params(ep) sys.exit(0) return _GetEndpointParams @@ -357,9 +360,10 @@ def run( if args.endpoint: # Prevent DCS from running this endpoint if necessary, and print proper steps to take instead. - self.safeguards.verify(args.endpoint) + ep = normalize_endpoint(self.endpoints, args.endpoint) + self.safeguards.verify(ep) try: - json_result = self.execute_endpoint(args.endpoint, endpoint_args) + json_result = self.execute_endpoint(ep, endpoint_args) except Exception as e: raise Exception(f"{e}") if not dryrun: @@ -437,29 +441,45 @@ def error_raised( error_raised(Exception, f"Error: {e}") @staticmethod - def _sanitize_base_url(raw_base_url: str) -> str: - """This function makes sure we have a trailing forward-slash on the base_url before it's passed - to any other functions + def _sanitize_path(raw_path: str) -> str: + """ + Normalizes a URL path: + - Collapses multiple internal slashes + - Ensures exactly one leading slash + - Ensures exactly one trailing slash + """ + cleaned = re.sub(r"/+", "/", raw_path.strip()) + return "/" + cleaned.strip("/") + "/" if cleaned else "/" - That is, "http://hostname.domain:port" --> "http://hostname.domain:port/" but - "http://hostname.domain:port/" --> "http://hostname.domain:port/" and - "http://hostname.domain:port/path?querykey1=value1&querykey2=value2" --> "http://hostname.domain:port/path?querykey1=value1&querykey2=value2" and + @staticmethod + def _sanitize_base_url(raw_base_url: str) -> str: + """ + Ensures the base URL has a trailing slash **only if**: + - It does not already have one + - It does not include query or fragment parts - So if there is a non-empty path, parameters, query, or fragment to our URL as defined by RFC 1808, we leave the URL alone + Leaves URLs with query or fragment untouched. """ - _parts = urlsplit(raw_base_url) - parts = ( - SplitResult( - scheme=_parts.scheme, - netloc=_parts.netloc, - path="/", - query=_parts.query, - fragment=_parts.fragment, - ) - if (_parts.path == "" and _parts.query == "" and _parts.fragment == "") - else _parts + parts = urlsplit(raw_base_url) + + # If query or fragment is present, return as-is + if parts.query or parts.fragment: + return raw_base_url + + # Normalize the path (ensure trailing slash) + path = parts.path or "/" + if not path.endswith("/"): + path += "/" + + # Collapse multiple slashes in path + path = re.sub(r"/+", "/", path) + + # Rebuild the URL with sanitized path + sanitized_parts = SplitResult( + scheme=parts.scheme, netloc=parts.netloc, path=path, query="", fragment="" ) - return urlunsplit(parts) + + return urlunsplit(sanitized_parts) def __parse_config_file(self: "FerryCLI") -> configparser.ConfigParser: configs = configparser.ConfigParser() @@ -585,6 +605,19 @@ def handle_no_args(_config_path: Optional[pathlib.Path]) -> bool: sys.exit(0) +def normalize_endpoint(endpoints: Dict[str, Any], raw: str) -> str: + # Extract and preserve a single leading underscore, if any + leading_underscore = "_" if raw.startswith("_") else "" + # Remove all leading underscores before processing + stripped = raw.lstrip("_") + # Convert to lowerCamelCase from snake_case or kebab-case + parts = re.split(r"[_-]+", stripped) + camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:]) + normalized = leading_underscore + camel + # Match endpoint case-insensitively and replace original argument if found + return next((ep for ep in endpoints if ep.lower() == normalized.lower()), raw) + + # pylint: disable=too-many-branches def main() -> None: _config_path = config.get_configfile_path() diff --git a/ferry_cli/helpers/api.py b/ferry_cli/helpers/api.py index 34e1885..7888256 100755 --- a/ferry_cli/helpers/api.py +++ b/ferry_cli/helpers/api.py @@ -81,6 +81,10 @@ def call_endpoint( raise ValueError("Unsupported HTTP method.") if debug: print(f"Called Endpoint: {response.request.url}") + if not response.ok: + raise RuntimeError( + f" *** API Failure: Status code {response.status_code} returned from endpoint /{endpoint}" + ) output = response.json() output["request_url"] = response.request.url diff --git a/tests/test_main.py b/tests/test_main.py index 52e7651..d345fac 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,7 @@ handle_show_configfile, get_config_info_from_user, help_called, + normalize_endpoint, ) import ferry_cli.__main__ as _main import ferry_cli.config.config as _config @@ -308,6 +309,31 @@ def test_handle_no_args_configfile_does_not_exist( assert pytest_wrapped_e.value.code == 0 +@pytest.mark.unit +def test_snakecase_and_underscore_conversion(): + test_endpoints = {"getUserInfo": object()} + + # test to make sure function does matching irrespective of capitalization + assert normalize_endpoint(test_endpoints, "Get_USeriNFo") == "getUserInfo" + + # test to make sure function never stops working for correct syntax + assert normalize_endpoint(test_endpoints, "getUserInfo") == "getUserInfo" + + # test that non-endpoint values are left untouched when no match is found + assert ( + normalize_endpoint(test_endpoints, "SomeOtherEndpoint") == "SomeOtherEndpoint" + ) + + +@pytest.mark.unit +def test_leading_underscore_preserved(): + test_endpoints = {"_internalEndpoint": object()} + + assert ( + normalize_endpoint(test_endpoints, "_Internal_endpoint") == "_internalEndpoint" + ) + + @pytest.mark.parametrize( "base_url, expected_base_url", [