"""
API Comparator Utility Module
This module provides core functionality for the API Comparator service including:
- API request handling and response fetching
- JSON validation and comparison
- User authentication utilities
- Response diff generation in various formats
"""
import asyncio
import difflib
import html
import json
import ssl
from dataclasses import dataclass
from datetime import datetime
from typing import Tuple, Dict
from typing import Union, Any
from urllib.parse import urlparse
import aiohttp
import structlog
from aiohttp.client_exceptions import ClientSSLError
from deepdiff import DeepDiff
log = structlog.get_logger()
# User credentials (in real app, this would be in a secure database)
USER_CREDENTIALS = {
"user-1": "passWord-123", # For testing only
"user1": "user-1",
}
@dataclass
class ApiRequest:
"""Configuration class for API requests.
Attributes:
url: The API endpoint URL
method: HTTP method to use (GET, POST, etc)
payload: Request payload data
headers: Request headers
timeout: Request timeout in seconds
max_retries: Maximum number of retry attempts
"""
url: str
method: str
payload: Dict
headers: Dict
timeout: int = 30
max_retries: int = 3
verify_ssl: bool = True
async def fetch_api_response(
request: ApiRequest,
semaphore: asyncio.Semaphore
) -> Tuple[Dict, float]:
"""Fetch response from a single API with retries and optional SSL verification."""
start_time = datetime.now()
# Optional SSL context to ignore certificate verification
ssl_context = None
if not getattr(request, 'verify_ssl', True):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with semaphore:
for attempt in range(1, request.max_retries + 1):
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method=request.method,
url=request.url,
json=request.payload if request.method in ['POST', 'PUT', 'PATCH'] else None,
params=request.payload if request.method == 'GET' else None,
headers=request.headers,
timeout=request.timeout,
ssl=ssl_context # 🔐 This handles the certificate issue
) as response:
json_response = await response.json()
execution_time = (datetime.now() - start_time).total_seconds()
return {
'status': response.status,
'data': json_response
}, execution_time
except ClientSSLError as ssl_err:
log.error("SSL Certificate Verification Failed", error=str(ssl_err), url=request.url)
raise ssl_err
except Exception as e:
if attempt < request.max_retries:
log.warning("Async API call failed, retrying",
url=request.url,
method=request.method,
attempt=attempt,
error=str(e))
await asyncio.sleep(attempt)
else:
log.error("Async API call failed after all retries",
url=request.url,
method=request.method,
attempts=request.max_retries,
error=str(e))
raise
async def fetch_api_responses(
url1: str,
method1: str,
payload1: Dict,
headers1: Dict,
url2: str,
method2: str,
payload2: Dict,
headers2: Dict,
timeout: int = 30
) -> Tuple[Tuple[aiohttp.ClientResponse, float], Tuple[aiohttp.ClientResponse, float]]:
"""Fetch responses from both APIs concurrently"""
log.info("Starting API calls",
api1={"url": url1, "method": method1},
api2={"url": url2, "method": method2})
# Create request objects
request1 = ApiRequest(url1, method1, payload1, headers1, timeout)
request2 = ApiRequest(url2, method2, payload2, headers2, timeout)
# Use semaphore to limit concurrent connections
semaphore = asyncio.Semaphore(2)
try:
# Fetch both responses concurrently
responses = await asyncio.gather(
fetch_api_response(request1, semaphore),
fetch_api_response(request2, semaphore)
)
return responses[0], responses[1]
except Exception as e:
log.error("Error fetching API responses", error=str(e))
raise
def parse_json_input(input_str: str, label: str) -> Dict:
"""Parse JSON input, return empty dict for empty strings"""
if not input_str.strip():
return {}
try:
return json.loads(input_str)
except json.JSONDecodeError as e:
raise ValueError(f"{label} is not valid JSON: {str(e)}") from e
def validate_urls(*urls: str) -> None:
"""Validate that URLs are properly formatted and not identical"""
url_set = set()
for url in urls:
try:
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError(f"'{url}' is not a valid URL")
url_set.add(url)
except Exception as e:
raise ValueError(f"'{url}' is not a valid URL") from e
if len(url_set) < len(urls):
raise ValueError("API URLs cannot be identical")
def validate_json_inputs(*inputs: Tuple[str, str]) -> None:
"""Validate that all inputs are valid JSON"""
for input_str, name in inputs:
try:
if input_str.strip(): # Only try to parse non-empty strings
json.loads(input_str)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in {name}: {str(e)}") from e
def compare_responses(json1: Any, json2: Any, view: str = 'tree') -> Union[Dict, str]:
"""Compare two JSON responses and return the differences"""
try:
diff_result = DeepDiff(json1, json2, ignore_order=True)
if not diff_result:
return {}
if view == 'tree':
# Convert DeepDiff to a plain dict that's JSON serializable
diff_dict = {}
for change_type, changes in diff_result.items():
if isinstance(changes, dict):
diff_dict[change_type] = {}
for path, value in changes.items():
if hasattr(value, '_values'):
diff_dict[change_type][str(path)] = list(value._values)
else:
diff_dict[change_type][str(path)] = str(value)
else:
diff_dict[change_type] = str(changes)
return diff_dict
# Convert DeepDiff to readable text format
text_diff = []
for change_type, changes in diff_result.items():
text_diff.append(f"\n{change_type}:")
if isinstance(changes, dict):
for path, value in changes.items():
text_diff.append(f" {path}: {value}")
else:
text_diff.append(f" {changes}")
return "\n".join(text_diff)
except Exception as e:
return {"error": f"Error comparing responses: {str(e)}"}
def json_line_diff(json1: Any, json2: Any) -> str:
"""Generate a line-by-line HTML diff of two JSON objects."""
try:
# Convert JSON to formatted strings
str1 = json.dumps(json1, indent=4, sort_keys=True).splitlines()
str2 = json.dumps(json2, indent=4, sort_keys=True).splitlines()
# Generate diff and convert to HTML
diff_lines = []
for line in difflib.unified_diff(str1, str2, lineterm=''):
if line.startswith('+'):
diff_lines.append(f'{html.escape(line)}')
elif line.startswith('-'):
diff_lines.append(f'{html.escape(line)}')
else:
diff_lines.append(html.escape(line))
return '
'.join(diff_lines)
except Exception as e:
return f"Error generating line diff: {str(e)}"
def highlight_json_diff(json1: Any, json2: Any) -> Tuple[str, str]:
"""Highlight differences between two JSON objects and return HTML formatted strings."""
try:
# Convert both objects to formatted strings
str1 = json.dumps(json1, indent=4, sort_keys=True)
str2 = json.dumps(json2, indent=4, sort_keys=True)
# Split into lines
lines1 = str1.splitlines()
lines2 = str2.splitlines()
# Get differences
diff = difflib.SequenceMatcher(None, lines1, lines2)
# Format the differences
html1 = []
html2 = []
for tag, i1, i2, j1, j2 in diff.get_opcodes():
if tag == 'equal':
# Add unchanged lines
html1.extend(html.escape(line) for line in lines1[i1:i2])
html2.extend(html.escape(line) for line in lines2[j1:j2])
else:
# Add changed lines with highlighting
html1.extend(f'{html.escape(line)}'
for line in lines1[i1:i2])
html2.extend(f'{html.escape(line)}'
for line in lines2[j1:j2])
return '\n'.join(html1), '\n'.join(html2)
except Exception as e:
return str(e), str(e)