Skip to content

Commit 318bd83

Browse files
authored
HTTP Client (#7)
* Bumping dependency versions * Drafting first http tools * HttpClient first version * AioHTTPClient refined * Setting generics aside for now * Updating homepage
1 parent 081f657 commit 318bd83

13 files changed

+551
-12
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ repos:
1818
- id: trailing-whitespace
1919

2020
- repo: https://github.com/psf/black
21-
rev: 23.1.0
21+
rev: 24.4.2
2222
hooks:
2323
- id: black
2424

@@ -29,12 +29,12 @@ repos:
2929
args: [ --profile, black ]
3030

3131
- repo: https://github.com/PyCQA/flake8
32-
rev: 6.0.0
32+
rev: 7.0.0
3333
hooks:
3434
- id: flake8
3535

3636
- repo: https://github.com/pre-commit/mirrors-mypy
37-
rev: v0.991
37+
rev: v1.10.0
3838
hooks:
3939
- id: mypy
4040
files: ^arangoasync/

arangoasync/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .version import __version__

arangoasync/http.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
__all__ = [
2+
"HTTPClient",
3+
"AioHTTPClient",
4+
"DefaultHTTPClient",
5+
]
6+
7+
from abc import ABC, abstractmethod
8+
from typing import Any, Optional
9+
10+
from aiohttp import BaseConnector, BasicAuth, ClientSession, ClientTimeout, TCPConnector
11+
12+
from arangoasync.request import Request
13+
from arangoasync.response import Response
14+
15+
16+
class HTTPClient(ABC): # pragma: no cover
17+
"""Abstract base class for HTTP clients.
18+
Custom HTTP clients should inherit from this class.
19+
"""
20+
21+
@abstractmethod
22+
def create_session(self, host: str) -> Any:
23+
"""Return a new session given the base host URL.
24+
25+
This method must be overridden by the user.
26+
27+
:param host: ArangoDB host URL.
28+
:type host: str
29+
:returns: Requests session object.
30+
:rtype: Any
31+
"""
32+
raise NotImplementedError
33+
34+
@abstractmethod
35+
async def send_request(
36+
self,
37+
session: Any,
38+
request: Request,
39+
) -> Response:
40+
"""Send an HTTP request.
41+
42+
This method must be overridden by the user.
43+
44+
:param session: Session object.
45+
:type session: Any
46+
:param request: HTTP request.
47+
:type request: arangoasync.request.Request
48+
:returns: HTTP response.
49+
:rtype: arangoasync.response.Response
50+
"""
51+
raise NotImplementedError
52+
53+
54+
class AioHTTPClient(HTTPClient):
55+
"""HTTP client implemented on top of [aiohttp](https://docs.aiohttp.org/en/stable/).
56+
57+
:param connector: Supports connection pooling.
58+
By default, 100 simultaneous connections are supported, with a 60-second timeout
59+
for connection reusing after release.
60+
:type connector: aiohttp.BaseConnector | None
61+
:param timeout: Timeout settings.
62+
300s total timeout by default for a complete request/response operation.
63+
:type timeout: aiohttp.ClientTimeout | None
64+
:param read_bufsize: Size of read buffer (64KB default).
65+
:type read_bufsize: int
66+
:param auth: HTTP authentication helper.
67+
Should be used for specifying authorization data in client API.
68+
:type auth: aiohttp.BasicAuth | None
69+
:param compression_threshold: Will compress requests to the server if
70+
the size of the request body (in bytes) is at least the value of this
71+
option.
72+
:type compression_threshold: int
73+
"""
74+
75+
def __init__(
76+
self,
77+
connector: Optional[BaseConnector] = None,
78+
timeout: Optional[ClientTimeout] = None,
79+
read_bufsize: int = 2**16,
80+
auth: Optional[BasicAuth] = None,
81+
compression_threshold: int = 1024,
82+
) -> None:
83+
self._connector = connector or TCPConnector(
84+
keepalive_timeout=60, # timeout for connection reusing after releasing
85+
limit=100, # total number simultaneous connections
86+
)
87+
self._timeout = timeout or ClientTimeout(
88+
total=300, # total number of seconds for the whole request
89+
connect=60, # max number of seconds for acquiring a pool connection
90+
)
91+
self._read_bufsize = read_bufsize
92+
self._auth = auth
93+
self._compression_threshold = compression_threshold
94+
95+
def create_session(self, host: str) -> ClientSession:
96+
"""Return a new session given the base host URL.
97+
98+
:param host: ArangoDB host URL. Typically, the address and port of a coordinator
99+
(e.g. "http://127.0.0.1:8529").
100+
:type host: str
101+
:returns: Session object.
102+
:rtype: aiohttp.ClientSession
103+
"""
104+
return ClientSession(
105+
base_url=host,
106+
connector=self._connector,
107+
timeout=self._timeout,
108+
auth=self._auth,
109+
read_bufsize=self._read_bufsize,
110+
)
111+
112+
async def send_request(
113+
self,
114+
session: ClientSession,
115+
request: Request,
116+
) -> Response:
117+
"""Send an HTTP request.
118+
119+
:param session: Session object.
120+
:type session: aiohttp.ClientSession
121+
:param request: HTTP request.
122+
:type request: arangoasync.request.Request
123+
:returns: HTTP response.
124+
:rtype: arangoasync.response.Response
125+
"""
126+
method = request.method
127+
endpoint = request.endpoint
128+
headers = request.headers
129+
params = request.params
130+
data = request.data
131+
compress = data is not None and len(data) >= self._compression_threshold
132+
133+
async with session.request(
134+
method.name,
135+
endpoint,
136+
headers=headers,
137+
params=params,
138+
data=data,
139+
compress=compress,
140+
) as response:
141+
raw_body = await response.read()
142+
return Response(
143+
method=method,
144+
url=str(response.real_url),
145+
headers=response.headers,
146+
status_code=response.status,
147+
status_text=response.reason,
148+
raw_body=raw_body,
149+
)
150+
151+
152+
DefaultHTTPClient = AioHTTPClient

arangoasync/request.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
__all__ = [
2+
"Method",
3+
"Request",
4+
]
5+
6+
from enum import Enum, auto
7+
from typing import Optional
8+
9+
from arangoasync.typings import Headers, Params
10+
from arangoasync.version import __version__
11+
12+
13+
class Method(Enum):
14+
"""HTTP methods."""
15+
16+
GET = auto()
17+
POST = auto()
18+
PUT = auto()
19+
PATCH = auto()
20+
DELETE = auto()
21+
HEAD = auto()
22+
OPTIONS = auto()
23+
24+
25+
class Request:
26+
"""HTTP request.
27+
28+
:param method: HTTP method.
29+
:type method: request.Method
30+
:param endpoint: API endpoint.
31+
:type endpoint: str
32+
:param headers: Request headers.
33+
:type headers: dict | None
34+
:param params: URL parameters.
35+
:type params: dict | None
36+
:param data: Request payload.
37+
:type data: Any
38+
:param deserialize: Whether the response body should be deserialized.
39+
:type deserialize: bool
40+
41+
:ivar method: HTTP method.
42+
:vartype method: request.Method
43+
:ivar endpoint: API endpoint, for example "_api/version".
44+
:vartype endpoint: str
45+
:ivar headers: Request headers.
46+
:vartype headers: dict | None
47+
:ivar params: URL (query) parameters.
48+
:vartype params: dict | None
49+
:ivar data: Request payload.
50+
:vartype data: Any
51+
:ivar deserialize: Whether the response body should be deserialized.
52+
:vartype deserialize: bool
53+
"""
54+
55+
__slots__ = (
56+
"method",
57+
"endpoint",
58+
"headers",
59+
"params",
60+
"data",
61+
"deserialize",
62+
)
63+
64+
def __init__(
65+
self,
66+
method: Method,
67+
endpoint: str,
68+
headers: Optional[Headers] = None,
69+
params: Optional[Params] = None,
70+
data: Optional[str] = None,
71+
deserialize: bool = True,
72+
) -> None:
73+
self.method: Method = method
74+
self.endpoint: str = endpoint
75+
self.headers: Headers = self._normalize_headers(headers)
76+
self.params: Params = self._normalize_params(params)
77+
self.data: Optional[str] = data
78+
self.deserialize: bool = deserialize
79+
80+
@staticmethod
81+
def _normalize_headers(headers: Optional[Headers]) -> Headers:
82+
"""Normalize request headers.
83+
84+
:param headers: Request headers.
85+
:type headers: dict | None
86+
:returns: Normalized request headers.
87+
:rtype: dict
88+
"""
89+
driver_header = f"arangoasync/{__version__}"
90+
normalized_headers: Headers = {
91+
"charset": "utf-8",
92+
"content-type": "application/json",
93+
"x-arango-driver": driver_header,
94+
}
95+
96+
if headers is not None:
97+
for key, value in headers.items():
98+
normalized_headers[key.lower()] = value
99+
100+
return normalized_headers
101+
102+
@staticmethod
103+
def _normalize_params(params: Optional[Params]) -> Params:
104+
"""Normalize URL parameters.
105+
106+
:param params: URL parameters.
107+
:type params: dict | None
108+
:returns: Normalized URL parameters.
109+
:rtype: dict
110+
"""
111+
normalized_params: Params = {}
112+
113+
if params is not None:
114+
for key, value in params.items():
115+
if isinstance(value, bool):
116+
value = int(value)
117+
normalized_params[key] = str(value)
118+
119+
return normalized_params

arangoasync/response.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
__all__ = [
2+
"Response",
3+
]
4+
5+
from typing import Optional
6+
7+
from arangoasync.request import Method
8+
from arangoasync.typings import Headers
9+
10+
11+
class Response:
12+
"""HTTP response.
13+
14+
:param method: HTTP method.
15+
:type method: request.Method
16+
:param url: API URL.
17+
:type url: str
18+
:param headers: Response headers.
19+
:type headers: dict | None
20+
:param status_code: Response status code.
21+
:type status_code: int
22+
:param status_text: Response status text.
23+
:type status_text: str
24+
:param raw_body: Raw response body.
25+
:type raw_body: str
26+
27+
:ivar method: HTTP method.
28+
:vartype method: request.Method
29+
:ivar url: API URL.
30+
:vartype url: str
31+
:ivar headers: Response headers.
32+
:vartype headers: dict | None
33+
:ivar status_code: Response status code.
34+
:vartype status_code: int
35+
:ivar status_text: Response status text.
36+
:vartype status_text: str
37+
:ivar raw_body: Raw response body.
38+
:vartype raw_body: str
39+
:ivar body: Response body after processing.
40+
:vartype body: Any
41+
:ivar error_code: Error code from ArangoDB server.
42+
:vartype error_code: int
43+
:ivar error_message: Error message from ArangoDB server.
44+
:vartype error_message: str
45+
:ivar is_success: True if response status code was 2XX.
46+
:vartype is_success: bool
47+
"""
48+
49+
__slots__ = (
50+
"method",
51+
"url",
52+
"headers",
53+
"status_code",
54+
"status_text",
55+
"body",
56+
"raw_body",
57+
"error_code",
58+
"error_message",
59+
"is_success",
60+
)
61+
62+
def __init__(
63+
self,
64+
method: Method,
65+
url: str,
66+
headers: Headers,
67+
status_code: int,
68+
status_text: str,
69+
raw_body: bytes,
70+
) -> None:
71+
self.method: Method = method
72+
self.url: str = url
73+
self.headers: Headers = headers
74+
self.status_code: int = status_code
75+
self.status_text: str = status_text
76+
self.raw_body: bytes = raw_body
77+
78+
# Populated later
79+
self.body: Optional[str] = None
80+
self.error_code: Optional[int] = None
81+
self.error_message: Optional[str] = None
82+
self.is_success: Optional[bool] = None

arangoasync/typings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
__all__ = [
2+
"Headers",
3+
"Params",
4+
]
5+
6+
from typing import MutableMapping
7+
8+
from multidict import MultiDict
9+
10+
Headers = MutableMapping[str, str] | MultiDict[str]
11+
Headers.__doc__ = """Type definition for HTTP headers"""
12+
13+
Params = MutableMapping[str, bool | int | str]
14+
Params.__doc__ = """Type definition for URL (query) parameters"""

arangoasync/version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.0.1"

0 commit comments

Comments
 (0)