From ba3d6172b2bea1261d1dbb6874eb6334933f657f Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sun, 23 Jun 2024 20:54:22 +0300 Subject: [PATCH] Drafting first http tools --- arangoasync/http.py | 145 ++++++++++++++++++++++++++++++++++++++++ arangoasync/request.py | 121 +++++++++++++++++++++++++++++++++ arangoasync/response.py | 80 ++++++++++++++++++++++ arangoasync/typings.py | 14 ++++ 4 files changed, 360 insertions(+) create mode 100644 arangoasync/http.py create mode 100644 arangoasync/request.py create mode 100644 arangoasync/response.py create mode 100644 arangoasync/typings.py diff --git a/arangoasync/http.py b/arangoasync/http.py new file mode 100644 index 0000000..aa8899c --- /dev/null +++ b/arangoasync/http.py @@ -0,0 +1,145 @@ +# TODO __all__ = [] + +from abc import ABC, abstractmethod +from typing import Optional + +from aiohttp import BaseConnector, BasicAuth, ClientSession, ClientTimeout +from request import Request +from response import Response + + +class Session(ABC): # pragma: no cover + """Abstract base class for HTTP sessions.""" + + @abstractmethod + async def request(self, request: Request) -> Response: + """Send an HTTP request. + + This method must be overridden by the user. + + :param request: HTTP request. + :type request: arangoasync.request.Request + :returns: HTTP response. + :rtype: arangoasync.response.Response + """ + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + """Close the session. + + This method must be overridden by the user. + """ + raise NotImplementedError + + +class HTTPClient(ABC): # pragma: no cover + """Abstract base class for HTTP clients.""" + + @abstractmethod + def create_session(self, host: str) -> Session: + """Return a new requests session given the host URL. + + This method must be overridden by the user. + + :param host: ArangoDB host URL. + :type host: str + :returns: Requests session object. + :rtype: arangoasync.http.Session + """ + raise NotImplementedError + + @abstractmethod + async def send_request( + self, + session: Session, + url: str, + request: Request, + ) -> Response: + """Send an HTTP request. + + This method must be overridden by the user. + + :param session: Session object. + :type session: arangoasync.http.Session + :param url: Request URL. + :type url: str + :param request: HTTP request. + :type request: arangoasync.request.Request + :returns: HTTP response. + :rtype: arango.response.Response + """ + raise NotImplementedError + + +class DefaultSession(Session): + """Wrapper on top of an aiohttp.ClientSession.""" + + def __init__( + self, + host: str, + connector: BaseConnector, + timeout: ClientTimeout, + read_bufsize: int = 2**16, + auth: Optional[BasicAuth] = None, + ) -> None: + """Initialize the session. + + :param host: ArangoDB coordinator URL (eg http://localhost:8530). + :type host: str + :param connector: Supports connection pooling. + :type connector: aiohttp.BaseConnector + :param timeout: Request timeout settings. + :type timeout: aiohttp.ClientTimeout + :param read_bufsize: Size of read buffer. 64 Kib by default. + :type read_bufsize: int + :param auth: HTTP Authorization. + :type auth: aiohttp.BasicAuth | None + """ + self._session = ClientSession( + base_url=host, + connector=connector, + timeout=timeout, + auth=auth, + read_bufsize=read_bufsize, + connector_owner=False, + auto_decompress=True, + ) + + async def request(self, request: Request) -> Response: + """Send an HTTP request. + + :param request: HTTP request. + :type request: arangoasync.request.Request + :returns: HTTP response. + :rtype: arangoasync.response.Response + """ + method = request.method + endpoint = request.endpoint + headers = request.headers + params = request.params + data = request.data + + async with self._session.request( + method.name, + endpoint, + headers=headers, + params=params, + data=data, + ) as response: + raw_body = await response.read() + return Response( + method=method, + url=str(response.real_url), + headers=response.headers, + status_code=response.status, + status_text=response.reason, + raw_body=raw_body, + ) + + async def close(self) -> None: + """Close the session.""" + await self._session.close() + + +# TODO implement DefaultHTTPClient diff --git a/arangoasync/request.py b/arangoasync/request.py new file mode 100644 index 0000000..b24e33b --- /dev/null +++ b/arangoasync/request.py @@ -0,0 +1,121 @@ +__all__ = [ + "Method", + "Request", +] + +from enum import Enum, auto +from typing import Generic, Optional, TypeVar + +from typings import Headers, Params +from version import __version__ as driver_version + +T = TypeVar("T") + + +class Method(Enum): + """HTTP methods.""" + + GET = auto() + POST = auto() + PUT = auto() + PATCH = auto() + DELETE = auto() + HEAD = auto() + OPTIONS = auto() + + +class Request(Generic[T]): + """HTTP request. + + :param method: HTTP method. + :type method: request.Method + :param endpoint: API endpoint. + :type endpoint: str + :param headers: Request headers. + :type headers: dict | None + :param params: URL (query) parameters. + :type params: dict | None + :param data: Request payload. + :type data: Any + :param deserialize: Whether the response body should be deserialized. + :type deserialize: bool + + :ivar method: HTTP method. + :vartype method: request.Method + :ivar endpoint: API endpoint. + :vartype endpoint: str + :ivar headers: Request headers. + :vartype headers: dict | None + :ivar params: URL (query) parameters. + :vartype params: dict | None + :ivar data: Request payload. + :vartype data: Any + :ivar deserialize: Whether the response body should be deserialized. + :vartype deserialize: bool + """ + + __slots__ = ( + "method", + "endpoint", + "headers", + "params", + "data", + "deserialize", + ) + + def __init__( + self, + method: Method, + endpoint: str, + headers: Optional[Headers] = None, + params: Optional[Params] = None, + data: Optional[T] = None, + deserialize: bool = True, + ) -> None: + self.method: Method = method + self.endpoint: str = endpoint + self.headers: Headers = self._normalize_headers(headers) + self.params: Params = self._normalize_params(params) + self.data: Optional[T] = data + self.deserialize: bool = deserialize + + @staticmethod + def _normalize_headers(headers: Optional[Headers]) -> Headers: + """Normalize request headers. + + :param headers: Request headers. + :type headers: dict | None + :returns: Normalized request headers. + :rtype: dict + """ + driver_header = f"arangoasync/{driver_version}" + normalized_headers: Headers = { + "charset": "utf-8", + "content-type": "application/json", + "x-arango-driver": driver_header, + } + + if headers is not None: + for key, value in headers.items(): + normalized_headers[key.lower()] = value + + return normalized_headers + + @staticmethod + def _normalize_params(params: Optional[Params]) -> Params: + """Normalize URL (query) parameters. + + :param params: URL (query) parameters. + :type params: dict | None + :returns: Normalized URL (query) parameters. + :rtype: dict + """ + normalized_params: Params = {} + + if params is not None: + for key, value in params.items(): + if isinstance(value, bool): + value = int(value) + normalized_params[key] = str(value) + + return normalized_params diff --git a/arangoasync/response.py b/arangoasync/response.py new file mode 100644 index 0000000..f44710a --- /dev/null +++ b/arangoasync/response.py @@ -0,0 +1,80 @@ +from typing import Generic, Optional, TypeVar + +from request import Method +from typings import Headers + +T = TypeVar("T") + + +class Response(Generic[T]): + """HTTP response. + + :param method: HTTP method. + :type method: request.Method + :param url: API URL. + :type url: str + :param headers: Response headers. + :type headers: dict | None + :param status_code: Response status code. + :type status_code: int + :param status_text: Response status text. + :type status_text: str + :param raw_body: Raw response body. + :type raw_body: str + + :ivar method: HTTP method. + :vartype method: request.Method + :ivar url: API URL. + :vartype url: str + :ivar headers: Response headers. + :vartype headers: dict | None + :ivar status_code: Response status code. + :vartype status_code: int + :ivar status_text: Response status text. + :vartype status_text: str + :ivar raw_body: Raw response body. + :vartype raw_body: str + :ivar body: Response body after processing. + :vartype body: Any + :ivar error_code: Error code from ArangoDB server. + :vartype error_code: int + :ivar error_message: Error message from ArangoDB server. + :vartype error_message: str + :ivar is_success: True if response status code was 2XX. + :vartype is_success: bool + """ + + __slots__ = ( + "method", + "url", + "headers", + "status_code", + "status_text", + "body", + "raw_body", + "error_code", + "error_message", + "is_success", + ) + + def __init__( + self, + method: Method, + url: str, + headers: Headers, + status_code: int, + status_text: str, + raw_body: bytes, + ) -> None: + self.method: Method = method + self.url: str = url + self.headers: Headers = headers + self.status_code: int = status_code + self.status_text: str = status_text + self.raw_body: bytes = raw_body + + # Populated later + self.body: Optional[T] = None + self.error_code: Optional[int] = None + self.error_message: Optional[str] = None + self.is_success: Optional[bool] = None diff --git a/arangoasync/typings.py b/arangoasync/typings.py new file mode 100644 index 0000000..a96bbac --- /dev/null +++ b/arangoasync/typings.py @@ -0,0 +1,14 @@ +__all__ = [ + "Headers", + "Params", +] + +from typing import MutableMapping + +from multidict import MultiDict + +Headers = MutableMapping[str, str] | MultiDict[str] +Headers.__doc__ = """Type definition for HTTP headers""" + +Params = MutableMapping[str, bool | int | str] +Params.__doc__ = """Type definition for URL (query) parameters"""