Skip to content

HTTP Client #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ repos:
- id: trailing-whitespace

- repo: https://github.com/psf/black
rev: 23.1.0
rev: 24.4.2
hooks:
- id: black

Expand All @@ -29,12 +29,12 @@ repos:
args: [ --profile, black ]

- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
rev: 7.0.0
hooks:
- id: flake8

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
rev: v1.10.0
hooks:
- id: mypy
files: ^arangoasync/
Expand Down
1 change: 1 addition & 0 deletions arangoasync/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .version import __version__
152 changes: 152 additions & 0 deletions arangoasync/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
__all__ = [
"HTTPClient",
"AioHTTPClient",
"DefaultHTTPClient",
]

from abc import ABC, abstractmethod
from typing import Any, Optional

from aiohttp import BaseConnector, BasicAuth, ClientSession, ClientTimeout, TCPConnector

from arangoasync.request import Request
from arangoasync.response import Response


class HTTPClient(ABC): # pragma: no cover
"""Abstract base class for HTTP clients.
Custom HTTP clients should inherit from this class.
"""

@abstractmethod
def create_session(self, host: str) -> Any:
"""Return a new session given the base host URL.
This method must be overridden by the user.
:param host: ArangoDB host URL.
:type host: str
:returns: Requests session object.
:rtype: Any
"""
raise NotImplementedError

@abstractmethod
async def send_request(
self,
session: Any,
request: Request,
) -> Response:
"""Send an HTTP request.
This method must be overridden by the user.
:param session: Session object.
:type session: Any
:param request: HTTP request.
:type request: arangoasync.request.Request
:returns: HTTP response.
:rtype: arangoasync.response.Response
"""
raise NotImplementedError


class AioHTTPClient(HTTPClient):
"""HTTP client implemented on top of [aiohttp](https://docs.aiohttp.org/en/stable/).
:param connector: Supports connection pooling.
By default, 100 simultaneous connections are supported, with a 60-second timeout
for connection reusing after release.
:type connector: aiohttp.BaseConnector | None
:param timeout: Timeout settings.
300s total timeout by default for a complete request/response operation.
:type timeout: aiohttp.ClientTimeout | None
:param read_bufsize: Size of read buffer (64KB default).
:type read_bufsize: int
:param auth: HTTP authentication helper.
Should be used for specifying authorization data in client API.
:type auth: aiohttp.BasicAuth | None
:param compression_threshold: Will compress requests to the server if
the size of the request body (in bytes) is at least the value of this
option.
:type compression_threshold: int
"""

def __init__(
self,
connector: Optional[BaseConnector] = None,
timeout: Optional[ClientTimeout] = None,
read_bufsize: int = 2**16,
auth: Optional[BasicAuth] = None,
compression_threshold: int = 1024,
) -> None:
self._connector = connector or TCPConnector(
keepalive_timeout=60, # timeout for connection reusing after releasing
limit=100, # total number simultaneous connections
)
self._timeout = timeout or ClientTimeout(
total=300, # total number of seconds for the whole request
connect=60, # max number of seconds for acquiring a pool connection
)
self._read_bufsize = read_bufsize
self._auth = auth
self._compression_threshold = compression_threshold

def create_session(self, host: str) -> ClientSession:
"""Return a new session given the base host URL.
:param host: ArangoDB host URL. Typically, the address and port of a coordinator
(e.g. "http://127.0.0.1:8529").
:type host: str
:returns: Session object.
:rtype: aiohttp.ClientSession
"""
return ClientSession(
base_url=host,
connector=self._connector,
timeout=self._timeout,
auth=self._auth,
read_bufsize=self._read_bufsize,
)

async def send_request(
self,
session: ClientSession,
request: Request,
) -> Response:
"""Send an HTTP request.
:param session: Session object.
:type session: aiohttp.ClientSession
: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
compress = data is not None and len(data) >= self._compression_threshold

async with session.request(
method.name,
endpoint,
headers=headers,
params=params,
data=data,
compress=compress,
) 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,
)


DefaultHTTPClient = AioHTTPClient
119 changes: 119 additions & 0 deletions arangoasync/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
__all__ = [
"Method",
"Request",
]

from enum import Enum, auto
from typing import Optional

from arangoasync.typings import Headers, Params
from arangoasync.version import __version__


class Method(Enum):
"""HTTP methods."""

GET = auto()
POST = auto()
PUT = auto()
PATCH = auto()
DELETE = auto()
HEAD = auto()
OPTIONS = auto()


class Request:
"""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 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, for example "_api/version".
: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[str] = 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[str] = 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/{__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 parameters.
:param params: URL parameters.
:type params: dict | None
:returns: Normalized URL 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
82 changes: 82 additions & 0 deletions arangoasync/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
__all__ = [
"Response",
]

from typing import Optional

from arangoasync.request import Method
from arangoasync.typings import Headers


class Response:
"""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[str] = None
self.error_code: Optional[int] = None
self.error_message: Optional[str] = None
self.is_success: Optional[bool] = None
14 changes: 14 additions & 0 deletions arangoasync/typings.py
Original file line number Diff line number Diff line change
@@ -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"""
1 change: 1 addition & 0 deletions arangoasync/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.0.1"
Loading
Loading