finalsa-common-http-client 0.0.8__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 +27 -2
- finalsa/http/sync_client/base.py +27 -2
- {finalsa_common_http_client-0.0.8.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.8.dist-info/RECORD +0 -10
- {finalsa_common_http_client-0.0.8.dist-info → finalsa_common_http_client-0.1.0.dist-info}/WHEEL +0 -0
- {finalsa_common_http_client-0.0.8.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,12 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import ssl
|
|
5
|
+
import time
|
|
4
6
|
from collections.abc import Mapping
|
|
5
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Type
|
|
6
8
|
|
|
7
9
|
import aiohttp
|
|
8
10
|
import certifi
|
|
9
11
|
from aiohttp import ClientSession, ClientTimeout
|
|
12
|
+
from finalsa.common.models import PaginationRequest, PaginationResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
10
14
|
|
|
11
15
|
from finalsa.http import _shared as shared
|
|
12
16
|
|
|
@@ -45,6 +49,7 @@ class BaseAsyncHttpClient:
|
|
|
45
49
|
service_name,
|
|
46
50
|
)
|
|
47
51
|
self._raise_for_status = raise_for_status
|
|
52
|
+
self._logger = logging.getLogger("http")
|
|
48
53
|
|
|
49
54
|
async def __aenter__(self) -> BaseAsyncHttpClient:
|
|
50
55
|
await self._ensure_session()
|
|
@@ -86,7 +91,11 @@ class BaseAsyncHttpClient:
|
|
|
86
91
|
return
|
|
87
92
|
if "Content-Type" in headers and headers["Content-Type"] == "application/json":
|
|
88
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})
|
|
89
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})
|
|
90
99
|
raise shared.InternalHttpError(response.status)
|
|
91
100
|
|
|
92
101
|
async def request(
|
|
@@ -115,17 +124,25 @@ class BaseAsyncHttpClient:
|
|
|
115
124
|
)
|
|
116
125
|
merged_headers = shared.merge_headers(self._default_headers, headers)
|
|
117
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()
|
|
118
130
|
response = await session.request(
|
|
119
131
|
method.upper(),
|
|
120
132
|
resolved_url,
|
|
121
133
|
headers=merged_headers,
|
|
122
|
-
params=params,
|
|
134
|
+
params=shared.parse_params(params),
|
|
123
135
|
json=json,
|
|
124
136
|
data=data,
|
|
125
137
|
timeout=request_timeout,
|
|
126
138
|
ssl=ssl_ctx,
|
|
127
139
|
**kwargs,
|
|
128
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})
|
|
129
146
|
return response
|
|
130
147
|
|
|
131
148
|
async def get(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
@@ -163,6 +180,14 @@ class BaseAsyncHttpClient:
|
|
|
163
180
|
)
|
|
164
181
|
return self._session
|
|
165
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
|
+
|
|
166
191
|
def _normalize_timeout(
|
|
167
192
|
self,
|
|
168
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.8.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=kWPqa2-zXZEKWaf3AmzRj6SqQ39UpkXBDi5J1jRJYMs,6094
|
|
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.8.dist-info/METADATA,sha256=ekcap3lEkuzDIr18bSSydM4CwwF_HextxQh7zXRVHz4,9102
|
|
8
|
-
finalsa_common_http_client-0.0.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
finalsa_common_http_client-0.0.8.dist-info/licenses/LICENSE.md,sha256=yqzhfnTBr2S4lUBx-yibVPOIXRUDPrSUN9-_7AsC6OU,1084
|
|
10
|
-
finalsa_common_http_client-0.0.8.dist-info/RECORD,,
|
{finalsa_common_http_client-0.0.8.dist-info → finalsa_common_http_client-0.1.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|