Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ config/swagger.json
/tmp/*
.make_creds.sh
remove
.vscode
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
79 changes: 56 additions & 23 deletions ferry_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
import pathlib
import re
import sys
import textwrap
from typing import Any, Dict, Optional, List, Type
Expand Down Expand Up @@ -226,15 +227,17 @@ 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):
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions ferry_cli/helpers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
[
Expand Down