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/config.py
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
"""Configuration management for RAPI module.
|
|
2
|
+
|
|
3
|
+
This module handles loading and resolving endpoint configurations
|
|
4
|
+
from kstlib.conf.yml or external ``*.rapi.yml`` files.
|
|
5
|
+
|
|
6
|
+
Supports:
|
|
7
|
+
- Loading from kstlib.conf.yml (default)
|
|
8
|
+
- Loading from external YAML files (``*.rapi.yml``)
|
|
9
|
+
- Auto-discovery of ``*.rapi.yml`` files in current directory
|
|
10
|
+
- Include patterns in kstlib.conf.yml
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from kstlib.rapi.exceptions import EndpointAmbiguousError, EndpointNotFoundError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Pattern for path parameters: {param} or {0}, {1}
|
|
29
|
+
_PATH_PARAM_PATTERN = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*|\d+)\}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Deep defense: allowed values for HMAC config (hardcoded limits)
|
|
33
|
+
_ALLOWED_HMAC_ALGORITHMS = frozenset({"sha256", "sha512"})
|
|
34
|
+
_ALLOWED_SIGNATURE_FORMATS = frozenset({"hex", "base64"})
|
|
35
|
+
_MAX_FIELD_NAME_LENGTH = 64 # Max length for field names (timestamp_field, etc.)
|
|
36
|
+
_MAX_HEADER_NAME_LENGTH = 128 # Max length for header names
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class HmacConfig:
|
|
41
|
+
"""HMAC signing configuration.
|
|
42
|
+
|
|
43
|
+
Supports various exchange APIs like Binance (SHA256) and Kraken (SHA512).
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
algorithm: Hash algorithm (sha256, sha512).
|
|
47
|
+
timestamp_field: Query param name for timestamp.
|
|
48
|
+
nonce_field: Query param name for nonce (alternative to timestamp).
|
|
49
|
+
signature_field: Query param name for signature.
|
|
50
|
+
signature_format: Output format (hex, base64).
|
|
51
|
+
key_header: Header name for API key.
|
|
52
|
+
sign_body: If True, sign request body instead of query string.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
>>> config = HmacConfig(algorithm="sha512", signature_format="base64")
|
|
56
|
+
>>> config.algorithm
|
|
57
|
+
'sha512'
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
algorithm: str = "sha256"
|
|
61
|
+
timestamp_field: str = "timestamp"
|
|
62
|
+
nonce_field: str | None = None
|
|
63
|
+
signature_field: str = "signature"
|
|
64
|
+
signature_format: str = "hex"
|
|
65
|
+
key_header: str | None = None
|
|
66
|
+
sign_body: bool = False
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
"""Validate HMAC config values (deep defense)."""
|
|
70
|
+
# Validate algorithm
|
|
71
|
+
if self.algorithm not in _ALLOWED_HMAC_ALGORITHMS:
|
|
72
|
+
raise ValueError(f"Invalid HMAC algorithm: {self.algorithm!r}. Allowed: {sorted(_ALLOWED_HMAC_ALGORITHMS)}")
|
|
73
|
+
|
|
74
|
+
# Validate signature format
|
|
75
|
+
if self.signature_format not in _ALLOWED_SIGNATURE_FORMATS:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Invalid signature format: {self.signature_format!r}. Allowed: {sorted(_ALLOWED_SIGNATURE_FORMATS)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Validate field name lengths
|
|
81
|
+
if len(self.timestamp_field) > _MAX_FIELD_NAME_LENGTH:
|
|
82
|
+
raise ValueError(f"timestamp_field too long: {len(self.timestamp_field)} > {_MAX_FIELD_NAME_LENGTH}")
|
|
83
|
+
if len(self.signature_field) > _MAX_FIELD_NAME_LENGTH:
|
|
84
|
+
raise ValueError(f"signature_field too long: {len(self.signature_field)} > {_MAX_FIELD_NAME_LENGTH}")
|
|
85
|
+
if self.nonce_field and len(self.nonce_field) > _MAX_FIELD_NAME_LENGTH:
|
|
86
|
+
raise ValueError(f"nonce_field too long: {len(self.nonce_field)} > {_MAX_FIELD_NAME_LENGTH}")
|
|
87
|
+
if self.key_header and len(self.key_header) > _MAX_HEADER_NAME_LENGTH:
|
|
88
|
+
raise ValueError(f"key_header too long: {len(self.key_header)} > {_MAX_HEADER_NAME_LENGTH}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_credentials_from_rapi(
|
|
92
|
+
data: dict[str, Any],
|
|
93
|
+
api_name: str,
|
|
94
|
+
file_path: Path,
|
|
95
|
+
) -> tuple[str | None, dict[str, Any]]:
|
|
96
|
+
"""Extract credentials configuration from RAPI file data.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
data: Parsed YAML data.
|
|
100
|
+
api_name: Name of the API.
|
|
101
|
+
file_path: Path to the file (for resolving relative paths).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tuple of (credentials_ref, credentials_config).
|
|
105
|
+
"""
|
|
106
|
+
credentials_config: dict[str, Any] = {}
|
|
107
|
+
credentials_ref: str | None = None
|
|
108
|
+
|
|
109
|
+
if "credentials" not in data:
|
|
110
|
+
return None, {}
|
|
111
|
+
|
|
112
|
+
cred_data = data["credentials"]
|
|
113
|
+
if isinstance(cred_data, dict):
|
|
114
|
+
# Inline credentials definition
|
|
115
|
+
credentials_ref = f"_rapi_{api_name}_cred"
|
|
116
|
+
# Resolve relative paths in credentials (expand ~ first)
|
|
117
|
+
if "path" in cred_data:
|
|
118
|
+
cred_path = Path(cred_data["path"]).expanduser()
|
|
119
|
+
if cred_path.is_absolute():
|
|
120
|
+
# Already absolute (or was ~ expanded to absolute)
|
|
121
|
+
cred_data["path"] = str(cred_path)
|
|
122
|
+
else:
|
|
123
|
+
# Relative path: resolve against file location
|
|
124
|
+
cred_data["path"] = str(file_path.parent / cred_data["path"])
|
|
125
|
+
credentials_config[credentials_ref] = cred_data
|
|
126
|
+
elif isinstance(cred_data, str):
|
|
127
|
+
# Reference to existing credential
|
|
128
|
+
credentials_ref = cred_data
|
|
129
|
+
|
|
130
|
+
return credentials_ref, credentials_config
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_auth_config(
|
|
134
|
+
data: dict[str, Any],
|
|
135
|
+
) -> tuple[str | None, HmacConfig | None]:
|
|
136
|
+
"""Extract auth type and HMAC config from RAPI file data.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
data: Parsed YAML data.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (auth_type, HmacConfig or None).
|
|
143
|
+
"""
|
|
144
|
+
if "auth" not in data:
|
|
145
|
+
return None, None
|
|
146
|
+
|
|
147
|
+
auth_data = data["auth"]
|
|
148
|
+
if isinstance(auth_data, str):
|
|
149
|
+
return auth_data, None
|
|
150
|
+
|
|
151
|
+
if not isinstance(auth_data, dict):
|
|
152
|
+
return None, None
|
|
153
|
+
|
|
154
|
+
auth_type = auth_data.get("type")
|
|
155
|
+
|
|
156
|
+
# Parse HMAC config if auth type is hmac
|
|
157
|
+
hmac_config: HmacConfig | None = None
|
|
158
|
+
if auth_type == "hmac":
|
|
159
|
+
hmac_config = HmacConfig(
|
|
160
|
+
algorithm=auth_data.get("algorithm", "sha256"),
|
|
161
|
+
timestamp_field=auth_data.get("timestamp_field", "timestamp"),
|
|
162
|
+
nonce_field=auth_data.get("nonce_field"),
|
|
163
|
+
signature_field=auth_data.get("signature_field", "signature"),
|
|
164
|
+
signature_format=auth_data.get("signature_format", "hex"),
|
|
165
|
+
key_header=auth_data.get("key_header"),
|
|
166
|
+
sign_body=auth_data.get("sign_body", False),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return auth_type, hmac_config
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
173
|
+
"""Parse a ``*.rapi.yml`` file into internal config format.
|
|
174
|
+
|
|
175
|
+
Converts the simplified format:
|
|
176
|
+
```yaml
|
|
177
|
+
name: github
|
|
178
|
+
base_url: "https://api.github.com"
|
|
179
|
+
credentials:
|
|
180
|
+
type: sops
|
|
181
|
+
path: "./tokens/github.sops.json"
|
|
182
|
+
auth:
|
|
183
|
+
type: bearer
|
|
184
|
+
endpoints:
|
|
185
|
+
user:
|
|
186
|
+
path: "/user"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Into the internal format:
|
|
190
|
+
```python
|
|
191
|
+
{
|
|
192
|
+
"api": {
|
|
193
|
+
"github": {
|
|
194
|
+
"base_url": "...",
|
|
195
|
+
"credentials": "_github_cred",
|
|
196
|
+
"auth_type": "bearer",
|
|
197
|
+
"endpoints": {...}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
path: Path to the ``*.rapi.yml`` file.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Tuple of (api_config, credentials_config).
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
TypeError: If file format is invalid.
|
|
211
|
+
ValueError: If required fields are missing.
|
|
212
|
+
"""
|
|
213
|
+
import yaml
|
|
214
|
+
|
|
215
|
+
content = path.read_text(encoding="utf-8")
|
|
216
|
+
data = yaml.safe_load(content)
|
|
217
|
+
|
|
218
|
+
if not isinstance(data, dict):
|
|
219
|
+
raise TypeError(f"Invalid RAPI config format in {path}: expected dict")
|
|
220
|
+
|
|
221
|
+
# Extract API name (or derive from filename)
|
|
222
|
+
api_name = data.get("name")
|
|
223
|
+
if not api_name:
|
|
224
|
+
api_name = path.stem.replace(".rapi", "")
|
|
225
|
+
log.debug("API name not specified, derived from filename: %s", api_name)
|
|
226
|
+
|
|
227
|
+
# Validate required fields
|
|
228
|
+
base_url = data.get("base_url")
|
|
229
|
+
if not base_url:
|
|
230
|
+
raise ValueError(f"Missing 'base_url' in {path}")
|
|
231
|
+
|
|
232
|
+
# Extract credentials and auth
|
|
233
|
+
credentials_ref, credentials_config = _extract_credentials_from_rapi(data, api_name, path)
|
|
234
|
+
auth_type, hmac_config = _extract_auth_config(data)
|
|
235
|
+
|
|
236
|
+
# Build API config
|
|
237
|
+
api_config: dict[str, Any] = {
|
|
238
|
+
"api": {
|
|
239
|
+
api_name: {
|
|
240
|
+
"base_url": base_url,
|
|
241
|
+
"credentials": credentials_ref,
|
|
242
|
+
"auth_type": auth_type,
|
|
243
|
+
"hmac_config": hmac_config,
|
|
244
|
+
"headers": data.get("headers", {}),
|
|
245
|
+
"endpoints": data.get("endpoints", {}),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
log.debug(
|
|
251
|
+
"Parsed %s: api=%s, %d endpoints, credentials=%s",
|
|
252
|
+
path.name,
|
|
253
|
+
api_name,
|
|
254
|
+
len(data.get("endpoints", {})),
|
|
255
|
+
"inline" if credentials_ref and credentials_ref.startswith("_rapi_") else credentials_ref,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return api_config, credentials_config
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, slots=True)
|
|
262
|
+
class EndpointConfig:
|
|
263
|
+
"""Configuration for a single API endpoint.
|
|
264
|
+
|
|
265
|
+
Attributes:
|
|
266
|
+
name: Endpoint name (e.g., "get_ip").
|
|
267
|
+
api_name: Parent API name (e.g., "httpbin").
|
|
268
|
+
path: URL path template (e.g., "/delay/{seconds}").
|
|
269
|
+
method: HTTP method (GET, POST, PUT, DELETE, PATCH).
|
|
270
|
+
query: Default query parameters.
|
|
271
|
+
headers: Endpoint-level headers (merged with service headers).
|
|
272
|
+
body_template: Default body template for POST/PUT.
|
|
273
|
+
auth: Whether to apply API-level authentication to this endpoint.
|
|
274
|
+
Set to False for public endpoints that don't require auth.
|
|
275
|
+
|
|
276
|
+
Examples:
|
|
277
|
+
>>> config = EndpointConfig(
|
|
278
|
+
... name="get_ip",
|
|
279
|
+
... api_name="httpbin",
|
|
280
|
+
... path="/ip",
|
|
281
|
+
... method="GET",
|
|
282
|
+
... )
|
|
283
|
+
>>> config.full_ref
|
|
284
|
+
'httpbin.get_ip'
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
name: str
|
|
288
|
+
api_name: str
|
|
289
|
+
path: str
|
|
290
|
+
method: str = "GET"
|
|
291
|
+
query: dict[str, str] = field(default_factory=dict)
|
|
292
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
293
|
+
body_template: dict[str, Any] | None = None
|
|
294
|
+
auth: bool = True
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def full_ref(self) -> str:
|
|
298
|
+
"""Return full reference: api_name.endpoint_name."""
|
|
299
|
+
return f"{self.api_name}.{self.name}"
|
|
300
|
+
|
|
301
|
+
def build_path(self, *args: Any, **kwargs: Any) -> str:
|
|
302
|
+
"""Build path with positional and keyword arguments.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
*args: Positional arguments for {0}, {1}, etc.
|
|
306
|
+
**kwargs: Keyword arguments for {name} placeholders.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Formatted path string.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ValueError: If required parameters are missing.
|
|
313
|
+
|
|
314
|
+
Examples:
|
|
315
|
+
>>> config = EndpointConfig(
|
|
316
|
+
... name="delay",
|
|
317
|
+
... api_name="httpbin",
|
|
318
|
+
... path="/delay/{seconds}",
|
|
319
|
+
... )
|
|
320
|
+
>>> config.build_path(seconds=5)
|
|
321
|
+
'/delay/5'
|
|
322
|
+
>>> config.build_path(5)
|
|
323
|
+
'/delay/5'
|
|
324
|
+
"""
|
|
325
|
+
path = self.path
|
|
326
|
+
|
|
327
|
+
# Find all placeholders
|
|
328
|
+
placeholders = _PATH_PARAM_PATTERN.findall(path)
|
|
329
|
+
|
|
330
|
+
for placeholder in placeholders:
|
|
331
|
+
if placeholder.isdigit():
|
|
332
|
+
# Positional: {0}, {1}
|
|
333
|
+
idx = int(placeholder)
|
|
334
|
+
if idx < len(args):
|
|
335
|
+
path = path.replace(f"{{{placeholder}}}", str(args[idx]))
|
|
336
|
+
else:
|
|
337
|
+
raise ValueError(f"Missing positional argument {idx} for path {self.path}")
|
|
338
|
+
elif placeholder in kwargs:
|
|
339
|
+
# Named: {name}
|
|
340
|
+
path = path.replace(f"{{{placeholder}}}", str(kwargs[placeholder]))
|
|
341
|
+
elif len(args) > 0:
|
|
342
|
+
# Try to use first positional arg for first named placeholder
|
|
343
|
+
path = path.replace(f"{{{placeholder}}}", str(args[0]))
|
|
344
|
+
args = args[1:]
|
|
345
|
+
else:
|
|
346
|
+
raise ValueError(f"Missing parameter '{placeholder}' for path {self.path}")
|
|
347
|
+
|
|
348
|
+
return path
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True, slots=True)
|
|
352
|
+
class ApiConfig:
|
|
353
|
+
"""Configuration for an API service.
|
|
354
|
+
|
|
355
|
+
Attributes:
|
|
356
|
+
name: API service name (e.g., "httpbin").
|
|
357
|
+
base_url: Base URL for the API.
|
|
358
|
+
credentials: Name of credential config to use.
|
|
359
|
+
auth_type: Authentication type (bearer, basic, api_key, hmac).
|
|
360
|
+
hmac_config: HMAC signing configuration (required when auth_type is hmac).
|
|
361
|
+
headers: Service-level headers (applied to all endpoints).
|
|
362
|
+
endpoints: Dictionary of endpoint configurations.
|
|
363
|
+
|
|
364
|
+
Examples:
|
|
365
|
+
>>> api = ApiConfig(
|
|
366
|
+
... name="httpbin",
|
|
367
|
+
... base_url="https://httpbin.org",
|
|
368
|
+
... endpoints={},
|
|
369
|
+
... )
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
name: str
|
|
373
|
+
base_url: str
|
|
374
|
+
credentials: str | None = None
|
|
375
|
+
auth_type: str | None = None
|
|
376
|
+
hmac_config: HmacConfig | None = None
|
|
377
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
378
|
+
endpoints: dict[str, EndpointConfig] = field(default_factory=dict)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class RapiConfigManager:
|
|
382
|
+
"""Manage RAPI configuration and endpoint resolution.
|
|
383
|
+
|
|
384
|
+
Loads API and endpoint configurations from kstlib.conf.yml and provides
|
|
385
|
+
resolution methods supporting both full references (api.endpoint) and
|
|
386
|
+
short references (endpoint only, auto-resolved if unique).
|
|
387
|
+
|
|
388
|
+
Supports loading from:
|
|
389
|
+
- kstlib.conf.yml (default)
|
|
390
|
+
- External ``*.rapi.yml`` files (via from_file/from_files)
|
|
391
|
+
- Auto-discovery of ``*.rapi.yml`` in current directory (via discover)
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
rapi_config: The 'rapi' section from configuration.
|
|
395
|
+
credentials_config: Inline credentials extracted from ``*.rapi.yml`` files.
|
|
396
|
+
|
|
397
|
+
Examples:
|
|
398
|
+
>>> manager = RapiConfigManager({"api": {"httpbin": {"base_url": "..."}}})
|
|
399
|
+
>>> endpoint = manager.resolve("httpbin.get_ip") # doctest: +SKIP
|
|
400
|
+
|
|
401
|
+
>>> manager = RapiConfigManager.from_file("github.rapi.yml") # doctest: +SKIP
|
|
402
|
+
>>> manager = RapiConfigManager.discover() # doctest: +SKIP
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
rapi_config: Mapping[str, Any] | None = None,
|
|
408
|
+
credentials_config: Mapping[str, Any] | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Initialize RapiConfigManager.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
rapi_config: The 'rapi' section from configuration.
|
|
414
|
+
credentials_config: Inline credentials from ``*.rapi.yml`` files.
|
|
415
|
+
"""
|
|
416
|
+
self._config = rapi_config or {}
|
|
417
|
+
self._credentials_config = dict(credentials_config) if credentials_config else {}
|
|
418
|
+
self._apis: dict[str, ApiConfig] = {}
|
|
419
|
+
self._endpoint_index: dict[str, list[str]] = {} # endpoint_name -> [api_names]
|
|
420
|
+
self._source_files: list[Path] = [] # Track loaded files for debugging
|
|
421
|
+
|
|
422
|
+
self._load_apis()
|
|
423
|
+
|
|
424
|
+
@classmethod
|
|
425
|
+
def from_file(
|
|
426
|
+
cls,
|
|
427
|
+
path: str | Path,
|
|
428
|
+
base_dir: Path | None = None,
|
|
429
|
+
) -> RapiConfigManager:
|
|
430
|
+
"""Load configuration from a single ``*.rapi.yml`` file.
|
|
431
|
+
|
|
432
|
+
The file format is simplified compared to kstlib.conf.yml,
|
|
433
|
+
with top-level keys: name, base_url, credentials, auth, headers, endpoints.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
path: Path to the ``*.rapi.yml`` file.
|
|
437
|
+
base_dir: Base directory for resolving relative paths in credentials.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Configured RapiConfigManager instance.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
FileNotFoundError: If file does not exist.
|
|
444
|
+
ValueError: If file format is invalid.
|
|
445
|
+
|
|
446
|
+
Examples:
|
|
447
|
+
>>> manager = RapiConfigManager.from_file("github.rapi.yml") # doctest: +SKIP
|
|
448
|
+
"""
|
|
449
|
+
return cls.from_files([path], base_dir=base_dir)
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def from_files(
|
|
453
|
+
cls,
|
|
454
|
+
paths: Sequence[str | Path],
|
|
455
|
+
base_dir: Path | None = None,
|
|
456
|
+
) -> RapiConfigManager:
|
|
457
|
+
"""Load configuration from multiple ``*.rapi.yml`` files.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
paths: List of paths to ``*.rapi.yml`` files.
|
|
461
|
+
base_dir: Base directory for resolving relative paths.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Configured RapiConfigManager instance with merged configs.
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
FileNotFoundError: If any file does not exist.
|
|
468
|
+
ValueError: If any file format is invalid.
|
|
469
|
+
|
|
470
|
+
Examples:
|
|
471
|
+
>>> manager = RapiConfigManager.from_files([
|
|
472
|
+
... "github.rapi.yml",
|
|
473
|
+
... "slack.rapi.yml",
|
|
474
|
+
... ]) # doctest: +SKIP
|
|
475
|
+
"""
|
|
476
|
+
merged_api_config: dict[str, Any] = {"api": {}}
|
|
477
|
+
merged_credentials: dict[str, Any] = {}
|
|
478
|
+
source_files: list[Path] = []
|
|
479
|
+
|
|
480
|
+
for file_path in paths:
|
|
481
|
+
path = Path(file_path)
|
|
482
|
+
if not path.is_absolute() and base_dir:
|
|
483
|
+
path = base_dir / path
|
|
484
|
+
|
|
485
|
+
if not path.exists():
|
|
486
|
+
raise FileNotFoundError(f"RAPI config file not found: {path}")
|
|
487
|
+
|
|
488
|
+
log.debug("Loading RAPI config from: %s", path)
|
|
489
|
+
api_config, credentials = _parse_rapi_file(path)
|
|
490
|
+
|
|
491
|
+
# Merge API config
|
|
492
|
+
for api_name, api_data in api_config.get("api", {}).items():
|
|
493
|
+
if api_name in merged_api_config["api"]:
|
|
494
|
+
log.warning("API '%s' redefined in %s, overwriting", api_name, path)
|
|
495
|
+
merged_api_config["api"][api_name] = api_data
|
|
496
|
+
|
|
497
|
+
# Merge credentials
|
|
498
|
+
merged_credentials.update(credentials)
|
|
499
|
+
source_files.append(path)
|
|
500
|
+
|
|
501
|
+
manager = cls(merged_api_config, merged_credentials)
|
|
502
|
+
manager._source_files = source_files
|
|
503
|
+
return manager
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def discover(
|
|
507
|
+
cls,
|
|
508
|
+
directory: str | Path | None = None,
|
|
509
|
+
pattern: str = "*.rapi.yml",
|
|
510
|
+
) -> RapiConfigManager:
|
|
511
|
+
"""Auto-discover and load ``*.rapi.yml`` files from a directory.
|
|
512
|
+
|
|
513
|
+
Searches for files matching the pattern in the specified directory
|
|
514
|
+
(defaults to current working directory).
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
directory: Directory to search in (default: current directory).
|
|
518
|
+
pattern: Glob pattern for files (default: ``*.rapi.yml``).
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Configured RapiConfigManager instance.
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
FileNotFoundError: If no matching files found.
|
|
525
|
+
|
|
526
|
+
Examples:
|
|
527
|
+
>>> manager = RapiConfigManager.discover() # doctest: +SKIP
|
|
528
|
+
>>> manager = RapiConfigManager.discover("./apis/") # doctest: +SKIP
|
|
529
|
+
"""
|
|
530
|
+
search_dir = Path(directory) if directory else Path.cwd()
|
|
531
|
+
|
|
532
|
+
if not search_dir.exists():
|
|
533
|
+
raise FileNotFoundError(f"Directory not found: {search_dir}")
|
|
534
|
+
|
|
535
|
+
# Find all matching files
|
|
536
|
+
files = list(search_dir.glob(pattern))
|
|
537
|
+
|
|
538
|
+
if not files:
|
|
539
|
+
raise FileNotFoundError(f"No RAPI config files found matching '{pattern}' in {search_dir}")
|
|
540
|
+
|
|
541
|
+
log.info("Discovered %d RAPI config file(s) in %s", len(files), search_dir)
|
|
542
|
+
for f in files:
|
|
543
|
+
log.debug(" - %s", f.name)
|
|
544
|
+
|
|
545
|
+
return cls.from_files(files, base_dir=search_dir)
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def credentials_config(self) -> dict[str, Any]:
|
|
549
|
+
"""Get inline credentials config extracted from ``*.rapi.yml`` files.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Dictionary of credentials configurations.
|
|
553
|
+
"""
|
|
554
|
+
return self._credentials_config
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def source_files(self) -> list[Path]:
|
|
558
|
+
"""Get list of source files loaded.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
List of Path objects for loaded files.
|
|
562
|
+
"""
|
|
563
|
+
return self._source_files
|
|
564
|
+
|
|
565
|
+
def _load_apis(self) -> None:
|
|
566
|
+
"""Load API configurations from config."""
|
|
567
|
+
api_section = self._config.get("api", {})
|
|
568
|
+
|
|
569
|
+
for api_name, api_data in api_section.items():
|
|
570
|
+
if not isinstance(api_data, dict):
|
|
571
|
+
log.warning("Skipping invalid API config: %s", api_name)
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
base_url = api_data.get("base_url", "")
|
|
575
|
+
if not base_url:
|
|
576
|
+
log.warning("API '%s' missing base_url, skipping", api_name)
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
# Parse endpoints
|
|
580
|
+
endpoints: dict[str, EndpointConfig] = {}
|
|
581
|
+
endpoints_data = api_data.get("endpoints", {})
|
|
582
|
+
|
|
583
|
+
for ep_name, ep_data in endpoints_data.items():
|
|
584
|
+
if not isinstance(ep_data, dict):
|
|
585
|
+
log.warning("Skipping invalid endpoint: %s.%s", api_name, ep_name)
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
endpoint = EndpointConfig(
|
|
589
|
+
name=ep_name,
|
|
590
|
+
api_name=api_name,
|
|
591
|
+
path=ep_data.get("path", f"/{ep_name}"),
|
|
592
|
+
method=ep_data.get("method", "GET").upper(),
|
|
593
|
+
query=dict(ep_data.get("query", {})),
|
|
594
|
+
headers=dict(ep_data.get("headers", {})),
|
|
595
|
+
body_template=ep_data.get("body"),
|
|
596
|
+
auth=ep_data.get("auth", True),
|
|
597
|
+
)
|
|
598
|
+
endpoints[ep_name] = endpoint
|
|
599
|
+
|
|
600
|
+
# Index for short reference lookup
|
|
601
|
+
if ep_name not in self._endpoint_index:
|
|
602
|
+
self._endpoint_index[ep_name] = []
|
|
603
|
+
self._endpoint_index[ep_name].append(api_name)
|
|
604
|
+
|
|
605
|
+
log.debug("Loaded endpoint: %s.%s", api_name, ep_name)
|
|
606
|
+
|
|
607
|
+
# Create API config
|
|
608
|
+
api_config = ApiConfig(
|
|
609
|
+
name=api_name,
|
|
610
|
+
base_url=base_url.rstrip("/"),
|
|
611
|
+
credentials=api_data.get("credentials"),
|
|
612
|
+
auth_type=api_data.get("auth_type"),
|
|
613
|
+
hmac_config=api_data.get("hmac_config"),
|
|
614
|
+
headers=dict(api_data.get("headers", {})),
|
|
615
|
+
endpoints=endpoints,
|
|
616
|
+
)
|
|
617
|
+
self._apis[api_name] = api_config
|
|
618
|
+
log.debug("Loaded API: %s (%d endpoints)", api_name, len(endpoints))
|
|
619
|
+
|
|
620
|
+
def _merge_apis(
|
|
621
|
+
self,
|
|
622
|
+
other: RapiConfigManager,
|
|
623
|
+
*,
|
|
624
|
+
overwrite: bool = False,
|
|
625
|
+
) -> None:
|
|
626
|
+
"""Merge APIs from another manager into this one.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
other: Source manager to merge from.
|
|
630
|
+
overwrite: If True, overwrite existing APIs. If False, skip conflicts.
|
|
631
|
+
"""
|
|
632
|
+
for api_name, api_config in other.apis.items():
|
|
633
|
+
if api_name in self._apis and not overwrite:
|
|
634
|
+
log.warning(
|
|
635
|
+
"API '%s' in include conflicts with inline config, keeping inline",
|
|
636
|
+
api_name,
|
|
637
|
+
)
|
|
638
|
+
continue
|
|
639
|
+
|
|
640
|
+
self._apis[api_name] = api_config
|
|
641
|
+
|
|
642
|
+
# Update endpoint index
|
|
643
|
+
for ep_name in api_config.endpoints:
|
|
644
|
+
if ep_name not in self._endpoint_index:
|
|
645
|
+
self._endpoint_index[ep_name] = []
|
|
646
|
+
if api_name not in self._endpoint_index[ep_name]:
|
|
647
|
+
self._endpoint_index[ep_name].append(api_name)
|
|
648
|
+
|
|
649
|
+
# Merge credentials
|
|
650
|
+
for cred_name, cred_config in other.credentials_config.items():
|
|
651
|
+
if cred_name not in self._credentials_config:
|
|
652
|
+
self._credentials_config[cred_name] = cred_config
|
|
653
|
+
|
|
654
|
+
def resolve(self, endpoint_ref: str) -> tuple[ApiConfig, EndpointConfig]:
|
|
655
|
+
"""Resolve endpoint reference to configuration.
|
|
656
|
+
|
|
657
|
+
Supports both full references (api.endpoint) and short references
|
|
658
|
+
(endpoint only). Short references are auto-resolved if the endpoint
|
|
659
|
+
name is unique across all APIs.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
endpoint_ref: Full reference (api.endpoint) or short (endpoint).
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Tuple of (ApiConfig, EndpointConfig).
|
|
666
|
+
|
|
667
|
+
Raises:
|
|
668
|
+
EndpointNotFoundError: If endpoint cannot be found.
|
|
669
|
+
EndpointAmbiguousError: If short reference matches multiple APIs.
|
|
670
|
+
|
|
671
|
+
Examples:
|
|
672
|
+
>>> manager = RapiConfigManager({...}) # doctest: +SKIP
|
|
673
|
+
>>> api, endpoint = manager.resolve("httpbin.get_ip") # doctest: +SKIP
|
|
674
|
+
>>> api, endpoint = manager.resolve("get_ip") # doctest: +SKIP
|
|
675
|
+
"""
|
|
676
|
+
log.debug("Resolving endpoint reference: %s", endpoint_ref)
|
|
677
|
+
|
|
678
|
+
if "." in endpoint_ref:
|
|
679
|
+
# Full reference: api.endpoint
|
|
680
|
+
return self._resolve_full(endpoint_ref)
|
|
681
|
+
|
|
682
|
+
# Short reference: endpoint only
|
|
683
|
+
return self._resolve_short(endpoint_ref)
|
|
684
|
+
|
|
685
|
+
def _resolve_full(self, endpoint_ref: str) -> tuple[ApiConfig, EndpointConfig]:
|
|
686
|
+
"""Resolve full reference (api.endpoint)."""
|
|
687
|
+
parts = endpoint_ref.split(".", 1)
|
|
688
|
+
if len(parts) != 2:
|
|
689
|
+
raise EndpointNotFoundError(endpoint_ref, list(self._apis))
|
|
690
|
+
|
|
691
|
+
api_name, endpoint_name = parts
|
|
692
|
+
|
|
693
|
+
if api_name not in self._apis:
|
|
694
|
+
raise EndpointNotFoundError(
|
|
695
|
+
endpoint_ref,
|
|
696
|
+
list(self._apis),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
api_config = self._apis[api_name]
|
|
700
|
+
|
|
701
|
+
if endpoint_name not in api_config.endpoints:
|
|
702
|
+
raise EndpointNotFoundError(
|
|
703
|
+
endpoint_ref,
|
|
704
|
+
[api_name],
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
endpoint_config = api_config.endpoints[endpoint_name]
|
|
708
|
+
log.debug("Resolved full reference: %s", endpoint_config.full_ref)
|
|
709
|
+
|
|
710
|
+
return api_config, endpoint_config
|
|
711
|
+
|
|
712
|
+
def _resolve_short(self, endpoint_name: str) -> tuple[ApiConfig, EndpointConfig]:
|
|
713
|
+
"""Resolve short reference (endpoint only, auto-resolve if unique)."""
|
|
714
|
+
if endpoint_name not in self._endpoint_index:
|
|
715
|
+
raise EndpointNotFoundError(endpoint_name, list(self._apis))
|
|
716
|
+
|
|
717
|
+
matching_apis = self._endpoint_index[endpoint_name]
|
|
718
|
+
|
|
719
|
+
if len(matching_apis) > 1:
|
|
720
|
+
raise EndpointAmbiguousError(endpoint_name, matching_apis)
|
|
721
|
+
|
|
722
|
+
api_name = matching_apis[0]
|
|
723
|
+
api_config = self._apis[api_name]
|
|
724
|
+
endpoint_config = api_config.endpoints[endpoint_name]
|
|
725
|
+
|
|
726
|
+
log.debug(
|
|
727
|
+
"Resolved short reference '%s' to '%s'",
|
|
728
|
+
endpoint_name,
|
|
729
|
+
endpoint_config.full_ref,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
return api_config, endpoint_config
|
|
733
|
+
|
|
734
|
+
def get_api(self, api_name: str) -> ApiConfig | None:
|
|
735
|
+
"""Get API configuration by name.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
api_name: API service name.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
ApiConfig or None if not found.
|
|
742
|
+
"""
|
|
743
|
+
return self._apis.get(api_name)
|
|
744
|
+
|
|
745
|
+
def list_apis(self) -> list[str]:
|
|
746
|
+
"""List all configured API names.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
List of API names.
|
|
750
|
+
"""
|
|
751
|
+
return list(self._apis)
|
|
752
|
+
|
|
753
|
+
@property
|
|
754
|
+
def apis(self) -> dict[str, ApiConfig]:
|
|
755
|
+
"""Get all configured APIs.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
Dictionary mapping API names to ApiConfig objects.
|
|
759
|
+
"""
|
|
760
|
+
return self._apis
|
|
761
|
+
|
|
762
|
+
def list_endpoints(self, api_name: str | None = None) -> list[str]:
|
|
763
|
+
"""List endpoint references.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
api_name: Filter by API name (optional).
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
List of full endpoint references.
|
|
770
|
+
"""
|
|
771
|
+
if api_name:
|
|
772
|
+
api = self._apis.get(api_name)
|
|
773
|
+
if not api:
|
|
774
|
+
return []
|
|
775
|
+
return [f"{api_name}.{ep}" for ep in api.endpoints]
|
|
776
|
+
|
|
777
|
+
# All endpoints
|
|
778
|
+
result: list[str] = []
|
|
779
|
+
for api in self._apis.values():
|
|
780
|
+
result.extend(f"{api.name}.{ep}" for ep in api.endpoints)
|
|
781
|
+
return result
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def load_rapi_config() -> RapiConfigManager:
|
|
785
|
+
"""Load RAPI configuration from kstlib.conf.yml with include support.
|
|
786
|
+
|
|
787
|
+
Supports including external ``*.rapi.yml`` files via glob patterns:
|
|
788
|
+
|
|
789
|
+
.. code-block:: yaml
|
|
790
|
+
|
|
791
|
+
rapi:
|
|
792
|
+
include:
|
|
793
|
+
- "./apis/``*.rapi.yml``"
|
|
794
|
+
- "~/.config/kstlib/``*.rapi.yml``"
|
|
795
|
+
api:
|
|
796
|
+
httpbin:
|
|
797
|
+
base_url: "https://httpbin.org"
|
|
798
|
+
# ...
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
Configured RapiConfigManager instance with merged configs.
|
|
802
|
+
|
|
803
|
+
Examples:
|
|
804
|
+
>>> manager = load_rapi_config() # doctest: +SKIP
|
|
805
|
+
"""
|
|
806
|
+
from kstlib.config import get_config
|
|
807
|
+
|
|
808
|
+
config = get_config()
|
|
809
|
+
rapi_section = dict(config.get("rapi", {})) # type: ignore[no-untyped-call]
|
|
810
|
+
|
|
811
|
+
log.debug("Loading RAPI config from kstlib.conf.yml")
|
|
812
|
+
|
|
813
|
+
# Process includes if present
|
|
814
|
+
include_patterns = rapi_section.pop("include", None)
|
|
815
|
+
|
|
816
|
+
# Create manager for inline config first
|
|
817
|
+
manager = RapiConfigManager(rapi_section)
|
|
818
|
+
|
|
819
|
+
# Merge included files if any
|
|
820
|
+
if include_patterns:
|
|
821
|
+
included_files = _resolve_include_patterns(include_patterns)
|
|
822
|
+
if included_files:
|
|
823
|
+
log.info("Including %d external RAPI config file(s)", len(included_files))
|
|
824
|
+
included_manager = RapiConfigManager.from_files(included_files)
|
|
825
|
+
# Merge included APIs (inline config takes precedence)
|
|
826
|
+
manager._merge_apis(included_manager, overwrite=False)
|
|
827
|
+
|
|
828
|
+
return manager
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _resolve_include_patterns(patterns: list[str] | str) -> list[Path]:
|
|
832
|
+
"""Resolve include patterns to file paths.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
patterns: Glob pattern or list of patterns.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
List of resolved file paths.
|
|
839
|
+
"""
|
|
840
|
+
if isinstance(patterns, str):
|
|
841
|
+
patterns = [patterns]
|
|
842
|
+
|
|
843
|
+
files: list[Path] = []
|
|
844
|
+
for pattern in patterns:
|
|
845
|
+
expanded = Path(pattern).expanduser()
|
|
846
|
+
if expanded.is_absolute():
|
|
847
|
+
matches = list(expanded.parent.glob(expanded.name))
|
|
848
|
+
else:
|
|
849
|
+
matches = list(Path.cwd().glob(pattern))
|
|
850
|
+
files.extend(matches)
|
|
851
|
+
|
|
852
|
+
return files
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
__all__ = [
|
|
856
|
+
"ApiConfig",
|
|
857
|
+
"EndpointConfig",
|
|
858
|
+
"HmacConfig",
|
|
859
|
+
"RapiConfigManager",
|
|
860
|
+
"load_rapi_config",
|
|
861
|
+
]
|