diff --git a/src/api_client/__init__.py b/src/api_client/__init__.py index d0020b4..e2561eb 100644 --- a/src/api_client/__init__.py +++ b/src/api_client/__init__.py @@ -1,4 +1,5 @@ -from .client import BaseAPIClient +from .async_client import BaseAPIClient as AsyncClient +from .sync_client import BaseAPIClient as SyncClient __version__ = "1.0.1" -__all__ = ["BaseAPIClient"] +__all__ = ["SyncClient", "AsyncClient"] diff --git a/src/api_client/_base.py b/src/api_client/_base.py new file mode 100644 index 0000000..c9aef95 --- /dev/null +++ b/src/api_client/_base.py @@ -0,0 +1,116 @@ +import logging +from abc import ABC, abstractmethod +from typing import Any, Coroutine + +import httpx +from pydantic import HttpUrl + +from .utils import get_logger +from .utils.logs import get_nonce, shortify_log_value + +_TypeSyncAsyncResponse = httpx.Response | Coroutine[Any, Any, httpx.Response] + + +class BaseClient(ABC): + base_url: HttpUrl + _session: httpx._client.BaseClient + _nonce: str + _logger: logging.Logger + + def __init__(self, nonce: str = "", session: httpx._client.BaseClient | None = None): + self._nonce = nonce or get_nonce() + self._session = session or httpx.Client() + self._logger = get_logger("APIClient") + + @abstractmethod + def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + @abstractmethod + def _request(self, method: str, endpoint: str, **kwargs: Any) -> _TypeSyncAsyncResponse: + pass + + def _make_full_url(self, endpoint: str) -> str: + return f"{self.base_url}{endpoint}" + + def prepare_authentication(self, request: httpx.Request) -> httpx.Request: + return request + + def _prepare_request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Request: + full_url = self._make_full_url(endpoint) + + self._debug_log_request(method, full_url) + request: httpx.Request = self._session.build_request(method=method, url=full_url, **kwargs) + self._debug_log_prepared_request(request) + self._debug(f"Prepared {request.method} request to '{request.url}'", extra=request.__dict__) + + self._info_log_request_sending(request, kwargs.get("content") or kwargs.get("files") or kwargs.get("data") or kwargs.get("json")) + return request + + def _debug_log_request(self, method: str, full_url: str) -> None: + self._debug(f"Preparing {method} request for '{full_url}'") + + def _debug_log_prepared_request(self, request: httpx.Request) -> None: + self._debug(f"Prepared {request.method} request to '{request.url}'", extra=request.__dict__) + + def _info_log_request_sending(self, request: httpx.Request, log_payload: Any) -> None: + self._info( + f"Sending {request.method} request to '{request.url}' with payload={shortify_log_value(log_payload)!r}", + extra={"payload": shortify_log_value(log_payload)}, + ) + + def _info_log_response(self, response: httpx.Response) -> None: + str_repr_content = response.content.decode("utf8")[:500] + self._info(f"Response {response.status_code=} {str_repr_content=}", extra={"status_code": response.status_code, "content": str_repr_content}) + + def _info(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("INFO", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("DEBUG", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("WARNING", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _error(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("ERROR", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _critical(self, msg: str, *args: Any, **kwargs: Any) -> None: + self._log("CRITICAL", f"[{self._nonce}] {msg}", *args, **kwargs) + + def _log(self, level: str, msg: object, *args: object, stacklevel: int = 4, extra: dict | None = None, **kwargs: Any) -> None: + extra = extra or {} + extra.update(nonce=self._nonce) + self._logger.log(logging.getLevelNamesMapping()[level], msg, *args, stacklevel=stacklevel, extra=extra, **kwargs) diff --git a/src/api_client/async_client.py b/src/api_client/async_client.py new file mode 100644 index 0000000..fd441e7 --- /dev/null +++ b/src/api_client/async_client.py @@ -0,0 +1,46 @@ +from typing import Any + +import httpx + +from ._base import BaseClient + + +class BaseAPIClient(BaseClient): + _session: httpx.AsyncClient + + def __init__(self, nonce: str = "", session: httpx.AsyncClient | None = None): + super().__init__(nonce, session or httpx.AsyncClient(http2=True)) + + async def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("POST", endpoint, json=json, data=data, **kwargs) + + async def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("PUT", endpoint, json=json, data=data, **kwargs) + + async def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, json=json, data=data, **kwargs) + + async def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return await self._request("GET", endpoint, params=params, **kwargs) + + async def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: + request = self._prepare_request(method, endpoint, **kwargs) + response = await self._session.send(request, auth=self.prepare_authentication) + self._info_log_response(response) + + return response diff --git a/src/api_client/client.py b/src/api_client/client.py deleted file mode 100644 index ede9efc..0000000 --- a/src/api_client/client.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -from abc import ABC -from typing import Any - -import requests -from pydantic import HttpUrl - -from .utils import get_logger -from .utils.logs import get_nonce, shortify_log_value - - -class BaseAPIClient(ABC): - base_url: HttpUrl - _session: requests.Session - _nonce: str - _logger: logging.Logger - - def __init__(self, nonce: str = ""): - self._nonce = nonce or get_nonce() - self._session = requests.Session() - self._logger = get_logger("APIClient") - - def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("GET", endpoint, params=params, **kwargs) - - def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("POST", endpoint, json=json, data=data, **kwargs) - - def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("PUT", endpoint, json=json, data=data, **kwargs) - - def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> requests.Response: - return self._request("PATCH", endpoint, json=json, data=data, **kwargs) - - def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("DELETE", endpoint, params=params, **kwargs) - - def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("HEAD", endpoint, params=params, **kwargs) - - def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("OPTIONS", endpoint, params=params, **kwargs) - - def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("TRACE", endpoint, params=params, **kwargs) - - def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> requests.Response: - return self._request("CONNECT", endpoint, params=params, **kwargs) - - def _make_full_url(self, endpoint: str) -> str: - return f"{self.base_url}{endpoint}" - - def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response: - full_url = self._make_full_url(endpoint) - - # Create the Request. - req = requests.Request( - method=method.upper(), - url=full_url, - headers=kwargs.get("headers"), - files=kwargs.get("files"), - data=kwargs.get("data"), - json=kwargs.get("json"), - params=kwargs.get("params"), - auth=kwargs.get("auth"), - cookies=kwargs.get("cookies"), - hooks=kwargs.get("hooks"), - ) - self.prepare_authentication(req) - self._debug(f'Preparing {req.method} request to "{req.url}"', extra=req.__dict__) - prepared_request: requests.PreparedRequest = self._session.prepare_request(req) - - self._info( - f"Sending request with payload={prepared_request.body!r}", - extra={"payload": shortify_log_value(kwargs.get("json", kwargs.get("data", {})))}, - ) - response = self._session.send(prepared_request) - - str_repr_content = response.content.decode("utf8")[:500] - self._info(f"Response {response.status_code=} {str_repr_content=}", extra={"status_code": response.status_code, "content": str_repr_content}) - - response.raise_for_status() - return response - - def prepare_authentication(self, request: requests.Request) -> None: - """Do auth setup in-place""" - pass - - def _info(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("INFO", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _debug(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("DEBUG", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _warn(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("WARNING", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _error(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("ERROR", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _critical(self, msg: str, *args: Any, **kwargs: Any) -> None: - self._log("CRITICAL", f"[{self._nonce}] {msg}", *args, **kwargs) - - def _log(self, level: str, msg: object, *args: object, stacklevel: int = 3, extra: dict | None = None, **kwargs: Any) -> None: - extra = extra or {} - extra.update(nonce=self._nonce) - self._logger.log(logging.getLevelNamesMapping()[level], msg, *args, stacklevel=stacklevel, extra=extra, **kwargs) diff --git a/src/api_client/sync_client.py b/src/api_client/sync_client.py new file mode 100644 index 0000000..6c191e8 --- /dev/null +++ b/src/api_client/sync_client.py @@ -0,0 +1,47 @@ +from abc import ABC +from typing import Any + +import httpx + +from ._base import BaseClient + + +class BaseAPIClient(BaseClient, ABC): + _session: httpx.Client + + def __init__(self, nonce: str = "", session: httpx.Client | None = None): + super().__init__(nonce, session or httpx.Client(http2=True)) + + def get(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("GET", endpoint, params=params, **kwargs) + + def post(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("POST", endpoint, json=json, data=data, **kwargs) + + def put(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("PUT", endpoint, json=json, data=data, **kwargs) + + def patch(self, endpoint: str, *, json: Any = None, data: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("PATCH", endpoint, json=json, data=data, **kwargs) + + def delete(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("DELETE", endpoint, params=params, **kwargs) + + def head(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("HEAD", endpoint, params=params, **kwargs) + + def options(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("OPTIONS", endpoint, params=params, **kwargs) + + def trace(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("TRACE", endpoint, params=params, **kwargs) + + def connect(self, endpoint: str, *, params: Any = None, **kwargs: Any) -> httpx.Response: + return self._request("CONNECT", endpoint, params=params, **kwargs) + + def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: + request = self._prepare_request(method, endpoint, **kwargs) + response = self._session.send(request, auth=self.prepare_authentication) + self._info_log_response(response) + + return response