# [MODULE] Сетевой клиент для API # @contract: Инкапсулирует низкоуровневую HTTP-логику, аутентификацию, повторные попытки и обработку сетевых ошибок. # @semantic_layers: # 1. Инициализация сессии `requests` с настройками SSL и таймаутов. # 2. Управление аутентификацией (получение и обновление access/CSRF токенов). # 3. Выполнение HTTP-запросов (GET, POST и т.д.) с автоматическими заголовками. # 4. Обработка пагинации для API-ответов. # 5. Обработка загрузки файлов. # @coherence: # - Полностью независим от `SupersetClient`, предоставляя ему чистый API для сетевых операций. # - Использует `SupersetLogger` для внутреннего логирования. # - Всегда выбрасывает типизированные исключения из `superset_tool.exceptions`. # [IMPORTS] Стандартная библиотека from typing import Optional, Dict, Any, BinaryIO, List, Union import json import io from pathlib import Path # [IMPORTS] Сторонние библиотеки import requests import urllib3 # Для отключения SSL-предупреждений # [IMPORTS] Локальные модули from ..exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError from .logger import SupersetLogger # Импорт логгера # [CONSTANTS] DEFAULT_RETRIES = 3 DEFAULT_BACKOFF_FACTOR = 0.5 class APIClient: """[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API. @contract: - Гарантирует retry-механизмы для запросов. - Выполняет SSL-валидацию или отключает ее по конфигурации. - Автоматически управляет access и CSRF токенами. - Преобразует HTTP-ошибки в типизированные исключения `superset_tool.exceptions`. @pre: - `base_url` должен быть валидным URL. - `auth` должен содержать необходимые данные для аутентификации. - `logger` должен быть инициализирован. @post: - Аутентификация выполняется при первом запросе или явно через `authenticate()`. - `self._tokens` всегда содержит актуальные access/CSRF токены после успешной аутентификации. @invariant: - Сессия `requests` активна и настроена. - Все запросы используют актуальные токены. """ def __init__( self, base_url: str, auth: Dict[str, Any], verify_ssl: bool = True, timeout: int = 30, logger: Optional[SupersetLogger] = None ): # [INIT] Основные параметры self.base_url = base_url self.auth = auth self.verify_ssl = verify_ssl self.timeout = timeout self.logger = logger or SupersetLogger(name="APIClient") # [COHERENCE_CHECK_PASSED] Инициализация логгера # [INIT] Сессия Requests self.session = self._init_session() self._tokens: Dict[str, str] = {} # [STATE] Хранилище токенов self._authenticated = False # [STATE] Флаг аутентификации self.logger.debug( "[INIT] APIClient инициализирован.", extra={"base_url": self.base_url, "verify_ssl": self.verify_ssl} ) def _init_session(self) -> requests.Session: """[HELPER] Настройка сессии `requests` с адаптерами и SSL-опциями. @semantic: Создает и конфигурирует объект `requests.Session`. """ session = requests.Session() # [CONTRACT] Настройка повторных попыток retries = requests.adapters.Retry( total=DEFAULT_RETRIES, backoff_factor=DEFAULT_BACKOFF_FACTOR, status_forcelist=[500, 502, 503, 504], allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"} ) session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries)) session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries)) session.verify = self.verify_ssl if not self.verify_ssl: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) self.logger.warning("[SECURITY] Отключена проверка SSL-сертификатов. Не использовать в продакшене без явной необходимости.") return session def authenticate(self) -> Dict[str, str]: """[AUTH-FLOW] Получение access и CSRF токенов. @pre: - `self.auth` содержит валидные учетные данные. @post: - `self._tokens` обновлен актуальными токенами. - Возвращает обновленные токены. - `self._authenticated` устанавливается в `True`. @raise: - `AuthenticationError`: При ошибках аутентификации (неверные credentials, проблемы с API security). - `NetworkError`: При проблемах с сетью. """ self.logger.info(f"[AUTH] Попытка аутентификации для {self.base_url}") try: # Шаг 1: Получение access_token login_url = f"{self.base_url}/security/login" response = self.session.post( login_url, json=self.auth, # Используем self.auth, который уже имеет "provider": "db", "refresh": True timeout=self.timeout ) response.raise_for_status() # Выбросит HTTPError для 4xx/5xx ответов access_token = response.json()["access_token"] self.logger.debug("[AUTH] Access token успешно получен.") # Шаг 2: Получение CSRF токена csrf_url = f"{self.base_url}/security/csrf_token/" csrf_response = self.session.get( csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.timeout ) csrf_response.raise_for_status() csrf_token = csrf_response.json()["result"] self.logger.debug("[AUTH] CSRF token успешно получен.") # [STATE] Сохранение токенов и обновление флага self._tokens = { "access_token": access_token, "csrf_token": csrf_token } self._authenticated = True self.logger.info("[COHERENCE_CHECK_PASSED] Аутентификация успешно завершена.") return self._tokens except requests.exceptions.HTTPError as e: error_msg = f"HTTP Error during authentication: {e.response.status_code} - {e.response.text}" self.logger.error(f"[AUTH_FAILED] {error_msg}", exc_info=True) if e.response.status_code == 401: # Unauthorized raise AuthenticationError( f"Неверные учетные данные или истекший токен.", url=login_url, username=self.auth.get("username"), status_code=e.response.status_code, response_text=e.response.text ) from e elif e.response.status_code == 403: # Forbidden raise PermissionDeniedError( "Недостаточно прав для аутентификации.", url=login_url, username=self.auth.get("username"), status_code=e.response.status_code, response_text=e.response.text ) from e else: raise SupersetAPIError( f"API ошибка при аутентификации: {error_msg}", url=login_url, status_code=e.response.status_code, response_text=e.response.text ) from e except requests.exceptions.RequestException as e: self.logger.error(f"[NETWORK_ERROR] Сетевая ошибка при аутентификации: {str(e)}", exc_info=True) raise NetworkError(f"Ошибка сети при аутентификации: {str(e)}", url=login_url) from e except KeyError as e: self.logger.error(f"[AUTH_FAILED] Некорректный формат ответа при аутентификации: {str(e)}", exc_info=True) raise AuthenticationError(f"Некорректный формат ответа API при аутентификации: {str(e)}") from e except Exception as e: self.logger.critical(f"[CRITICAL] Непредвиденная ошибка аутентификации: {str(e)}", exc_info=True) raise AuthenticationError(f"Непредвиденная ошибка аутентификации: {str(e)}") from e @property def headers(self) -> Dict[str, str]: """[INTERFACE] Возвращает стандартные заголовки с текущими токенами. @semantic: Если токены не получены, пытается выполнить аутентификацию. @post: Всегда возвращает словарь с 'Authorization' и 'X-CSRFToken'. @raise: `AuthenticationError` если аутентификация невозможна. """ if not self._authenticated: self.authenticate() # Попытка аутентификации при первом запросе заголовков # [CONTRACT] Проверка наличия токенов if not self._tokens or "access_token" not in self._tokens or "csrf_token" not in self._tokens: self.logger.error("[CONTRACT_VIOLATION] Токены отсутствуют после попытки аутентификации.", extra={"tokens": self._tokens}) raise AuthenticationError("Не удалось получить токены для заголовков.") return { "Authorization": f"Bearer {self._tokens['access_token']}", "X-CSRFToken": self._tokens["csrf_token"], "Referer": self.base_url, "Content-Type": "application/json" } def request( self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs ) -> Union[requests.Response, Dict[str, Any]]: """[NETWORK-CORE] Обертка для всех HTTP-запросов к Superset API. @semantic: - Выполняет запрос с заданными параметрами. - Автоматически добавляет базовые заголовки (токены, CSRF). - Обрабатывает HTTP-ошибки и преобразует их в типизированные исключения. - В случае 401/403, пытается обновить токен и повторить запрос один раз. @pre: - `method` - валидный HTTP-метод ('GET', 'POST', 'PUT', 'DELETE'). - `endpoint` - валидный путь API. @post: - Возвращает объект `requests.Response` (если `raw_response=True`) или `dict` (JSON-ответ). @raise: - `AuthenticationError`, `PermissionDeniedError`, `NetworkError`, `SupersetAPIError`, `DashboardNotFoundError`. """ full_url = f"{self.base_url}{endpoint}" self.logger.debug(f"[REQUEST] Выполнение запроса: {method} {full_url}", extra={"kwargs_keys": list(kwargs.keys())}) # [STATE] Заголовки для текущего запроса _headers = self.headers.copy() # Получаем базовые заголовки с актуальными токенами if headers: # Объединяем с переданными кастомными заголовками (переданные имеют приоритет) _headers.update(headers) retries_left = 1 # Одна попытка на обновление токена while retries_left >= 0: try: response = self.session.request( method, full_url, headers=_headers, #timeout=self.timeout, **kwargs ) response.raise_for_status() # Проверяем статус сразу self.logger.debug(f"[COHERENCE_CHECK_PASSED] Запрос {method} {endpoint} успешно выполнен.") return response if raw_response else response.json() except requests.exceptions.HTTPError as e: status_code = e.response.status_code error_context = { "method": method, "url": full_url, "status_code": status_code, "response_text": e.response.text } if status_code in [401, 403] and retries_left > 0: self.logger.warning(f"[AUTH_REFRESH] Токен истек или недействителен ({status_code}). Попытка обновить и повторить...", extra=error_context) try: self.authenticate() # Попытка обновить токены _headers = self.headers.copy() # Обновляем заголовки с новыми токенами if headers: _headers.update(headers) retries_left -= 1 continue # Повторяем цикл except AuthenticationError as auth_err: self.logger.error("[AUTH_FAILED] Не удалось обновить токены.", exc_info=True) raise PermissionDeniedError("Аутентификация не удалась или права отсутствуют после обновления токена.", **error_context) from auth_err # [ERROR_MAPPING] Преобразование стандартных HTTP-ошибок в кастомные исключения if status_code == 404: raise DashboardNotFoundError(endpoint, context=error_context) from e elif status_code == 403: raise PermissionDeniedError("Доступ запрещен.", **error_context) from e elif status_code == 401: raise AuthenticationError("Аутентификация не удалась.", **error_context) from e else: raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **error_context) from e except requests.exceptions.Timeout as e: self.logger.error(f"[NETWORK_ERROR] Таймаут запроса: {str(e)}", exc_info=True, extra={"url": full_url}) raise NetworkError("Таймаут запроса", url=full_url) from e except requests.exceptions.ConnectionError as e: self.logger.error(f"[NETWORK_ERROR] Ошибка соединения: {str(e)}", exc_info=True, extra={"url": full_url}) raise NetworkError("Ошибка соединения", url=full_url) from e except requests.exceptions.RequestException as e: self.logger.critical(f"[CRITICAL] Неизвестная ошибка запроса: {str(e)}", exc_info=True, extra={"url": full_url}) raise NetworkError(f"Неизвестная сетевая ошибка: {str(e)}", url=full_url) from e except json.JSONDecodeError as e: self.logger.error(f"[API_FAILED] Ошибка парсинга JSON ответа: {str(e)}", exc_info=True, extra={"url": full_url, "response_text_sample": response.text[:200]}) raise SupersetAPIError(f"Некорректный JSON ответ: {str(e)}", url=full_url) from e except Exception as e: self.logger.critical(f"[CRITICAL] Непредвиденная ошибка в APIClient.request: {str(e)}", exc_info=True, extra={"url": full_url}) raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", url=full_url) from e # [COHERENCE_CHECK_FAILED] Если дошли сюда, значит, все повторные попытки провалились self.logger.error(f"[CONTRACT_VIOLATION] Все повторные попытки для запроса {method} {endpoint} исчерпаны.") raise SupersetAPIError(f"Все повторные попытки запроса {method} {endpoint} исчерпаны.") def upload_file( self, endpoint: str, file_obj: Union[str, Path, BinaryIO], # Может быть Path, str или байтовый поток file_name: str, form_field: str = "file", extra_data: Optional[Dict] = None, timeout: Optional[int] = None ) -> Dict: """[CONTRACT] Отправка файла на сервер через POST-запрос. @pre: - `endpoint` - валидный API endpoint для загрузки. - `file_obj` - путь к файлу или открытый бинарный файловый объект. - `file_name` - имя файла для отправки в форме. @post: - Возвращает JSON-ответ от сервера в виде словаря. @raise: - `FileNotFoundError`: Если `file_obj` является путем и файл не найден. - `PermissionDeniedError`: Если недостаточно прав. - `SupersetAPIError`, `NetworkError`. """ full_url = f"{self.base_url}{endpoint}" _headers = self.headers.copy() # [IMPORTANT] Content-Type для files формируется requests, поэтому удаляем его из общих заголовков _headers.pop('Content-Type', None) files_payload = None should_close_file = False if isinstance(file_obj, (str, Path)): file_path = Path(file_obj) if not file_path.exists(): self.logger.error(f"[CONTRACT_VIOLATION] Файл для загрузки не найден: {file_path}", extra={"file_path": str(file_path)}) raise FileNotFoundError(f"Файл {file_path} не найден для загрузки.") files_payload = {form_field: (file_name, open(file_path, 'rb'), 'application/x-zip-compressed')} should_close_file = True self.logger.debug(f"[UPLOAD] Загрузка файла из пути: {file_path}") elif isinstance(file_obj, io.BytesIO): # In-memory binary file files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')} self.logger.debug(f"[UPLOAD] Загрузка файла из байтового потока (in-memory).") elif hasattr(file_obj, 'read') and hasattr(file_obj, 'seek'): # Generic binary file-like object files_payload = {form_field: (file_name, file_obj, 'application/x-zip-compressed')} self.logger.debug(f"[UPLOAD] Загрузка файла из файлового объекта.") else: self.logger.error(f"[CONTRACT_VIOLATION] Неподдерживаемый тип файла для загрузки: {type(file_obj).__name__}") raise TypeError("Неподдерживаемый тип 'file_obj'. Ожидается Path, str, io.BytesIO или другой файлоподобный объект.") try: response = self.session.post( url=full_url, files=files_payload, data=extra_data or {}, headers=_headers, timeout=timeout or self.timeout ) response.raise_for_status() # [COHERENCE_CHECK_PASSED] Файл успешно загружен. self.logger.info(f"[UPLOAD_SUCCESS] Файл '{file_name}' успешно загружен на {endpoint}.") return response.json() except requests.exceptions.HTTPError as e: error_context = { "endpoint": endpoint, "file": file_name, "status_code": e.response.status_code, "response_text": e.response.text } if e.response.status_code == 403: raise PermissionDeniedError("Доступ запрещен для загрузки файла.", **error_context) from e else: raise SupersetAPIError(f"Ошибка API при загрузке файла: {e.response.status_code} - {e.response.text}", **error_context) from e except requests.exceptions.RequestException as e: error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__} self.logger.error(f"[NETWORK_ERROR] Ошибка запроса при загрузке файла: {str(e)}", exc_info=True, extra=error_context) raise NetworkError(f"Ошибка сети при загрузке файла: {str(e)}", url=full_url) from e except Exception as e: error_context = {"endpoint": endpoint, "file": file_name, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при загрузке файла: {str(e)}", exc_info=True, extra=error_context) raise SupersetAPIError(f"Непредвиденная ошибка загрузки файла: {str(e)}", context=error_context) from e finally: # Закрываем файл, если он был открыт в этом методе if should_close_file and files_payload and files_payload[form_field] and hasattr(files_payload[form_field][1], 'close'): files_payload[form_field][1].close() self.logger.debug(f"[UPLOAD] Закрыт файл '{file_name}'.") def fetch_paginated_count( self, endpoint: str, query_params: Dict, count_field: str = "count", timeout: Optional[int] = None ) -> int: """[CONTRACT] Получение общего количества элементов в пагинированном API. @delegates: - Использует `self.request` для выполнения HTTP-запроса. @pre: - `endpoint` должен указывать на пагинированный ресурс. - `query_params` должны быть валидны для запроса количества. @post: - Возвращает целочисленное количество элементов. @raise: - `NetworkError`, `SupersetAPIError`, `KeyError` (если `count_field` не найден). """ self.logger.debug(f"[PAGINATION] Запрос количества элементов для {endpoint} с параметрами: {query_params}") try: response_json = self.request( method="GET", endpoint=endpoint, params={"q": json.dumps(query_params)}, timeout=timeout or self.timeout ) if count_field not in response_json: self.logger.error( f"[CONTRACT_VIOLATION] Ответ API для {endpoint} не содержит поле '{count_field}'", extra={"response_keys": list(response_json.keys())} ) raise KeyError(f"Ответ API для {endpoint} не содержит поле '{count_field}'") count = response_json[count_field] self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено количество: {count} для {endpoint}.") return count except (KeyError, SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e: self.logger.error(f"[ERROR] Ошибка получения количества элементов для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except Exception as e: error_ctx = {"endpoint": endpoint, "params": query_params, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении количества: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка при получении count для {endpoint}: {str(e)}", context=error_ctx) from e def fetch_paginated_data( self, endpoint: str, base_query: Dict, total_count: int, results_field: str = "result", timeout: Optional[int] = None ) -> List[Any]: """[CONTRACT] Получение всех данных с пагинированного API. @delegates: - Использует `self.request` для выполнения запросов по страницам. @pre: - `base_query` должен содержать 'page_size'. - `total_count` должен быть корректным общим количеством элементов. @post: - Возвращает список всех собранных данных со всех страниц. @raise: - `NetworkError`, `SupersetAPIError`, `ValueError` (если `page_size` невалиден), `KeyError`. """ self.logger.debug(f"[PAGINATION] Запуск получения всех данных для {endpoint}. Total: {total_count}, Base Query: {base_query}") page_size = base_query.get('page_size') if not page_size or page_size <= 0: self.logger.error("[CONTRACT_VIOLATION] 'page_size' в базовом запросе невалиден.", extra={"page_size": page_size}) raise ValueError("Параметр 'page_size' должен быть положительным числом.") total_pages = (total_count + page_size - 1) // page_size results = [] for page in range(total_pages): query = {**base_query, 'page': page} self.logger.debug(f"[PAGINATION] Запрос страницы {page+1}/{total_pages} для {endpoint}.") try: response_json = self.request( method="GET", endpoint=endpoint, params={"q": json.dumps(query)}, timeout=timeout or self.timeout ) if results_field not in response_json: self.logger.warning( f"[CONTRACT_VIOLATION] Ответ API для {endpoint} на странице {page} не содержит поле '{results_field}'", extra={"response_keys": list(response_json.keys())} ) # Если поле результатов отсутствует на одной странице, это может быть не фатально, но надо залогировать. continue results.extend(response_json[results_field]) except (SupersetAPIError, NetworkError, PermissionDeniedError, DashboardNotFoundError) as e: self.logger.error(f"[ERROR] Ошибка получения страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise # Пробрасываем ошибку выше, так как не можем продолжить пагинацию except Exception as e: error_ctx = {"endpoint": endpoint, "page": page, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении страницы {page+1} для {endpoint}: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка пагинации для {endpoint}: {str(e)}", context=error_ctx) from e self.logger.debug(f"[COHERENCE_CHECK_PASSED] Все данные с пагинацией для {endpoint} успешно собраны. Всего элементов: {len(results)}") return results