finalsa-common-http-client 0.0.7__py3-none-any.whl → 0.1.0__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/__init__.py +12 -1
- finalsa/http/_shared.py +55 -1
- finalsa/http/async_client/base.py +40 -4
- finalsa/http/sync_client/base.py +27 -2
- {finalsa_common_http_client-0.0.7.dist-info → finalsa_common_http_client-0.1.0.dist-info}/METADATA +5 -5
- finalsa_common_http_client-0.1.0.dist-info/RECORD +10 -0
- finalsa_common_http_client-0.0.7.dist-info/RECORD +0 -10
- {finalsa_common_http_client-0.0.7.dist-info → finalsa_common_http_client-0.1.0.dist-info}/WHEEL +0 -0
- {finalsa_common_http_client-0.0.7.dist-info → finalsa_common_http_client-0.1.0.dist-info}/licenses/LICENSE.md +0 -0
finalsa/http/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from ._shared import (
|
|
2
|
+
ValidationError,
|
|
2
3
|
build_default_headers,
|
|
3
4
|
build_url,
|
|
4
5
|
ensure_scheme,
|
|
@@ -9,5 +10,15 @@ from ._shared import (
|
|
|
9
10
|
from .async_client import BaseAsyncHttpClient
|
|
10
11
|
from .sync_client import BaseSyncHttpClient
|
|
11
12
|
|
|
12
|
-
__all__ = [
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BaseAsyncHttpClient",
|
|
15
|
+
"BaseSyncHttpClient",
|
|
16
|
+
"ValidationError",
|
|
17
|
+
"build_default_headers",
|
|
18
|
+
"build_url",
|
|
19
|
+
"ensure_scheme",
|
|
20
|
+
"get_package_version",
|
|
21
|
+
"merge_headers",
|
|
22
|
+
"normalize_base_url",
|
|
23
|
+
]
|
|
13
24
|
|
finalsa/http/_shared.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import Mapping
|
|
4
4
|
from importlib import metadata
|
|
5
|
-
from typing import Any, Dict
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
6
|
from urllib.parse import urljoin
|
|
7
7
|
|
|
8
8
|
from finalsa.common.models import BaseDomainException
|
|
@@ -11,6 +11,7 @@ from finalsa.traceability.functions import (
|
|
|
11
11
|
HTTP_HEADER_TRACEPARENT,
|
|
12
12
|
HTTP_HEADER_TRACESTATE,
|
|
13
13
|
)
|
|
14
|
+
from orjson import dumps
|
|
14
15
|
|
|
15
16
|
Headers = Mapping[str, str]
|
|
16
17
|
|
|
@@ -27,6 +28,32 @@ class InternalHttpError(BaseDomainException):
|
|
|
27
28
|
)
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
class ValidationError(BaseDomainException):
|
|
32
|
+
"""Exception raised when FastAPI/Pydantic returns validation errors (422)."""
|
|
33
|
+
|
|
34
|
+
response_code = 422
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
fields: List[Dict[str, Any]],
|
|
39
|
+
error: List[Dict[str, Any]],
|
|
40
|
+
response_code: int | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
message = "Validation error"
|
|
43
|
+
if fields:
|
|
44
|
+
msg_parts = [
|
|
45
|
+
f"{'/'.join(str(loc) for loc in f.get('loc', []))}: {f.get('msg', '')}" for f in fields]
|
|
46
|
+
if msg_parts:
|
|
47
|
+
message = "; ".join(msg_parts)
|
|
48
|
+
super().__init__(
|
|
49
|
+
message=message,
|
|
50
|
+
response_code=response_code or self.response_code,
|
|
51
|
+
name="ValidationError",
|
|
52
|
+
)
|
|
53
|
+
self.fields = fields
|
|
54
|
+
self.error = error
|
|
55
|
+
|
|
56
|
+
|
|
30
57
|
def normalize_base_url(
|
|
31
58
|
base_url: str | None,
|
|
32
59
|
default_scheme: str,
|
|
@@ -42,6 +69,20 @@ def normalize_base_url(
|
|
|
42
69
|
return value.rstrip("/")
|
|
43
70
|
|
|
44
71
|
|
|
72
|
+
def parse_params(params: Mapping[str, Any] | None ) -> dict[str, str | int | float | bytes] | None:
|
|
73
|
+
if params is None:
|
|
74
|
+
return None
|
|
75
|
+
result = {}
|
|
76
|
+
for key, value in params.items():
|
|
77
|
+
if value is not None:
|
|
78
|
+
result[key] = value
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
result[key] = dumps(value)
|
|
81
|
+
if isinstance(value, list):
|
|
82
|
+
result[key] = dumps(value)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
45
86
|
def ensure_scheme(candidate: str, default_scheme: str) -> str:
|
|
46
87
|
"""Ensure that the provided candidate has an explicit scheme."""
|
|
47
88
|
trimmed = candidate.strip()
|
|
@@ -107,7 +148,20 @@ def get_package_version(package_name: str = "finalsa-http-client") -> str:
|
|
|
107
148
|
return "0.0.0"
|
|
108
149
|
|
|
109
150
|
|
|
151
|
+
def _is_pydantic_validation_error(response: Dict[str, Any], status: int) -> bool:
|
|
152
|
+
return status == 422 and "detail" in response and isinstance(response["detail"], list)
|
|
153
|
+
|
|
154
|
+
|
|
110
155
|
def raise_for_response(response: Dict[str, Any], status: int) -> None:
|
|
156
|
+
if _is_pydantic_validation_error(response, status):
|
|
157
|
+
detail = response["detail"]
|
|
158
|
+
fields = [d for d in detail if isinstance(
|
|
159
|
+
d, dict) and ("loc" in d or "msg" in d)]
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
fields=fields,
|
|
162
|
+
error=detail,
|
|
163
|
+
response_code=status,
|
|
164
|
+
)
|
|
111
165
|
if response.get("message") and response.get("name"):
|
|
112
166
|
raise BaseDomainException(
|
|
113
167
|
message=response.get("message"),
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
3
6
|
from collections.abc import Mapping
|
|
4
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Type
|
|
5
8
|
|
|
6
9
|
import aiohttp
|
|
10
|
+
import certifi
|
|
7
11
|
from aiohttp import ClientSession, ClientTimeout
|
|
12
|
+
from finalsa.common.models import PaginationRequest, PaginationResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
8
14
|
|
|
9
15
|
from finalsa.http import _shared as shared
|
|
10
16
|
|
|
17
|
+
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
class BaseAsyncHttpClient:
|
|
13
21
|
"""Reusable aiohttp-based HTTP client with sane defaults."""
|
|
@@ -27,6 +35,7 @@ class BaseAsyncHttpClient:
|
|
|
27
35
|
service_name: str = "finalsa-http-client",
|
|
28
36
|
trust_env: bool = False,
|
|
29
37
|
session: ClientSession | None = None,
|
|
38
|
+
raise_for_status: bool = True,
|
|
30
39
|
) -> None:
|
|
31
40
|
self._default_scheme = default_scheme
|
|
32
41
|
self.base_url = shared.normalize_base_url(base_url, default_scheme)
|
|
@@ -39,6 +48,8 @@ class BaseAsyncHttpClient:
|
|
|
39
48
|
default_headers,
|
|
40
49
|
service_name,
|
|
41
50
|
)
|
|
51
|
+
self._raise_for_status = raise_for_status
|
|
52
|
+
self._logger = logging.getLogger("http")
|
|
42
53
|
|
|
43
54
|
async def __aenter__(self) -> BaseAsyncHttpClient:
|
|
44
55
|
await self._ensure_session()
|
|
@@ -73,14 +84,18 @@ class BaseAsyncHttpClient:
|
|
|
73
84
|
default_scheme=self._default_scheme,
|
|
74
85
|
)
|
|
75
86
|
|
|
76
|
-
async def
|
|
87
|
+
async def __raise_for_status__(self, response: aiohttp.ClientResponse) -> None:
|
|
77
88
|
headers = response.headers if isinstance(
|
|
78
89
|
response.headers, Mapping) else response['headers']
|
|
79
90
|
if response.status < 400:
|
|
80
91
|
return
|
|
81
92
|
if "Content-Type" in headers and headers["Content-Type"] == "application/json":
|
|
82
93
|
data = await response.json()
|
|
94
|
+
self._logger.error(f"Received response with status {response.status}", extra={
|
|
95
|
+
"status": response.status, "headers": headers, "data": data})
|
|
83
96
|
shared.raise_for_response(data, response.status)
|
|
97
|
+
self._logger.error(f"Received response with status {response.status}", extra={
|
|
98
|
+
"status": response.status, "headers": headers})
|
|
84
99
|
raise shared.InternalHttpError(response.status)
|
|
85
100
|
|
|
86
101
|
async def request(
|
|
@@ -109,16 +124,25 @@ class BaseAsyncHttpClient:
|
|
|
109
124
|
)
|
|
110
125
|
merged_headers = shared.merge_headers(self._default_headers, headers)
|
|
111
126
|
request_timeout = self._normalize_timeout(timeout) or self._timeout
|
|
127
|
+
self._logger.info(f"{method.upper()} request to {resolved_url} with headers {merged_headers} and timeout {request_timeout}", extra={
|
|
128
|
+
"headers": merged_headers, "timeout": request_timeout, "params": params})
|
|
129
|
+
start_time = time.time()
|
|
112
130
|
response = await session.request(
|
|
113
131
|
method.upper(),
|
|
114
132
|
resolved_url,
|
|
115
133
|
headers=merged_headers,
|
|
116
|
-
params=params,
|
|
134
|
+
params=shared.parse_params(params),
|
|
117
135
|
json=json,
|
|
118
136
|
data=data,
|
|
119
137
|
timeout=request_timeout,
|
|
138
|
+
ssl=ssl_ctx,
|
|
120
139
|
**kwargs,
|
|
121
140
|
)
|
|
141
|
+
end_time = time.time()
|
|
142
|
+
duration = end_time - start_time
|
|
143
|
+
self._logger.info(
|
|
144
|
+
f"Received response from {resolved_url} with status {response.status}",
|
|
145
|
+
extra={"status": response.status, "duration": duration})
|
|
122
146
|
return response
|
|
123
147
|
|
|
124
148
|
async def get(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
@@ -145,13 +169,25 @@ class BaseAsyncHttpClient:
|
|
|
145
169
|
|
|
146
170
|
async def _ensure_session(self) -> ClientSession:
|
|
147
171
|
if self._session is None or self._session.closed:
|
|
172
|
+
if self._raise_for_status:
|
|
173
|
+
raise_for_status = self.__raise_for_status__
|
|
174
|
+
else:
|
|
175
|
+
raise_for_status = None
|
|
148
176
|
self._session = aiohttp.ClientSession(
|
|
149
177
|
timeout=self._timeout,
|
|
150
178
|
trust_env=self._trust_env,
|
|
151
|
-
raise_for_status=
|
|
179
|
+
raise_for_status=raise_for_status,
|
|
152
180
|
)
|
|
153
181
|
return self._session
|
|
154
182
|
|
|
183
|
+
async def get_paginated(self, path: str, pagination_request: PaginationRequest, response_model: Type[BaseModel], **kwargs: Any) -> PaginationResponse[Type[BaseModel]]:
|
|
184
|
+
response = await self.get(path, params=pagination_request.model_dump(), **kwargs)
|
|
185
|
+
data = await response.json()
|
|
186
|
+
items = [response_model.model_validate_json(
|
|
187
|
+
item) for item in data["items"]]
|
|
188
|
+
data["items"] = items
|
|
189
|
+
return PaginationResponse[response_model](**data)
|
|
190
|
+
|
|
155
191
|
def _normalize_timeout(
|
|
156
192
|
self,
|
|
157
193
|
timeout: float | ClientTimeout | None,
|
finalsa/http/sync_client/base.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
3
5
|
from collections.abc import Mapping
|
|
4
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Type
|
|
5
7
|
|
|
6
8
|
import requests
|
|
9
|
+
from finalsa.common.models import PaginationRequest, PaginationResponse
|
|
10
|
+
from pydantic import BaseModel
|
|
7
11
|
|
|
8
12
|
from finalsa.http import _shared as shared
|
|
9
13
|
|
|
@@ -40,6 +44,7 @@ class BaseSyncHttpClient:
|
|
|
40
44
|
default_headers,
|
|
41
45
|
service_name,
|
|
42
46
|
)
|
|
47
|
+
self._logger = logging.getLogger("http")
|
|
43
48
|
|
|
44
49
|
def __enter__(self) -> BaseSyncHttpClient:
|
|
45
50
|
self._ensure_session()
|
|
@@ -100,23 +105,35 @@ class BaseSyncHttpClient:
|
|
|
100
105
|
merged_headers = shared.merge_headers(self._default_headers, headers)
|
|
101
106
|
request_timeout = timeout if timeout is not None else self._timeout
|
|
102
107
|
try:
|
|
108
|
+
self._logger.info(f"{method.upper()} request to {resolved_url} with headers {merged_headers} and timeout {request_timeout}", extra={
|
|
109
|
+
"headers": merged_headers, "timeout": request_timeout, "params": params})
|
|
110
|
+
start_time = time.time()
|
|
103
111
|
response = session.request(
|
|
104
112
|
method.upper(),
|
|
105
113
|
resolved_url,
|
|
106
114
|
headers=merged_headers,
|
|
107
|
-
params=params,
|
|
115
|
+
params=shared.parse_params(params),
|
|
108
116
|
json=json,
|
|
109
117
|
data=data,
|
|
110
118
|
timeout=request_timeout,
|
|
111
119
|
**kwargs,
|
|
112
120
|
)
|
|
121
|
+
end_time = time.time()
|
|
122
|
+
duration = end_time - start_time
|
|
123
|
+
self._logger.info(f"Received response from {resolved_url} with status {response.status}", extra={
|
|
124
|
+
"status": response.status, "duration": duration})
|
|
113
125
|
if self._raise_for_status:
|
|
114
126
|
response.raise_for_status()
|
|
115
127
|
return response
|
|
116
128
|
except requests.RequestException as e:
|
|
117
129
|
if e.response is not None and "Content-Type" in e.response.headers and e.response.headers["Content-Type"] == "application/json":
|
|
118
130
|
data = e.response.json()
|
|
131
|
+
self._logger.error(f"Received response with status {e.response.status_code}", extra={
|
|
132
|
+
"status": e.response.status_code, "headers": e.response.headers, "data": data})
|
|
119
133
|
shared.raise_for_response(data, e.response.status_code)
|
|
134
|
+
|
|
135
|
+
self._logger.error(f"Received response with status {e.response.status_code}", extra={
|
|
136
|
+
"status": e.response.status_code, "headers": e.response.headers})
|
|
120
137
|
raise e
|
|
121
138
|
|
|
122
139
|
def get(self, path: str, **kwargs: Any) -> requests.Response:
|
|
@@ -140,6 +157,14 @@ class BaseSyncHttpClient:
|
|
|
140
157
|
self._session.close()
|
|
141
158
|
self._session = None
|
|
142
159
|
|
|
160
|
+
def get_paginated(self, path: str, pagination_request: PaginationRequest, response_model: Type[BaseModel], **kwargs: Any) -> PaginationResponse[Type[BaseModel]]:
|
|
161
|
+
response = self.get(path, params=pagination_request.model_dump(), **kwargs)
|
|
162
|
+
data = response.json()
|
|
163
|
+
items = [response_model.model_validate_json(
|
|
164
|
+
item) for item in data["items"]]
|
|
165
|
+
data["items"] = items
|
|
166
|
+
return PaginationResponse[response_model](**data)
|
|
167
|
+
|
|
143
168
|
def _ensure_session(self) -> requests.Session:
|
|
144
169
|
if self._session is None:
|
|
145
170
|
session = requests.Session()
|
{finalsa_common_http_client-0.0.7.dist-info → finalsa_common_http_client-0.1.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: finalsa-common-http-client
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: HTTP client library for common data types used in business applications
|
|
5
5
|
Project-URL: Homepage, https://github.com/finalsa/finalsa-http-client
|
|
6
6
|
Project-URL: Documentation, https://github.com/finalsa/finalsa-http-client#readme
|
|
@@ -45,11 +45,11 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
45
45
|
Classifier: Topic :: Software Development :: Localization
|
|
46
46
|
Classifier: Typing :: Typed
|
|
47
47
|
Requires-Python: >=3.10
|
|
48
|
-
Requires-Dist: aiohttp>=3.
|
|
49
|
-
Requires-Dist: fastapi>=0.
|
|
50
|
-
Requires-Dist: finalsa-common-models>=2.
|
|
48
|
+
Requires-Dist: aiohttp>=3.13.3
|
|
49
|
+
Requires-Dist: fastapi>=0.128.0
|
|
50
|
+
Requires-Dist: finalsa-common-models>=2.1.1
|
|
51
51
|
Requires-Dist: finalsa-traceability>=1.0.1
|
|
52
|
-
Requires-Dist: requests>=2.32.
|
|
52
|
+
Requires-Dist: requests>=2.32.5
|
|
53
53
|
Description-Content-Type: text/markdown
|
|
54
54
|
|
|
55
55
|
# Finalsa HTTP Client
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
finalsa/http/__init__.py,sha256=w6M9CRcx2gCy1u-HLhLZ_0kQX92JEbFiGi4MBYqR3OI,498
|
|
2
|
+
finalsa/http/_shared.py,sha256=N3lH8m7hCHLTRCDASi2U8-TCweuEIfKQDVtMQgX_j38,5246
|
|
3
|
+
finalsa/http/async_client/__init__.py,sha256=L25WfVpUpPZlMv6-cgeWlNvmK5UJOhLZ1NOndcIsA9w,73
|
|
4
|
+
finalsa/http/async_client/base.py,sha256=aZc_wY9RxiTGDhbXw6afMyCm9FAv_GsZqRO_SiOOJYY,7658
|
|
5
|
+
finalsa/http/sync_client/__init__.py,sha256=ocLE6dofwYCD9_rJpIP1KhlIujRFcaJfhoaeXVrSCVE,59
|
|
6
|
+
finalsa/http/sync_client/base.py,sha256=OQckgQgHrnxiNIRo6TNDtUGFs60b7F8PQoRAyd-ui3c,6769
|
|
7
|
+
finalsa_common_http_client-0.1.0.dist-info/METADATA,sha256=W8yXtBhlYOiWmoh_-nohjXdygNbtwVczGv6qWrJhiWE,9102
|
|
8
|
+
finalsa_common_http_client-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
finalsa_common_http_client-0.1.0.dist-info/licenses/LICENSE.md,sha256=yqzhfnTBr2S4lUBx-yibVPOIXRUDPrSUN9-_7AsC6OU,1084
|
|
10
|
+
finalsa_common_http_client-0.1.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
finalsa/http/__init__.py,sha256=tTeA_AP5gfFiXF783AxX5ZtSiQXxR1jkQ196J9YxXNk,419
|
|
2
|
-
finalsa/http/_shared.py,sha256=EdWPQwVrOd-yBs0VdIAKE8u2kiEm2gyraEvODJIZzek,3489
|
|
3
|
-
finalsa/http/async_client/__init__.py,sha256=L25WfVpUpPZlMv6-cgeWlNvmK5UJOhLZ1NOndcIsA9w,73
|
|
4
|
-
finalsa/http/async_client/base.py,sha256=22L6QLfjAF4H8z9CcE8PhNuI2lJFbUkRLE405YXUFWw,5735
|
|
5
|
-
finalsa/http/sync_client/__init__.py,sha256=ocLE6dofwYCD9_rJpIP1KhlIujRFcaJfhoaeXVrSCVE,59
|
|
6
|
-
finalsa/http/sync_client/base.py,sha256=IsyVZrTweg78eAA0cahp6RGJFhmHpeNYuTtVJuhZIZs,5126
|
|
7
|
-
finalsa_common_http_client-0.0.7.dist-info/METADATA,sha256=wNuVrZs-9bFbQ_9jUQKJBlyDf0wSNU7N9COu-8fB3ZA,9102
|
|
8
|
-
finalsa_common_http_client-0.0.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
finalsa_common_http_client-0.0.7.dist-info/licenses/LICENSE.md,sha256=yqzhfnTBr2S4lUBx-yibVPOIXRUDPrSUN9-_7AsC6OU,1084
|
|
10
|
-
finalsa_common_http_client-0.0.7.dist-info/RECORD,,
|
{finalsa_common_http_client-0.0.7.dist-info → finalsa_common_http_client-0.1.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|