finalsa-common-http-client 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- finalsa/http/_shared.py +94 -0
- finalsa/http/async/__init__.py +3 -0
- finalsa/http/async/base.py +154 -0
- finalsa/http/sync/__init__.py +1 -0
- finalsa/http/sync/base.py +143 -0
- finalsa_common_http_client-0.0.1.dist-info/METADATA +235 -0
- finalsa_common_http_client-0.0.1.dist-info/RECORD +9 -0
- finalsa_common_http_client-0.0.1.dist-info/WHEEL +4 -0
- finalsa_common_http_client-0.0.1.dist-info/licenses/LICENSE.md +21 -0
finalsa/http/_shared.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from finalsa.traceability import get_w3c_traceparent, get_w3c_tracestate
|
|
8
|
+
from finalsa.traceability.functions import (
|
|
9
|
+
HTTP_HEADER_TRACEPARENT,
|
|
10
|
+
HTTP_HEADER_TRACESTATE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Headers = Mapping[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_base_url(
|
|
17
|
+
base_url: str | None,
|
|
18
|
+
default_scheme: str,
|
|
19
|
+
) -> str | None:
|
|
20
|
+
"""Return a normalized base URL or None when no usable value is provided."""
|
|
21
|
+
if not base_url:
|
|
22
|
+
return None
|
|
23
|
+
value = base_url.strip()
|
|
24
|
+
if not value:
|
|
25
|
+
return None
|
|
26
|
+
if "://" not in value:
|
|
27
|
+
value = f"{default_scheme}://{value}"
|
|
28
|
+
return value.rstrip("/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_scheme(candidate: str, default_scheme: str) -> str:
|
|
32
|
+
"""Ensure that the provided candidate has an explicit scheme."""
|
|
33
|
+
trimmed = candidate.strip()
|
|
34
|
+
if "://" in trimmed:
|
|
35
|
+
return trimmed
|
|
36
|
+
return f"{default_scheme}://{trimmed}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_url(
|
|
40
|
+
*,
|
|
41
|
+
path: str | None,
|
|
42
|
+
url: str | None,
|
|
43
|
+
base_url: str | None,
|
|
44
|
+
default_scheme: str,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Resolve the final request URL from the supplied inputs."""
|
|
47
|
+
if url:
|
|
48
|
+
return ensure_scheme(url, default_scheme)
|
|
49
|
+
|
|
50
|
+
if path is None:
|
|
51
|
+
raise ValueError("Either 'path' or 'url' must be provided.")
|
|
52
|
+
|
|
53
|
+
candidate = path.strip()
|
|
54
|
+
if candidate.startswith(("http://", "https://")):
|
|
55
|
+
return candidate
|
|
56
|
+
if base_url:
|
|
57
|
+
base = base_url if base_url.endswith("/") else f"{base_url}/"
|
|
58
|
+
return urljoin(base, candidate.lstrip("/"))
|
|
59
|
+
return ensure_scheme(candidate, default_scheme)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def merge_headers(
|
|
63
|
+
default_headers: Mapping[str, str],
|
|
64
|
+
headers: Mapping[str, str] | None,
|
|
65
|
+
) -> dict[str, str]:
|
|
66
|
+
"""Merge default headers with any per-request overrides."""
|
|
67
|
+
merged = dict(default_headers)
|
|
68
|
+
if headers:
|
|
69
|
+
merged.update(headers)
|
|
70
|
+
merged[HTTP_HEADER_TRACEPARENT] = get_w3c_traceparent()
|
|
71
|
+
merged[HTTP_HEADER_TRACESTATE] = get_w3c_tracestate()
|
|
72
|
+
return merged
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_default_headers(
|
|
76
|
+
default_headers: Mapping[str, str],
|
|
77
|
+
user_headers: Mapping[str, str] | None,
|
|
78
|
+
service_name: str,
|
|
79
|
+
) -> dict[str, str]:
|
|
80
|
+
"""Construct the default header set for an HTTP client."""
|
|
81
|
+
headers = dict(default_headers)
|
|
82
|
+
headers.setdefault("User-Agent", f"{service_name}/{get_package_version()}")
|
|
83
|
+
if user_headers:
|
|
84
|
+
headers.update(user_headers)
|
|
85
|
+
return headers
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_package_version(package_name: str = "finalsa-http-client") -> str:
|
|
89
|
+
"""Return the installed package version or a sensible fallback in dev mode."""
|
|
90
|
+
try:
|
|
91
|
+
return metadata.version(package_name)
|
|
92
|
+
except metadata.PackageNotFoundError:
|
|
93
|
+
return "0.0.0"
|
|
94
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from aiohttp import ClientSession, ClientTimeout
|
|
8
|
+
|
|
9
|
+
from finalsa.http import _shared as shared
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseAsyncHttpClient:
|
|
13
|
+
"""Reusable aiohttp-based HTTP client with sane defaults."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_HEADERS: Mapping[str, str] = {
|
|
16
|
+
"Accept": "application/json",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
default_headers: Mapping[str, str] | None = None,
|
|
25
|
+
timeout: float | ClientTimeout | None = None,
|
|
26
|
+
default_scheme: str = "http",
|
|
27
|
+
service_name: str = "finalsa-http-client",
|
|
28
|
+
trust_env: bool = False,
|
|
29
|
+
raise_for_status: bool = True,
|
|
30
|
+
session: ClientSession | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._default_scheme = default_scheme
|
|
33
|
+
self.base_url = shared.normalize_base_url(base_url, default_scheme)
|
|
34
|
+
self._timeout = self._normalize_timeout(timeout)
|
|
35
|
+
self._trust_env = trust_env
|
|
36
|
+
self._raise_for_status = raise_for_status
|
|
37
|
+
self._session = session
|
|
38
|
+
self._owns_session = session is None
|
|
39
|
+
self._default_headers = shared.build_default_headers(
|
|
40
|
+
self.DEFAULT_HEADERS,
|
|
41
|
+
default_headers,
|
|
42
|
+
service_name,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def __aenter__(self) -> BaseAsyncHttpClient:
|
|
46
|
+
await self._ensure_session()
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
|
50
|
+
await self.close()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def session(self) -> ClientSession | None:
|
|
54
|
+
"""Return the underlying aiohttp session, if it has been created."""
|
|
55
|
+
return self._session
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def default_headers(self) -> Mapping[str, str]:
|
|
59
|
+
"""Return a copy of the default headers."""
|
|
60
|
+
return dict(self._default_headers)
|
|
61
|
+
|
|
62
|
+
def update_default_headers(self, headers: Mapping[str, str]) -> None:
|
|
63
|
+
"""Update the headers that will be sent with every request."""
|
|
64
|
+
if not headers:
|
|
65
|
+
return
|
|
66
|
+
self._default_headers.update(headers)
|
|
67
|
+
|
|
68
|
+
def build_url(self, path_or_url: str) -> str:
|
|
69
|
+
"""Resolve the final URL for a request."""
|
|
70
|
+
return shared.build_url(
|
|
71
|
+
path=path_or_url,
|
|
72
|
+
url=None,
|
|
73
|
+
base_url=self.base_url,
|
|
74
|
+
default_scheme=self._default_scheme,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str | None = None,
|
|
81
|
+
*,
|
|
82
|
+
url: str | None = None,
|
|
83
|
+
headers: Mapping[str, str] | None = None,
|
|
84
|
+
params: Mapping[str, str | int | float | None] | None = None,
|
|
85
|
+
json: Any | None = None,
|
|
86
|
+
data: Any | None = None,
|
|
87
|
+
timeout: float | ClientTimeout | None = None,
|
|
88
|
+
**kwargs: Any,
|
|
89
|
+
) -> aiohttp.ClientResponse:
|
|
90
|
+
"""Send an HTTP request using aiohttp.ClientSession.request."""
|
|
91
|
+
if path is None and url is None:
|
|
92
|
+
raise ValueError("Either 'path' or 'url' must be provided.")
|
|
93
|
+
|
|
94
|
+
session = await self._ensure_session()
|
|
95
|
+
resolved_url = shared.build_url(
|
|
96
|
+
path=path,
|
|
97
|
+
url=url,
|
|
98
|
+
base_url=self.base_url,
|
|
99
|
+
default_scheme=self._default_scheme,
|
|
100
|
+
)
|
|
101
|
+
merged_headers = shared.merge_headers(self._default_headers, headers)
|
|
102
|
+
request_timeout = self._normalize_timeout(timeout) or self._timeout
|
|
103
|
+
|
|
104
|
+
return await session.request(
|
|
105
|
+
method.upper(),
|
|
106
|
+
resolved_url,
|
|
107
|
+
headers=merged_headers,
|
|
108
|
+
params=params,
|
|
109
|
+
json=json,
|
|
110
|
+
data=data,
|
|
111
|
+
timeout=request_timeout,
|
|
112
|
+
**kwargs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def get(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
116
|
+
return await self.request("GET", path, **kwargs)
|
|
117
|
+
|
|
118
|
+
async def post(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
119
|
+
return await self.request("POST", path, **kwargs)
|
|
120
|
+
|
|
121
|
+
async def put(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
122
|
+
return await self.request("PUT", path, **kwargs)
|
|
123
|
+
|
|
124
|
+
async def patch(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
125
|
+
return await self.request("PATCH", path, **kwargs)
|
|
126
|
+
|
|
127
|
+
async def delete(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
128
|
+
return await self.request("DELETE", path, **kwargs)
|
|
129
|
+
|
|
130
|
+
async def close(self) -> None:
|
|
131
|
+
"""Close the underlying aiohttp session if we created it."""
|
|
132
|
+
if self._session and self._owns_session and not self._session.closed:
|
|
133
|
+
await self._session.close()
|
|
134
|
+
if self._owns_session:
|
|
135
|
+
self._session = None
|
|
136
|
+
|
|
137
|
+
async def _ensure_session(self) -> ClientSession:
|
|
138
|
+
if self._session is None or self._session.closed:
|
|
139
|
+
self._session = aiohttp.ClientSession(
|
|
140
|
+
timeout=self._timeout,
|
|
141
|
+
trust_env=self._trust_env,
|
|
142
|
+
raise_for_status=self._raise_for_status,
|
|
143
|
+
)
|
|
144
|
+
return self._session
|
|
145
|
+
|
|
146
|
+
def _normalize_timeout(
|
|
147
|
+
self,
|
|
148
|
+
timeout: float | ClientTimeout | None,
|
|
149
|
+
) -> ClientTimeout | None:
|
|
150
|
+
if timeout is None:
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(timeout, ClientTimeout):
|
|
153
|
+
return timeout
|
|
154
|
+
return ClientTimeout(total=timeout)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base import BaseSyncHttpClient as BaseSyncHttpClient
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from finalsa.http import _shared as shared
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseSyncHttpClient:
|
|
12
|
+
"""Reusable requests-based HTTP client with sane defaults."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_HEADERS: Mapping[str, str] = {
|
|
15
|
+
"Accept": "application/json",
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
base_url: str | None = None,
|
|
23
|
+
default_headers: Mapping[str, str] | None = None,
|
|
24
|
+
timeout: float | tuple[float, float] | None = None,
|
|
25
|
+
default_scheme: str = "http",
|
|
26
|
+
service_name: str = "finalsa-http-client",
|
|
27
|
+
trust_env: bool = True,
|
|
28
|
+
raise_for_status: bool = True,
|
|
29
|
+
session: requests.Session | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._default_scheme = default_scheme
|
|
32
|
+
self.base_url = shared.normalize_base_url(base_url, default_scheme)
|
|
33
|
+
self._timeout = timeout
|
|
34
|
+
self._trust_env = trust_env
|
|
35
|
+
self._raise_for_status = raise_for_status
|
|
36
|
+
self._session = session
|
|
37
|
+
self._owns_session = session is None
|
|
38
|
+
self._default_headers = shared.build_default_headers(
|
|
39
|
+
self.DEFAULT_HEADERS,
|
|
40
|
+
default_headers,
|
|
41
|
+
service_name,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> BaseSyncHttpClient:
|
|
45
|
+
self._ensure_session()
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def session(self) -> requests.Session | None:
|
|
53
|
+
"""Return the underlying requests session, if it has been created."""
|
|
54
|
+
return self._session
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def default_headers(self) -> Mapping[str, str]:
|
|
58
|
+
"""Return a copy of the default headers."""
|
|
59
|
+
return dict(self._default_headers)
|
|
60
|
+
|
|
61
|
+
def update_default_headers(self, headers: Mapping[str, str]) -> None:
|
|
62
|
+
"""Update the headers that will be sent with every request."""
|
|
63
|
+
if not headers:
|
|
64
|
+
return
|
|
65
|
+
self._default_headers.update(headers)
|
|
66
|
+
|
|
67
|
+
def build_url(self, path_or_url: str) -> str:
|
|
68
|
+
"""Resolve the final URL for a request."""
|
|
69
|
+
return shared.build_url(
|
|
70
|
+
path=path_or_url,
|
|
71
|
+
url=None,
|
|
72
|
+
base_url=self.base_url,
|
|
73
|
+
default_scheme=self._default_scheme,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def request(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str | None = None,
|
|
80
|
+
*,
|
|
81
|
+
url: str | None = None,
|
|
82
|
+
headers: Mapping[str, str] | None = None,
|
|
83
|
+
params: Mapping[str, str | int | float | None] | None = None,
|
|
84
|
+
json: Any | None = None,
|
|
85
|
+
data: Any | None = None,
|
|
86
|
+
timeout: float | tuple[float, float] | None = None,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> requests.Response:
|
|
89
|
+
"""Send an HTTP request using requests.Session.request."""
|
|
90
|
+
if path is None and url is None:
|
|
91
|
+
raise ValueError("Either 'path' or 'url' must be provided.")
|
|
92
|
+
|
|
93
|
+
session = self._ensure_session()
|
|
94
|
+
resolved_url = shared.build_url(
|
|
95
|
+
path=path,
|
|
96
|
+
url=url,
|
|
97
|
+
base_url=self.base_url,
|
|
98
|
+
default_scheme=self._default_scheme,
|
|
99
|
+
)
|
|
100
|
+
merged_headers = shared.merge_headers(self._default_headers, headers)
|
|
101
|
+
request_timeout = timeout if timeout is not None else self._timeout
|
|
102
|
+
|
|
103
|
+
response = session.request(
|
|
104
|
+
method.upper(),
|
|
105
|
+
resolved_url,
|
|
106
|
+
headers=merged_headers,
|
|
107
|
+
params=params,
|
|
108
|
+
json=json,
|
|
109
|
+
data=data,
|
|
110
|
+
timeout=request_timeout,
|
|
111
|
+
**kwargs,
|
|
112
|
+
)
|
|
113
|
+
if self._raise_for_status:
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
def get(self, path: str, **kwargs: Any) -> requests.Response:
|
|
118
|
+
return self.request("GET", path, **kwargs)
|
|
119
|
+
|
|
120
|
+
def post(self, path: str, **kwargs: Any) -> requests.Response:
|
|
121
|
+
return self.request("POST", path, **kwargs)
|
|
122
|
+
|
|
123
|
+
def put(self, path: str, **kwargs: Any) -> requests.Response:
|
|
124
|
+
return self.request("PUT", path, **kwargs)
|
|
125
|
+
|
|
126
|
+
def patch(self, path: str, **kwargs: Any) -> requests.Response:
|
|
127
|
+
return self.request("PATCH", path, **kwargs)
|
|
128
|
+
|
|
129
|
+
def delete(self, path: str, **kwargs: Any) -> requests.Response:
|
|
130
|
+
return self.request("DELETE", path, **kwargs)
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
"""Close the underlying requests session if we created it."""
|
|
134
|
+
if self._session and self._owns_session:
|
|
135
|
+
self._session.close()
|
|
136
|
+
self._session = None
|
|
137
|
+
|
|
138
|
+
def _ensure_session(self) -> requests.Session:
|
|
139
|
+
if self._session is None:
|
|
140
|
+
session = requests.Session()
|
|
141
|
+
session.trust_env = self._trust_env
|
|
142
|
+
self._session = session
|
|
143
|
+
return self._session
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finalsa-common-http-client
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: HTTP client library for common data types used in business applications
|
|
5
|
+
Project-URL: Homepage, https://github.com/finalsa/finalsa-http-client
|
|
6
|
+
Project-URL: Documentation, https://github.com/finalsa/finalsa-http-client#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/finalsa/finalsa-http-client.git
|
|
8
|
+
Project-URL: Issues, https://github.com/finalsa/finalsa-http-client/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/finalsa/finalsa-http-client/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Luis Jimenez <luis@finalsa.com>
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2021 Luis Diego Jiménez Delgado
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
License-File: LICENSE.md
|
|
33
|
+
Keywords: client,finalsa,http
|
|
34
|
+
Classifier: Development Status :: 4 - Beta
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Classifier: Topic :: Software Development :: Localization
|
|
46
|
+
Classifier: Typing :: Typed
|
|
47
|
+
Requires-Python: >=3.9
|
|
48
|
+
Requires-Dist: aiohttp>=3.10.5
|
|
49
|
+
Requires-Dist: fastapi>=0.116.1
|
|
50
|
+
Requires-Dist: finalsa-traceability>=1.0.1
|
|
51
|
+
Requires-Dist: requests>=2.32.4
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# Finalsa HTTP Client
|
|
55
|
+
|
|
56
|
+
`finalsa-http-client` provides small, well-tested base classes for building HTTP
|
|
57
|
+
clients that share a consistent set of defaults across Finalsa services. One
|
|
58
|
+
implementation wraps `requests` for synchronous workloads and the other wraps
|
|
59
|
+
`aiohttp` for asyncio-aware code.
|
|
60
|
+
|
|
61
|
+
- Normalizes `base_url` values and fills in schemes automatically.
|
|
62
|
+
- Builds a predictable `User-Agent` header (e.g. `finalsa-http-client/0.0.1`).
|
|
63
|
+
- Merges per-request headers with service defaults.
|
|
64
|
+
- Manages the underlying session lifecycle (context manager friendly).
|
|
65
|
+
- Lets you opt into strict `raise_for_status` behavior on every request.
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pip install finalsa-http-client
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Synchronous usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from finalsa.http.sync import BaseSyncHttpClient
|
|
77
|
+
|
|
78
|
+
with BaseSyncHttpClient(
|
|
79
|
+
base_url="https://api.example.test",
|
|
80
|
+
service_name="billing-api",
|
|
81
|
+
default_headers={"X-App": "billing"},
|
|
82
|
+
timeout=5, # seconds
|
|
83
|
+
trust_env=False,
|
|
84
|
+
) as client:
|
|
85
|
+
response = client.post(
|
|
86
|
+
"/v1/invoices",
|
|
87
|
+
json={"customer_id": "cust_123", "total": 1999},
|
|
88
|
+
params={"dry_run": False},
|
|
89
|
+
)
|
|
90
|
+
print(response.json())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Key synchronous notes:
|
|
94
|
+
|
|
95
|
+
- `base_url` can be provided without a scheme (`api.example.test`), the client
|
|
96
|
+
will inject the configured `default_scheme` (defaults to `http`).
|
|
97
|
+
- Use `.build_url()` when you need the normalized URL without sending a request.
|
|
98
|
+
- `service_name` is used to stamp the `User-Agent`. You can still call
|
|
99
|
+
`.update_default_headers()` later if you need to augment the defaults.
|
|
100
|
+
- Set `trust_env=False` (default is `True`) to keep the underlying `requests`
|
|
101
|
+
session from honoring proxy-related environment variables.
|
|
102
|
+
- Pass `url="https://status.example.test/health"` to `.request()` to bypass the
|
|
103
|
+
configured `base_url`.
|
|
104
|
+
|
|
105
|
+
## Asynchronous usage
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import asyncio
|
|
109
|
+
|
|
110
|
+
from finalsa.http.async import BaseAsyncHttpClient
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def main() -> None:
|
|
114
|
+
async with BaseAsyncHttpClient(
|
|
115
|
+
base_url="https://api.example.test",
|
|
116
|
+
service_name="billing-api",
|
|
117
|
+
trust_env=True, # honor proxy-related env vars
|
|
118
|
+
timeout=10,
|
|
119
|
+
) as client:
|
|
120
|
+
response = await client.get("/v1/health", params={"extended": True})
|
|
121
|
+
payload = await response.json()
|
|
122
|
+
print(payload)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
asyncio.run(main())
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Asynchronous specifics:
|
|
129
|
+
|
|
130
|
+
- Timeouts accept either a float (seconds) or an explicit `aiohttp.ClientTimeout`.
|
|
131
|
+
- `service_name` mirrors the sync client parameter and controls the leading
|
|
132
|
+
portion of the `User-Agent` header.
|
|
133
|
+
- `trust_env=True` allows `aiohttp` to reuse proxy / SSL options from the host
|
|
134
|
+
environment.
|
|
135
|
+
- You can supply an existing `aiohttp.ClientSession` via the `session` keyword
|
|
136
|
+
when you want to keep full control over connection pooling. The client will
|
|
137
|
+
detect that it does not own the session and skip closing it.
|
|
138
|
+
|
|
139
|
+
## URL handling & headers
|
|
140
|
+
|
|
141
|
+
- Both clients share helpers in `finalsa.http._shared` for URL resolution,
|
|
142
|
+
header merging, and scheme enforcement.
|
|
143
|
+
- Per-request headers passed to `.request()` (or `.get()`, `.post()`, etc.) are
|
|
144
|
+
merged on top of the client's defaults without mutating the stored defaults.
|
|
145
|
+
- When neither `path` nor `url` is provided, `.request()` raises `ValueError` to
|
|
146
|
+
surface misconfigurations early.
|
|
147
|
+
|
|
148
|
+
## Sessions, timeouts & errors
|
|
149
|
+
|
|
150
|
+
- Entering the client as a context manager ensures the underlying session is
|
|
151
|
+
created once and cleaned up automatically. You can also call `.close()` when
|
|
152
|
+
managing the lifecycle manually.
|
|
153
|
+
- `BaseSyncHttpClient` defaults to `raise_for_status=True` and will immediately
|
|
154
|
+
raise any non-2xx response. The async variant passes the same flag through to
|
|
155
|
+
`aiohttp.ClientSession`.
|
|
156
|
+
- Supplying your own session (`requests.Session` or `aiohttp.ClientSession`)
|
|
157
|
+
allows you to plug in custom retry adapters, authentication, or tracing
|
|
158
|
+
middleware while still benefiting from URL building and header management.
|
|
159
|
+
|
|
160
|
+
## Migrating from `@responses.activate`
|
|
161
|
+
|
|
162
|
+
Many of our legacy tests used the `responses` package to stub outgoing HTTP
|
|
163
|
+
traffic. You can keep the same ergonomics with `BaseSyncHttpClient` and adopt a
|
|
164
|
+
similar pattern for asyncio-based code.
|
|
165
|
+
|
|
166
|
+
### Sync tests (requests + responses)
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import responses
|
|
170
|
+
from finalsa.http.sync import BaseSyncHttpClient
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@responses.activate
|
|
174
|
+
def test_creates_invoice() -> None:
|
|
175
|
+
responses.add(
|
|
176
|
+
method=responses.POST,
|
|
177
|
+
url="https://api.example.test/v1/invoices",
|
|
178
|
+
json={"id": "inv_123", "status": "draft"},
|
|
179
|
+
status=201,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
client = BaseSyncHttpClient(base_url="https://api.example.test")
|
|
183
|
+
|
|
184
|
+
response = client.post("/v1/invoices", json={"total": 5000})
|
|
185
|
+
|
|
186
|
+
assert response.json()["status"] == "draft"
|
|
187
|
+
assert len(responses.calls) == 1
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Nothing special is required: because the client reuses `requests.Session`, any
|
|
191
|
+
`responses.activate` or `responses.RequestsMock` context automatically captures
|
|
192
|
+
the outgoing call.
|
|
193
|
+
|
|
194
|
+
### Async tests (aiohttp + aioresponses)
|
|
195
|
+
|
|
196
|
+
Use [`aioresponses`](https://github.com/pnuckowski/aioresponses) (or your async
|
|
197
|
+
HTTP mock of choice) to intercept `aiohttp` traffic:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
import pytest
|
|
201
|
+
from aioresponses import aioresponses
|
|
202
|
+
from finalsa.http.async import BaseAsyncHttpClient
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.asyncio
|
|
206
|
+
async def test_health_check() -> None:
|
|
207
|
+
client = BaseAsyncHttpClient(base_url="https://api.example.test")
|
|
208
|
+
|
|
209
|
+
with aioresponses() as mocked:
|
|
210
|
+
mocked.get(
|
|
211
|
+
"https://api.example.test/v1/health",
|
|
212
|
+
payload={"status": "ok"},
|
|
213
|
+
status=200,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
response = await client.get("/v1/health")
|
|
217
|
+
assert await response.json() == {"status": "ok"}
|
|
218
|
+
assert ("GET", "https://api.example.test/v1/health") in mocked.requests
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Behind the scenes the async client creates an `aiohttp.ClientSession`, so tools
|
|
222
|
+
like `aioresponses`, `aresponses`, or `pytest-aiohttp` slot in naturally. For
|
|
223
|
+
more brittle tests you can also inject your own `ClientSession` (e.g. one backed
|
|
224
|
+
by a stub transport) through the `session` constructor argument.
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
python -m pip install -e ".[test]"
|
|
230
|
+
pytest
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The test suite demonstrates additional patterns (custom timeouts, header
|
|
234
|
+
merging, session injection) if you need more examples. Feel free to open an
|
|
235
|
+
issue or pull request on GitHub with improvements or questions.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
finalsa/http/_shared.py,sha256=cYrac3Tjai7fv8KiyKi7Y5M5u88Q-lcTFS3XsEgy-IQ,2729
|
|
2
|
+
finalsa/http/async/__init__.py,sha256=L25WfVpUpPZlMv6-cgeWlNvmK5UJOhLZ1NOndcIsA9w,73
|
|
3
|
+
finalsa/http/async/base.py,sha256=lAsouBWu6j811xyXm9-aAJvNscCnrYYrhzUVnELJC6c,5301
|
|
4
|
+
finalsa/http/sync/__init__.py,sha256=ocLE6dofwYCD9_rJpIP1KhlIujRFcaJfhoaeXVrSCVE,59
|
|
5
|
+
finalsa/http/sync/base.py,sha256=Z0CfY05BfnyMLDNEpGAjmZUx1tLznaqehTn81IsHTCA,4741
|
|
6
|
+
finalsa_common_http_client-0.0.1.dist-info/METADATA,sha256=YaWFDN60zuIMtcAy029J-kXkEDY2dTwfqJJ5SBS6bHY,9056
|
|
7
|
+
finalsa_common_http_client-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
finalsa_common_http_client-0.0.1.dist-info/licenses/LICENSE.md,sha256=_lu-V-f2tGID1BS2V_W6D2XWppBsylFF1J2KEpfIXN0,1084
|
|
9
|
+
finalsa_common_http_client-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Luis Diego Jiménez Delgado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|