"""Reusable authenticated HTTP client for iklim.co APIs.""" from __future__ import annotations import json import logging import re import time import uuid from dataclasses import dataclass from email.utils import parsedate_to_datetime from pathlib import Path from typing import Any, Callable import requests from .config import Settings from .errors import ApiError from .security import TokenStore, create_signed_headers, token_is_valid LOGGER = logging.getLogger(__name__) RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} SENSITIVE_KEY_PARTS = { "apikey", "authorization", "cookie", "password", "secret", "signature", "token", "username", } REDACTED = "" @dataclass(frozen=True) class ApiResponse: status_code: int data: Any def _error_message(response: requests.Response, data: Any) -> str: if isinstance(data, dict): message = data.get("message") or data.get("messages") or data.get("error") if isinstance(message, list): return "; ".join(str(item) for item in message) if message: return str(message) return response.reason or "Bilinmeyen API hatası" def _normalized_key(value: object) -> str: return "".join(character for character in str(value).lower() if character.isalnum()) def _redact(value: Any, parent_key: str = "") -> Any: if isinstance(value, dict): redact_all_values = _normalized_key(parent_key) == "requestheaders" result = {} for key, item in value.items(): normalized = _normalized_key(key) sensitive = redact_all_values or any( part in normalized for part in SENSITIVE_KEY_PARTS ) result[key] = REDACTED if sensitive else _redact(item, str(key)) return result if isinstance(value, list): return [_redact(item, parent_key) for item in value] return value def _redact_text(value: str) -> str: value = re.sub(r"(?i)Bearer\s+\S+", f"Bearer {REDACTED}", value) return re.sub( r"\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b", REDACTED, value, ) def _request_body_for_log(body: str) -> Any: if not body: return None try: return _redact(json.loads(body)) except json.JSONDecodeError: return _redact_text(body[:20_000]) def _response_body_for_log(response: requests.Response) -> Any: if not response.content: return None try: return _redact(response.json()) except requests.exceptions.JSONDecodeError: return _redact_text(response.text[:20_000]) class AuthenticationManager: LOGIN_PATH = "/v1/auth/login" REFRESH_PATH = "/v1/auth/refresh" def __init__(self, client: "IklimClient", token_store: TokenStore) -> None: self.client = client self.token_store = token_store def ensure_access_token(self) -> str: access_token = self.token_store.access_token if not access_token: LOGGER.info("Access token bulunamadı; otomatik login yapılıyor.") self.login() return self._required_saved_access_token() if token_is_valid(access_token, self.client.settings.token_expiry_skew_seconds): return access_token LOGGER.info("Access token süresi dolmuş veya dolmak üzere.") refresh_token = self.token_store.refresh_token if token_is_valid( refresh_token, self.client.settings.token_expiry_skew_seconds ): self.refresh() else: LOGGER.info("Refresh token geçerli değil; otomatik login yapılıyor.") self.login() return self._required_saved_access_token() def recover_after_unauthorized(self) -> None: refresh_token = self.token_store.refresh_token if token_is_valid( refresh_token, self.client.settings.token_expiry_skew_seconds ): LOGGER.info("HTTP 401 alındı; access token yenileniyor.") self.refresh() else: LOGGER.info("HTTP 401 alındı ve refresh token geçersiz; yeniden login yapılıyor.") self.login() def login(self) -> dict[str, Any]: username, password = self.client.settings.credentials() LOGGER.info("iklim.co API login işlemi başlatıldı.") response = self.client.request_json( "POST", self.LOGIN_PATH, json_body={"username": username, "password": password}, authenticated=False, ) self._save_auth_response(response.data) LOGGER.info("Login başarılı; tokenlar .env dosyasına kaydedildi.") return response.data def refresh(self) -> dict[str, Any]: refresh_token = self.token_store.refresh_token if not refresh_token: LOGGER.info("Refresh token bulunamadı; otomatik login yapılıyor.") return self.login() LOGGER.info("Access token refresh işlemi başlatıldı.") try: response = self.client.request_json( "POST", self.REFRESH_PATH, json_body={"refreshToken": refresh_token}, authenticated=False, ) except ApiError as exc: if exc.status_code in {400, 401, 403}: LOGGER.info("Refresh token API tarafından reddedildi; yeniden login yapılıyor.") return self.login() raise self._save_auth_response(response.data) LOGGER.info("Token yenileme başarılı; yeni tokenlar .env dosyasına kaydedildi.") return response.data def _save_auth_response(self, data: Any) -> None: if not isinstance(data, dict): raise ApiError("Kimlik doğrulama endpoint'i beklenen JSON nesnesini döndürmedi.") self.token_store.save(data) def _required_saved_access_token(self) -> str: access_token = self.token_store.access_token if not access_token: raise ApiError("Login/refresh sonrasında access token kaydedilemedi.") return access_token class IklimClient: def __init__( self, settings: Settings, *, session: requests.Session | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: self.settings = settings self.session = session or requests.Session() self.sleep = sleep self.token_store = TokenStore(settings.env_file) self.auth = AuthenticationManager(self, self.token_store) @classmethod def from_env(cls, env_file: Path | str | None = None) -> "IklimClient": settings = Settings.from_env() if env_file is None else Settings.from_env(env_file) return cls(settings) def request_json( self, method: str, path_with_query: str, *, json_body: Any = None, authenticated: bool = True, idempotency_key: str | None = None, ) -> ApiResponse: method = method.upper() if not path_with_query.startswith("/"): raise ValueError("API yolu '/' ile başlamalıdır.") body = ( "" if json_body is None else json.dumps(json_body, ensure_ascii=False, separators=(",", ":")) ) operation_idempotency_key = idempotency_key or str(uuid.uuid4()) response: requests.Response | None = None for auth_round in range(2): access_token = self.auth.ensure_access_token() if authenticated else None response = self._send_with_retries( method=method, path_with_query=path_with_query, body=body, access_token=access_token, idempotency_key=operation_idempotency_key, ) if response.status_code != 401 or not authenticated: break if auth_round == 0: self.auth.recover_after_unauthorized() if response is None: raise ApiError("API isteği için yanıt alınamadı.") data = self._decode_response(response) if not response.ok: raise ApiError( f"HTTP {response.status_code}: {_error_message(response, data)}", status_code=response.status_code, response_data=data, ) return ApiResponse(response.status_code, data) def _send_with_retries( self, *, method: str, path_with_query: str, body: str, access_token: str | None, idempotency_key: str, ) -> requests.Response: for attempt in range(self.settings.max_retries + 1): headers = create_signed_headers( method=method, path_with_query=path_with_query, body=body, secret=self.settings.hmac_secret, accept_language=self.settings.accept_language, idempotency_key=idempotency_key, ) if access_token: headers["Authorization"] = f"Bearer {access_token}" url = f"{self.settings.base_url}{path_with_query}" LOGGER.debug( "HTTP request | %s", json.dumps( { "attempt": attempt + 1, "method": method, "url": url, "headers": _redact(headers), "body": _request_body_for_log(body), }, ensure_ascii=False, default=str, ), ) try: response = self.session.request( method, url, data=None if not body else body.encode("utf-8"), headers=headers, timeout=self.settings.timeout_seconds, ) except requests.RequestException as exc: LOGGER.debug( "HTTP network error | %s", json.dumps( { "attempt": attempt + 1, "method": method, "url": url, "error": _redact_text(str(exc)), }, ensure_ascii=False, ), ) if attempt >= self.settings.max_retries: raise ApiError( f"Ağ hatası; maksimum deneme sayısına ulaşıldı: {exc}" ) from exc self._wait_before_retry(attempt, None) continue LOGGER.debug( "HTTP response | %s", json.dumps( { "attempt": attempt + 1, "method": method, "url": url, "status": response.status_code, "headers": _redact(dict(response.headers)), "body": _response_body_for_log(response), }, ensure_ascii=False, default=str, ), ) if ( response.status_code in RETRYABLE_STATUS_CODES and attempt < self.settings.max_retries ): self._wait_before_retry(attempt, response) continue return response raise ApiError("API isteği beklenmeyen biçimde tamamlanamadı.") def _wait_before_retry( self, attempt: int, response: requests.Response | None ) -> None: delay = self.settings.retry_base_seconds * (2**attempt) if response is not None and response.status_code == 429: delay = self._retry_after_seconds(response, delay) LOGGER.warning( "İstek yeniden denenecek (%s/%s); %.1f saniye bekleniyor.", attempt + 1, self.settings.max_retries, delay, ) self.sleep(delay) @staticmethod def _retry_after_seconds(response: requests.Response, fallback: float) -> float: raw_value = response.headers.get("Retry-After", "").strip() if not raw_value: return fallback try: return max(0.0, float(raw_value)) except ValueError: try: retry_at = parsedate_to_datetime(raw_value) return max(0.0, retry_at.timestamp() - time.time()) except (TypeError, ValueError, OverflowError): return fallback @staticmethod def _decode_response(response: requests.Response) -> Any: if not response.content: return None try: return response.json() except requests.exceptions.JSONDecodeError as exc: raise ApiError( f"API JSON olmayan bir yanıt döndürdü (HTTP {response.status_code}).", status_code=response.status_code, ) from exc