|
3 | 3 | import json |
4 | 4 | import os |
5 | 5 | import pathlib |
| 6 | +import re |
6 | 7 | import sys |
7 | 8 | import textwrap |
8 | 9 | from typing import Any, Dict, Optional, List, Type |
@@ -226,15 +227,17 @@ def __call__( # type: ignore |
226 | 227 |
|
227 | 228 | def get_endpoint_params_action(self): # type: ignore |
228 | 229 | safeguards = self.safeguards |
| 230 | + ferrycli = self |
229 | 231 | ferrycli_get_endpoint_params = self.get_endpoint_params |
230 | 232 |
|
231 | 233 | class _GetEndpointParams(argparse.Action): |
232 | 234 | def __call__( # type: ignore |
233 | 235 | self: "_GetEndpointParams", parser, args, values, option_string=None |
234 | 236 | ) -> None: |
235 | 237 | # Prevent DCS from running this endpoint if necessary, and print proper steps to take instead. |
236 | | - safeguards.verify(values) |
237 | | - ferrycli_get_endpoint_params(values) |
| 238 | + ep = normalize_endpoint(ferrycli.endpoints, values) |
| 239 | + safeguards.verify(ep) |
| 240 | + ferrycli_get_endpoint_params(ep) |
238 | 241 | sys.exit(0) |
239 | 242 |
|
240 | 243 | return _GetEndpointParams |
@@ -357,9 +360,10 @@ def run( |
357 | 360 |
|
358 | 361 | if args.endpoint: |
359 | 362 | # Prevent DCS from running this endpoint if necessary, and print proper steps to take instead. |
360 | | - self.safeguards.verify(args.endpoint) |
| 363 | + ep = normalize_endpoint(self.endpoints, args.endpoint) |
| 364 | + self.safeguards.verify(ep) |
361 | 365 | try: |
362 | | - json_result = self.execute_endpoint(args.endpoint, endpoint_args) |
| 366 | + json_result = self.execute_endpoint(ep, endpoint_args) |
363 | 367 | except Exception as e: |
364 | 368 | raise Exception(f"{e}") |
365 | 369 | if not dryrun: |
@@ -437,29 +441,45 @@ def error_raised( |
437 | 441 | error_raised(Exception, f"Error: {e}") |
438 | 442 |
|
439 | 443 | @staticmethod |
440 | | - def _sanitize_base_url(raw_base_url: str) -> str: |
441 | | - """This function makes sure we have a trailing forward-slash on the base_url before it's passed |
442 | | - to any other functions |
| 444 | + def _sanitize_path(raw_path: str) -> str: |
| 445 | + """ |
| 446 | + Normalizes a URL path: |
| 447 | + - Collapses multiple internal slashes |
| 448 | + - Ensures exactly one leading slash |
| 449 | + - Ensures exactly one trailing slash |
| 450 | + """ |
| 451 | + cleaned = re.sub(r"/+", "/", raw_path.strip()) |
| 452 | + return "/" + cleaned.strip("/") + "/" if cleaned else "/" |
443 | 453 |
|
444 | | - That is, "http://hostname.domain:port" --> "http://hostname.domain:port/" but |
445 | | - "http://hostname.domain:port/" --> "http://hostname.domain:port/" and |
446 | | - "http://hostname.domain:port/path?querykey1=value1&querykey2=value2" --> "http://hostname.domain:port/path?querykey1=value1&querykey2=value2" and |
| 454 | + @staticmethod |
| 455 | + def _sanitize_base_url(raw_base_url: str) -> str: |
| 456 | + """ |
| 457 | + Ensures the base URL has a trailing slash **only if**: |
| 458 | + - It does not already have one |
| 459 | + - It does not include query or fragment parts |
447 | 460 |
|
448 | | - 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 |
| 461 | + Leaves URLs with query or fragment untouched. |
449 | 462 | """ |
450 | | - _parts = urlsplit(raw_base_url) |
451 | | - parts = ( |
452 | | - SplitResult( |
453 | | - scheme=_parts.scheme, |
454 | | - netloc=_parts.netloc, |
455 | | - path="/", |
456 | | - query=_parts.query, |
457 | | - fragment=_parts.fragment, |
458 | | - ) |
459 | | - if (_parts.path == "" and _parts.query == "" and _parts.fragment == "") |
460 | | - else _parts |
| 463 | + parts = urlsplit(raw_base_url) |
| 464 | + |
| 465 | + # If query or fragment is present, return as-is |
| 466 | + if parts.query or parts.fragment: |
| 467 | + return raw_base_url |
| 468 | + |
| 469 | + # Normalize the path (ensure trailing slash) |
| 470 | + path = parts.path or "/" |
| 471 | + if not path.endswith("/"): |
| 472 | + path += "/" |
| 473 | + |
| 474 | + # Collapse multiple slashes in path |
| 475 | + path = re.sub(r"/+", "/", path) |
| 476 | + |
| 477 | + # Rebuild the URL with sanitized path |
| 478 | + sanitized_parts = SplitResult( |
| 479 | + scheme=parts.scheme, netloc=parts.netloc, path=path, query="", fragment="" |
461 | 480 | ) |
462 | | - return urlunsplit(parts) |
| 481 | + |
| 482 | + return urlunsplit(sanitized_parts) |
463 | 483 |
|
464 | 484 | def __parse_config_file(self: "FerryCLI") -> configparser.ConfigParser: |
465 | 485 | configs = configparser.ConfigParser() |
@@ -585,6 +605,19 @@ def handle_no_args(_config_path: Optional[pathlib.Path]) -> bool: |
585 | 605 | sys.exit(0) |
586 | 606 |
|
587 | 607 |
|
| 608 | +def normalize_endpoint(endpoints: Dict[str, Any], raw: str) -> str: |
| 609 | + # Extract and preserve a single leading underscore, if any |
| 610 | + leading_underscore = "_" if raw.startswith("_") else "" |
| 611 | + # Remove all leading underscores before processing |
| 612 | + stripped = raw.lstrip("_") |
| 613 | + # Convert to lowerCamelCase from snake_case or kebab-case |
| 614 | + parts = re.split(r"[_-]+", stripped) |
| 615 | + camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:]) |
| 616 | + normalized = leading_underscore + camel |
| 617 | + # Match endpoint case-insensitively and replace original argument if found |
| 618 | + return next((ep for ep in endpoints if ep.lower() == normalized.lower()), raw) |
| 619 | + |
| 620 | + |
588 | 621 | # pylint: disable=too-many-branches |
589 | 622 | def main() -> None: |
590 | 623 | _config_path = config.get_configfile_path() |
|
0 commit comments