385 lines
13 KiB
Python
385 lines
13 KiB
Python
"""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 = "<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
|