finalsa-common-http-client 0.0.8__tar.gz → 0.1.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: finalsa-common-http-client
3
- Version: 0.0.8
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,24 @@
1
+ from ._shared import (
2
+ ValidationError,
3
+ build_default_headers,
4
+ build_url,
5
+ ensure_scheme,
6
+ get_package_version,
7
+ merge_headers,
8
+ normalize_base_url,
9
+ )
10
+ from .async_client import BaseAsyncHttpClient
11
+ from .sync_client import BaseSyncHttpClient
12
+
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
+ ]
24
+
@@ -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,
@@ -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,15 +1,15 @@
1
1
  [project]
2
2
  name = "finalsa-common-http-client"
3
- version = "0.0.8"
3
+ version = "0.1.0"
4
4
  description = "HTTP client library for common data types used in business applications"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
- "aiohttp>=3.10.5",
9
- "fastapi>=0.116.1",
10
- "finalsa-common-models>=2.0.4",
8
+ "aiohttp>=3.13.3",
9
+ "fastapi>=0.128.0",
10
+ "finalsa-common-models>=2.1.1",
11
11
  "finalsa-traceability>=1.0.1",
12
- "requests>=2.32.4",
12
+ "requests>=2.32.5",
13
13
  ]
14
14
  authors = [
15
15
  {name = "Luis Jimenez", email = "luis@finalsa.com"},
@@ -1,13 +0,0 @@
1
- from ._shared import (
2
- build_default_headers,
3
- build_url,
4
- ensure_scheme,
5
- get_package_version,
6
- merge_headers,
7
- normalize_base_url,
8
- )
9
- from .async_client import BaseAsyncHttpClient
10
- from .sync_client import BaseSyncHttpClient
11
-
12
- __all__ = ["BaseSyncHttpClient", "BaseAsyncHttpClient", "normalize_base_url", "ensure_scheme", "build_url", "merge_headers", "build_default_headers", "get_package_version"]
13
-