""" 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)