""" Flare – API Executor (v2.0 Β· session-aware token management) """ from __future__ import annotations import json, re, time, requests from typing import Any, Dict, Optional, Union import os from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer from config.config_provider import ConfigProvider, APIConfig from chat_session.session import Session MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30")) _placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}") def _get_variable_value(session: Session, var_path: str) -> Any: cfg = ConfigProvider.get() """Get variable value with proper type from session""" if var_path.startswith("variables."): var_name = var_path.split(".", 1)[1] return session.variables.get(var_name) elif var_path.startswith("auth_tokens."): parts = var_path.split(".") if len(parts) >= 3: token_api = parts[1] token_field = parts[2] token_data = session._auth_tokens.get(token_api, {}) return token_data.get(token_field) elif var_path.startswith("config."): attr_name = var_path.split(".", 1)[1] return getattr(cfg.global_config, attr_name, None) return None def _render_value(value: Any) -> Union[str, int, float, bool, None]: """Convert value to appropriate JSON type""" if value is None: return None elif isinstance(value, bool): return value elif isinstance(value, (int, float)): return value elif isinstance(value, str): # Check if it's a number string if value.isdigit(): return int(value) try: return float(value) except ValueError: pass # Check if it's a boolean string if value.lower() in ('true', 'false'): return value.lower() == 'true' # Return as string return value else: return str(value) def _render_json(obj: Any, session: Session, api_name: str) -> Any: """Render JSON preserving types""" if isinstance(obj, str): # Check if entire string is a template template_match = _placeholder.fullmatch(obj.strip()) if template_match: # This is a pure template like {{variables.pnr}} var_path = template_match.group(1).strip() value = _get_variable_value(session, var_path) return _render_value(value) else: # String with embedded templates or regular string def replacer(match): var_path = match.group(1).strip() value = _get_variable_value(session, var_path) return str(value) if value is not None else "" return _placeholder.sub(replacer, obj) elif isinstance(obj, dict): return {k: _render_json(v, session, api_name) for k, v in obj.items()} elif isinstance(obj, list): return [_render_json(v, session, api_name) for v in obj] else: # Return as-is for numbers, booleans, None return obj def _render(obj: Any, session: Session, api_name: str) -> Any: """Render template with session variables and tokens""" # For headers and other string-only contexts if isinstance(obj, str): def replacer(match): var_path = match.group(1).strip() value = _get_variable_value(session, var_path) return str(value) if value is not None else "" return _placeholder.sub(replacer, obj) elif isinstance(obj, dict): return {k: _render(v, session, api_name) for k, v in obj.items()} elif isinstance(obj, list): return [_render(v, session, api_name) for v in obj] return obj def _fetch_token(api: APIConfig, session: Session) -> None: """Fetch new auth token""" if not api.auth or not api.auth.enabled: return log_info(f"πŸ”‘ Fetching token for {api.name}") try: # Use _render_json for body to preserve types body = _render_json(api.auth.token_request_body, session, api.name) headers = {"Content-Type": "application/json"} response = requests.post( str(api.auth.token_endpoint), json=body, headers=headers, timeout=api.timeout_seconds ) response.raise_for_status() json_data = response.json() # Extract token using path token = json_data for path_part in api.auth.response_token_path.split("."): token = token.get(path_part) if token is None: raise ValueError(f"Token path {api.auth.response_token_path} not found in response") # Store in session session._auth_tokens[api.name] = { "token": token, "expiry": time.time() + 3500, # ~1 hour "refresh_token": json_data.get("refresh_token") } log_info(f"βœ… Token obtained for {api.name}") except Exception as e: log_error(f"❌ Token fetch failed for {api.name}", e) raise def _refresh_token(api: APIConfig, session: Session) -> bool: """Refresh existing token""" if not api.auth or not api.auth.token_refresh_endpoint: return False token_info = session._auth_tokens.get(api.name, {}) if not token_info.get("refresh_token"): return False log_info(f"πŸ”„ Refreshing token for {api.name}") try: body = _render_json(api.auth.token_refresh_body or {}, session, api.name) body["refresh_token"] = token_info["refresh_token"] response = requests.post( str(api.auth.token_refresh_endpoint), json=body, timeout=api.timeout_seconds ) response.raise_for_status() json_data = response.json() # Extract new token token = json_data for path_part in api.auth.response_token_path.split("."): token = token.get(path_part) if token is None: raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response") # Update session session._auth_tokens[api.name] = { "token": token, "expiry": time.time() + 3500, "refresh_token": json_data.get("refresh_token", token_info["refresh_token"]) } log_info(f"βœ… Token refreshed for {api.name}") return True except Exception as e: log_error(f"❌ Token refresh failed for {api.name}", e) return False def _ensure_token(api: APIConfig, session: Session) -> None: """Ensure valid token exists for API""" if not api.auth or not api.auth.enabled: return token_info = session._auth_tokens.get(api.name) # No token yet if not token_info: _fetch_token(api, session) return # Token still valid if token_info.get("expiry", 0) > time.time(): return # Try refresh first if _refresh_token(api, session): return # Refresh failed, get new token _fetch_token(api, session) def call_api(api: APIConfig, session: Session) -> requests.Response: """Execute API call with automatic token management and better error handling""" # Ensure valid token _ensure_token(api, session) # Prepare request headers = _render(api.headers, session, api.name) body = _render_json(api.body_template, session, api.name) # Get timeout with fallback timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT # Handle proxy proxies = None if api.proxy: if isinstance(api.proxy, str): proxies = {"http": api.proxy, "https": api.proxy} elif hasattr(api.proxy, "enabled") and api.proxy.enabled: proxy_url = str(api.proxy.url) proxies = {"http": proxy_url, "https": proxy_url} # Prepare request parameters request_params = { "method": api.method, "url": str(api.url), "headers": headers, "timeout": timeout, # Use configured timeout "stream": True # Enable streaming for large responses } # Add body based on method if api.method in ("POST", "PUT", "PATCH"): request_params["json"] = body elif api.method == "GET" and body: request_params["params"] = body if proxies: request_params["proxies"] = proxies # Execute with retry retry_count = api.retry.retry_count if api.retry else 0 last_error = None response = None for attempt in range(retry_count + 1): try: # Use LogTimer for performance tracking with LogTimer(f"API call {api.name}", attempt=attempt + 1): log_info( f"🌐 API call starting", api=api.name, method=api.method, url=api.url, attempt=f"{attempt + 1}/{retry_count + 1}", timeout=timeout ) if body: log_debug(f"πŸ“‹ Request body", body=json.dumps(body, ensure_ascii=False)[:500]) # Make request with streaming response = requests.request(**request_params) # Check response size from headers content_length = response.headers.get('content-length') if content_length and int(content_length) > MAX_RESPONSE_SIZE: response.close() raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})") # Handle 401 Unauthorized if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count: log_warning(f"πŸ”’ Got 401, refreshing token", api=api.name) _fetch_token(api, session) headers = _render(api.headers, session, api.name) request_params["headers"] = headers response.close() continue # Read response with size limit content_size = 0 chunks = [] for chunk in response.iter_content(chunk_size=8192): chunks.append(chunk) content_size += len(chunk) if content_size > MAX_RESPONSE_SIZE: response.close() raise ValueError(f"Response exceeded size limit: {content_size} bytes") # Reconstruct response content response._content = b''.join(chunks) response._content_consumed = True # Check status response.raise_for_status() log_info( f"βœ… API call successful", api=api.name, status_code=response.status_code, response_size=content_size, duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}" ) # Mevcut response mapping işlemi korunacak if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings: try: if response.status_code != 204 and response.content: response_json = response.json() for mapping in api.response_mappings: var_name = mapping.get('variable_name') var_type = mapping.get('type', 'str') json_path = mapping.get('json_path') if not all([var_name, json_path]): continue # JSON path'ten değeri al value = response_json for path_part in json_path.split('.'): if isinstance(value, dict): value = value.get(path_part) if value is None: break if value is not None: # Type conversion if var_type == 'int': value = int(value) elif var_type == 'float': value = float(value) elif var_type == 'bool': value = bool(value) elif var_type == 'date': value = str(value) else: # str value = str(value) # Session'a kaydet session.variables[var_name] = value log_info(f"πŸ“ Mapped response", variable=var_name, value=value) except Exception as e: log_error("⚠️ Response mapping error", error=str(e)) return response except requests.exceptions.Timeout as e: last_error = e log_warning( f"⏱️ API timeout", api=api.name, attempt=attempt + 1, timeout=timeout ) except requests.exceptions.RequestException as e: last_error = e log_error( f"❌ API request error", api=api.name, error=str(e), attempt=attempt + 1 ) except ValueError as e: # Size limit exceeded log_error( f"❌ Response size error", api=api.name, error=str(e) ) raise # Don't retry for size errors except Exception as e: last_error = e log_error( f"❌ Unexpected API error", api=api.name, error=str(e), attempt=attempt + 1 ) # Retry backoff if attempt < retry_count: backoff = api.retry.backoff_seconds if api.retry else 2 if api.retry and api.retry.strategy == "exponential": backoff = backoff * (2 ** attempt) log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2) time.sleep(backoff) # All retries failed error_msg = f"API call failed after {retry_count + 1} attempts" log_error(error_msg, api=api.name, last_error=str(last_error)) if last_error: raise last_error raise requests.exceptions.RequestException(error_msg) def format_size(size_bytes: int) -> str: """Format bytes to human readable format""" for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: return f"{size_bytes:.2f} {unit}" size_bytes /= 1024.0 return f"{size_bytes:.2f} TB"