kstlib 1.0.1__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/cli/commands/rapi/list.py +193 -31
- kstlib/kstlib.conf.yml +11 -0
- kstlib/meta.py +1 -1
- kstlib/rapi/__init__.py +8 -0
- kstlib/rapi/client.py +51 -2
- kstlib/rapi/config.py +563 -25
- kstlib/rapi/exceptions.py +164 -0
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/METADATA +7 -2
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/RECORD +13 -13
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/WHEEL +0 -0
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/entry_points.txt +0 -0
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/licenses/LICENSE.md +0 -0
- {kstlib-1.0.1.dist-info → kstlib-1.1.0.dist-info}/top_level.txt +0 -0
kstlib/rapi/config.py
CHANGED
|
@@ -18,7 +18,13 @@ from dataclasses import dataclass, field
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import TYPE_CHECKING, Any
|
|
20
20
|
|
|
21
|
-
from kstlib.rapi.exceptions import
|
|
21
|
+
from kstlib.rapi.exceptions import (
|
|
22
|
+
EndpointAmbiguousError,
|
|
23
|
+
EndpointCollisionError,
|
|
24
|
+
EndpointNotFoundError,
|
|
25
|
+
EnvVarError,
|
|
26
|
+
SafeguardMissingError,
|
|
27
|
+
)
|
|
22
28
|
|
|
23
29
|
if TYPE_CHECKING:
|
|
24
30
|
from collections.abc import Mapping, Sequence
|
|
@@ -35,6 +41,86 @@ _ALLOWED_SIGNATURE_FORMATS = frozenset({"hex", "base64"})
|
|
|
35
41
|
_MAX_FIELD_NAME_LENGTH = 64 # Max length for field names (timestamp_field, etc.)
|
|
36
42
|
_MAX_HEADER_NAME_LENGTH = 128 # Max length for header names
|
|
37
43
|
|
|
44
|
+
# Deep defense: safeguard validation
|
|
45
|
+
_MAX_SAFEGUARD_LENGTH = 128
|
|
46
|
+
_SAFEGUARD_PATTERN = re.compile(r"^[A-Za-z0-9_\-\s\{\}/]+$")
|
|
47
|
+
|
|
48
|
+
# Default HTTP methods that require safeguard
|
|
49
|
+
_DEFAULT_SAFEGUARD_METHODS = frozenset({"DELETE", "PUT"})
|
|
50
|
+
|
|
51
|
+
# Pattern for environment variable substitution: ${VAR} or ${VAR:-default}
|
|
52
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::-([^}]*))?\}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expand_env_vars(value: str, source: str | None = None) -> str:
|
|
56
|
+
"""Expand environment variables in a string value.
|
|
57
|
+
|
|
58
|
+
Supports two syntaxes:
|
|
59
|
+
- ``${VAR}`` - required variable, raises EnvVarError if not set
|
|
60
|
+
- ``${VAR:-default}`` - optional variable with default value
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
value: String potentially containing ${VAR} patterns.
|
|
64
|
+
source: Source file for error messages.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
String with environment variables expanded.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
EnvVarError: If required variable is not set.
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
>>> import os
|
|
74
|
+
>>> os.environ["TEST_VAR"] = "hello"
|
|
75
|
+
>>> _expand_env_vars("${TEST_VAR} world")
|
|
76
|
+
'hello world'
|
|
77
|
+
>>> _expand_env_vars("${MISSING:-default}")
|
|
78
|
+
'default'
|
|
79
|
+
"""
|
|
80
|
+
import os
|
|
81
|
+
|
|
82
|
+
def replacer(match: re.Match[str]) -> str:
|
|
83
|
+
var_name = match.group(1)
|
|
84
|
+
default_value = match.group(2)
|
|
85
|
+
|
|
86
|
+
env_value = os.environ.get(var_name)
|
|
87
|
+
if env_value is not None:
|
|
88
|
+
return env_value
|
|
89
|
+
|
|
90
|
+
if default_value is not None:
|
|
91
|
+
return default_value
|
|
92
|
+
|
|
93
|
+
raise EnvVarError(var_name, source)
|
|
94
|
+
|
|
95
|
+
return _ENV_VAR_PATTERN.sub(replacer, value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _expand_env_vars_recursive(data: Any, source: str | None = None) -> Any:
|
|
99
|
+
"""Recursively expand environment variables in config data.
|
|
100
|
+
|
|
101
|
+
Applies ``_expand_env_vars`` to all string values in dicts and lists.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
data: Configuration data (dict, list, or scalar).
|
|
105
|
+
source: Source file for error messages.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Data with all environment variables expanded.
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
>>> import os
|
|
112
|
+
>>> os.environ["HOST"] = "example.com"
|
|
113
|
+
>>> _expand_env_vars_recursive({"url": "https://${HOST}"})
|
|
114
|
+
{'url': 'https://example.com'}
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
return {k: _expand_env_vars_recursive(v, source) for k, v in data.items()}
|
|
118
|
+
if isinstance(data, list):
|
|
119
|
+
return [_expand_env_vars_recursive(item, source) for item in data]
|
|
120
|
+
if isinstance(data, str):
|
|
121
|
+
return _expand_env_vars(data, source)
|
|
122
|
+
return data
|
|
123
|
+
|
|
38
124
|
|
|
39
125
|
@dataclass(frozen=True, slots=True)
|
|
40
126
|
class HmacConfig:
|
|
@@ -88,6 +174,29 @@ class HmacConfig:
|
|
|
88
174
|
raise ValueError(f"key_header too long: {len(self.key_header)} > {_MAX_HEADER_NAME_LENGTH}")
|
|
89
175
|
|
|
90
176
|
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class SafeguardConfig:
|
|
179
|
+
"""Global safeguard configuration for dangerous HTTP methods.
|
|
180
|
+
|
|
181
|
+
Configures which HTTP methods require a safeguard (confirmation string)
|
|
182
|
+
to be defined on endpoints. This is a safety mechanism to prevent
|
|
183
|
+
accidental calls to destructive endpoints.
|
|
184
|
+
|
|
185
|
+
Attributes:
|
|
186
|
+
required_methods: HTTP methods that must have a safeguard configured.
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
>>> config = SafeguardConfig()
|
|
190
|
+
>>> "DELETE" in config.required_methods
|
|
191
|
+
True
|
|
192
|
+
>>> config = SafeguardConfig(required_methods=frozenset({"DELETE"}))
|
|
193
|
+
>>> "PUT" in config.required_methods
|
|
194
|
+
False
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
required_methods: frozenset[str] = field(default_factory=lambda: _DEFAULT_SAFEGUARD_METHODS)
|
|
198
|
+
|
|
199
|
+
|
|
91
200
|
def _extract_credentials_from_rapi(
|
|
92
201
|
data: dict[str, Any],
|
|
93
202
|
api_name: str,
|
|
@@ -169,7 +278,40 @@ def _extract_auth_config(
|
|
|
169
278
|
return auth_type, hmac_config
|
|
170
279
|
|
|
171
280
|
|
|
172
|
-
def
|
|
281
|
+
def _merge_with_defaults(data: dict[str, Any], defaults: dict[str, Any] | None) -> dict[str, Any]:
|
|
282
|
+
"""Merge file data with defaults (file wins on conflict).
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
data: Configuration data from the file.
|
|
286
|
+
defaults: Default values to apply.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Merged configuration with file values taking precedence.
|
|
290
|
+
"""
|
|
291
|
+
if not defaults:
|
|
292
|
+
return data
|
|
293
|
+
|
|
294
|
+
# Start with defaults, then overlay file data
|
|
295
|
+
merged = dict(defaults)
|
|
296
|
+
|
|
297
|
+
for key, value in data.items():
|
|
298
|
+
if key == "headers" and isinstance(value, dict) and isinstance(merged.get("headers"), dict):
|
|
299
|
+
# Merge headers dicts (file headers override default headers)
|
|
300
|
+
merged["headers"] = {**merged["headers"], **value}
|
|
301
|
+
elif key == "credentials" and isinstance(value, dict) and isinstance(merged.get("credentials"), dict):
|
|
302
|
+
# Merge credentials dicts (file credentials override default credentials)
|
|
303
|
+
merged["credentials"] = {**merged["credentials"], **value}
|
|
304
|
+
else:
|
|
305
|
+
# File value takes precedence
|
|
306
|
+
merged[key] = value
|
|
307
|
+
|
|
308
|
+
return merged
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _parse_rapi_file(
|
|
312
|
+
path: Path,
|
|
313
|
+
defaults: dict[str, Any] | None = None,
|
|
314
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
173
315
|
"""Parse a ``*.rapi.yml`` file into internal config format.
|
|
174
316
|
|
|
175
317
|
Converts the simplified format:
|
|
@@ -200,8 +342,17 @@ def _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
|
200
342
|
}
|
|
201
343
|
```
|
|
202
344
|
|
|
345
|
+
With defaults support, a minimal file can inherit from kstlib.conf.yml:
|
|
346
|
+
```yaml
|
|
347
|
+
name: github
|
|
348
|
+
endpoints:
|
|
349
|
+
user:
|
|
350
|
+
path: "/user"
|
|
351
|
+
```
|
|
352
|
+
|
|
203
353
|
Args:
|
|
204
354
|
path: Path to the ``*.rapi.yml`` file.
|
|
355
|
+
defaults: Default values inherited from kstlib.conf.yml rapi.defaults section.
|
|
205
356
|
|
|
206
357
|
Returns:
|
|
207
358
|
Tuple of (api_config, credentials_config).
|
|
@@ -218,6 +369,12 @@ def _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
|
218
369
|
if not isinstance(data, dict):
|
|
219
370
|
raise TypeError(f"Invalid RAPI config format in {path}: expected dict")
|
|
220
371
|
|
|
372
|
+
# Merge with defaults first (file wins on conflict)
|
|
373
|
+
data = _merge_with_defaults(data, defaults)
|
|
374
|
+
|
|
375
|
+
# Expand environment variables in all string values (after merge so defaults can use env vars too)
|
|
376
|
+
data = _expand_env_vars_recursive(data, source=str(path))
|
|
377
|
+
|
|
221
378
|
# Extract API name (or derive from filename)
|
|
222
379
|
api_name = data.get("name")
|
|
223
380
|
if not api_name:
|
|
@@ -255,9 +412,149 @@ def _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
|
255
412
|
"inline" if credentials_ref and credentials_ref.startswith("_rapi_") else credentials_ref,
|
|
256
413
|
)
|
|
257
414
|
|
|
415
|
+
# Handle nested includes (relative to this file)
|
|
416
|
+
include_patterns = data.get("include")
|
|
417
|
+
if include_patterns:
|
|
418
|
+
included_endpoints, included_creds = _resolve_rapi_includes(include_patterns, path.parent, defaults)
|
|
419
|
+
# Merge included endpoints into this API
|
|
420
|
+
api_config["api"][api_name]["endpoints"].update(included_endpoints)
|
|
421
|
+
credentials_config.update(included_creds)
|
|
422
|
+
log.debug(
|
|
423
|
+
"Merged %d endpoints from includes into %s",
|
|
424
|
+
len(included_endpoints),
|
|
425
|
+
api_name,
|
|
426
|
+
)
|
|
427
|
+
|
|
258
428
|
return api_config, credentials_config
|
|
259
429
|
|
|
260
430
|
|
|
431
|
+
def _resolve_rapi_includes(
|
|
432
|
+
patterns: list[str] | str,
|
|
433
|
+
base_dir: Path,
|
|
434
|
+
defaults: dict[str, Any] | None,
|
|
435
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
436
|
+
"""Resolve include patterns relative to a rapi file.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
patterns: Include pattern(s) relative to base_dir.
|
|
440
|
+
base_dir: Directory containing the parent rapi file.
|
|
441
|
+
defaults: Default values to pass to included files.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Tuple of (merged_endpoints, merged_credentials).
|
|
445
|
+
"""
|
|
446
|
+
if isinstance(patterns, str):
|
|
447
|
+
patterns = [patterns]
|
|
448
|
+
|
|
449
|
+
merged_endpoints: dict[str, Any] = {}
|
|
450
|
+
merged_credentials: dict[str, Any] = {}
|
|
451
|
+
|
|
452
|
+
for pattern in patterns:
|
|
453
|
+
# Resolve relative path (remove leading ./)
|
|
454
|
+
clean_pattern = pattern.removeprefix("./")
|
|
455
|
+
resolved_path = base_dir / clean_pattern
|
|
456
|
+
|
|
457
|
+
# Support glob patterns or single file
|
|
458
|
+
matches = (
|
|
459
|
+
list(base_dir.glob(clean_pattern))
|
|
460
|
+
if "*" in clean_pattern
|
|
461
|
+
else ([resolved_path] if resolved_path.exists() else [])
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
for file_path in matches:
|
|
465
|
+
if not file_path.exists():
|
|
466
|
+
log.warning("Include file not found: %s", file_path)
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
log.debug("Including nested file: %s", file_path.name)
|
|
470
|
+
api_config, creds = _parse_rapi_file(file_path, defaults=defaults)
|
|
471
|
+
|
|
472
|
+
# Extract endpoints from the included file (ignore API name)
|
|
473
|
+
for api_data in api_config.get("api", {}).values():
|
|
474
|
+
endpoints = api_data.get("endpoints", {})
|
|
475
|
+
merged_endpoints.update(endpoints)
|
|
476
|
+
|
|
477
|
+
merged_credentials.update(creds)
|
|
478
|
+
|
|
479
|
+
return merged_endpoints, merged_credentials
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _check_endpoint_collisions(
|
|
483
|
+
api_name: str,
|
|
484
|
+
existing_api: dict[str, Any],
|
|
485
|
+
api_data: dict[str, Any],
|
|
486
|
+
path: Path,
|
|
487
|
+
ctx: tuple[dict[str, list[str]], bool],
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Check for endpoint collisions and warn or raise.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
api_name: Name of the API.
|
|
493
|
+
existing_api: Existing API data from previous files.
|
|
494
|
+
api_data: New API data from current file.
|
|
495
|
+
path: Current file path.
|
|
496
|
+
ctx: Tuple of (endpoint_sources dict, strict flag).
|
|
497
|
+
"""
|
|
498
|
+
endpoint_sources, strict = ctx
|
|
499
|
+
existing_endpoints = set(existing_api.get("endpoints", {}).keys())
|
|
500
|
+
new_endpoints = set(api_data.get("endpoints", {}).keys())
|
|
501
|
+
collisions = existing_endpoints & new_endpoints
|
|
502
|
+
|
|
503
|
+
for ep_name in collisions:
|
|
504
|
+
full_ref = f"{api_name}.{ep_name}"
|
|
505
|
+
if full_ref not in endpoint_sources:
|
|
506
|
+
endpoint_sources[full_ref] = []
|
|
507
|
+
endpoint_sources[full_ref].append(str(path))
|
|
508
|
+
if strict:
|
|
509
|
+
raise EndpointCollisionError(full_ref, endpoint_sources[full_ref])
|
|
510
|
+
log.warning(
|
|
511
|
+
"Endpoint '%s' redefined in %s, overwriting (use rapi.strict: true to error)",
|
|
512
|
+
full_ref,
|
|
513
|
+
path.name,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _merge_api_endpoints(
|
|
518
|
+
api_name: str,
|
|
519
|
+
existing_api: dict[str, Any],
|
|
520
|
+
api_data: dict[str, Any],
|
|
521
|
+
path: Path,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Merge endpoints from existing API with new API data.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
api_name: Name of the API.
|
|
527
|
+
existing_api: Existing API data from previous files.
|
|
528
|
+
api_data: New API data from current file (modified in place).
|
|
529
|
+
path: Current file path (for logging).
|
|
530
|
+
"""
|
|
531
|
+
merged_endpoints = {**existing_api.get("endpoints", {}), **api_data.get("endpoints", {})}
|
|
532
|
+
api_data["endpoints"] = merged_endpoints
|
|
533
|
+
log.warning("API '%s' redefined in %s, merging endpoints", api_name, path.name)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _track_endpoint_sources(
|
|
537
|
+
api_name: str,
|
|
538
|
+
api_data: dict[str, Any],
|
|
539
|
+
path: Path,
|
|
540
|
+
endpoint_sources: dict[str, list[str]],
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Track endpoint sources for debugging.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
api_name: Name of the API.
|
|
546
|
+
api_data: API data from current file.
|
|
547
|
+
path: Current file path.
|
|
548
|
+
endpoint_sources: Tracking dict for endpoint sources.
|
|
549
|
+
"""
|
|
550
|
+
for ep_name in api_data.get("endpoints", {}):
|
|
551
|
+
full_ref = f"{api_name}.{ep_name}"
|
|
552
|
+
if full_ref not in endpoint_sources:
|
|
553
|
+
endpoint_sources[full_ref] = []
|
|
554
|
+
if str(path) not in endpoint_sources[full_ref]:
|
|
555
|
+
endpoint_sources[full_ref].append(str(path))
|
|
556
|
+
|
|
557
|
+
|
|
261
558
|
@dataclass(frozen=True, slots=True)
|
|
262
559
|
class EndpointConfig:
|
|
263
560
|
"""Configuration for a single API endpoint.
|
|
@@ -272,6 +569,7 @@ class EndpointConfig:
|
|
|
272
569
|
body_template: Default body template for POST/PUT.
|
|
273
570
|
auth: Whether to apply API-level authentication to this endpoint.
|
|
274
571
|
Set to False for public endpoints that don't require auth.
|
|
572
|
+
description: Human-readable description of the endpoint.
|
|
275
573
|
|
|
276
574
|
Examples:
|
|
277
575
|
>>> config = EndpointConfig(
|
|
@@ -292,6 +590,19 @@ class EndpointConfig:
|
|
|
292
590
|
headers: dict[str, str] = field(default_factory=dict)
|
|
293
591
|
body_template: dict[str, Any] | None = None
|
|
294
592
|
auth: bool = True
|
|
593
|
+
safeguard: str | None = None
|
|
594
|
+
description: str | None = None
|
|
595
|
+
|
|
596
|
+
def __post_init__(self) -> None:
|
|
597
|
+
"""Validate safeguard field (deep defense)."""
|
|
598
|
+
if self.safeguard is not None:
|
|
599
|
+
if len(self.safeguard) > _MAX_SAFEGUARD_LENGTH:
|
|
600
|
+
raise ValueError(f"safeguard too long: {len(self.safeguard)} > {_MAX_SAFEGUARD_LENGTH}")
|
|
601
|
+
if not _SAFEGUARD_PATTERN.match(self.safeguard):
|
|
602
|
+
raise ValueError(
|
|
603
|
+
f"safeguard contains invalid characters: {self.safeguard!r}. "
|
|
604
|
+
f"Allowed: A-Z, a-z, 0-9, _, -, space, {'{}'}, /"
|
|
605
|
+
)
|
|
295
606
|
|
|
296
607
|
@property
|
|
297
608
|
def full_ref(self) -> str:
|
|
@@ -347,6 +658,49 @@ class EndpointConfig:
|
|
|
347
658
|
|
|
348
659
|
return path
|
|
349
660
|
|
|
661
|
+
def build_safeguard(self, *args: Any, **kwargs: Any) -> str | None:
|
|
662
|
+
"""Build safeguard string with variable substitution.
|
|
663
|
+
|
|
664
|
+
Substitutes ``{param}`` placeholders in the safeguard string with
|
|
665
|
+
provided arguments, similar to ``build_path``.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
*args: Positional arguments for {0}, {1}, etc.
|
|
669
|
+
**kwargs: Keyword arguments for {name} placeholders.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Substituted safeguard string, or None if no safeguard configured.
|
|
673
|
+
|
|
674
|
+
Examples:
|
|
675
|
+
>>> config = EndpointConfig(
|
|
676
|
+
... name="delete",
|
|
677
|
+
... api_name="test",
|
|
678
|
+
... path="/users/{userId}",
|
|
679
|
+
... method="DELETE",
|
|
680
|
+
... safeguard="DELETE USER {userId}",
|
|
681
|
+
... )
|
|
682
|
+
>>> config.build_safeguard(userId="abc123")
|
|
683
|
+
'DELETE USER abc123'
|
|
684
|
+
"""
|
|
685
|
+
if self.safeguard is None:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
result = self.safeguard
|
|
689
|
+
placeholders = _PATH_PARAM_PATTERN.findall(result)
|
|
690
|
+
|
|
691
|
+
for placeholder in placeholders:
|
|
692
|
+
if placeholder.isdigit():
|
|
693
|
+
idx = int(placeholder)
|
|
694
|
+
if idx < len(args):
|
|
695
|
+
result = result.replace(f"{{{placeholder}}}", str(args[idx]))
|
|
696
|
+
elif placeholder in kwargs:
|
|
697
|
+
result = result.replace(f"{{{placeholder}}}", str(kwargs[placeholder]))
|
|
698
|
+
elif len(args) > 0:
|
|
699
|
+
result = result.replace(f"{{{placeholder}}}", str(args[0]))
|
|
700
|
+
args = args[1:]
|
|
701
|
+
|
|
702
|
+
return result
|
|
703
|
+
|
|
350
704
|
|
|
351
705
|
@dataclass(frozen=True, slots=True)
|
|
352
706
|
class ApiConfig:
|
|
@@ -406,17 +760,24 @@ class RapiConfigManager:
|
|
|
406
760
|
self,
|
|
407
761
|
rapi_config: Mapping[str, Any] | None = None,
|
|
408
762
|
credentials_config: Mapping[str, Any] | None = None,
|
|
763
|
+
safeguard_config: SafeguardConfig | None = None,
|
|
764
|
+
strict: bool = False,
|
|
409
765
|
) -> None:
|
|
410
766
|
"""Initialize RapiConfigManager.
|
|
411
767
|
|
|
412
768
|
Args:
|
|
413
769
|
rapi_config: The 'rapi' section from configuration.
|
|
414
770
|
credentials_config: Inline credentials from ``*.rapi.yml`` files.
|
|
771
|
+
safeguard_config: Safeguard configuration (default: DELETE and PUT require safeguard).
|
|
772
|
+
strict: If True, raise error on endpoint collisions. If False, warn and overwrite.
|
|
415
773
|
"""
|
|
416
774
|
self._config = rapi_config or {}
|
|
417
775
|
self._credentials_config = dict(credentials_config) if credentials_config else {}
|
|
776
|
+
self._safeguard_config = safeguard_config or SafeguardConfig()
|
|
777
|
+
self._strict = strict
|
|
418
778
|
self._apis: dict[str, ApiConfig] = {}
|
|
419
779
|
self._endpoint_index: dict[str, list[str]] = {} # endpoint_name -> [api_names]
|
|
780
|
+
self._endpoint_sources: dict[str, str] = {} # full_ref -> source file
|
|
420
781
|
self._source_files: list[Path] = [] # Track loaded files for debugging
|
|
421
782
|
|
|
422
783
|
self._load_apis()
|
|
@@ -426,6 +787,9 @@ class RapiConfigManager:
|
|
|
426
787
|
cls,
|
|
427
788
|
path: str | Path,
|
|
428
789
|
base_dir: Path | None = None,
|
|
790
|
+
safeguard_config: SafeguardConfig | None = None,
|
|
791
|
+
defaults: dict[str, Any] | None = None,
|
|
792
|
+
strict: bool = False,
|
|
429
793
|
) -> RapiConfigManager:
|
|
430
794
|
"""Load configuration from a single ``*.rapi.yml`` file.
|
|
431
795
|
|
|
@@ -435,6 +799,9 @@ class RapiConfigManager:
|
|
|
435
799
|
Args:
|
|
436
800
|
path: Path to the ``*.rapi.yml`` file.
|
|
437
801
|
base_dir: Base directory for resolving relative paths in credentials.
|
|
802
|
+
safeguard_config: Safeguard configuration (default: DELETE and PUT require safeguard).
|
|
803
|
+
defaults: Default values inherited from kstlib.conf.yml rapi.defaults section.
|
|
804
|
+
strict: If True, raise error on endpoint collisions. If False, warn and overwrite.
|
|
438
805
|
|
|
439
806
|
Returns:
|
|
440
807
|
Configured RapiConfigManager instance.
|
|
@@ -446,19 +813,28 @@ class RapiConfigManager:
|
|
|
446
813
|
Examples:
|
|
447
814
|
>>> manager = RapiConfigManager.from_file("github.rapi.yml") # doctest: +SKIP
|
|
448
815
|
"""
|
|
449
|
-
return cls.from_files(
|
|
816
|
+
return cls.from_files(
|
|
817
|
+
[path], base_dir=base_dir, safeguard_config=safeguard_config, defaults=defaults, strict=strict
|
|
818
|
+
)
|
|
450
819
|
|
|
451
820
|
@classmethod
|
|
452
821
|
def from_files(
|
|
453
822
|
cls,
|
|
454
823
|
paths: Sequence[str | Path],
|
|
455
824
|
base_dir: Path | None = None,
|
|
825
|
+
safeguard_config: SafeguardConfig | None = None,
|
|
826
|
+
defaults: dict[str, Any] | None = None,
|
|
827
|
+
strict: bool = False,
|
|
456
828
|
) -> RapiConfigManager:
|
|
457
829
|
"""Load configuration from multiple ``*.rapi.yml`` files.
|
|
458
830
|
|
|
459
831
|
Args:
|
|
460
832
|
paths: List of paths to ``*.rapi.yml`` files.
|
|
461
833
|
base_dir: Base directory for resolving relative paths.
|
|
834
|
+
safeguard_config: Safeguard configuration (default: DELETE and PUT require safeguard).
|
|
835
|
+
defaults: Default values inherited from kstlib.conf.yml rapi.defaults section.
|
|
836
|
+
Supports: base_url, credentials, auth, headers.
|
|
837
|
+
strict: If True, raise error on endpoint collisions. If False, warn and overwrite.
|
|
462
838
|
|
|
463
839
|
Returns:
|
|
464
840
|
Configured RapiConfigManager instance with merged configs.
|
|
@@ -466,6 +842,7 @@ class RapiConfigManager:
|
|
|
466
842
|
Raises:
|
|
467
843
|
FileNotFoundError: If any file does not exist.
|
|
468
844
|
ValueError: If any file format is invalid.
|
|
845
|
+
EndpointCollisionError: If strict=True and endpoints collide.
|
|
469
846
|
|
|
470
847
|
Examples:
|
|
471
848
|
>>> manager = RapiConfigManager.from_files([
|
|
@@ -476,6 +853,8 @@ class RapiConfigManager:
|
|
|
476
853
|
merged_api_config: dict[str, Any] = {"api": {}}
|
|
477
854
|
merged_credentials: dict[str, Any] = {}
|
|
478
855
|
source_files: list[Path] = []
|
|
856
|
+
# Track endpoint sources: full_ref -> [source_files]
|
|
857
|
+
endpoint_sources: dict[str, list[str]] = {}
|
|
479
858
|
|
|
480
859
|
for file_path in paths:
|
|
481
860
|
path = Path(file_path)
|
|
@@ -486,20 +865,29 @@ class RapiConfigManager:
|
|
|
486
865
|
raise FileNotFoundError(f"RAPI config file not found: {path}")
|
|
487
866
|
|
|
488
867
|
log.debug("Loading RAPI config from: %s", path)
|
|
489
|
-
api_config, credentials = _parse_rapi_file(path)
|
|
868
|
+
api_config, credentials = _parse_rapi_file(path, defaults=defaults)
|
|
490
869
|
|
|
491
|
-
# Merge API config
|
|
870
|
+
# Merge API config with collision detection
|
|
871
|
+
collision_ctx = (endpoint_sources, strict)
|
|
492
872
|
for api_name, api_data in api_config.get("api", {}).items():
|
|
493
|
-
|
|
494
|
-
|
|
873
|
+
existing_api = merged_api_config["api"].get(api_name)
|
|
874
|
+
if existing_api:
|
|
875
|
+
_check_endpoint_collisions(api_name, existing_api, api_data, path, collision_ctx)
|
|
876
|
+
_merge_api_endpoints(api_name, existing_api, api_data, path)
|
|
877
|
+
|
|
878
|
+
_track_endpoint_sources(api_name, api_data, path, endpoint_sources)
|
|
495
879
|
merged_api_config["api"][api_name] = api_data
|
|
496
880
|
|
|
497
881
|
# Merge credentials
|
|
498
882
|
merged_credentials.update(credentials)
|
|
499
883
|
source_files.append(path)
|
|
500
884
|
|
|
501
|
-
manager = cls(merged_api_config, merged_credentials)
|
|
885
|
+
manager = cls(merged_api_config, merged_credentials, safeguard_config, strict=strict)
|
|
502
886
|
manager._source_files = source_files
|
|
887
|
+
# Store endpoint sources for debugging
|
|
888
|
+
for full_ref, sources in endpoint_sources.items():
|
|
889
|
+
if sources:
|
|
890
|
+
manager._endpoint_sources[full_ref] = sources[0]
|
|
503
891
|
return manager
|
|
504
892
|
|
|
505
893
|
@classmethod
|
|
@@ -562,6 +950,15 @@ class RapiConfigManager:
|
|
|
562
950
|
"""
|
|
563
951
|
return self._source_files
|
|
564
952
|
|
|
953
|
+
@property
|
|
954
|
+
def safeguard_config(self) -> SafeguardConfig:
|
|
955
|
+
"""Get safeguard configuration.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
SafeguardConfig instance.
|
|
959
|
+
"""
|
|
960
|
+
return self._safeguard_config
|
|
961
|
+
|
|
565
962
|
def _load_apis(self) -> None:
|
|
566
963
|
"""Load API configurations from config."""
|
|
567
964
|
api_section = self._config.get("api", {})
|
|
@@ -585,16 +982,26 @@ class RapiConfigManager:
|
|
|
585
982
|
log.warning("Skipping invalid endpoint: %s.%s", api_name, ep_name)
|
|
586
983
|
continue
|
|
587
984
|
|
|
985
|
+
method = ep_data.get("method", "GET").upper()
|
|
986
|
+
safeguard = ep_data.get("safeguard")
|
|
987
|
+
|
|
588
988
|
endpoint = EndpointConfig(
|
|
589
989
|
name=ep_name,
|
|
590
990
|
api_name=api_name,
|
|
591
991
|
path=ep_data.get("path", f"/{ep_name}"),
|
|
592
|
-
method=
|
|
992
|
+
method=method,
|
|
593
993
|
query=dict(ep_data.get("query", {})),
|
|
594
994
|
headers=dict(ep_data.get("headers", {})),
|
|
595
995
|
body_template=ep_data.get("body"),
|
|
596
996
|
auth=ep_data.get("auth", True),
|
|
997
|
+
safeguard=safeguard,
|
|
998
|
+
description=ep_data.get("description"),
|
|
597
999
|
)
|
|
1000
|
+
|
|
1001
|
+
# Validate safeguard requirement
|
|
1002
|
+
if method in self._safeguard_config.required_methods and safeguard is None:
|
|
1003
|
+
raise SafeguardMissingError(endpoint.full_ref, method)
|
|
1004
|
+
|
|
598
1005
|
endpoints[ep_name] = endpoint
|
|
599
1006
|
|
|
600
1007
|
# Index for short reference lookup
|
|
@@ -631,26 +1038,83 @@ class RapiConfigManager:
|
|
|
631
1038
|
"""
|
|
632
1039
|
for api_name, api_config in other.apis.items():
|
|
633
1040
|
if api_name in self._apis and not overwrite:
|
|
634
|
-
|
|
635
|
-
"API '%s' in include conflicts with inline config, keeping inline",
|
|
636
|
-
api_name,
|
|
637
|
-
)
|
|
1041
|
+
self._handle_api_conflict(api_name, api_config, other)
|
|
638
1042
|
continue
|
|
639
1043
|
|
|
640
1044
|
self._apis[api_name] = api_config
|
|
641
|
-
|
|
642
|
-
|
|
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)
|
|
1045
|
+
self._update_endpoint_index(api_name, api_config)
|
|
1046
|
+
self._copy_endpoint_sources(api_name, api_config, other)
|
|
648
1047
|
|
|
649
1048
|
# Merge credentials
|
|
650
1049
|
for cred_name, cred_config in other.credentials_config.items():
|
|
651
1050
|
if cred_name not in self._credentials_config:
|
|
652
1051
|
self._credentials_config[cred_name] = cred_config
|
|
653
1052
|
|
|
1053
|
+
def _handle_api_conflict(
|
|
1054
|
+
self,
|
|
1055
|
+
api_name: str,
|
|
1056
|
+
api_config: ApiConfig,
|
|
1057
|
+
other: RapiConfigManager,
|
|
1058
|
+
) -> None:
|
|
1059
|
+
"""Handle API name conflict during merge.
|
|
1060
|
+
|
|
1061
|
+
Args:
|
|
1062
|
+
api_name: Name of the conflicting API.
|
|
1063
|
+
api_config: The incoming API config.
|
|
1064
|
+
other: Source manager to merge from.
|
|
1065
|
+
"""
|
|
1066
|
+
existing_endpoints = set(self._apis[api_name].endpoints.keys())
|
|
1067
|
+
new_endpoints = set(api_config.endpoints.keys())
|
|
1068
|
+
collisions = existing_endpoints & new_endpoints
|
|
1069
|
+
|
|
1070
|
+
for ep_name in collisions:
|
|
1071
|
+
full_ref = f"{api_name}.{ep_name}"
|
|
1072
|
+
sources = ["inline config"]
|
|
1073
|
+
if full_ref in other._endpoint_sources:
|
|
1074
|
+
sources.append(other._endpoint_sources[full_ref])
|
|
1075
|
+
if self._strict:
|
|
1076
|
+
raise EndpointCollisionError(full_ref, sources)
|
|
1077
|
+
log.warning(
|
|
1078
|
+
"Endpoint '%s' in include conflicts with inline config, keeping inline",
|
|
1079
|
+
full_ref,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
log.warning(
|
|
1083
|
+
"API '%s' in include conflicts with inline config, keeping inline",
|
|
1084
|
+
api_name,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
def _update_endpoint_index(self, api_name: str, api_config: ApiConfig) -> None:
|
|
1088
|
+
"""Update endpoint index for an API.
|
|
1089
|
+
|
|
1090
|
+
Args:
|
|
1091
|
+
api_name: Name of the API.
|
|
1092
|
+
api_config: The API configuration.
|
|
1093
|
+
"""
|
|
1094
|
+
for ep_name in api_config.endpoints:
|
|
1095
|
+
if ep_name not in self._endpoint_index:
|
|
1096
|
+
self._endpoint_index[ep_name] = []
|
|
1097
|
+
if api_name not in self._endpoint_index[ep_name]:
|
|
1098
|
+
self._endpoint_index[ep_name].append(api_name)
|
|
1099
|
+
|
|
1100
|
+
def _copy_endpoint_sources(
|
|
1101
|
+
self,
|
|
1102
|
+
api_name: str,
|
|
1103
|
+
api_config: ApiConfig,
|
|
1104
|
+
other: RapiConfigManager,
|
|
1105
|
+
) -> None:
|
|
1106
|
+
"""Copy endpoint source tracking from another manager.
|
|
1107
|
+
|
|
1108
|
+
Args:
|
|
1109
|
+
api_name: Name of the API.
|
|
1110
|
+
api_config: The API configuration.
|
|
1111
|
+
other: Source manager to copy from.
|
|
1112
|
+
"""
|
|
1113
|
+
for ep_name in api_config.endpoints:
|
|
1114
|
+
full_ref = f"{api_name}.{ep_name}"
|
|
1115
|
+
if full_ref in other._endpoint_sources:
|
|
1116
|
+
self._endpoint_sources[full_ref] = other._endpoint_sources[full_ref]
|
|
1117
|
+
|
|
654
1118
|
def resolve(self, endpoint_ref: str) -> tuple[ApiConfig, EndpointConfig]:
|
|
655
1119
|
"""Resolve endpoint reference to configuration.
|
|
656
1120
|
|
|
@@ -781,22 +1245,77 @@ class RapiConfigManager:
|
|
|
781
1245
|
return result
|
|
782
1246
|
|
|
783
1247
|
|
|
1248
|
+
def _parse_safeguard_config(rapi_section: dict[str, Any]) -> SafeguardConfig:
|
|
1249
|
+
"""Parse safeguard configuration from rapi section.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
rapi_section: The 'rapi' section from configuration.
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
SafeguardConfig instance.
|
|
1256
|
+
"""
|
|
1257
|
+
safeguard_data = rapi_section.get("safeguard", {})
|
|
1258
|
+
if not safeguard_data:
|
|
1259
|
+
return SafeguardConfig()
|
|
1260
|
+
|
|
1261
|
+
required_methods = safeguard_data.get("required_methods")
|
|
1262
|
+
if required_methods is None:
|
|
1263
|
+
return SafeguardConfig()
|
|
1264
|
+
|
|
1265
|
+
# Convert list to frozenset, uppercase all methods
|
|
1266
|
+
methods = frozenset(m.upper() for m in required_methods)
|
|
1267
|
+
return SafeguardConfig(required_methods=methods)
|
|
1268
|
+
|
|
1269
|
+
|
|
784
1270
|
def load_rapi_config() -> RapiConfigManager:
|
|
785
1271
|
"""Load RAPI configuration from kstlib.conf.yml with include support.
|
|
786
1272
|
|
|
787
|
-
Supports including external ``*.rapi.yml`` files via glob patterns
|
|
1273
|
+
Supports including external ``*.rapi.yml`` files via glob patterns,
|
|
1274
|
+
and a ``defaults`` section that is inherited by included files:
|
|
788
1275
|
|
|
789
1276
|
.. code-block:: yaml
|
|
790
1277
|
|
|
791
1278
|
rapi:
|
|
1279
|
+
# Strict mode: error on endpoint collisions (default: false = warn only)
|
|
1280
|
+
strict: true
|
|
1281
|
+
|
|
1282
|
+
# Defaults inherited by all included *.rapi.yml files
|
|
1283
|
+
defaults:
|
|
1284
|
+
base_url: "https://${VIYA_HOST}"
|
|
1285
|
+
credentials:
|
|
1286
|
+
type: file
|
|
1287
|
+
path: ~/.sas/credentials.json
|
|
1288
|
+
token_path: ".Default['access-token']"
|
|
1289
|
+
auth: bearer
|
|
1290
|
+
headers:
|
|
1291
|
+
Accept: application/json
|
|
1292
|
+
|
|
792
1293
|
include:
|
|
793
|
-
- "./apis
|
|
794
|
-
- "~/.config/kstlib
|
|
1294
|
+
- "./apis/*.rapi.yml"
|
|
1295
|
+
- "~/.config/kstlib/*.rapi.yml"
|
|
1296
|
+
|
|
1297
|
+
safeguard:
|
|
1298
|
+
required_methods:
|
|
1299
|
+
- DELETE
|
|
1300
|
+
|
|
795
1301
|
api:
|
|
796
1302
|
httpbin:
|
|
797
1303
|
base_url: "https://httpbin.org"
|
|
798
1304
|
# ...
|
|
799
1305
|
|
|
1306
|
+
With defaults, included files can be minimal:
|
|
1307
|
+
|
|
1308
|
+
.. code-block:: yaml
|
|
1309
|
+
|
|
1310
|
+
# annotations.rapi.yml
|
|
1311
|
+
name: annotations
|
|
1312
|
+
headers:
|
|
1313
|
+
Accept: application/vnd.sas.annotation+json
|
|
1314
|
+
endpoints:
|
|
1315
|
+
root:
|
|
1316
|
+
path: /annotations/
|
|
1317
|
+
method: GET
|
|
1318
|
+
|
|
800
1319
|
Returns:
|
|
801
1320
|
Configured RapiConfigManager instance with merged configs.
|
|
802
1321
|
|
|
@@ -810,18 +1329,36 @@ def load_rapi_config() -> RapiConfigManager:
|
|
|
810
1329
|
|
|
811
1330
|
log.debug("Loading RAPI config from kstlib.conf.yml")
|
|
812
1331
|
|
|
1332
|
+
# Extract strict mode (default: False = warn on collisions)
|
|
1333
|
+
strict = rapi_section.pop("strict", False)
|
|
1334
|
+
if strict:
|
|
1335
|
+
log.debug("Strict mode enabled: endpoint collisions will raise errors")
|
|
1336
|
+
|
|
1337
|
+
# Extract defaults for included files
|
|
1338
|
+
defaults = rapi_section.pop("defaults", None)
|
|
1339
|
+
if defaults:
|
|
1340
|
+
log.debug("Found rapi.defaults section with keys: %s", list(defaults.keys()))
|
|
1341
|
+
|
|
813
1342
|
# Process includes if present
|
|
814
1343
|
include_patterns = rapi_section.pop("include", None)
|
|
815
1344
|
|
|
1345
|
+
# Parse safeguard config
|
|
1346
|
+
safeguard_config = _parse_safeguard_config(rapi_section)
|
|
1347
|
+
|
|
816
1348
|
# Create manager for inline config first
|
|
817
|
-
manager = RapiConfigManager(rapi_section)
|
|
1349
|
+
manager = RapiConfigManager(rapi_section, safeguard_config=safeguard_config, strict=strict)
|
|
818
1350
|
|
|
819
1351
|
# Merge included files if any
|
|
820
1352
|
if include_patterns:
|
|
821
1353
|
included_files = _resolve_include_patterns(include_patterns)
|
|
822
1354
|
if included_files:
|
|
823
1355
|
log.info("Including %d external RAPI config file(s)", len(included_files))
|
|
824
|
-
included_manager = RapiConfigManager.from_files(
|
|
1356
|
+
included_manager = RapiConfigManager.from_files(
|
|
1357
|
+
included_files,
|
|
1358
|
+
safeguard_config=safeguard_config,
|
|
1359
|
+
defaults=defaults,
|
|
1360
|
+
strict=strict,
|
|
1361
|
+
)
|
|
825
1362
|
# Merge included APIs (inline config takes precedence)
|
|
826
1363
|
manager._merge_apis(included_manager, overwrite=False)
|
|
827
1364
|
|
|
@@ -857,5 +1394,6 @@ __all__ = [
|
|
|
857
1394
|
"EndpointConfig",
|
|
858
1395
|
"HmacConfig",
|
|
859
1396
|
"RapiConfigManager",
|
|
1397
|
+
"SafeguardConfig",
|
|
860
1398
|
"load_rapi_config",
|
|
861
1399
|
]
|