kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/rapi/client.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
"""HTTP client for RAPI module.
|
|
2
|
+
|
|
3
|
+
This module provides the RapiClient class for making REST API calls
|
|
4
|
+
with config-driven endpoints, multi-source credentials, and detailed logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from kstlib.limits import get_rapi_limits
|
|
21
|
+
from kstlib.rapi.config import (
|
|
22
|
+
ApiConfig,
|
|
23
|
+
EndpointConfig,
|
|
24
|
+
HmacConfig,
|
|
25
|
+
RapiConfigManager,
|
|
26
|
+
load_rapi_config,
|
|
27
|
+
)
|
|
28
|
+
from kstlib.rapi.credentials import CredentialRecord, CredentialResolver
|
|
29
|
+
from kstlib.rapi.exceptions import (
|
|
30
|
+
RequestError,
|
|
31
|
+
ResponseTooLargeError,
|
|
32
|
+
)
|
|
33
|
+
from kstlib.ssl import build_ssl_context
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
|
|
38
|
+
from kstlib.logging import TRACE_LEVEL, get_logger
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _log_trace(msg: str, *args: Any) -> None:
|
|
44
|
+
"""Log at TRACE level."""
|
|
45
|
+
log.log(TRACE_LEVEL, msg, *args)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RapiResponse:
|
|
50
|
+
"""Response from an API call.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
status_code: HTTP status code.
|
|
54
|
+
headers: Response headers.
|
|
55
|
+
data: Parsed JSON response (or None if not JSON).
|
|
56
|
+
text: Raw response text.
|
|
57
|
+
elapsed: Request duration in seconds.
|
|
58
|
+
endpoint_ref: Full endpoint reference used.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
>>> response = RapiResponse(status_code=200, data={"ip": "1.2.3.4"})
|
|
62
|
+
>>> response.ok
|
|
63
|
+
True
|
|
64
|
+
>>> response.data["ip"]
|
|
65
|
+
'1.2.3.4'
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
status_code: int
|
|
69
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
70
|
+
data: Any = None
|
|
71
|
+
text: str = ""
|
|
72
|
+
elapsed: float = 0.0
|
|
73
|
+
endpoint_ref: str = ""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ok(self) -> bool:
|
|
77
|
+
"""Return True if status code indicates success (2xx)."""
|
|
78
|
+
return 200 <= self.status_code < 300
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RapiClient:
|
|
82
|
+
"""Config-driven REST API client.
|
|
83
|
+
|
|
84
|
+
Makes HTTP requests to configured API endpoints with automatic
|
|
85
|
+
credential resolution, header merging, and detailed logging.
|
|
86
|
+
|
|
87
|
+
Supports loading configuration from:
|
|
88
|
+
- kstlib.conf.yml (default)
|
|
89
|
+
- External ``*.rapi.yml`` files (via from_file)
|
|
90
|
+
- Auto-discovery of ``*.rapi.yml`` in current directory (via discover)
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config_manager: Optional RapiConfigManager (loads from config if None).
|
|
94
|
+
credentials_config: Optional credentials configuration.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
>>> client = RapiClient() # doctest: +SKIP
|
|
98
|
+
>>> response = client.call("httpbin.get_ip") # doctest: +SKIP
|
|
99
|
+
>>> response.data # doctest: +SKIP
|
|
100
|
+
{'origin': '...'}
|
|
101
|
+
|
|
102
|
+
>>> client = RapiClient.from_file("github.rapi.yml") # doctest: +SKIP
|
|
103
|
+
>>> client = RapiClient.discover() # doctest: +SKIP
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
config_manager: RapiConfigManager | None = None,
|
|
109
|
+
credentials_config: Mapping[str, Any] | None = None,
|
|
110
|
+
*,
|
|
111
|
+
ssl_verify: bool | None = None,
|
|
112
|
+
ssl_ca_bundle: str | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Initialize RapiClient.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
config_manager: Optional RapiConfigManager instance.
|
|
118
|
+
credentials_config: Optional credentials configuration.
|
|
119
|
+
ssl_verify: Override SSL verification (True/False).
|
|
120
|
+
If None, uses global config from kstlib.conf.yml.
|
|
121
|
+
ssl_ca_bundle: Override CA bundle path.
|
|
122
|
+
If None, uses global config from kstlib.conf.yml.
|
|
123
|
+
"""
|
|
124
|
+
self._config_manager = config_manager or load_rapi_config()
|
|
125
|
+
|
|
126
|
+
# Merge credentials: inline from config_manager + explicit credentials_config
|
|
127
|
+
merged_credentials: dict[str, Any] = {}
|
|
128
|
+
if hasattr(self._config_manager, "credentials_config"):
|
|
129
|
+
merged_credentials.update(self._config_manager.credentials_config)
|
|
130
|
+
if credentials_config:
|
|
131
|
+
merged_credentials.update(credentials_config)
|
|
132
|
+
|
|
133
|
+
self._credential_resolver = CredentialResolver(merged_credentials or None)
|
|
134
|
+
self._limits = get_rapi_limits()
|
|
135
|
+
|
|
136
|
+
# Build SSL context (cascade: kwargs > global config > default)
|
|
137
|
+
self._ssl_context = build_ssl_context(
|
|
138
|
+
ssl_verify=ssl_verify,
|
|
139
|
+
ssl_ca_bundle=ssl_ca_bundle,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
log.debug(
|
|
143
|
+
"RapiClient initialized (timeout=%.1fs, max_retries=%d)",
|
|
144
|
+
self._limits.timeout,
|
|
145
|
+
self._limits.max_retries,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_file(
|
|
150
|
+
cls,
|
|
151
|
+
path: str,
|
|
152
|
+
credentials_config: Mapping[str, Any] | None = None,
|
|
153
|
+
) -> RapiClient:
|
|
154
|
+
"""Create client from a ``*.rapi.yml`` file.
|
|
155
|
+
|
|
156
|
+
Loads API configuration from an external YAML file with simplified format.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Path to the ``*.rapi.yml`` file.
|
|
160
|
+
credentials_config: Additional credentials (merged with inline).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Configured RapiClient instance.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
FileNotFoundError: If file does not exist.
|
|
167
|
+
ValueError: If file format is invalid.
|
|
168
|
+
|
|
169
|
+
Examples:
|
|
170
|
+
>>> client = RapiClient.from_file("github.rapi.yml") # doctest: +SKIP
|
|
171
|
+
>>> response = client.call("github.user") # doctest: +SKIP
|
|
172
|
+
"""
|
|
173
|
+
config_manager = RapiConfigManager.from_file(path)
|
|
174
|
+
return cls(config_manager, credentials_config)
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def discover(
|
|
178
|
+
cls,
|
|
179
|
+
directory: str | None = None,
|
|
180
|
+
pattern: str = "*.rapi.yml",
|
|
181
|
+
credentials_config: Mapping[str, Any] | None = None,
|
|
182
|
+
) -> RapiClient:
|
|
183
|
+
"""Create client by auto-discovering ``*.rapi.yml`` files.
|
|
184
|
+
|
|
185
|
+
Searches for files matching the pattern in the specified directory
|
|
186
|
+
(defaults to current working directory) and loads all found configs.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
directory: Directory to search in (default: current directory).
|
|
190
|
+
pattern: Glob pattern for files (default: ``*.rapi.yml``).
|
|
191
|
+
credentials_config: Additional credentials (merged with inline).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Configured RapiClient instance.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
FileNotFoundError: If no matching files found.
|
|
198
|
+
|
|
199
|
+
Examples:
|
|
200
|
+
>>> client = RapiClient.discover() # doctest: +SKIP
|
|
201
|
+
>>> client = RapiClient.discover("./apis/") # doctest: +SKIP
|
|
202
|
+
"""
|
|
203
|
+
config_manager = RapiConfigManager.discover(directory, pattern)
|
|
204
|
+
return cls(config_manager, credentials_config)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def config_manager(self) -> RapiConfigManager:
|
|
208
|
+
"""Get the configuration manager.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
RapiConfigManager instance.
|
|
212
|
+
"""
|
|
213
|
+
return self._config_manager
|
|
214
|
+
|
|
215
|
+
def list_apis(self) -> list[str]:
|
|
216
|
+
"""List all configured API names.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of API names.
|
|
220
|
+
"""
|
|
221
|
+
return self._config_manager.list_apis()
|
|
222
|
+
|
|
223
|
+
def list_endpoints(self, api_name: str | None = None) -> list[str]:
|
|
224
|
+
"""List endpoint references.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
api_name: Filter by API name (optional).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
List of full endpoint references (api.endpoint).
|
|
231
|
+
"""
|
|
232
|
+
return self._config_manager.list_endpoints(api_name)
|
|
233
|
+
|
|
234
|
+
def call(
|
|
235
|
+
self,
|
|
236
|
+
endpoint_ref: str,
|
|
237
|
+
*args: Any,
|
|
238
|
+
body: Any = None,
|
|
239
|
+
headers: Mapping[str, str] | None = None,
|
|
240
|
+
timeout: float | None = None,
|
|
241
|
+
**kwargs: Any,
|
|
242
|
+
) -> RapiResponse:
|
|
243
|
+
"""Make a synchronous API call.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
endpoint_ref: Endpoint reference (full: api.endpoint or short: endpoint).
|
|
247
|
+
*args: Positional arguments for path parameters.
|
|
248
|
+
body: Request body (dict for JSON, str for raw).
|
|
249
|
+
headers: Runtime headers (override service/endpoint headers).
|
|
250
|
+
timeout: Request timeout (uses config default if None).
|
|
251
|
+
**kwargs: Keyword arguments for path parameters and query params.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
RapiResponse with parsed data.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
RequestError: If request fails after retries.
|
|
258
|
+
ResponseTooLargeError: If response exceeds max size.
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
>>> client = RapiClient() # doctest: +SKIP
|
|
262
|
+
>>> client.call("httpbin.get_ip") # doctest: +SKIP
|
|
263
|
+
>>> client.call("httpbin.delayed", 5) # doctest: +SKIP
|
|
264
|
+
>>> client.call("httpbin.post_data", body={"key": "value"}) # doctest: +SKIP
|
|
265
|
+
"""
|
|
266
|
+
log.debug("Calling endpoint: %s", endpoint_ref)
|
|
267
|
+
|
|
268
|
+
# Resolve endpoint
|
|
269
|
+
api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
|
|
270
|
+
_log_trace("Resolved to: %s", endpoint_config.full_ref)
|
|
271
|
+
|
|
272
|
+
# Build request
|
|
273
|
+
request = self._build_request(
|
|
274
|
+
api_config,
|
|
275
|
+
endpoint_config,
|
|
276
|
+
args,
|
|
277
|
+
kwargs,
|
|
278
|
+
body,
|
|
279
|
+
headers,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Execute with retries
|
|
283
|
+
effective_timeout = timeout if timeout is not None else self._limits.timeout
|
|
284
|
+
return self._execute_with_retry(request, endpoint_config, effective_timeout)
|
|
285
|
+
|
|
286
|
+
async def call_async(
|
|
287
|
+
self,
|
|
288
|
+
endpoint_ref: str,
|
|
289
|
+
*args: Any,
|
|
290
|
+
body: Any = None,
|
|
291
|
+
headers: Mapping[str, str] | None = None,
|
|
292
|
+
timeout: float | None = None,
|
|
293
|
+
**kwargs: Any,
|
|
294
|
+
) -> RapiResponse:
|
|
295
|
+
"""Make an asynchronous API call.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
endpoint_ref: Endpoint reference (full: api.endpoint or short: endpoint).
|
|
299
|
+
*args: Positional arguments for path parameters.
|
|
300
|
+
body: Request body (dict for JSON, str for raw).
|
|
301
|
+
headers: Runtime headers (override service/endpoint headers).
|
|
302
|
+
timeout: Request timeout (uses config default if None).
|
|
303
|
+
**kwargs: Keyword arguments for path parameters and query params.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
RapiResponse with parsed data.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
RequestError: If request fails after retries.
|
|
310
|
+
ResponseTooLargeError: If response exceeds max size.
|
|
311
|
+
"""
|
|
312
|
+
log.debug("Calling endpoint (async): %s", endpoint_ref)
|
|
313
|
+
|
|
314
|
+
# Resolve endpoint
|
|
315
|
+
api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
|
|
316
|
+
_log_trace("Resolved to: %s", endpoint_config.full_ref)
|
|
317
|
+
|
|
318
|
+
# Build request
|
|
319
|
+
request = self._build_request(
|
|
320
|
+
api_config,
|
|
321
|
+
endpoint_config,
|
|
322
|
+
args,
|
|
323
|
+
kwargs,
|
|
324
|
+
body,
|
|
325
|
+
headers,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Execute with retries
|
|
329
|
+
effective_timeout = timeout if timeout is not None else self._limits.timeout
|
|
330
|
+
return await self._execute_with_retry_async(
|
|
331
|
+
request,
|
|
332
|
+
endpoint_config,
|
|
333
|
+
effective_timeout,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def _extract_query_params(
|
|
337
|
+
self,
|
|
338
|
+
endpoint_config: EndpointConfig,
|
|
339
|
+
kwargs: dict[str, Any],
|
|
340
|
+
) -> dict[str, str]:
|
|
341
|
+
"""Extract query parameters from kwargs (excluding path params)."""
|
|
342
|
+
import re
|
|
343
|
+
|
|
344
|
+
query_params = dict(endpoint_config.query)
|
|
345
|
+
path_params: set[str] = set()
|
|
346
|
+
|
|
347
|
+
for match in re.finditer(r"\{([a-zA-Z_][a-zA-Z0-9_]*|\d+)\}", endpoint_config.path):
|
|
348
|
+
param = match.group(1)
|
|
349
|
+
if not param.isdigit():
|
|
350
|
+
path_params.add(param)
|
|
351
|
+
|
|
352
|
+
for key, value in kwargs.items():
|
|
353
|
+
if key not in path_params:
|
|
354
|
+
query_params[key] = str(value)
|
|
355
|
+
|
|
356
|
+
return query_params
|
|
357
|
+
|
|
358
|
+
def _prepare_body(
|
|
359
|
+
self,
|
|
360
|
+
body: Any,
|
|
361
|
+
headers: dict[str, str],
|
|
362
|
+
) -> bytes | None:
|
|
363
|
+
"""Prepare request body and set Content-Type header if needed."""
|
|
364
|
+
if body is None:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
content: bytes | None = None
|
|
368
|
+
if isinstance(body, dict):
|
|
369
|
+
content = json.dumps(body).encode("utf-8")
|
|
370
|
+
if "Content-Type" not in headers:
|
|
371
|
+
headers["Content-Type"] = "application/json"
|
|
372
|
+
elif isinstance(body, str):
|
|
373
|
+
content = body.encode("utf-8")
|
|
374
|
+
elif isinstance(body, bytes):
|
|
375
|
+
content = body
|
|
376
|
+
|
|
377
|
+
if content:
|
|
378
|
+
_log_trace("Request body size: %d bytes", len(content))
|
|
379
|
+
# Log body content (truncate if too large)
|
|
380
|
+
body_preview = content.decode("utf-8", errors="replace")
|
|
381
|
+
if len(body_preview) > 1000:
|
|
382
|
+
_log_trace(">>> Body: %s... [truncated]", body_preview[:1000])
|
|
383
|
+
else:
|
|
384
|
+
_log_trace(">>> Body: %s", body_preview)
|
|
385
|
+
|
|
386
|
+
return content
|
|
387
|
+
|
|
388
|
+
def _build_request(
|
|
389
|
+
self,
|
|
390
|
+
api_config: ApiConfig,
|
|
391
|
+
endpoint_config: EndpointConfig,
|
|
392
|
+
args: tuple[Any, ...],
|
|
393
|
+
kwargs: dict[str, Any],
|
|
394
|
+
body: Any,
|
|
395
|
+
runtime_headers: Mapping[str, str] | None,
|
|
396
|
+
) -> httpx.Request:
|
|
397
|
+
"""Build HTTP request from configuration.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
api_config: API service configuration.
|
|
401
|
+
endpoint_config: Endpoint configuration.
|
|
402
|
+
args: Positional path parameters.
|
|
403
|
+
kwargs: Keyword parameters (path + query).
|
|
404
|
+
body: Request body.
|
|
405
|
+
runtime_headers: Runtime header overrides.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Prepared httpx.Request.
|
|
409
|
+
"""
|
|
410
|
+
# Build URL with path parameter substitution
|
|
411
|
+
_log_trace("Path template: %s", endpoint_config.path)
|
|
412
|
+
if args:
|
|
413
|
+
_log_trace("Path args (positional): %s", args)
|
|
414
|
+
if kwargs:
|
|
415
|
+
_log_trace("Path/query kwargs: %s", kwargs)
|
|
416
|
+
path = endpoint_config.build_path(*args, **kwargs)
|
|
417
|
+
url = f"{api_config.base_url}{path}"
|
|
418
|
+
|
|
419
|
+
# Extract query params from kwargs
|
|
420
|
+
query_params = self._extract_query_params(endpoint_config, kwargs)
|
|
421
|
+
|
|
422
|
+
_log_trace("Final URL: %s", url)
|
|
423
|
+
if query_params:
|
|
424
|
+
_log_trace("Query params: %s", query_params)
|
|
425
|
+
|
|
426
|
+
# Merge headers (service < endpoint < runtime)
|
|
427
|
+
merged_headers = self._merge_headers(
|
|
428
|
+
api_config.headers,
|
|
429
|
+
endpoint_config.headers,
|
|
430
|
+
dict(runtime_headers) if runtime_headers else {},
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Prepare body first (needed for HMAC signing if sign_body=True)
|
|
434
|
+
content = self._prepare_body(body, merged_headers)
|
|
435
|
+
|
|
436
|
+
# Apply authentication (may modify headers and query_params for HMAC)
|
|
437
|
+
# Skip auth if endpoint explicitly disables it (auth: false)
|
|
438
|
+
if api_config.credentials and endpoint_config.auth:
|
|
439
|
+
self._apply_auth(merged_headers, api_config, query_params, content)
|
|
440
|
+
|
|
441
|
+
# Create request
|
|
442
|
+
request = httpx.Request(
|
|
443
|
+
method=endpoint_config.method,
|
|
444
|
+
url=url,
|
|
445
|
+
params=query_params if query_params else None,
|
|
446
|
+
headers=merged_headers,
|
|
447
|
+
content=content,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
self._log_request(request)
|
|
451
|
+
return request
|
|
452
|
+
|
|
453
|
+
def _merge_headers(
|
|
454
|
+
self,
|
|
455
|
+
service_headers: dict[str, str],
|
|
456
|
+
endpoint_headers: dict[str, str],
|
|
457
|
+
runtime_headers: dict[str, str],
|
|
458
|
+
) -> dict[str, str]:
|
|
459
|
+
"""Merge headers from three levels.
|
|
460
|
+
|
|
461
|
+
Order: service < endpoint < runtime (later overrides earlier).
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
service_headers: Service-level headers.
|
|
465
|
+
endpoint_headers: Endpoint-level headers.
|
|
466
|
+
runtime_headers: Runtime headers.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Merged headers dictionary.
|
|
470
|
+
"""
|
|
471
|
+
merged = {}
|
|
472
|
+
merged.update(service_headers)
|
|
473
|
+
merged.update(endpoint_headers)
|
|
474
|
+
merged.update(runtime_headers)
|
|
475
|
+
|
|
476
|
+
_log_trace(
|
|
477
|
+
"Headers merged: service=%d, endpoint=%d, runtime=%d -> total=%d",
|
|
478
|
+
len(service_headers),
|
|
479
|
+
len(endpoint_headers),
|
|
480
|
+
len(runtime_headers),
|
|
481
|
+
len(merged),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return merged
|
|
485
|
+
|
|
486
|
+
def _apply_auth(
|
|
487
|
+
self,
|
|
488
|
+
headers: dict[str, str],
|
|
489
|
+
api_config: ApiConfig,
|
|
490
|
+
query_params: dict[str, str] | None = None,
|
|
491
|
+
body_content: bytes | None = None,
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Apply authentication to headers and query params.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
headers: Headers dict to modify.
|
|
497
|
+
api_config: API config with credentials reference.
|
|
498
|
+
query_params: Query params dict to modify (for HMAC signing).
|
|
499
|
+
body_content: Request body content (for HMAC signing).
|
|
500
|
+
"""
|
|
501
|
+
if not api_config.credentials:
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
cred = self._credential_resolver.resolve(api_config.credentials)
|
|
506
|
+
except Exception as e:
|
|
507
|
+
log.warning("Failed to resolve credential '%s': %s", api_config.credentials, e)
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
auth_type = api_config.auth_type or "bearer"
|
|
511
|
+
|
|
512
|
+
if auth_type == "bearer":
|
|
513
|
+
headers["Authorization"] = f"Bearer {cred.value}"
|
|
514
|
+
_log_trace("Applied Bearer auth")
|
|
515
|
+
elif auth_type == "basic":
|
|
516
|
+
auth_str = f"{cred.value}:{cred.secret}" if cred.secret else f"{cred.value}:"
|
|
517
|
+
encoded = base64.b64encode(auth_str.encode()).decode()
|
|
518
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
519
|
+
_log_trace("Applied Basic auth")
|
|
520
|
+
elif auth_type == "api_key":
|
|
521
|
+
headers["X-API-Key"] = cred.value
|
|
522
|
+
_log_trace("Applied API Key auth")
|
|
523
|
+
elif auth_type == "hmac":
|
|
524
|
+
self._apply_hmac_auth(
|
|
525
|
+
headers,
|
|
526
|
+
api_config,
|
|
527
|
+
cred,
|
|
528
|
+
query_params if query_params is not None else {},
|
|
529
|
+
body_content,
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
log.warning("Unknown auth_type: %s", auth_type)
|
|
533
|
+
|
|
534
|
+
def _apply_hmac_auth(
|
|
535
|
+
self,
|
|
536
|
+
headers: dict[str, str],
|
|
537
|
+
api_config: ApiConfig,
|
|
538
|
+
cred: CredentialRecord,
|
|
539
|
+
query_params: dict[str, str],
|
|
540
|
+
body_content: bytes | None,
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Apply HMAC authentication.
|
|
543
|
+
|
|
544
|
+
Supports various exchange APIs like Binance (SHA256, hex) and
|
|
545
|
+
Kraken (SHA512, base64).
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
headers: Headers dict to modify.
|
|
549
|
+
api_config: API config with HMAC configuration.
|
|
550
|
+
cred: Resolved credential with API key and secret.
|
|
551
|
+
query_params: Query params dict to modify (timestamp/signature added).
|
|
552
|
+
body_content: Request body content (for signing if sign_body=True).
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
ValueError: If secret is not available in credentials.
|
|
556
|
+
"""
|
|
557
|
+
if not cred.secret:
|
|
558
|
+
raise ValueError("HMAC auth requires secret_key in credentials")
|
|
559
|
+
|
|
560
|
+
hmac_cfg = api_config.hmac_config or HmacConfig()
|
|
561
|
+
|
|
562
|
+
# 1. Generate timestamp or nonce
|
|
563
|
+
ts_value = str(int(time.time() * 1000))
|
|
564
|
+
ts_field = hmac_cfg.nonce_field or hmac_cfg.timestamp_field
|
|
565
|
+
|
|
566
|
+
# Add timestamp/nonce to query params
|
|
567
|
+
query_params[ts_field] = ts_value
|
|
568
|
+
|
|
569
|
+
# 2. Build payload to sign
|
|
570
|
+
if hmac_cfg.sign_body and body_content:
|
|
571
|
+
payload = body_content.decode("utf-8", errors="replace")
|
|
572
|
+
else:
|
|
573
|
+
# Query string with timestamp (same order as httpx will send)
|
|
574
|
+
payload = urlencode(query_params)
|
|
575
|
+
|
|
576
|
+
# 3. Generate signature
|
|
577
|
+
hash_func = hashlib.sha512 if hmac_cfg.algorithm == "sha512" else hashlib.sha256
|
|
578
|
+
|
|
579
|
+
signature = hmac.new(
|
|
580
|
+
cred.secret.encode("utf-8"),
|
|
581
|
+
payload.encode("utf-8"),
|
|
582
|
+
hash_func,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if hmac_cfg.signature_format == "base64":
|
|
586
|
+
sig_value = base64.b64encode(signature.digest()).decode("utf-8")
|
|
587
|
+
else:
|
|
588
|
+
sig_value = signature.hexdigest()
|
|
589
|
+
|
|
590
|
+
# 4. Add signature to query params
|
|
591
|
+
query_params[hmac_cfg.signature_field] = sig_value
|
|
592
|
+
|
|
593
|
+
# 5. Set API key header if configured
|
|
594
|
+
if hmac_cfg.key_header:
|
|
595
|
+
headers[hmac_cfg.key_header] = cred.value
|
|
596
|
+
|
|
597
|
+
_log_trace(
|
|
598
|
+
"Applied HMAC auth (algorithm=%s, format=%s)",
|
|
599
|
+
hmac_cfg.algorithm,
|
|
600
|
+
hmac_cfg.signature_format,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _log_request(self, request: httpx.Request) -> None:
|
|
604
|
+
"""Log request details at TRACE level."""
|
|
605
|
+
_log_trace(">>> %s %s", request.method, request.url)
|
|
606
|
+
|
|
607
|
+
# Log headers (redact sensitive ones)
|
|
608
|
+
for name, value in request.headers.items():
|
|
609
|
+
if name.lower() in ("authorization", "x-api-key", "cookie"):
|
|
610
|
+
_log_trace(">>> %s: [REDACTED]", name)
|
|
611
|
+
else:
|
|
612
|
+
_log_trace(">>> %s: %s", name, value)
|
|
613
|
+
|
|
614
|
+
def _log_response(self, response: httpx.Response, elapsed: float) -> None:
|
|
615
|
+
"""Log response details at TRACE level."""
|
|
616
|
+
_log_trace("<<< %d %s (%.3fs)", response.status_code, response.reason_phrase, elapsed)
|
|
617
|
+
_log_trace("<<< Content-Type: %s", response.headers.get("content-type", "unknown"))
|
|
618
|
+
_log_trace("<<< Content-Length: %s", response.headers.get("content-length", "unknown"))
|
|
619
|
+
# Log response body (truncate if too large)
|
|
620
|
+
try:
|
|
621
|
+
body_text = response.text
|
|
622
|
+
if len(body_text) > 2000:
|
|
623
|
+
_log_trace("<<< Body: %s... [truncated, %d bytes total]", body_text[:2000], len(body_text))
|
|
624
|
+
else:
|
|
625
|
+
_log_trace("<<< Body: %s", body_text)
|
|
626
|
+
except Exception:
|
|
627
|
+
_log_trace("<<< Body: [unable to decode]")
|
|
628
|
+
|
|
629
|
+
def _execute_with_retry(
|
|
630
|
+
self,
|
|
631
|
+
request: httpx.Request,
|
|
632
|
+
endpoint_config: EndpointConfig,
|
|
633
|
+
timeout: float,
|
|
634
|
+
) -> RapiResponse:
|
|
635
|
+
"""Execute request with retry logic.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
request: Prepared HTTP request.
|
|
639
|
+
endpoint_config: Endpoint configuration.
|
|
640
|
+
timeout: Request timeout in seconds.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
RapiResponse.
|
|
644
|
+
|
|
645
|
+
Raises:
|
|
646
|
+
RequestError: If all retries fail.
|
|
647
|
+
ResponseTooLargeError: If response is too large.
|
|
648
|
+
"""
|
|
649
|
+
last_error: Exception | None = None
|
|
650
|
+
delay = self._limits.retry_delay
|
|
651
|
+
|
|
652
|
+
for attempt in range(self._limits.max_retries + 1):
|
|
653
|
+
if attempt > 0:
|
|
654
|
+
log.debug("Retry %d/%d after %.1fs", attempt, self._limits.max_retries, delay)
|
|
655
|
+
_log_trace("Waiting %.1fs before retry...", delay)
|
|
656
|
+
time.sleep(delay)
|
|
657
|
+
delay *= self._limits.retry_backoff
|
|
658
|
+
|
|
659
|
+
_log_trace("Attempt %d/%d", attempt + 1, self._limits.max_retries + 1)
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
start_time = time.monotonic()
|
|
663
|
+
with httpx.Client(timeout=timeout, verify=self._ssl_context) as client:
|
|
664
|
+
response = client.send(request)
|
|
665
|
+
elapsed = time.monotonic() - start_time
|
|
666
|
+
|
|
667
|
+
self._log_response(response, elapsed)
|
|
668
|
+
|
|
669
|
+
# Check response size
|
|
670
|
+
content_length = response.headers.get("content-length")
|
|
671
|
+
if content_length and int(content_length) > self._limits.max_response_size:
|
|
672
|
+
raise ResponseTooLargeError(
|
|
673
|
+
int(content_length),
|
|
674
|
+
self._limits.max_response_size,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Parse response
|
|
678
|
+
return self._parse_response(response, endpoint_config, elapsed)
|
|
679
|
+
|
|
680
|
+
except httpx.TimeoutException as e:
|
|
681
|
+
log.warning("Request timeout (attempt %d): %s", attempt + 1, e)
|
|
682
|
+
last_error = e
|
|
683
|
+
except httpx.NetworkError as e:
|
|
684
|
+
log.warning("Network error (attempt %d): %s", attempt + 1, e)
|
|
685
|
+
last_error = e
|
|
686
|
+
except ResponseTooLargeError:
|
|
687
|
+
raise
|
|
688
|
+
except httpx.HTTPStatusError as e:
|
|
689
|
+
# Don't retry client errors (4xx), only server errors (5xx)
|
|
690
|
+
if 400 <= e.response.status_code < 500:
|
|
691
|
+
return self._parse_response(e.response, endpoint_config, 0.0)
|
|
692
|
+
log.warning("HTTP error (attempt %d): %s", attempt + 1, e)
|
|
693
|
+
last_error = e
|
|
694
|
+
|
|
695
|
+
# All retries exhausted
|
|
696
|
+
raise RequestError(
|
|
697
|
+
f"Request failed after {self._limits.max_retries + 1} attempts: {last_error}",
|
|
698
|
+
retryable=False,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
async def _execute_with_retry_async(
|
|
702
|
+
self,
|
|
703
|
+
request: httpx.Request,
|
|
704
|
+
endpoint_config: EndpointConfig,
|
|
705
|
+
timeout: float,
|
|
706
|
+
) -> RapiResponse:
|
|
707
|
+
"""Execute async request with retry logic.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
request: Prepared HTTP request.
|
|
711
|
+
endpoint_config: Endpoint configuration.
|
|
712
|
+
timeout: Request timeout in seconds.
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
RapiResponse.
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
RequestError: If all retries fail.
|
|
719
|
+
ResponseTooLargeError: If response is too large.
|
|
720
|
+
"""
|
|
721
|
+
import asyncio
|
|
722
|
+
|
|
723
|
+
last_error: Exception | None = None
|
|
724
|
+
delay = self._limits.retry_delay
|
|
725
|
+
|
|
726
|
+
for attempt in range(self._limits.max_retries + 1):
|
|
727
|
+
if attempt > 0:
|
|
728
|
+
log.debug("Retry %d/%d after %.1fs", attempt, self._limits.max_retries, delay)
|
|
729
|
+
_log_trace("Waiting %.1fs before retry...", delay)
|
|
730
|
+
await asyncio.sleep(delay)
|
|
731
|
+
delay *= self._limits.retry_backoff
|
|
732
|
+
|
|
733
|
+
_log_trace("Attempt %d/%d", attempt + 1, self._limits.max_retries + 1)
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
start_time = time.monotonic()
|
|
737
|
+
async with httpx.AsyncClient(timeout=timeout, verify=self._ssl_context) as client:
|
|
738
|
+
response = await client.send(request)
|
|
739
|
+
elapsed = time.monotonic() - start_time
|
|
740
|
+
|
|
741
|
+
self._log_response(response, elapsed)
|
|
742
|
+
|
|
743
|
+
# Check response size
|
|
744
|
+
content_length = response.headers.get("content-length")
|
|
745
|
+
if content_length and int(content_length) > self._limits.max_response_size:
|
|
746
|
+
raise ResponseTooLargeError(
|
|
747
|
+
int(content_length),
|
|
748
|
+
self._limits.max_response_size,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Parse response
|
|
752
|
+
return self._parse_response(response, endpoint_config, elapsed)
|
|
753
|
+
|
|
754
|
+
except httpx.TimeoutException as e:
|
|
755
|
+
log.warning("Request timeout (attempt %d): %s", attempt + 1, e)
|
|
756
|
+
last_error = e
|
|
757
|
+
except httpx.NetworkError as e:
|
|
758
|
+
log.warning("Network error (attempt %d): %s", attempt + 1, e)
|
|
759
|
+
last_error = e
|
|
760
|
+
except ResponseTooLargeError:
|
|
761
|
+
raise
|
|
762
|
+
except httpx.HTTPStatusError as e:
|
|
763
|
+
# Don't retry client errors (4xx)
|
|
764
|
+
if 400 <= e.response.status_code < 500:
|
|
765
|
+
return self._parse_response(e.response, endpoint_config, 0.0)
|
|
766
|
+
log.warning("HTTP error (attempt %d): %s", attempt + 1, e)
|
|
767
|
+
last_error = e
|
|
768
|
+
|
|
769
|
+
# All retries exhausted
|
|
770
|
+
raise RequestError(
|
|
771
|
+
f"Request failed after {self._limits.max_retries + 1} attempts: {last_error}",
|
|
772
|
+
retryable=False,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def _parse_response(
|
|
776
|
+
self,
|
|
777
|
+
response: httpx.Response,
|
|
778
|
+
endpoint_config: EndpointConfig,
|
|
779
|
+
elapsed: float,
|
|
780
|
+
) -> RapiResponse:
|
|
781
|
+
"""Parse HTTP response into RapiResponse.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
response: Raw HTTP response.
|
|
785
|
+
endpoint_config: Endpoint configuration.
|
|
786
|
+
elapsed: Request duration in seconds.
|
|
787
|
+
|
|
788
|
+
Returns:
|
|
789
|
+
Parsed RapiResponse.
|
|
790
|
+
"""
|
|
791
|
+
text = response.text
|
|
792
|
+
data: Any = None
|
|
793
|
+
|
|
794
|
+
# Try to parse as JSON
|
|
795
|
+
content_type = response.headers.get("content-type", "")
|
|
796
|
+
if "application/json" in content_type or text.startswith(("{", "[")):
|
|
797
|
+
try:
|
|
798
|
+
data = response.json()
|
|
799
|
+
except json.JSONDecodeError:
|
|
800
|
+
log.debug("Response is not valid JSON")
|
|
801
|
+
|
|
802
|
+
return RapiResponse(
|
|
803
|
+
status_code=response.status_code,
|
|
804
|
+
headers=dict(response.headers),
|
|
805
|
+
data=data,
|
|
806
|
+
text=text,
|
|
807
|
+
elapsed=elapsed,
|
|
808
|
+
endpoint_ref=endpoint_config.full_ref,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def call(
|
|
813
|
+
endpoint_ref: str,
|
|
814
|
+
*args: Any,
|
|
815
|
+
body: Any = None,
|
|
816
|
+
headers: Mapping[str, str] | None = None,
|
|
817
|
+
**kwargs: Any,
|
|
818
|
+
) -> RapiResponse:
|
|
819
|
+
"""Convenience function for quick API calls.
|
|
820
|
+
|
|
821
|
+
Creates a temporary RapiClient and makes the call.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
endpoint_ref: Endpoint reference.
|
|
825
|
+
*args: Positional path parameters.
|
|
826
|
+
body: Request body.
|
|
827
|
+
headers: Runtime headers.
|
|
828
|
+
**kwargs: Keyword parameters.
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
RapiResponse.
|
|
832
|
+
|
|
833
|
+
Examples:
|
|
834
|
+
>>> from kstlib.rapi import call # doctest: +SKIP
|
|
835
|
+
>>> response = call("httpbin.get_ip") # doctest: +SKIP
|
|
836
|
+
"""
|
|
837
|
+
client = RapiClient()
|
|
838
|
+
return client.call(endpoint_ref, *args, body=body, headers=headers, **kwargs)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
async def call_async(
|
|
842
|
+
endpoint_ref: str,
|
|
843
|
+
*args: Any,
|
|
844
|
+
body: Any = None,
|
|
845
|
+
headers: Mapping[str, str] | None = None,
|
|
846
|
+
**kwargs: Any,
|
|
847
|
+
) -> RapiResponse:
|
|
848
|
+
"""Convenience function for async API calls.
|
|
849
|
+
|
|
850
|
+
Creates a temporary RapiClient and makes the async call.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
endpoint_ref: Endpoint reference.
|
|
854
|
+
*args: Positional path parameters.
|
|
855
|
+
body: Request body.
|
|
856
|
+
headers: Runtime headers.
|
|
857
|
+
**kwargs: Keyword parameters.
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
RapiResponse.
|
|
861
|
+
|
|
862
|
+
Examples:
|
|
863
|
+
>>> from kstlib.rapi import call_async # doctest: +SKIP
|
|
864
|
+
>>> response = await call_async("httpbin.get_ip") # doctest: +SKIP
|
|
865
|
+
"""
|
|
866
|
+
client = RapiClient()
|
|
867
|
+
return await client.call_async(endpoint_ref, *args, body=body, headers=headers, **kwargs)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
__all__ = [
|
|
871
|
+
"RapiClient",
|
|
872
|
+
"RapiResponse",
|
|
873
|
+
"call",
|
|
874
|
+
"call_async",
|
|
875
|
+
]
|