import asyncio import orjson as json from abc import ABC, abstractmethod from aiohttp import ClientConnectionError, ClientResponseError, ClientSession, ClientTimeout from apollo_utils.service.exceptions import APIInvalidResponse, APIUnavailable, ClientAPIError from datetime import date from http import HTTPStatus from typing import Any, Dict, Optional from server.client.base.config import ApiKeyClientConfig, HttpClientConfig from server.client.exceptions import APIUnauthorized from server.client.utils import passed_headers, retry class BaseHttpClient(ABC): """Base client to communicate with another service by http.""" config: HttpClientConfig session: ClientSession def __init__(self, session: ClientSession, config: HttpClientConfig): self.session = session self.config = config self.timeout = ClientTimeout(total=config.timeout) if config.timeout else None @abstractmethod async def _authorize(self, headers): """Get and add authorization data (token, api_key, etc.) to the request parameters.""" @classmethod def prepare_request_data( cls, params: Dict or None, headers: Dict or None, body: Dict or None, data: Dict or None, **kwargs ): """Perform some common for client/service actions with request data.""" if params: for k, v in list(params.items()): if v is None: del params[k] elif type(v) in (bool, date): params[k] = str(v).lower() async def send_request( self, relative_url: str, method: str = "GET", params: Dict = None, headers: Dict = None, body: Dict or None = None, data: Dict or None = None, status_as_result: bool = False, include_original_response: bool = False, include_original_status_code: bool = False, **kwargs, ) -> Any: """Send http request. Authorize, add necessary headers, handle errors, retries if needed. Args: relative_url: Request URL without service uri prefix. method: HTTP method. params: Request params passed as a dict. headers: Headers. body: Request body. data: Request data. status_as_result: flag to return response status as a result. include_original_response: flag to return whole response too. include_original_status_code: flag to return response status too. Raises: APIUnauthorized: if authorization has failed. APIInvalidResponse: if got non OK (200) HTTP code response. APIUnavailable: If connection cannot be established or timeout exceeded. ClientAPIError: if the request was cancelled. """ self.prepare_request_data(params=params, headers=headers, body=body, data=data, **kwargs) headers = headers or {} self._add_headers(headers) await self._authorize(headers) handler = retry( allowed=self.config.retry_allowed, count=self.config.retry_count, delay=self.config.retry_delay, excepted=self.config.retry_excepted, )(self._send_request) return await handler( relative_url, method=method, params=params, headers=headers, body=body, data=data, status_as_result=status_as_result, include_original_response=include_original_response, include_original_status_code=include_original_status_code, ) def _add_headers(self, headers): _headers = passed_headers.get() or {} filtered_headers = ( _headers if self.config.passed_headers is None else {k: v for k, v in _headers.items() if k in self.config.passed_headers} ) headers.update(filtered_headers) async def _send_request( self, relative_url: str, method: str = "GET", params: Dict = None, headers: Dict = None, body: Dict or None = None, data: Dict or None = None, status_as_result: bool = False, include_original_response: bool = False, include_original_status_code: bool = False, ) -> Optional[Dict[str, Any]]: """Send http request. Handle errors, retries if needed. Args: relative_url: Request URL without service uri prefix. method: HTTP method. params: Request params passed as a dict. headers: Headers. body: Request body. data: Request data. status_as_result: flag to return response status as a result. include_original_response: flag to return whole response too. Raises: APIUnauthorized: if authorization has failed. APIInvalidResponse: if got non OK (200) HTTP code response. APIUnavailable: If connection cannot be established or timeout exceeded. ClientAPIError: if the request was cancelled. """ result, original_response, original_status_code = None, None, None url = f"{self.config.uri}/{relative_url}" request_params = { "params": params, "headers": headers, "json": body, "data": data, "raise_for_status": False, } if self.timeout: request_params["timeout"] = self.timeout try: async with self.session.request(method, url, **request_params) as original_response: original_status_code = original_response.status if status_as_result: result = original_status_code elif original_response.content and original_response.content_type == "application/json": result = await original_response.json(loads=json.loads) elif ( original_response.content and original_response.content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ): result = await original_response.content.read() if original_response.status == HTTPStatus.UNAUTHORIZED: raise APIUnauthorized( service=self.config.service, original_status_code=original_response.status, original_response=result, ) try: original_response.raise_for_status() except ClientResponseError as ex: raise APIInvalidResponse( service=self.config.service, original_status_code=ex.status, original_response=result, headers=ex.headers, ) except ClientConnectionError as ex: # including timeouts raise APIUnavailable( extra=ex.strerror if hasattr(ex, "strerror") else "Connection error", service=self.config.service, ) except asyncio.CancelledError: raise ClientAPIError(detail="Request was cancelled.", service=self.config.service) if include_original_response and include_original_status_code: return original_response, original_status_code, result if include_original_response: return original_response, result if include_original_status_code: return original_status_code, result return result class ApiKeyClient(BaseHttpClient): """Base client to communicate with another service with authorization by api key.""" config: ApiKeyClientConfig async def _authorize(self, headers): headers.update({self.config.auth_name: self.config.auth_secret}) class Client(BaseHttpClient): """Base client to communicate with another service with no authorization""" async def _authorize(self, headers): pass