csrd-versioning 0.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.
Files changed (48) hide show
  1. csrd/versioning/__init__.py +99 -0
  2. csrd/versioning/_constants.py +28 -0
  3. csrd/versioning/_core.py +240 -0
  4. csrd/versioning/_dependencies.py +67 -0
  5. csrd/versioning/_dependency_wiring.py +272 -0
  6. csrd/versioning/_dispatch.py +203 -0
  7. csrd/versioning/_docs.py +495 -0
  8. csrd/versioning/_fastapi_types.py +44 -0
  9. csrd/versioning/_helpers.py +149 -0
  10. csrd/versioning/_orchestration.py +399 -0
  11. csrd/versioning/_redoc.py +98 -0
  12. csrd/versioning/_settings.py +88 -0
  13. csrd/versioning/_swagger_ui_version.py +12 -0
  14. csrd/versioning/_types.py +14 -0
  15. csrd/versioning/actuator/README.md +261 -0
  16. csrd/versioning/actuator/__init__.py +3 -0
  17. csrd/versioning/actuator/actuator.py +111 -0
  18. csrd/versioning/actuator/plugins/__init__.py +65 -0
  19. csrd/versioning/actuator/plugins/base.py +77 -0
  20. csrd/versioning/actuator/plugins/env/__init__.py +31 -0
  21. csrd/versioning/actuator/plugins/env/plugin.py +145 -0
  22. csrd/versioning/actuator/plugins/env/providers.py +105 -0
  23. csrd/versioning/actuator/plugins/env/registry.py +211 -0
  24. csrd/versioning/actuator/plugins/health/__init__.py +37 -0
  25. csrd/versioning/actuator/plugins/health/auto.py +244 -0
  26. csrd/versioning/actuator/plugins/health/indicators.py +137 -0
  27. csrd/versioning/actuator/plugins/health/plugin.py +271 -0
  28. csrd/versioning/actuator/plugins/info.py +120 -0
  29. csrd/versioning/actuator/tools/README.md +54 -0
  30. csrd/versioning/actuator/tools/__init__.py +0 -0
  31. csrd/versioning/actuator/tools/generate_service_info_from_git.py +126 -0
  32. csrd/versioning/exception_handlers.py +126 -0
  33. csrd/versioning/py.typed +0 -0
  34. csrd/versioning/swagger_plugins/__init__.py +15 -0
  35. csrd/versioning/swagger_plugins/_base.py +153 -0
  36. csrd/versioning/swagger_plugins/file_upload/__init__.py +35 -0
  37. csrd/versioning/swagger_plugins/file_upload/_body_factory.py +121 -0
  38. csrd/versioning/swagger_plugins/file_upload/_schema_patcher.py +165 -0
  39. csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.css +181 -0
  40. csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.js +529 -0
  41. csrd/versioning/templates/__init__.py +0 -0
  42. csrd/versioning/templates/favicon.png +0 -0
  43. csrd/versioning/templates/swagger_ui.css +616 -0
  44. csrd/versioning/templates/swagger_ui.html +43 -0
  45. csrd/versioning/templates/swagger_ui.js +131 -0
  46. csrd_versioning-0.1.0.dist-info/METADATA +15 -0
  47. csrd_versioning-0.1.0.dist-info/RECORD +48 -0
  48. csrd_versioning-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,88 @@
1
+ import logging
2
+ from functools import lru_cache
3
+
4
+ from pydantic import AliasChoices, Field, SecretStr
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class VersioningSettings(BaseSettings):
11
+ """Environment-backed settings required for the versioning system to operate.
12
+
13
+ This includes identity and auth values (e.g. ``jwt_secret``) that
14
+ versioned endpoints depend on, not just version-routing configuration.
15
+ """
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_prefix="",
19
+ case_sensitive=False,
20
+ extra="ignore",
21
+ )
22
+
23
+ app_name: str | None = Field(
24
+ default=None,
25
+ validation_alias=AliasChoices("APP_NAME", "app_name"),
26
+ )
27
+ jwt_secret: SecretStr | None = Field(
28
+ default=None,
29
+ validation_alias=AliasChoices("JWT_SECRET", "JAVELIN_JWT_KEY_CREDENTIALS_JAVELIN_JWT_KEY"),
30
+ )
31
+ service_root_domain: str | None = Field(
32
+ default=None,
33
+ validation_alias=AliasChoices("SERVICE_ROOT_DOMAIN"),
34
+ )
35
+ build_tag: str | None = Field(
36
+ default=None,
37
+ validation_alias=AliasChoices("BUILD_TAG"),
38
+ )
39
+
40
+
41
+ @lru_cache(maxsize=1)
42
+ def load_versioning_settings() -> VersioningSettings:
43
+ """Return cached settings read from environment variables.
44
+
45
+ The result is cached for the lifetime of the process — only add
46
+ fields here for values that are fixed at startup (e.g. ``APP_NAME``,
47
+ ``JWT_SECRET``). Environment variable changes after the first call
48
+ will not be reflected.
49
+ """
50
+ return VersioningSettings()
51
+
52
+
53
+ def load_app_name(override: str | None = None) -> str:
54
+ """Resolve application name with an explicit override or environment fallback.
55
+
56
+ When *override* is a non-empty string it is returned immediately.
57
+ When *override* is ``None``, the function reads ``APP_NAME`` from
58
+ environment-backed Pydantic settings. If ``APP_NAME`` is missing, or any
59
+ error occurs, the function falls back to ``"UNKNOWN_APP"``.
60
+
61
+ Raises:
62
+ ValueError: If *override* is an empty string.
63
+ """
64
+ if isinstance(override, str):
65
+ if not override:
66
+ raise ValueError("override must be a non-empty string or None")
67
+ return override
68
+
69
+ fallback = "UNKNOWN_APP"
70
+
71
+ try:
72
+ settings = load_versioning_settings()
73
+ if settings.app_name:
74
+ logger.debug("App name loaded from settings: %s", settings.app_name)
75
+ return settings.app_name
76
+
77
+ logger.warning("No APP_NAME value found in settings, defaulting to %s", fallback)
78
+ return fallback
79
+ except Exception as e:
80
+ logger.warning(
81
+ "error retrieving app_name from settings defaulting to %s: %s",
82
+ fallback,
83
+ e,
84
+ )
85
+ return fallback
86
+
87
+
88
+ __all__ = ("VersioningSettings", "load_app_name", "load_versioning_settings")
@@ -0,0 +1,12 @@
1
+ # Pinned Swagger UI version and SRI hashes for jsDelivr CDN assets.
2
+ #
3
+ # Auto-generated — do not edit manually.
4
+ # Regenerate with::
5
+ # python scripts/update_swagger_ui.py <version>
6
+ #
7
+ # Example:
8
+ # python scripts/update_swagger_ui.py 5.18.2
9
+
10
+ SWAGGER_UI_VERSION = "5.18.2"
11
+ SWAGGER_UI_CSS_SRI = "rcbEi6xgdPk0iWkAQzT2F3FeBJXdG+ydrawGlfHAFIZG7wU6aKbQaRewysYpmrlW"
12
+ SWAGGER_UI_JS_SRI = "NXtFPpN61oWCuN4D42K6Zd5Rt2+uxeIT36R7kpXBuY9tLnZorzrJ4ykpqwJfgjpZ"
@@ -0,0 +1,14 @@
1
+ from collections.abc import Mapping
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ VersionKey = Enum | str | int | None
6
+ VersionMap = Mapping[VersionKey, Any]
7
+ VersionedAppState = Mapping[str, Any]
8
+
9
+
10
+ __all__ = (
11
+ "VersionKey",
12
+ "VersionMap",
13
+ "VersionedAppState",
14
+ )
@@ -0,0 +1,261 @@
1
+ # Actuator Extension Model
2
+
3
+ This package provides Spring Boot-style management endpoints under `/actuator`.
4
+
5
+ ## Defaults
6
+
7
+ - `register_actuator_router(app)` mounts `/actuator` and auto-wires built-in plugins.
8
+ - `/_info` and `/_info/health` are separate versioning introspection endpoints and remain unchanged.
9
+ - Consumers can pass plugin overrides via `configure_versioned_api(..., actuator_plugins=[...])`
10
+ or `create_versioned_app(..., actuator_plugins=[...])`. On name conflicts,
11
+ consumer plugins override built-in plugins.
12
+
13
+ ## Plugin Contract
14
+
15
+ Implement the `ActuatorPlugin` protocol from `plugins/base.py`:
16
+
17
+ - `name`: short plugin identifier
18
+ - `register(router, app=..., prefix=...)`: add routes and return link metadata
19
+
20
+ The returned link metadata is merged into the root payload:
21
+
22
+ ```json
23
+ {
24
+ "_links": {
25
+ "self": {"href": ".../actuator", "templated": false}
26
+ }
27
+ }
28
+ ```
29
+
30
+ When the info plugin is active and the metadata file exists, the root
31
+ payload also includes an `info` link.
32
+
33
+ When the env plugin is active, the root payload includes an `env` link.
34
+ When the env plugin is active, the root payload also includes a templated
35
+ `env-toMatch` link.
36
+
37
+ ## Built-in Plugin
38
+
39
+ `InfoActuatorPlugin` serves `/actuator/info` by reading JSON metadata from:
40
+
41
+ 1. `ACTUATOR_INFO_PATH` environment variable
42
+ 2. `service_info.json` (default, relative to the process working directory)
43
+
44
+ If the resolved file does not exist, `/actuator/info` is not registered.
45
+ If the file exists but is invalid JSON, the endpoint returns `{}`.
46
+
47
+ `EnvActuatorPlugin` serves `/actuator/env` with runtime profile metadata:
48
+
49
+ ```json
50
+ {
51
+ "activeProfiles": ["dev", "stage"],
52
+ "defaultProfiles": [],
53
+ "propertySources": []
54
+ }
55
+ ```
56
+
57
+ Profiles are read from `SPRING_PROFILES_ACTIVE` as a comma-separated list.
58
+
59
+ `EnvActuatorPlugin` also serves `/actuator/env/{toMatch}` and filters
60
+ environment variables by case-insensitive key match into `propertySources`.
61
+
62
+ `HealthActuatorPlugin` serves `/actuator/health` with composable health
63
+ indicators:
64
+
65
+ ```json
66
+ {
67
+ "status": "UP",
68
+ "groups": ["liveness", "readiness"],
69
+ "components": {
70
+ "ping": {"status": "UP"},
71
+ "diskSpace": {"status": "UP", "details": {"total": ..., "free": ..., "threshold": 10485760, "path": ".", "exists": true}},
72
+ "livenessState": {"status": "UP"},
73
+ "readinessState": {"status": "UP"}
74
+ }
75
+ }
76
+ ```
77
+
78
+ Default indicators: `PingHealthIndicator`, `DiskSpaceHealthIndicator`,
79
+ `LivenessIndicator`, `ReadinessIndicator`.
80
+
81
+ Default health groups:
82
+ - `liveness` → `["livenessState"]`
83
+ - `readiness` → `["readinessState", "ping", "diskSpace"]`
84
+
85
+ `/actuator/health/{component}` returns an individual indicator's status.
86
+ `/actuator/health/{group}` returns the aggregated status for a health group.
87
+
88
+ ### Custom Indicators
89
+
90
+ Extend `BaseHealthIndicator` for exception-safe custom indicators.
91
+ Override `_check()` — the base class wraps it in try/except automatically:
92
+
93
+ ```python
94
+ from api_versioning.fastapi.versioning.actuator.plugins import BaseHealthIndicator
95
+
96
+ class MyDatabaseIndicator(BaseHealthIndicator):
97
+ _name = "db"
98
+
99
+ def __init__(self, pool):
100
+ self._pool = pool
101
+
102
+ def _check(self):
103
+ self._pool.execute("SELECT 1")
104
+ return {"status": "UP"}
105
+ ```
106
+
107
+ Both sync and async `_check()` are supported:
108
+
109
+ ```python
110
+ class MyAsyncIndicator(BaseHealthIndicator):
111
+ _name = "myService"
112
+
113
+ async def _check(self):
114
+ result = await some_client.ping()
115
+ return {"status": "UP", "details": result}
116
+ ```
117
+
118
+ Register via `HealthActuatorPlugin.with_indicators()`:
119
+
120
+ ```python
121
+ HealthActuatorPlugin.with_indicators(db=MyDatabaseIndicator(pool))
122
+ ```
123
+
124
+ ### Auto-Discovery
125
+
126
+ When `auto_discover=True` (the default), the health plugin inspects
127
+ `app.state` on each request and registers indicators for detected services:
128
+
129
+ | Indicator | `app.state` key | Source package |
130
+ |------------------|-----------------------------------|-------------------------------------|
131
+ | `rabbit` | `rabbit` | RabbitMQ client |
132
+ | `kafka` | `kafka_producer_manager` / `kafka_consumer_manager` | Kafka client |
133
+ | `mongo` | `mongo_client` | MongoDB client |
134
+ | `launchDarkly` | `launchdarkly_client` | LaunchDarkly client |
135
+
136
+ Auto-discovered indicators are appended to the `readiness` group.
137
+ Consumer-provided indicators with the same name take precedence.
138
+
139
+ To build custom `app.state`-based indicators, extend `AppStateHealthIndicator`
140
+ which adds `_get_state(key)` and `_require_attr(obj, attr)` helpers with
141
+ one-time attribute-missing warnings:
142
+
143
+ ```python
144
+ from api_versioning.fastapi.versioning.actuator.plugins import AppStateHealthIndicator
145
+
146
+ class ElasticHealthIndicator(AppStateHealthIndicator):
147
+ _name = "elastic"
148
+
149
+ def _check(self):
150
+ client = self._get_state("elastic_client")
151
+ if client is None:
152
+ return {"status": "DOWN", "details": {"error": "State removed"}}
153
+ ok, ping = self._require_attr(client, "ping")
154
+ if not ok:
155
+ return {"status": "DOWN", "details": {"error": "No ping method"}}
156
+ ping()
157
+ return {"status": "UP"}
158
+ ```
159
+
160
+ Disable auto-discovery with `auto_discover=False`:
161
+
162
+ ```python
163
+ HealthActuatorPlugin(auto_discover=False)
164
+ ```
165
+
166
+ ### Indicator Class Hierarchy
167
+
168
+ ```
169
+ HealthIndicator (Protocol — duck-typing contract)
170
+ │
171
+ BaseHealthIndicator (exception-safe template method)
172
+ ├── PingHealthIndicator
173
+ ├── LivenessIndicator
174
+ ├── ReadinessIndicator
175
+ ├── DiskSpaceHealthIndicator
176
+ │
177
+ └── AppStateHealthIndicator (adds app.state helpers + attribute guards)
178
+ ├── RabbitHealthIndicator
179
+ ├── KafkaHealthIndicator
180
+ ├── MongoHealthIndicator
181
+ └── LaunchDarklyHealthIndicator
182
+ ```
183
+
184
+ ### ShowDetails
185
+
186
+ `ShowDetails` controls component visibility:
187
+
188
+ - `ALWAYS` (default) – include `components` with per-indicator details
189
+ - `NEVER` – only return `{"status": "UP"}`
190
+ - `WHEN_AUTHORIZED` – reserved; currently behaves like `NEVER`
191
+
192
+ Build-time generation patterns (including Docker multi-stage examples) live in
193
+ `tools/README.md`.
194
+
195
+ ## Property Sources
196
+
197
+ `EnvActuatorPlugin` uses a `PropertySourceRegistry` to collect environment
198
+ metadata from composable providers.
199
+
200
+ Built-in providers (registered when `with_defaults=True`):
201
+
202
+ - `SystemEnvironmentProvider` – `os.environ` snapshot
203
+ - `SystemPropertiesProvider` – Python runtime info (`sys.version`, platform, etc.)
204
+ - `DockerConfigProvider` – Docker-related env vars (when present)
205
+
206
+ All values are wrapped in `{"value": ...}` dicts, matching Spring Boot format.
207
+
208
+ ### Custom Providers
209
+
210
+ Implement the `PropertySourceProvider` protocol:
211
+
212
+ ```python
213
+ class PropertySourceProvider(Protocol):
214
+ def get_sources(self, to_match: str | None = None) -> list[dict[str, Any]]: ...
215
+ ```
216
+
217
+ Register via `EnvActuatorPlugin.with_providers()`:
218
+
219
+ ```python
220
+ EnvActuatorPlugin.with_providers(
221
+ myFeatures=MyFeatureProvider(),
222
+ )
223
+ ```
224
+
225
+ ### Auto-Wiring
226
+
227
+ Plugins that extend `BaseActuatorPlugin` can contribute property source
228
+ providers via `register_property_source_provider(name=..., provider=...)`.
229
+ During plugin resolution, `_wire_env_property_sources()` collects all
230
+ contributions and injects them into the env plugin automatically.
231
+
232
+ ## Sanitization
233
+
234
+ Property values are redacted by default. Control behavior with
235
+ `SanitizationConfig`:
236
+
237
+ ```python
238
+ from api_versioning.fastapi.versioning.actuator.plugins import (
239
+ EnvActuatorPlugin, SanitizationConfig, ShowValues,
240
+ )
241
+
242
+ EnvActuatorPlugin.with_providers(
243
+ sanitization=SanitizationConfig(show_values=ShowValues.ALWAYS),
244
+ )
245
+ ```
246
+
247
+ `ShowValues` modes:
248
+
249
+ - `NEVER` (default) – redact **all** values to `"******"`
250
+ - `ALWAYS` – show benign values; sensitive keys still redacted
251
+ - `WHEN_AUTHORIZED` – reserved; currently behaves like `NEVER` (safe default until auth is wired)
252
+
253
+ Sensitive keys matched by default: `password`, `secret`, `key`, `token`,
254
+ `.*credentials.*`, `vcap_services`. Extend with `additional_keys_to_sanitize`
255
+ or replace entirely with `keys_to_sanitize`.
256
+
257
+ ## Adding a Plugin
258
+
259
+ 1. Add a module under `plugins/`.
260
+ 2. Implement `ActuatorPlugin`.
261
+ 3. Add it to `default_actuator_plugins()` in `plugins/__init__.py`.
@@ -0,0 +1,3 @@
1
+ from .actuator import register_actuator_router
2
+
3
+ __all__ = ("register_actuator_router",)
@@ -0,0 +1,111 @@
1
+ """Spring Boot-style ``/actuator`` management endpoints for FastAPI."""
2
+
3
+ import logging
4
+ from collections.abc import Mapping, Sequence
5
+
6
+ from fastapi import APIRouter, FastAPI, Request
7
+
8
+ from .plugins import ActuatorLink, ActuatorPlugin, default_actuator_plugins
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def register_actuator_router(
14
+ app: FastAPI,
15
+ *,
16
+ plugins: Sequence[ActuatorPlugin] | None = None,
17
+ prefix: str = "/actuator",
18
+ ) -> None:
19
+ """Register actuator routes directly on *app*."""
20
+ router = APIRouter(prefix=prefix, tags=["Actuator"], include_in_schema=False)
21
+
22
+ plugin_links = _register_plugins(
23
+ router,
24
+ app=app,
25
+ prefix=prefix,
26
+ plugins=_resolve_plugins(plugins),
27
+ )
28
+
29
+ @router.get("", include_in_schema=False)
30
+ async def actuator_root(
31
+ request: Request,
32
+ ) -> dict[str, dict[str, dict[str, bool | str]]]:
33
+ base = str(request.base_url).rstrip("/")
34
+ links: dict[str, dict[str, bool | str]] = {
35
+ "self": {"href": f"{base}{prefix}", "templated": False}
36
+ }
37
+ for name, link in plugin_links.items():
38
+ links[name] = {
39
+ "href": f"{base}{link.href}",
40
+ "templated": link.templated,
41
+ }
42
+ return {"_links": links}
43
+
44
+ app.include_router(router)
45
+
46
+
47
+ def _register_plugins(
48
+ router: APIRouter,
49
+ *,
50
+ app: FastAPI,
51
+ prefix: str,
52
+ plugins: Sequence[ActuatorPlugin],
53
+ ) -> dict[str, ActuatorLink]:
54
+ links: dict[str, ActuatorLink] = {}
55
+ for plugin in plugins:
56
+ plugin_links = plugin.register(router, app=app, prefix=prefix)
57
+ _merge_plugin_links(links, plugin_links)
58
+ return links
59
+
60
+
61
+ def _merge_plugin_links(
62
+ links: dict[str, ActuatorLink],
63
+ plugin_links: Mapping[str, ActuatorLink],
64
+ ) -> None:
65
+ for name, link in plugin_links.items():
66
+ links[name] = link
67
+
68
+
69
+ def _resolve_plugins(
70
+ plugins: Sequence[ActuatorPlugin] | None,
71
+ ) -> tuple[ActuatorPlugin, ...]:
72
+ resolved: dict[str, ActuatorPlugin] = {
73
+ plugin.name: plugin for plugin in default_actuator_plugins()
74
+ }
75
+
76
+ if plugins is not None:
77
+ for plugin in plugins:
78
+ if plugin.name in resolved:
79
+ logger.debug("Actuator plugin '%s' overridden by consumer", plugin.name)
80
+ resolved[plugin.name] = plugin
81
+
82
+ _wire_env_property_sources(resolved)
83
+
84
+ return tuple(resolved.values())
85
+
86
+
87
+ def _wire_env_property_sources(resolved: dict[str, ActuatorPlugin]) -> None:
88
+ contributed: dict[str, object] = {}
89
+
90
+ for plugin in resolved.values():
91
+ providers_getter = getattr(plugin, "property_source_providers", None)
92
+ if callable(providers_getter):
93
+ providers = providers_getter()
94
+ if isinstance(providers, Mapping):
95
+ contributed.update(providers)
96
+
97
+ if not contributed:
98
+ return
99
+
100
+ env_plugin = resolved.get("env")
101
+ if env_plugin is None:
102
+ return
103
+
104
+ register_providers = getattr(env_plugin, "register_property_source_providers", None)
105
+ if callable(register_providers):
106
+ register_providers(contributed)
107
+ logger.debug(
108
+ "Auto-wired %d property source provider(s) into env plugin: %s",
109
+ len(contributed),
110
+ ", ".join(contributed),
111
+ )
@@ -0,0 +1,65 @@
1
+ from collections.abc import Sequence
2
+
3
+ from .base import ActuatorLink, ActuatorPlugin, BaseActuatorPlugin
4
+ from .env import (
5
+ EnvActuatorPlugin,
6
+ PropertySourceProvider,
7
+ PropertySourceRegistry,
8
+ SanitizationConfig,
9
+ ShowValues,
10
+ create_property_source_registry,
11
+ )
12
+ from .health import (
13
+ AppStateHealthIndicator,
14
+ BaseHealthIndicator,
15
+ DiskSpaceHealthIndicator,
16
+ HealthActuatorPlugin,
17
+ HealthIndicator,
18
+ KafkaHealthIndicator,
19
+ LaunchDarklyHealthIndicator,
20
+ LivenessIndicator,
21
+ MongoHealthIndicator,
22
+ PingHealthIndicator,
23
+ RabbitHealthIndicator,
24
+ ReadinessIndicator,
25
+ ShowDetails,
26
+ Status,
27
+ discover_indicators,
28
+ )
29
+ from .info import InfoActuatorPlugin
30
+
31
+
32
+ def default_actuator_plugins() -> Sequence[ActuatorPlugin]:
33
+ """Return the built-in actuator plugins auto-wired by default."""
34
+
35
+ return (InfoActuatorPlugin(), EnvActuatorPlugin(), HealthActuatorPlugin())
36
+
37
+
38
+ __all__ = (
39
+ "ActuatorLink",
40
+ "ActuatorPlugin",
41
+ "AppStateHealthIndicator",
42
+ "BaseActuatorPlugin",
43
+ "BaseHealthIndicator",
44
+ "DiskSpaceHealthIndicator",
45
+ "EnvActuatorPlugin",
46
+ "HealthActuatorPlugin",
47
+ "HealthIndicator",
48
+ "InfoActuatorPlugin",
49
+ "KafkaHealthIndicator",
50
+ "LaunchDarklyHealthIndicator",
51
+ "LivenessIndicator",
52
+ "MongoHealthIndicator",
53
+ "PingHealthIndicator",
54
+ "PropertySourceProvider",
55
+ "PropertySourceRegistry",
56
+ "RabbitHealthIndicator",
57
+ "ReadinessIndicator",
58
+ "SanitizationConfig",
59
+ "ShowDetails",
60
+ "ShowValues",
61
+ "Status",
62
+ "create_property_source_registry",
63
+ "default_actuator_plugins",
64
+ "discover_indicators",
65
+ )
@@ -0,0 +1,77 @@
1
+ from collections.abc import Callable, Mapping
2
+ from dataclasses import dataclass
3
+ from typing import Any, Protocol
4
+
5
+ from fastapi import APIRouter, FastAPI
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ActuatorLink:
10
+ """Link metadata rendered by the actuator root endpoint."""
11
+
12
+ href: str
13
+ templated: bool = False
14
+
15
+
16
+ class ActuatorPlugin(Protocol):
17
+ """Contract for actuator plugins that register routes and expose links."""
18
+
19
+ name: str
20
+
21
+ def register(
22
+ self,
23
+ router: APIRouter,
24
+ *,
25
+ app: FastAPI,
26
+ prefix: str,
27
+ ) -> Mapping[str, ActuatorLink]:
28
+ """Register endpoints on *router* and return link entries."""
29
+
30
+
31
+ class BaseActuatorPlugin:
32
+ """Shared helpers for actuator plugins.
33
+
34
+ Provides optional in-memory memoization of endpoint payloads and a
35
+ consistent link-construction helper.
36
+ """
37
+
38
+ def __init__(self, *, cache_enabled: bool = True) -> None:
39
+ self._cache_enabled = cache_enabled
40
+ self._payload_cache: dict[str, dict[str, Any]] = {}
41
+ self._property_source_providers: dict[str, Any] = {}
42
+
43
+ def memoized_payload(
44
+ self,
45
+ *,
46
+ cache_key: str,
47
+ loader: Callable[[], dict[str, Any]],
48
+ ) -> dict[str, Any]:
49
+ if not self._cache_enabled:
50
+ return loader()
51
+
52
+ cached = self._payload_cache.get(cache_key)
53
+ if cached is not None:
54
+ return cached
55
+
56
+ payload = loader()
57
+ self._payload_cache[cache_key] = payload
58
+ return payload
59
+
60
+ def clear_payload_cache(self) -> None:
61
+ self._payload_cache.clear()
62
+
63
+ def register_property_source_provider(self, *, name: str, provider: Any) -> None:
64
+ """Register a property source provider contribution from this plugin.
65
+
66
+ Contributions are auto-wired into the env actuator plugin when
67
+ ``register_actuator_router`` resolves the plugin set.
68
+ """
69
+ self._property_source_providers[name] = provider
70
+
71
+ def property_source_providers(self) -> Mapping[str, Any]:
72
+ """Return this plugin's property source provider contributions."""
73
+ return dict(self._property_source_providers)
74
+
75
+ @staticmethod
76
+ def build_link(*, prefix: str, route_path: str) -> ActuatorLink:
77
+ return ActuatorLink(href=f"{prefix}{route_path}")
@@ -0,0 +1,31 @@
1
+ """Spring Boot-style env actuator with property source registry."""
2
+
3
+ from .plugin import EnvActuatorPlugin
4
+ from .providers import (
5
+ DockerConfigProvider,
6
+ PropertySourceProvider,
7
+ SystemEnvironmentProvider,
8
+ SystemPropertiesProvider,
9
+ )
10
+ from .registry import (
11
+ DEFAULT_KEYS_TO_SANITIZE,
12
+ REDACTED_VALUE,
13
+ PropertySourceRegistry,
14
+ SanitizationConfig,
15
+ ShowValues,
16
+ create_property_source_registry,
17
+ )
18
+
19
+ __all__ = (
20
+ "DEFAULT_KEYS_TO_SANITIZE",
21
+ "REDACTED_VALUE",
22
+ "DockerConfigProvider",
23
+ "EnvActuatorPlugin",
24
+ "PropertySourceProvider",
25
+ "PropertySourceRegistry",
26
+ "SanitizationConfig",
27
+ "ShowValues",
28
+ "SystemEnvironmentProvider",
29
+ "SystemPropertiesProvider",
30
+ "create_property_source_registry",
31
+ )