kstlib 1.0.2__py3-none-any.whl → 1.1.1__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/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 EndpointAmbiguousError, EndpointNotFoundError
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 _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
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([path], base_dir=base_dir)
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
- if api_name in merged_api_config["api"]:
494
- log.warning("API '%s' redefined in %s, overwriting", api_name, path)
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=ep_data.get("method", "GET").upper(),
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
- log.warning(
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
- # 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)
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/``*.rapi.yml``"
794
- - "~/.config/kstlib/``*.rapi.yml``"
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(included_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
  ]