Source code for steplib.modules.api.transforms
"""Transformers for the API module: HttpMethod, Url, JsonPath."""
from __future__ import annotations
import json
from typing import Any
VALID_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
[docs]
class HttpMethod:
"""Validates and normalizes HTTP method strings."""
def __init__(self, method: str) -> None:
"""Initialize and validate the HTTP method."""
self.value = method.upper().strip()
if self.value not in VALID_METHODS:
raise ValueError(
f"Invalid HTTP method '{method}'. Valid methods: {sorted(VALID_METHODS)}"
)
def __str__(self) -> str:
"""Return the method string."""
return self.value
def __eq__(self, other: object) -> bool:
"""Compare with another HttpMethod or string."""
if isinstance(other, HttpMethod):
return self.value == other.value
if isinstance(other, str):
return self.value == other.upper()
return False
def __hash__(self) -> int:
"""Return hash of the method value."""
return hash(self.value)
[docs]
class Url:
"""Represents a URL, resolving relative paths against a base URL.
Args:
url: The URL string (absolute or relative).
base_url: Optional base URL for resolving relative URLs.
"""
def __init__(self, url: str, base_url: str = "") -> None:
"""Initialize and resolve the URL against the base URL."""
self.raw = url
self.base_url = base_url.rstrip("/")
self.value = self._resolve(url, base_url)
@staticmethod
def _resolve(url: str, base_url: str) -> str:
"""Resolve *url* against *base_url* if *url* is relative."""
if url.startswith(("http://", "https://")):
return url
if not base_url:
return url
base = base_url.rstrip("/")
path = url if url.startswith("/") else f"/{url}"
return f"{base}{path}"
def __str__(self) -> str:
"""Return the resolved URL string."""
return self.value
def __eq__(self, other: object) -> bool:
"""Compare with another Url or string."""
if isinstance(other, Url):
return self.value == other.value
if isinstance(other, str):
return self.value == other
return False
def __hash__(self) -> int:
"""Return hash of the URL value."""
return hash(self.value)
[docs]
class JsonPath:
"""Simple JSONPath evaluator supporting ``$.path.to.value`` syntax.
Args:
path: A JSONPath expression starting with ``$`` (e.g. ``"$.users[0].name"``).
"""
def __init__(self, path: str) -> None:
"""Initialize and validate the JSONPath expression."""
if not path.startswith("$"):
raise ValueError(f"JsonPath must start with '$', got: '{path}'")
self.path = path
[docs]
def evaluate(self, data: Any) -> Any:
"""Evaluate the path against *data* and return the matched value.
Args:
data: The JSON data to traverse (typically a dict or list).
Returns:
The value at the matched path.
Raises:
KeyError: If the path does not exist in *data*.
"""
if self.path == "$":
return data
# Strip leading "$." or "$"
expr = self.path[1:]
if expr.startswith("."):
expr = expr[1:]
current: Any = data
# Split on "." but handle array indexing [n]
parts = self._tokenize(expr)
for part in parts:
current = self._access(current, part)
return current
@staticmethod
def _tokenize(expr: str) -> list[str]:
"""Tokenize a path expression into keys and indices."""
tokens: list[str] = []
current = ""
i = 0
while i < len(expr):
ch = expr[i]
if ch == ".":
if current:
tokens.append(current)
current = ""
elif ch == "[":
if current:
tokens.append(current)
current = ""
j = expr.find("]", i)
if j == -1:
raise ValueError(f"JsonPath has unclosed '[' in expression: '{expr}'")
idx_str = expr[i + 1 : j]
tokens.append(f"[{idx_str}]")
i = j
else:
current += ch
i += 1
if current:
tokens.append(current)
return tokens
@staticmethod
def _access(current: Any, token: str) -> Any:
"""Access a single key or index from *current*."""
if token.startswith("[") and token.endswith("]"):
idx_str = token[1:-1]
if not idx_str:
raise ValueError(f"JsonPath has empty index '[]' in token: '{token}'")
try:
idx = int(idx_str)
except ValueError as exc:
raise ValueError(
f"JsonPath has non-numeric index '{idx_str}' in token: '{token}'"
) from exc
if not isinstance(current, (list, tuple)):
raise KeyError(f"Cannot index non-list value with '{token}'.")
if idx < 0 or idx >= len(current):
raise KeyError(f"Index {idx} out of range for list of length {len(current)}.")
return current[idx]
if not isinstance(current, dict):
raise KeyError(f"Cannot access key '{token}' on non-dict value.")
if token not in current:
raise KeyError(f"Key '{token}' not found.")
return current[token]
def __str__(self) -> str:
"""Return the path string."""
return self.path
[docs]
def parse_json(text: str) -> Any:
"""Parse a JSON string, raising ValueError on invalid input.
Args:
text: A JSON string.
Returns:
The parsed JSON data.
Raises:
json.JSONDecodeError: If the text is not valid JSON.
"""
return json.loads(text)