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 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__ = ["BaseSyncHttpClient", "BaseAsyncHttpClient", "normalize_base_url", "ensure_scheme", "build_url", "merge_headers", "build_default_headers", "get_package_version"]
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 raise_for_status(self, response: aiohttp.ClientResponse) -> None:
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=self.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,
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: finalsa-common-http-client
3
- Version: 0.0.7
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.10.5
49
- Requires-Dist: fastapi>=0.116.1
50
- Requires-Dist: finalsa-common-models>=2.0.4
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.4
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,,