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,99 @@
1
+ """API versioning, dispatch, docs, and actuator for FastAPI."""
2
+
3
+ from ._constants import (
4
+ API_VERSION_HEADER_NAME,
5
+ APP_ID_HEADER_NAME,
6
+ AUTH_HEADER_NAME,
7
+ HIT_ID_HEADER_NAME,
8
+ HTTP_METHODS,
9
+ UNVERSIONED,
10
+ UNVERSIONED_DISPLAY_LABEL,
11
+ VERSIONING_SETTINGS_STATE_KEY,
12
+ )
13
+ from ._core import (
14
+ map_version_path,
15
+ normalize_prefix,
16
+ normalize_unversioned_label,
17
+ normalize_version,
18
+ resolve_prefix,
19
+ resolve_version,
20
+ validate_version_mapping_keys,
21
+ )
22
+ from ._dependencies import (
23
+ ApiVersionDep,
24
+ AppIdDep,
25
+ HitIdDep,
26
+ param_factory,
27
+ uuid_id_factory,
28
+ )
29
+ from ._fastapi_types import (
30
+ ExceptionHandlerProvider,
31
+ ExHandler,
32
+ Middleware,
33
+ NormalizedDependencySpec,
34
+ PathParamDependencies,
35
+ PathParamDependencySpec,
36
+ VersionedAppConfigurer,
37
+ VersionedAppLifespan,
38
+ )
39
+ from ._helpers import (
40
+ HeadersGetter,
41
+ find_bearer,
42
+ find_token,
43
+ )
44
+ from ._orchestration import (
45
+ configure_versioned_api,
46
+ create_versioned_app,
47
+ default_exception_handlers_provider,
48
+ get_current_user_claims,
49
+ )
50
+ from ._settings import VersioningSettings, load_app_name, load_versioning_settings
51
+ from ._swagger_ui_version import SWAGGER_UI_VERSION
52
+ from ._types import VersionedAppState, VersionKey, VersionMap
53
+ from .actuator import register_actuator_router
54
+
55
+ __all__ = (
56
+ "API_VERSION_HEADER_NAME",
57
+ "APP_ID_HEADER_NAME",
58
+ "AUTH_HEADER_NAME",
59
+ "HIT_ID_HEADER_NAME",
60
+ "HTTP_METHODS",
61
+ "SWAGGER_UI_VERSION",
62
+ "UNVERSIONED",
63
+ "UNVERSIONED_DISPLAY_LABEL",
64
+ "VERSIONING_SETTINGS_STATE_KEY",
65
+ "ApiVersionDep",
66
+ "AppIdDep",
67
+ "ExHandler",
68
+ "ExceptionHandlerProvider",
69
+ "HeadersGetter",
70
+ "HitIdDep",
71
+ "Middleware",
72
+ "NormalizedDependencySpec",
73
+ "PathParamDependencies",
74
+ "PathParamDependencySpec",
75
+ "VersionKey",
76
+ "VersionMap",
77
+ "VersionedAppConfigurer",
78
+ "VersionedAppLifespan",
79
+ "VersionedAppState",
80
+ "VersioningSettings",
81
+ "configure_versioned_api",
82
+ "create_versioned_app",
83
+ "default_exception_handlers_provider",
84
+ "find_bearer",
85
+ "find_token",
86
+ "get_current_user_claims",
87
+ "load_app_name",
88
+ "load_versioning_settings",
89
+ "map_version_path",
90
+ "normalize_prefix",
91
+ "normalize_unversioned_label",
92
+ "normalize_version",
93
+ "param_factory",
94
+ "register_actuator_router",
95
+ "resolve_prefix",
96
+ "resolve_version",
97
+ "uuid_id_factory",
98
+ "validate_version_mapping_keys",
99
+ )
@@ -0,0 +1,28 @@
1
+ from csrd.context._constants import APP_ID_HEADER_NAME, HIT_ID_HEADER_NAME
2
+
3
+ AUTH_HEADER_NAME = "authorization"
4
+ API_VERSION_HEADER_NAME = "x-api-version"
5
+ VERSIONING_SETTINGS_STATE_KEY = "_versioning_settings"
6
+ UNVERSIONED_DISPLAY_LABEL = "Unversioned"
7
+ UNVERSIONED = "unv"
8
+ """Sentinel for unversioned routes in version mappings.
9
+
10
+ Use as a key in ``version_mapping`` or as the ``default_version`` argument
11
+ to indicate routes that do not belong to any numbered API version.
12
+ This is the canonical normalized form — ``normalize_version(None)``
13
+ and ``normalize_version("unversioned")`` both produce this value.
14
+ """
15
+
16
+ HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch", "options", "head"})
17
+
18
+
19
+ __all__ = (
20
+ "API_VERSION_HEADER_NAME",
21
+ "APP_ID_HEADER_NAME",
22
+ "AUTH_HEADER_NAME",
23
+ "HIT_ID_HEADER_NAME",
24
+ "HTTP_METHODS",
25
+ "UNVERSIONED",
26
+ "UNVERSIONED_DISPLAY_LABEL",
27
+ "VERSIONING_SETTINGS_STATE_KEY",
28
+ )
@@ -0,0 +1,240 @@
1
+ import logging
2
+ import re
3
+ from enum import Enum
4
+
5
+ from ._constants import UNVERSIONED_DISPLAY_LABEL
6
+ from ._types import VersionKey, VersionMap
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ _UNVERSIONED_ALIASES: frozenset[str] = frozenset({"null", "unv", "none", "unversioned"})
11
+
12
+
13
+ def normalize_version(version: VersionKey) -> str:
14
+ """Normalize version-like values to lowercase canonical routing keys.
15
+
16
+ - ``Enum`` members are unwrapped via ``.value`` before normalization.
17
+ - ``None``, empty strings, whitespace-only strings, and the aliases
18
+ ``"null"``, ``"unv"``, ``"none"``, ``"unversioned"`` (case-insensitive)
19
+ all normalize to ``"unv"``.
20
+ - All other values are stripped, then lowercased via ``str(version).lower()``,
21
+ including bare integers (e.g. ``3`` → ``"3"``).
22
+ """
23
+ if isinstance(version, Enum):
24
+ version = version.value
25
+ stringified = str(version).strip() if version is not None else ""
26
+ if version is None or not stringified or stringified.lower() in _UNVERSIONED_ALIASES:
27
+ return "unv"
28
+ return stringified.lower()
29
+
30
+
31
+ def normalize_prefix(prefix: str) -> str:
32
+ """Return an API prefix guaranteed to start with ``/``.
33
+
34
+ Raises ``ValueError`` for empty strings — use ``"/"`` explicitly
35
+ if you intend to match all paths.
36
+ """
37
+ if not prefix:
38
+ raise ValueError("prefix must not be empty; pass '/' explicitly to match all paths")
39
+ normalized = prefix if prefix.startswith("/") else f"/{prefix}"
40
+ if normalized != "/":
41
+ normalized = normalized.rstrip("/")
42
+ return normalized
43
+
44
+
45
+ def normalize_unversioned_label(version: VersionKey) -> str:
46
+ """Map unversioned-like keys to a stable display label."""
47
+ normalized = normalize_version(version)
48
+ if normalized == "unv":
49
+ return UNVERSIONED_DISPLAY_LABEL
50
+ return normalized
51
+
52
+
53
+ def validate_version_mapping_keys(version_mapping: VersionMap) -> None:
54
+ """Ensure version keys do not collide after normalization."""
55
+ seen: dict[str, str] = {}
56
+ for key in version_mapping:
57
+ normalized = normalize_version(key)
58
+ if normalized in seen:
59
+ existing_key = seen[normalized]
60
+ raise ValueError(
61
+ "Duplicate version keys after normalization: "
62
+ f"{existing_key!r} and {key!r} both normalize to '{normalized}'."
63
+ )
64
+ seen[normalized] = str(key)
65
+
66
+
67
+ def _latest_mapped_version(version_values: list[str]) -> str:
68
+ """Pick the latest mapped version deterministically.
69
+
70
+ Comparison uses only numeric segments (e.g. ``"v2"`` → ``(2,)``).
71
+ Non-numeric characters between digits are ignored, so versions
72
+ like ``"v1a2"`` and ``"v1b1"`` are compared as ``(1, 2)`` vs ``(1, 1)``.
73
+ """
74
+ candidates = [v for v in version_values if v != "unv"]
75
+ if not candidates:
76
+ return "unv"
77
+
78
+ numeric_candidates = [v for v in candidates if re.search(r"\d", v)]
79
+ if numeric_candidates:
80
+ return max(
81
+ numeric_candidates,
82
+ key=lambda value: (tuple(int(x) for x in re.findall(r"\d+", value)), value),
83
+ )
84
+
85
+ return sorted(candidates)[-1]
86
+
87
+
88
+ def _default_mapped_version(
89
+ *, version_values: set[str], default_version: VersionKey | None
90
+ ) -> str | None:
91
+ """Return a normalized default version only when it exists in the mapping."""
92
+ if default_version is None:
93
+ return None
94
+
95
+ normalized_default = normalize_version(default_version)
96
+ if normalized_default in version_values:
97
+ return normalized_default
98
+
99
+ return None
100
+
101
+
102
+ def _resolve_missing_requested_version(
103
+ *,
104
+ version_values: set[str],
105
+ mapped_default: str | None,
106
+ ) -> str:
107
+ """Resolve version when the request does not include a version header."""
108
+ if "unv" in version_values:
109
+ return "unv"
110
+
111
+ if mapped_default is not None:
112
+ return mapped_default
113
+
114
+ return _latest_mapped_version(list(version_values))
115
+
116
+
117
+ def _resolve_unknown_requested_version(
118
+ *,
119
+ version_values: set[str],
120
+ mapped_default: str | None,
121
+ ) -> str:
122
+ """Resolve version when the request header is present but not mapped."""
123
+ if mapped_default is not None:
124
+ return mapped_default
125
+
126
+ if "unv" in version_values:
127
+ return "unv"
128
+
129
+ return _latest_mapped_version(list(version_values))
130
+
131
+
132
+ def resolve_version(
133
+ *,
134
+ requested_version: str | None,
135
+ version_mapping: VersionMap | None = None,
136
+ default_version: VersionKey | None = None,
137
+ strict: bool = False,
138
+ ) -> str:
139
+ """Resolve request version using explicit, deterministic fallback precedence.
140
+
141
+ Fallback order when the requested version is not in *version_mapping*:
142
+
143
+ 1. *default_version* (if provided and present in the mapping)
144
+ 2. ``"unv"`` (if present in the mapping)
145
+ 3. Latest numeric version
146
+
147
+ When *strict* is ``True``, an unrecognised requested version raises
148
+ ``ValueError`` instead of falling back. Missing headers (``None``)
149
+ still fall back normally — strict mode only rejects explicit but
150
+ unknown version values.
151
+
152
+ .. important::
153
+
154
+ Raises ``ValueError`` if *version_mapping* keys collide after
155
+ normalization (e.g. ``None`` and ``"unversioned"`` both normalize
156
+ to ``"unv"``).
157
+ """
158
+ if version_mapping is None:
159
+ if requested_version is None:
160
+ return "unv"
161
+ return normalize_version(requested_version)
162
+
163
+ version_values = {normalize_version(key) for key in version_mapping}
164
+ if len(version_values) < len(version_mapping):
165
+ validate_version_mapping_keys(version_mapping)
166
+ mapped_default = _default_mapped_version(
167
+ version_values=version_values,
168
+ default_version=default_version,
169
+ )
170
+ requested_normalized = (
171
+ normalize_version(requested_version) if requested_version is not None else None
172
+ )
173
+
174
+ if requested_normalized in version_values:
175
+ return requested_normalized
176
+
177
+ if requested_normalized is None:
178
+ return _resolve_missing_requested_version(
179
+ version_values=version_values,
180
+ mapped_default=mapped_default,
181
+ )
182
+
183
+ if strict:
184
+ raise ValueError(
185
+ f"Requested API version {requested_normalized!r} is not available. "
186
+ f"Available versions: {', '.join(sorted(version_values))}"
187
+ )
188
+
189
+ resolved = _resolve_unknown_requested_version(
190
+ version_values=version_values,
191
+ mapped_default=mapped_default,
192
+ )
193
+ logger.warning(
194
+ "Requested API version %r is not mapped (available: %s); falling back to %r",
195
+ requested_normalized,
196
+ ", ".join(sorted(version_values)),
197
+ resolved,
198
+ )
199
+ return resolved
200
+
201
+
202
+ def map_version_path(path: str, *, version: str, prefix: str) -> str:
203
+ """Rewrite an incoming path to include resolved version under the API prefix.
204
+
205
+ The prefix precondition is also enforced upstream by the dispatch guard
206
+ ``_should_dispatch_request``, but the check here is intentional
207
+ defense-in-depth — do not remove it.
208
+ """
209
+ if prefix == "/":
210
+ if not path.startswith("/"):
211
+ raise ValueError(f"path {path!r} does not start with prefix {prefix!r}")
212
+ elif not (path == prefix or path.startswith(f"{prefix}/")):
213
+ raise ValueError(f"path {path!r} does not start with prefix {prefix!r}")
214
+ normalized_version = normalize_version(version)
215
+ remainder = path[len(prefix) :]
216
+ if remainder and not remainder.startswith("/"):
217
+ remainder = f"/{remainder}"
218
+ # Normalize bare trailing slash so /api/ behaves identically to /api.
219
+ if remainder == "/":
220
+ remainder = ""
221
+ return f"{prefix.rstrip('/')}/{normalized_version}{remainder}"
222
+
223
+
224
+ def resolve_prefix(prefix: str | None) -> str:
225
+ """Return normalized API prefix, defaulting to `/api`."""
226
+ if prefix is None:
227
+ prefix = "/api"
228
+
229
+ return normalize_prefix(prefix)
230
+
231
+
232
+ __all__ = (
233
+ "map_version_path",
234
+ "normalize_prefix",
235
+ "normalize_unversioned_label",
236
+ "normalize_version",
237
+ "resolve_prefix",
238
+ "resolve_version",
239
+ "validate_version_mapping_keys",
240
+ )
@@ -0,0 +1,67 @@
1
+ """FastAPI dependency functions for injecting versioning context values."""
2
+
3
+ from typing import Annotated
4
+ from uuid import UUID
5
+
6
+ from fastapi import Depends, HTTPException, Path
7
+
8
+ from csrd.context import get_api_version, get_app_id, get_hit_id
9
+
10
+
11
+ def param_factory(name: str = "value", validator=None):
12
+ """Create a FastAPI dependency that extracts a string path parameter and,
13
+ optionally, validates it using a user-provided callable.
14
+ """
15
+
16
+ def dependency(value: Annotated[str, Path(alias=name)]):
17
+ if validator is not None:
18
+ try:
19
+ validator(value)
20
+ except (ValueError, TypeError, AssertionError) as exc:
21
+ raise HTTPException(
22
+ status_code=422, detail=f"Invalid path parameter {name}: {exc}"
23
+ ) from exc
24
+ return str(value)
25
+
26
+ return dependency
27
+
28
+
29
+ def uuid_id_factory(name: str = "id"):
30
+ def dependency(value: Annotated[str, Path(alias=name)]):
31
+ try:
32
+ return UUID(str(value))
33
+ except (ValueError, TypeError, AttributeError) as exc:
34
+ raise HTTPException(
35
+ status_code=422, detail=f"{name} is not a valid UUID: {value}"
36
+ ) from exc
37
+
38
+ return dependency
39
+
40
+
41
+ def api_version_dependency() -> str | None:
42
+ """Return the resolved API version for the current request context."""
43
+ return get_api_version()
44
+
45
+
46
+ def app_id_dependency() -> str | None:
47
+ """Return the current request app-id header value."""
48
+ return get_app_id()
49
+
50
+
51
+ def hit_id_dependency() -> str | None:
52
+ """Return the current request hit-id header value."""
53
+ return get_hit_id()
54
+
55
+
56
+ ApiVersionDep = Annotated[str | None, Depends(api_version_dependency)]
57
+ AppIdDep = Annotated[str | None, Depends(app_id_dependency)]
58
+ HitIdDep = Annotated[str | None, Depends(hit_id_dependency)]
59
+
60
+
61
+ __all__ = (
62
+ "ApiVersionDep",
63
+ "AppIdDep",
64
+ "HitIdDep",
65
+ "param_factory",
66
+ "uuid_id_factory",
67
+ )
@@ -0,0 +1,272 @@
1
+ """Dependency injection wiring for versioned FastAPI routes."""
2
+
3
+ import logging
4
+ import re
5
+ from collections.abc import AsyncIterator, Callable
6
+ from http import HTTPStatus
7
+ from typing import Any
8
+
9
+ from fastapi import Depends, FastAPI
10
+ from fastapi.routing import APIRoute
11
+ from fastapi.security import HTTPBearer
12
+ from starlette.requests import Request
13
+ from starlette.routing import Route
14
+
15
+ from csrd.context import (
16
+ PathValue,
17
+ reset_path_params,
18
+ reset_query_params,
19
+ set_path_params,
20
+ set_query_params,
21
+ )
22
+ from csrd.versioning._core import normalize_prefix, normalize_version
23
+
24
+ from ._dependencies import param_factory
25
+ from ._fastapi_types import (
26
+ NormalizedDependencySpec,
27
+ PathParamDependencies,
28
+ VersionMap,
29
+ )
30
+ from ._helpers import (
31
+ DependsParam,
32
+ _dep_already_present,
33
+ _extract_param_names,
34
+ _normalize_dep,
35
+ _rebuild_route,
36
+ _route_has_param,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ _PATH_PARAM_RE = re.compile(r"\{(\w+)(?::\w+)?\}")
42
+
43
+
44
+ def _get_route_param_names(route: APIRoute) -> set[str]:
45
+ """Extract path parameter names from the route path template."""
46
+ return set(_PATH_PARAM_RE.findall(route.path))
47
+
48
+
49
+ class PathParamParser:
50
+ """FastAPI dependency that captures path/query params into core contextvars."""
51
+
52
+ async def __call__(self, request: Request) -> AsyncIterator[None]:
53
+ path_token = set_path_params(PathValue(request.path_params))
54
+ query_token = set_query_params(PathValue(request.query_params))
55
+
56
+ try:
57
+ yield
58
+ finally:
59
+ reset_path_params(path_token)
60
+ reset_query_params(query_token)
61
+
62
+
63
+ def _status_description(status_code: int) -> str:
64
+ """Return a friendly description for an HTTP status code."""
65
+ try:
66
+ return HTTPStatus(status_code).phrase
67
+ except ValueError:
68
+ return f"HTTP {status_code}"
69
+
70
+
71
+ def _documented_error_statuses_from_handlers(
72
+ resolved_handler_map: dict[int | type[Exception], Any],
73
+ ) -> set[int]:
74
+ """Extract numeric status codes from resolved exception handler keys."""
75
+ status_codes = {key for key in resolved_handler_map if isinstance(key, int)}
76
+ status_codes.update({401, 403})
77
+ return status_codes
78
+
79
+
80
+ def _uncovered_route_params(
81
+ route: APIRoute, existing: list[NormalizedDependencySpec] | None = None
82
+ ) -> set[str]:
83
+ if existing is None:
84
+ existing = []
85
+
86
+ covered: set[str] = set()
87
+ for _, __, required_params in existing:
88
+ if all(_route_has_param(route, name) for name in required_params):
89
+ covered.update(required_params)
90
+
91
+ return _get_route_param_names(route) - covered
92
+
93
+
94
+ def _route_bearer_guard_opt_out(route: APIRoute) -> bool:
95
+ """Return True when a route opts out of bearer dependency enforcement."""
96
+ openapi_extra = route.openapi_extra or {}
97
+ return openapi_extra.get("x-bearer-guard") is False
98
+
99
+
100
+ def _remove_bearer_dependencies(
101
+ dependencies: list[DependsParam],
102
+ ) -> tuple[list[DependsParam], bool]:
103
+ """Remove HTTPBearer-based dependencies from a dependency list."""
104
+ filtered_dependencies: list[DependsParam] = []
105
+ removed_any = False
106
+
107
+ for dependency in dependencies:
108
+ dep_callable = getattr(dependency, "dependency", None)
109
+ if isinstance(dep_callable, HTTPBearer):
110
+ removed_any = True
111
+ continue
112
+ filtered_dependencies.append(dependency)
113
+
114
+ return filtered_dependencies, removed_any
115
+
116
+
117
+ def _has_dependency(dependencies: list[DependsParam], dependency: Callable[..., Any]) -> bool:
118
+ """Return True when a dependency callable is already present by identity."""
119
+ return any(dep.dependency is dependency for dep in dependencies)
120
+
121
+
122
+ def _build_normalized_dependency_specs(
123
+ path_param_dependencies: PathParamDependencies | None,
124
+ ) -> list[NormalizedDependencySpec]:
125
+ """Normalize and filter path-param dependency specs for route injection."""
126
+ normalized_specs: list[NormalizedDependencySpec] = []
127
+
128
+ if path_param_dependencies:
129
+ for spec in path_param_dependencies:
130
+ dep_param, dep_callable = _normalize_dep(spec)
131
+ required_params = _extract_param_names(dep_callable)
132
+ if not required_params:
133
+ continue
134
+ normalized_specs.append((dep_param, dep_callable, required_params))
135
+
136
+ return normalized_specs
137
+
138
+
139
+ def _strip_prefix_from_versioned_routes(versioned_app: FastAPI, prefix: str) -> None:
140
+ """Remove already-present API prefix from mounted versioned route paths."""
141
+ normalized_prefix = normalize_prefix(prefix)
142
+ if normalized_prefix == "/":
143
+ return
144
+
145
+ for route in versioned_app.routes:
146
+ if isinstance(route, Route):
147
+ if route.path == normalized_prefix:
148
+ route.path = "/"
149
+ continue
150
+
151
+ if route.path.startswith(f"{normalized_prefix}/"):
152
+ route.path = route.path[len(normalized_prefix) :]
153
+
154
+
155
+ def _apply_path_param_deps_to_route(
156
+ route: APIRoute,
157
+ dependencies: list[DependsParam],
158
+ normalized_specs: list[NormalizedDependencySpec],
159
+ ) -> bool:
160
+ """Inject path-param dependencies for route-matching parameter names."""
161
+ if not normalized_specs:
162
+ return False
163
+
164
+ changed = False
165
+ route_specs = list(normalized_specs)
166
+ params = _uncovered_route_params(route, route_specs)
167
+
168
+ for param in params:
169
+ factory = param_factory(param)
170
+ route_specs.append((Depends(factory), factory, {param}))
171
+
172
+ for dep_param, _dep_callable, required_params in route_specs:
173
+ if all(
174
+ _route_has_param(route, name) for name in required_params
175
+ ) and not _dep_already_present(dependencies, dep_param):
176
+ dependencies.append(dep_param)
177
+ changed = True
178
+
179
+ return changed
180
+
181
+
182
+ def _ensure_path_param_parser_dependency(dependencies: list[DependsParam]) -> bool:
183
+ """Ensure `PathParamParser` exists in route dependencies."""
184
+ if any(isinstance(dep.dependency, PathParamParser) for dep in dependencies):
185
+ return False
186
+
187
+ dependencies.append(Depends(PathParamParser()))
188
+ return True
189
+
190
+
191
+ def _process_versioned_route(
192
+ route: APIRoute,
193
+ *,
194
+ normalized_specs: list[NormalizedDependencySpec],
195
+ documented_error_statuses: set[int] | None,
196
+ ) -> tuple[list[DependsParam], bool]:
197
+ """Apply dependency mutations to a single versioned route."""
198
+ dependencies = list(route.dependencies or [])
199
+ dependencies_changed = False
200
+
201
+ if _route_bearer_guard_opt_out(route):
202
+ dependencies, removed_bearer_dependency = _remove_bearer_dependencies(dependencies)
203
+ if removed_bearer_dependency:
204
+ dependencies_changed = True
205
+ logger.debug("Bearer dependency removed (opt-out) from route: %s", route.path)
206
+
207
+ if _apply_path_param_deps_to_route(
208
+ route,
209
+ dependencies,
210
+ normalized_specs,
211
+ ):
212
+ dependencies_changed = True
213
+
214
+ if _ensure_path_param_parser_dependency(dependencies):
215
+ dependencies_changed = True
216
+
217
+ return dependencies, dependencies_changed
218
+
219
+
220
+ def _apply_route_dependency_updates(
221
+ app: FastAPI,
222
+ versioned_app: FastAPI,
223
+ route_index: int,
224
+ route: APIRoute,
225
+ dependencies: list[DependsParam],
226
+ ) -> None:
227
+ """Replace route with a new instance carrying updated dependencies."""
228
+ versioned_app.routes[route_index] = _rebuild_route(route, dependencies)
229
+ versioned_app.openapi_schema = None
230
+ app.openapi_schema = None
231
+
232
+
233
+ def _mount_and_wire_versions(
234
+ app: FastAPI,
235
+ version_mapping: VersionMap,
236
+ prefix: str,
237
+ path_param_dependencies: PathParamDependencies | None = None,
238
+ documented_error_statuses: set[int] | None = None,
239
+ ) -> None:
240
+ """Mount versioned apps and inject dependencies required for routing."""
241
+ normalized_specs = _build_normalized_dependency_specs(path_param_dependencies)
242
+
243
+ for api, versioned_app in version_mapping.items():
244
+ _strip_prefix_from_versioned_routes(versioned_app, prefix)
245
+
246
+ for i, route in enumerate(versioned_app.routes):
247
+ if not isinstance(route, APIRoute):
248
+ continue
249
+
250
+ dependencies, dependencies_changed = _process_versioned_route(
251
+ route,
252
+ normalized_specs=normalized_specs,
253
+ documented_error_statuses=documented_error_statuses,
254
+ )
255
+
256
+ if dependencies_changed:
257
+ _apply_route_dependency_updates(
258
+ app,
259
+ versioned_app,
260
+ i,
261
+ route,
262
+ dependencies,
263
+ )
264
+
265
+ app.include_router(versioned_app.router, prefix=f"{prefix}/{normalize_version(api)}")
266
+ logger.debug(
267
+ "Mounted version %s at %s/%s with %d routes",
268
+ api,
269
+ prefix,
270
+ normalize_version(api),
271
+ sum(1 for r in versioned_app.routes if isinstance(r, APIRoute)),
272
+ )