Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Reusable iklim.co API client components."""
|
||||
|
||||
from .client import ApiResponse, IklimClient
|
||||
from .config import Settings
|
||||
from .errors import ApiError, ConfigurationError
|
||||
from .logging_config import configure_logging
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"ApiResponse",
|
||||
"ConfigurationError",
|
||||
"IklimClient",
|
||||
"Settings",
|
||||
"configure_logging",
|
||||
]
|
||||
@@ -0,0 +1,384 @@
|
||||
"""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
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Environment-backed configuration shared by every service client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .errors import ConfigurationError
|
||||
|
||||
|
||||
DEFAULT_ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
raise ConfigurationError(f".env içinde {name} eksik veya boş.")
|
||||
if value.lower() == "change-me":
|
||||
raise ConfigurationError(f".env içindeki {name} placeholder değeri değiştirilmemiş.")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_float(name: str, default: str) -> float:
|
||||
raw_value = os.getenv(name, default).strip()
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError(f"{name} sayısal bir değer olmalıdır.") from exc
|
||||
if value <= 0:
|
||||
raise ConfigurationError(f"{name} sıfırdan büyük olmalıdır.")
|
||||
return value
|
||||
|
||||
|
||||
def _non_negative_int(name: str, default: str) -> int:
|
||||
raw_value = os.getenv(name, default).strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError(f"{name} tam sayı olmalıdır.") from exc
|
||||
if value < 0:
|
||||
raise ConfigurationError(f"{name} negatif olamaz.")
|
||||
return value
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool = False) -> bool:
|
||||
raw_value = os.getenv(name)
|
||||
if raw_value is None or not raw_value.strip():
|
||||
return default
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ConfigurationError(f"{name} true veya false olmalıdır.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
env_file: Path
|
||||
base_url: str
|
||||
hmac_secret: str
|
||||
accept_language: str
|
||||
timeout_seconds: float
|
||||
token_expiry_skew_seconds: int
|
||||
max_retries: int
|
||||
retry_base_seconds: float
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env_file: Path = DEFAULT_ENV_FILE) -> "Settings":
|
||||
env_file = Path(env_file).resolve()
|
||||
if not env_file.is_file():
|
||||
raise ConfigurationError(f".env dosyası bulunamadı: {env_file}")
|
||||
|
||||
load_dotenv(env_file, override=True)
|
||||
environment = _required("IKLIM_API_ENV").lower()
|
||||
url_variables = {
|
||||
"test": "IKLIM_API_TEST_URL",
|
||||
"prod": "IKLIM_API_PROD_URL",
|
||||
}
|
||||
if environment not in url_variables:
|
||||
raise ConfigurationError("IKLIM_API_ENV yalnızca test veya prod olabilir.")
|
||||
|
||||
base_url = _required(url_variables[environment]).rstrip("/")
|
||||
parsed_url = urlsplit(base_url)
|
||||
if parsed_url.scheme != "https" or not parsed_url.netloc:
|
||||
raise ConfigurationError("Seçilen API adresi geçerli bir HTTPS adresi olmalıdır.")
|
||||
|
||||
language = os.getenv("IKLIM_ACCEPT_LANGUAGE", "en").strip().lower() or "en"
|
||||
if language not in {"tr", "en"}:
|
||||
raise ConfigurationError("IKLIM_ACCEPT_LANGUAGE yalnızca tr veya en olabilir.")
|
||||
|
||||
return cls(
|
||||
env_file=env_file,
|
||||
base_url=base_url,
|
||||
hmac_secret=_required("IKLIM_HMAC_SECRET"),
|
||||
accept_language=language,
|
||||
timeout_seconds=_positive_float("IKLIM_TIMEOUT_SECONDS", "30"),
|
||||
token_expiry_skew_seconds=_non_negative_int(
|
||||
"IKLIM_TOKEN_EXPIRY_SKEW_SECONDS", "60"
|
||||
),
|
||||
max_retries=_non_negative_int("IKLIM_MAX_RETRIES", "3"),
|
||||
retry_base_seconds=_positive_float("IKLIM_RETRY_BASE_SECONDS", "2"),
|
||||
)
|
||||
|
||||
def credentials(self) -> tuple[str, str]:
|
||||
return _required("IKLIM_USERNAME"), _required("IKLIM_PASSWORD")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared exceptions for iklim.co clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
"""Raised when a required local setting is missing or invalid."""
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised after an API request fails and eligible retries are exhausted."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
response_data: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_data = response_data
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Console and rotating debug-file logging configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def configure_logging(debug_log_file: Path) -> Path:
|
||||
debug_log_file = debug_log_file.resolve()
|
||||
debug_log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.handlers.clear()
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
||||
)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
debug_log_file,
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
logging.getLogger(__name__).info("Debug log dosyası: %s", debug_log_file)
|
||||
return debug_log_file
|
||||
@@ -0,0 +1,111 @@
|
||||
"""HMAC request signing and local JWT token persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import set_key
|
||||
|
||||
from .errors import ApiError
|
||||
|
||||
|
||||
def jwt_expiration(token: str) -> int:
|
||||
"""Read a JWT exp claim; this does not replace server-side verification."""
|
||||
try:
|
||||
encoded_payload = token.split(".")[1]
|
||||
padding = "=" * (-len(encoded_payload) % 4)
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(encoded_payload + padding).decode("utf-8")
|
||||
)
|
||||
return int(payload["exp"])
|
||||
except (
|
||||
IndexError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
binascii.Error,
|
||||
) as exc:
|
||||
raise ApiError("API geçerli bir JWT token döndürmedi.") from exc
|
||||
|
||||
|
||||
def token_is_valid(token: str | None, skew_seconds: int) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
return jwt_expiration(token) > int(time.time()) + skew_seconds
|
||||
except ApiError:
|
||||
return False
|
||||
|
||||
|
||||
def create_signed_headers(
|
||||
*,
|
||||
method: str,
|
||||
path_with_query: str,
|
||||
body: str,
|
||||
secret: str,
|
||||
accept_language: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, str]:
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
data_to_sign = f"{method.upper()}|{path_with_query}|{timestamp}|{body}"
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
data_to_sign.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": accept_language,
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": signature,
|
||||
"X-Timestamp": timestamp,
|
||||
"X-Nonce": str(uuid.uuid4()),
|
||||
"X-Idempotency-Key": idempotency_key,
|
||||
}
|
||||
|
||||
|
||||
class TokenStore:
|
||||
def __init__(self, env_file: Path) -> None:
|
||||
self.env_file = env_file
|
||||
|
||||
@property
|
||||
def access_token(self) -> str | None:
|
||||
return os.getenv("IKLIM_ACCESS_TOKEN", "").strip() or None
|
||||
|
||||
@property
|
||||
def refresh_token(self) -> str | None:
|
||||
return os.getenv("IKLIM_REFRESH_TOKEN", "").strip() or None
|
||||
|
||||
def save(self, response_data: dict[str, Any]) -> None:
|
||||
access_token = response_data.get("accessToken")
|
||||
refresh_token = response_data.get("refreshToken")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise ApiError("Kimlik doğrulama yanıtında accessToken bulunamadı.")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise ApiError("Kimlik doğrulama yanıtında refreshToken bulunamadı.")
|
||||
|
||||
values = {
|
||||
"IKLIM_ACCESS_TOKEN": access_token,
|
||||
"IKLIM_REFRESH_TOKEN": refresh_token,
|
||||
"IKLIM_ACCESS_TOKEN_EXPIRES_AT": str(jwt_expiration(access_token)),
|
||||
"IKLIM_REFRESH_TOKEN_EXPIRES_AT": str(jwt_expiration(refresh_token)),
|
||||
"IKLIM_TOKENS_UPDATED_AT": str(int(time.time())),
|
||||
}
|
||||
for key, value in values.items():
|
||||
set_key(self.env_file, key, value, quote_mode="never")
|
||||
os.environ[key] = value
|
||||
|
||||
def set_webhook_initialized(self, initialized: bool) -> None:
|
||||
value = "true" if initialized else "false"
|
||||
set_key(self.env_file, "IKLIM_WEBHOOK_INITIALIZED", value, quote_mode="never")
|
||||
os.environ["IKLIM_WEBHOOK_INITIALIZED"] = value
|
||||
Reference in New Issue
Block a user