Skip to main content
Glama
Bdata0
by Bdata0
client.py20.4 kB
import httpx from typing import Optional, Dict, Any, List, Tuple import logging import json # Убедимся, что импортируем все необходимые схемы from .schemas import ( HotelAvailabilityRequestParams, HotelAvailabilityResponse, HotelReservationRequest, HotelReservationResponse, CancelReservationRequestPayload, CancelReservationResponsePayload, ErrorDetail, # Добавим схему для параметров запроса hotel_info, если она будет простой # или определим ее прямо здесь, если она специфична только для этого клиента ) from app.config import settings logger = logging.getLogger(__name__) class ExelyApiException(Exception): """Custom exception for Exely API errors.""" def __init__(self, status_code: Optional[int] = None, error_response: Optional[Dict[str, Any]] = None, message: Optional[str] = None, request_url: Optional[str] = None): self.status_code = status_code self.error_response = error_response self.request_url = request_url super().__init__(message or "Exely API request failed") def _flatten_availability_params(params: HotelAvailabilityRequestParams) -> List[Tuple[str, str]]: flat_params: List[Tuple[str, Any]] = [] if params.language is not None: flat_params.append(("language", params.language)) if params.currency is not None: flat_params.append(("currency", params.currency)) if params.include_rates is not None: flat_params.append(("include_rates", params.include_rates)) if params.include_transfers is not None: flat_params.append(("include_transfers", params.include_transfers)) if params.include_all_placements is not None: flat_params.append(("include_all_placements", params.include_all_placements)) if params.include_promo_restricted is not None: flat_params.append(("include_promo_restricted", params.include_promo_restricted)) for i, criterion in enumerate(params.criterions): flat_params.append((f"criterions[{i}].ref", criterion.ref or "0")) for j, hotel_ref in enumerate(criterion.hotels): flat_params.append((f"criterions[{i}].hotels[{j}].code", hotel_ref.code)) flat_params.append((f"criterions[{i}].dates", criterion.dates)) flat_params.append((f"criterions[{i}].adults", criterion.adults)) if criterion.children is not None: flat_params.append((f"criterions[{i}].children", criterion.children)) final_params: List[Tuple[str, str]] = [] for k, v in flat_params: if isinstance(v, bool): final_params.append((k, str(v).lower())) else: final_params.append((k, str(v))) return final_params class ExelyDistributionApiClient: def __init__(self, api_key: str, base_url: str = "https://ibe.hopenapi.com"): self.api_key = api_key self.base_url = base_url.rstrip('/') self.headers = { "X-ApiKey": self.api_key, "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", } client_timeout = getattr(settings, 'EXELY_CLIENT_TIMEOUT', 30.0) self._client = httpx.AsyncClient(base_url=self.base_url, headers=self.headers, timeout=client_timeout) logger.debug(f"ExelyDistributionApiClient initialized for base_url: {self.base_url}, timeout: {client_timeout}s") if self.api_key == "YOUR_EXELY_API_KEY_PLACEHOLDER" or not self.api_key: logger.critical("EXELY_API_KEY is a placeholder or empty! API calls will likely fail.") async def _request( self, method: str, endpoint: str, params: Optional[List[Tuple[str, str]]] = None, json_data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: log_url = httpx.URL(self.base_url + endpoint) if params: log_url = log_url.copy_with(params=params) request_details_for_log = [ f"Exely API Request:", f" Method: {method}", f" URL: {log_url}", ] if json_data and settings.DEBUG_MODE: try: request_details_for_log.append(f" Body (prepared for httpx):\n{json.dumps(json_data, indent=2, ensure_ascii=False)}") except TypeError as te: request_details_for_log.append(f" Body: (Could not serialize to JSON for logging: {te})") logger.error(f"Error serializing json_data for logging (request to {log_url}): {te}", exc_info=True) logger.debug("\n".join(request_details_for_log)) try: response = await self._client.request(method, endpoint, params=params, json=json_data) response_body_text = response.text response_body_json_parsed: Optional[Dict[str, Any]] = None try: response_body_json_parsed = json.loads(response_body_text) except json.JSONDecodeError: logger.warning(f"Response body for {method} {endpoint} is not valid JSON.") log_response_parts = [ f"Exely API Response:", f" Status Code: {response.status_code}", f" URL: {response.url}" ] if settings.DEBUG_MODE: log_response_parts.append(f" Headers:") for k, v_resp in response.headers.items(): log_response_parts.append(f" {k}: {v_resp}") log_response_parts.append(f" Body:") if response_body_json_parsed: log_response_parts.append(json.dumps(response_body_json_parsed, indent=2, ensure_ascii=False)) else: log_response_parts.append(response_body_text if len(response_body_text) < 2000 else response_body_text[:2000] + "...") logger.debug("\n".join(log_response_parts)) response.raise_for_status() if response_body_json_parsed is None: err_msg = f"API request to {response.url} received status {response.status_code} but response was not valid JSON." logger.error(err_msg) raise ExelyApiException( status_code=response.status_code, message=err_msg, request_url=str(response.url) ) return response_body_json_parsed except httpx.HTTPStatusError as e: error_response_parsed: Optional[Dict[str, Any]] = None try: error_response_parsed = e.response.json() except json.JSONDecodeError: pass msg_for_exception = ( f"API request to {e.request.url} failed with status {e.response.status_code}. " f"Response: {json.dumps(error_response_parsed, indent=2, ensure_ascii=False) if error_response_parsed else e.response.text}" ) logger.error(f"HTTPStatusError during Exely API call: {msg_for_exception}", exc_info=settings.DEBUG_MODE) raise ExelyApiException( status_code=e.response.status_code, error_response=error_response_parsed or {"raw_error_text": e.response.text}, message=msg_for_exception, request_url=str(e.request.url) ) from e except httpx.RequestError as e: msg = f"Network request failed for {e.request.url}: {str(e)}" logger.error(msg, exc_info=settings.DEBUG_MODE) raise ExelyApiException(message=msg, request_url=str(e.request.url)) from e except TypeError as te: msg = f"TypeError during request preparation for {log_url}: {str(te)}. This often means a non-serializable type was passed." logger.error(msg, exc_info=True) raise ExelyApiException(message=msg, request_url=str(log_url)) from te except Exception as e: msg = f"An unexpected error occurred during API request to {log_url}: {str(e)}" logger.exception(msg) raise ExelyApiException(message=msg, request_url=str(log_url)) from e async def get_hotel_info( self, hotel_code: str, language: str = settings.DEFAULT_LANGUAGE ) -> Dict[str, Any]: # TODO: Заменить Dict[str, Any] на Pydantic модель HotelInfoResponse, когда она будет определена """ Получает подробную информацию об отеле. """ logger.info(f"Запрос информации об отеле: код={hotel_code}, язык={language}") query_params: List[Tuple[str, str]] = [ ("language", language), ("hotels[0].code", hotel_code) # Exely API ожидает параметры в таком формате для массивов ] api_response_dict = await self._request( method="GET", endpoint="/ChannelDistributionApi/BookingForm/hotel_info", params=query_params, ) # TODO: Добавить валидацию с помощью Pydantic модели, когда HotelInfoResponse будет готова. # try: # validated_response = HotelInfoResponse.model_validate(api_response_dict) # logger.info(f"Информация об отеле {hotel_code} успешно получена и валидирована.") # return validated_response # except Exception as e_val: # msg = f"Не удалось валидировать HotelInfoResponse для отеля {hotel_code}: {str(e_val)}" # response_summary_for_log = json.dumps(api_response_dict, indent=2, ensure_ascii=False) # if len(response_summary_for_log) > 2000 and not settings.DEBUG_MODE: # response_summary_for_log = response_summary_for_log[:2000] + "..." # logger.error(f"{msg}\nRaw API response for validation was:\n{response_summary_for_log}", exc_info=settings.DEBUG_MODE) # raise ExelyApiException(message=msg, error_response=api_response_dict, status_code=200) from e_val # Пока что возвращаем сырой словарь if api_response_dict.get("errors"): logger.warning(f"API вернул ошибки при запросе hotel_info для {hotel_code}: {api_response_dict['errors']}") # Можно бросить исключение или вернуть словарь с ошибками # raise ExelyApiException(status_code=400, error_response=api_response_dict, message=f"API errors for hotel_info {hotel_code}") logger.info(f"Информация об отеле {hotel_code} успешно получена.") return api_response_dict async def get_hotel_availability( self, request_data: HotelAvailabilityRequestParams ) -> HotelAvailabilityResponse: criterions_summary = "N/A" if request_data.criterions: first_crit = request_data.criterions[0] hotel_codes = [h.code for h in first_crit.hotels] criterions_summary = f"hotels: {hotel_codes}, dates: {first_crit.dates}, adults: {first_crit.adults}" logger.info(f"Запрос доступности отеля ({criterions_summary})") if not request_data.criterions: logger.error("Список критериев не может быть пустым для поиска hotel_availability.") raise ValueError("Список критериев не может быть пустым для поиска hotel_availability.") query_params: List[Tuple[str, str]] = _flatten_availability_params(request_data) api_response_dict = await self._request( method="GET", endpoint="/ChannelDistributionApi/BookingForm/hotel_availability", params=query_params, ) try: validated_response = HotelAvailabilityResponse.model_validate(api_response_dict) logger.info(f"Ответ о доступности отеля валидирован. Найдено {len(validated_response.room_stays)} вариантов комнат.") return validated_response except Exception as e_val: msg = f"Не удалось валидировать HotelAvailabilityResponse: {str(e_val)}" response_summary_for_log = json.dumps(api_response_dict, indent=2, ensure_ascii=False) if len(response_summary_for_log) > 2000 and not settings.DEBUG_MODE: response_summary_for_log = response_summary_for_log[:2000] + "..." logger.error(f"{msg}\nRaw API response for validation was:\n{response_summary_for_log}", exc_info=settings.DEBUG_MODE) raise ExelyApiException(message=msg, error_response=api_response_dict, status_code=200) from e_val async def create_hotel_reservation( self, reservation_request_data: HotelReservationRequest ) -> HotelReservationResponse: hotel_code = "N/A" if reservation_request_data.hotel_reservations: hotel_code = reservation_request_data.hotel_reservations[0].hotel_ref.code logger.info(f"Создание бронирования для отеля: {hotel_code}") json_payload = reservation_request_data.model_dump(mode='json', by_alias=True, exclude_none=True) if settings.DEBUG_MODE: logger.debug(f"Полезная нагрузка для бронирования (JSON):\n{json.dumps(json_payload, indent=2, ensure_ascii=False)}") api_response_dict = await self._request( method="POST", endpoint="/ChannelDistributionApi/BookingForm/hotel_reservation_2", json_data=json_payload, ) if isinstance(api_response_dict.get("errors"), list) and api_response_dict["errors"]: first_error = api_response_dict["errors"][0] error_message_from_api = first_error.get("message", "Неизвестная ошибка из массива ошибок API.") error_code_from_api = first_error.get("error_code", "N/A") full_error_message_for_exception = ( f"API запрос к {self.base_url}/ChannelDistributionApi/BookingForm/hotel_reservation_2 " f"получил 200 OK, но ответ содержит ошибки уровня приложения. " f"Первая ошибка (код: {error_code_from_api}): {error_message_from_api}" ) logger.warning(f"create_hotel_reservation: {full_error_message_for_exception}. Полные ошибки от API: {json.dumps(api_response_dict['errors'], indent=2, ensure_ascii=False)}") raise ExelyApiException( status_code=400, error_response=api_response_dict, message=full_error_message_for_exception, request_url=self.base_url + "/ChannelDistributionApi/BookingForm/hotel_reservation_2" ) try: validated_response = HotelReservationResponse.model_validate(api_response_dict) if validated_response.hotel_reservations and validated_response.hotel_reservations[0].number: logger.info(f"Ответ о бронировании отеля валидирован. Номер брони: {validated_response.hotel_reservations[0].number}, Статус: {validated_response.hotel_reservations[0].status}") else: logger.warning("Ответ о бронировании отеля валидирован, но номер брони не найден или отсутствуют другие ожидаемые данные (и нет массива 'errors' в ответе).") return validated_response except Exception as e_val: msg = f"Не удалось валидировать HotelReservationResponse (ожидалась успешная структура): {str(e_val)}" response_summary_for_log = json.dumps(api_response_dict, indent=2, ensure_ascii=False) if len(response_summary_for_log) > 2000 and not settings.DEBUG_MODE: response_summary_for_log = response_summary_for_log[:2000] + "..." logger.error(f"{msg}\nRaw API response for validation was:\n{response_summary_for_log}", exc_info=settings.DEBUG_MODE) raise ExelyApiException(message=msg, error_response=api_response_dict, status_code=200) from e_val async def cancel_hotel_reservation( self, cancel_request_data: CancelReservationRequestPayload ) -> CancelReservationResponsePayload: booking_number_to_cancel = "N/A" if cancel_request_data.hotel_reservation_refs: booking_number_to_cancel = cancel_request_data.hotel_reservation_refs[0].number logger.info(f"Попытка отменить бронирование отеля: {booking_number_to_cancel}") json_payload = cancel_request_data.model_dump(mode='json', by_alias=True, exclude_none=True) if settings.DEBUG_MODE: logger.debug(f"Полезная нагрузка для отмены бронирования (JSON):\n{json.dumps(json_payload, indent=2, ensure_ascii=False)}") api_response_dict = await self._request( method="POST", endpoint="/ChannelDistributionApi/BookingForm/cancel_reservation_2", json_data=json_payload, ) if isinstance(api_response_dict.get("errors"), list) and api_response_dict["errors"]: first_error = api_response_dict["errors"][0] error_message_from_api = first_error.get("message", "Неизвестная ошибка из массива ошибок API при отмене.") error_code_from_api = first_error.get("error_code", "N/A") full_error_message_for_exception = ( f"API запрос на отмену бронирования {booking_number_to_cancel} " f"получил 200 OK, но ответ содержит ошибки уровня приложения. " f"Первая ошибка (код: {error_code_from_api}): {error_message_from_api}" ) logger.warning(f"cancel_hotel_reservation: {full_error_message_for_exception}. Полные ошибки от API: {json.dumps(api_response_dict['errors'], indent=2, ensure_ascii=False)}") raise ExelyApiException( status_code=400, error_response=api_response_dict, message=full_error_message_for_exception, request_url=self.base_url + "/ChannelDistributionApi/BookingForm/cancel_reservation_2" ) try: validated_response = CancelReservationResponsePayload.model_validate(api_response_dict) if validated_response.hotel_reservations: logger.info(f"Ответ об отмене бронирования валидирован для {validated_response.hotel_reservations[0].number}. Статус: {validated_response.hotel_reservations[0].status}") else: logger.warning("Ответ об отмене бронирования валидирован, но нет массива hotel_reservations (и нет массива 'errors').") return validated_response except Exception as e_val: msg = f"Не удалось валидировать CancelReservationResponsePayload: {str(e_val)}" response_summary_for_log = json.dumps(api_response_dict, indent=2, ensure_ascii=False) if len(response_summary_for_log) > 2000 and not settings.DEBUG_MODE: response_summary_for_log = response_summary_for_log[:2000] + "..." logger.error(f"{msg}\nRaw API response for validation was:\n{response_summary_for_log}", exc_info=settings.DEBUG_MODE) raise ExelyApiException(message=msg, error_response=api_response_dict, status_code=200) from e_val async def close(self): await self._client.aclose() logger.debug("ExelyDistributionApiClient HTTP клиент закрыт.")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Bdata0/Exely_MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server