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.
- csrd/versioning/__init__.py +99 -0
- csrd/versioning/_constants.py +28 -0
- csrd/versioning/_core.py +240 -0
- csrd/versioning/_dependencies.py +67 -0
- csrd/versioning/_dependency_wiring.py +272 -0
- csrd/versioning/_dispatch.py +203 -0
- csrd/versioning/_docs.py +495 -0
- csrd/versioning/_fastapi_types.py +44 -0
- csrd/versioning/_helpers.py +149 -0
- csrd/versioning/_orchestration.py +399 -0
- csrd/versioning/_redoc.py +98 -0
- csrd/versioning/_settings.py +88 -0
- csrd/versioning/_swagger_ui_version.py +12 -0
- csrd/versioning/_types.py +14 -0
- csrd/versioning/actuator/README.md +261 -0
- csrd/versioning/actuator/__init__.py +3 -0
- csrd/versioning/actuator/actuator.py +111 -0
- csrd/versioning/actuator/plugins/__init__.py +65 -0
- csrd/versioning/actuator/plugins/base.py +77 -0
- csrd/versioning/actuator/plugins/env/__init__.py +31 -0
- csrd/versioning/actuator/plugins/env/plugin.py +145 -0
- csrd/versioning/actuator/plugins/env/providers.py +105 -0
- csrd/versioning/actuator/plugins/env/registry.py +211 -0
- csrd/versioning/actuator/plugins/health/__init__.py +37 -0
- csrd/versioning/actuator/plugins/health/auto.py +244 -0
- csrd/versioning/actuator/plugins/health/indicators.py +137 -0
- csrd/versioning/actuator/plugins/health/plugin.py +271 -0
- csrd/versioning/actuator/plugins/info.py +120 -0
- csrd/versioning/actuator/tools/README.md +54 -0
- csrd/versioning/actuator/tools/__init__.py +0 -0
- csrd/versioning/actuator/tools/generate_service_info_from_git.py +126 -0
- csrd/versioning/exception_handlers.py +126 -0
- csrd/versioning/py.typed +0 -0
- csrd/versioning/swagger_plugins/__init__.py +15 -0
- csrd/versioning/swagger_plugins/_base.py +153 -0
- csrd/versioning/swagger_plugins/file_upload/__init__.py +35 -0
- csrd/versioning/swagger_plugins/file_upload/_body_factory.py +121 -0
- csrd/versioning/swagger_plugins/file_upload/_schema_patcher.py +165 -0
- csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.css +181 -0
- csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.js +529 -0
- csrd/versioning/templates/__init__.py +0 -0
- csrd/versioning/templates/favicon.png +0 -0
- csrd/versioning/templates/swagger_ui.css +616 -0
- csrd/versioning/templates/swagger_ui.html +43 -0
- csrd/versioning/templates/swagger_ui.js +131 -0
- csrd_versioning-0.1.0.dist-info/METADATA +15 -0
- csrd_versioning-0.1.0.dist-info/RECORD +48 -0
- 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
|
+
)
|
csrd/versioning/_core.py
ADDED
|
@@ -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
|
+
)
|