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,44 @@
1
+ """Type aliases for versioned FastAPI application configuration.
2
+
3
+ Defines the public type vocabulary used by ``configure_versioned_api`` and
4
+ ``create_versioned_app``: version maps, middleware specs, exception handler
5
+ providers, context-guard callables, and path-parameter dependency descriptors.
6
+ """
7
+
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from contextlib import AbstractAsyncContextManager
10
+ from typing import Annotated, Any
11
+
12
+ from fastapi import FastAPI
13
+ from fastapi.params import Depends as DependsParam
14
+ from starlette.types import ExceptionHandler
15
+
16
+ from csrd.versioning._types import VersionedAppState, VersionKey
17
+
18
+ VersionMap = Mapping[VersionKey, FastAPI]
19
+ VersionedAppConfigurer = Callable[[FastAPI], None]
20
+ VersionedAppLifespan = Callable[[FastAPI], AbstractAsyncContextManager[Any]]
21
+
22
+ Middleware = type | tuple[type, dict[str, Any] | None]
23
+
24
+ ExHandler = tuple[int | type[Exception], ExceptionHandler]
25
+ ExceptionHandlerProvider = Callable[[], Mapping[int | type[Exception], ExceptionHandler]]
26
+
27
+
28
+ PathParamDependencySpec = DependsParam | Callable[..., Any] | Annotated[Any, DependsParam]
29
+ PathParamDependencies = Sequence[PathParamDependencySpec]
30
+ NormalizedDependencySpec = tuple[DependsParam, Callable[..., Any], set[str]]
31
+
32
+ __all__ = (
33
+ "ExHandler",
34
+ "ExceptionHandlerProvider",
35
+ "Middleware",
36
+ "NormalizedDependencySpec",
37
+ "PathParamDependencies",
38
+ "PathParamDependencySpec",
39
+ "VersionKey",
40
+ "VersionMap",
41
+ "VersionedAppConfigurer",
42
+ "VersionedAppLifespan",
43
+ "VersionedAppState",
44
+ )
@@ -0,0 +1,149 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from http import HTTPStatus
4
+ from typing import Annotated, Any, get_args, get_origin
5
+
6
+ from fastapi import Depends, HTTPException
7
+ from fastapi.params import Depends as DependsParam
8
+ from fastapi.params import Param as PathParam
9
+ from fastapi.routing import APIRoute
10
+ from starlette.datastructures import Headers
11
+
12
+ from csrd.context import get_headers
13
+ from csrd.versioning._constants import AUTH_HEADER_NAME
14
+
15
+ HeadersGetter = Callable[[], Headers | dict] | Headers | dict
16
+
17
+
18
+ def find_bearer(
19
+ headers_or_getter: HeadersGetter | None = None, *, fail_on_missing: bool = True
20
+ ) -> str | None:
21
+ """Return the bearer/authorization header value from provided or contextual headers."""
22
+ if headers_or_getter is not None and callable(headers_or_getter):
23
+ headers = headers_or_getter()
24
+ elif headers_or_getter is not None and isinstance(headers_or_getter, (Headers, dict)):
25
+ headers = headers_or_getter
26
+ else:
27
+ headers = get_headers()
28
+
29
+ if headers:
30
+ bearer = headers.get(AUTH_HEADER_NAME) or headers.get("Authorization")
31
+ if bearer is not None:
32
+ return bearer
33
+
34
+ if fail_on_missing:
35
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
36
+
37
+ return None
38
+
39
+
40
+ def find_token() -> str:
41
+ """Extract and return raw token from bearer header value."""
42
+ bearer = find_bearer()
43
+ if bearer is None:
44
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
45
+
46
+ bearer = bearer.strip()
47
+ if bearer == "":
48
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
49
+
50
+ if bearer.lower() == "bearer":
51
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED)
52
+
53
+ if bearer.lower().startswith("bearer "):
54
+ token = bearer[7:].strip()
55
+ if token == "":
56
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED)
57
+ return token
58
+
59
+ return bearer
60
+
61
+
62
+ def _route_path_template(route: APIRoute) -> str:
63
+ """Return canonical route path template used for dependency calculations."""
64
+ return str(getattr(route, "path_format", None) or route.path)
65
+
66
+
67
+ def _route_has_param(route: APIRoute, name: str) -> bool:
68
+ """Return True when route path template contains a named path parameter."""
69
+ return f"{{{name}}}" in _route_path_template(route)
70
+
71
+
72
+ def _unwrap_depends_from_annotated(obj: Any) -> DependsParam | None:
73
+ """Extract `Depends` metadata from `typing.Annotated`, if present."""
74
+ if get_origin(obj) is not Annotated:
75
+ return None
76
+ meta = get_args(obj)[1:]
77
+ for m in meta:
78
+ if isinstance(m, DependsParam):
79
+ return m
80
+ return None
81
+
82
+
83
+ def _normalize_dep(obj: Any) -> tuple[DependsParam, Callable[..., Any]]:
84
+ """Normalize supported dependency forms into `(DependsParam, callable)`."""
85
+ if isinstance(obj, DependsParam):
86
+ if obj.dependency is None:
87
+ raise TypeError("Depends(...) must wrap a callable dependency.")
88
+ return obj, obj.dependency
89
+
90
+ ann = _unwrap_depends_from_annotated(obj)
91
+ if ann is not None:
92
+ if ann.dependency is None:
93
+ raise TypeError("Annotated Depends(...) must wrap a callable dependency.")
94
+ return ann, ann.dependency
95
+
96
+ if callable(obj):
97
+ d = Depends(obj)
98
+ return d, obj
99
+
100
+ raise TypeError(f"Object is not a valid dependency: {obj!r}")
101
+
102
+
103
+ def _extract_param_names(dep_callable: Callable[..., Any]) -> set[str]:
104
+ """Collect path-parameter names consumed by a dependency callable."""
105
+ names: set[str] = set()
106
+ sig = inspect.signature(dep_callable)
107
+
108
+ for p in sig.parameters.values():
109
+ if isinstance(p.default, PathParam):
110
+ alias = getattr(p.default, "alias", None)
111
+ names.add(alias or p.name)
112
+ continue
113
+
114
+ ann = p.annotation
115
+ if get_origin(ann) is Annotated:
116
+ meta = get_args(ann)[1:]
117
+ for m in meta:
118
+ if isinstance(m, PathParam):
119
+ alias = getattr(m, "alias", None)
120
+ names.add(alias or p.name)
121
+
122
+ return names
123
+
124
+
125
+ def _dep_already_present(deps: list[Any], dep: DependsParam) -> bool:
126
+ """Return True when dependency list already contains the same callable."""
127
+ return any(isinstance(d, DependsParam) and d.dependency is dep.dependency for d in deps)
128
+
129
+
130
+ def _rebuild_route(route: APIRoute, dependencies: list[DependsParam]) -> APIRoute:
131
+ """Create a new APIRoute with updated dependencies."""
132
+ sig = inspect.signature(APIRoute.__init__)
133
+ kwargs: dict[str, Any] = {}
134
+ for name in sig.parameters:
135
+ if name == "self":
136
+ continue
137
+ if name == "dependencies":
138
+ kwargs["dependencies"] = dependencies
139
+ continue
140
+ if hasattr(route, name):
141
+ kwargs[name] = getattr(route, name)
142
+ return APIRoute(**kwargs)
143
+
144
+
145
+ __all__ = (
146
+ "HeadersGetter",
147
+ "find_bearer",
148
+ "find_token",
149
+ )
@@ -0,0 +1,399 @@
1
+ import inspect
2
+ import logging
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI
7
+ from starlette.types import ExceptionHandler
8
+
9
+ from csrd.context import (
10
+ configure_headers_context_provider,
11
+ get_api_version,
12
+ )
13
+ from csrd.context._fastapi_headers import get_headers as fastapi_get_headers
14
+ from csrd.context._fastapi_headers import headers_context as fastapi_headers_context
15
+ from csrd.context.platform import user_info_context
16
+ from csrd.models.claims import UserClaims
17
+ from csrd.versioning._constants import (
18
+ APP_ID_HEADER_NAME,
19
+ HIT_ID_HEADER_NAME,
20
+ UNVERSIONED,
21
+ VERSIONING_SETTINGS_STATE_KEY,
22
+ )
23
+ from csrd.versioning._core import (
24
+ normalize_version,
25
+ resolve_prefix,
26
+ validate_version_mapping_keys,
27
+ )
28
+ from csrd.versioning._settings import load_app_name, load_versioning_settings
29
+
30
+ from . import _dependency_wiring as dependency_wiring
31
+ from . import _docs as docs
32
+ from ._dispatch import VersionDispatchMiddleware
33
+ from ._fastapi_types import (
34
+ ExceptionHandlerProvider,
35
+ ExHandler,
36
+ Middleware,
37
+ PathParamDependencies,
38
+ VersionedAppConfigurer,
39
+ VersionedAppLifespan,
40
+ VersionedAppState,
41
+ VersionKey,
42
+ VersionMap,
43
+ )
44
+ from .actuator import register_actuator_router
45
+ from .actuator.plugins import ActuatorPlugin
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ _VERSIONING_CONFIGURED_KEY = "_versioning_configured"
50
+
51
+
52
+ def default_exception_handlers_provider() -> Mapping[int | type[Exception], ExceptionHandler]:
53
+ """Return the default exception-handler map used by versioning integrations."""
54
+ from csrd.versioning.exception_handlers import EXCEPTION_HANDLERS
55
+
56
+ return EXCEPTION_HANDLERS # type: ignore[return-value]
57
+
58
+
59
+ def get_current_user_claims() -> UserClaims | None:
60
+ """Return current request user claims from FastAPI/platform contextvars."""
61
+ return user_info_context.get() # type: ignore[return-value]
62
+
63
+
64
+ def configure_versioned_api(
65
+ app: FastAPI,
66
+ version_mapping: VersionMap,
67
+ *,
68
+ default_version: VersionKey | None = None,
69
+ app_name: str | None = None,
70
+ middleware: list[Middleware] | None = None,
71
+ ex_handlers: list[ExHandler] | None = None,
72
+ exception_handler_provider: ExceptionHandlerProvider | None = None,
73
+ prefix: str | None = None,
74
+ hit_id_header: str | None = None,
75
+ app_id_header: str | None = None,
76
+ path_param_dependencies: PathParamDependencies | None = None,
77
+ current_user_claims_provider: Callable[[], Any] | None = None,
78
+ strict_version_matching: bool = False,
79
+ include_info_endpoints: bool = True,
80
+ include_root_favicon_alias: bool = True,
81
+ include_actuator_endpoints: bool = True,
82
+ actuator_plugins: list[ActuatorPlugin] | None = None,
83
+ swagger_plugins: list | None = None,
84
+ ) -> None:
85
+ """Configure versioned routing, docs, middleware, and exception handling."""
86
+ if getattr(app.state, _VERSIONING_CONFIGURED_KEY, False):
87
+ logger.warning("configure_versioned_api() already called on this app; skipping.")
88
+ return
89
+
90
+ if exception_handler_provider is None:
91
+ exception_handler_provider = default_exception_handlers_provider
92
+ if current_user_claims_provider is None:
93
+ current_user_claims_provider = get_current_user_claims
94
+
95
+ configure_headers_context_provider(
96
+ get_headers=fastapi_get_headers,
97
+ set_headers=fastapi_headers_context.set,
98
+ reset_headers=fastapi_headers_context.reset,
99
+ )
100
+
101
+ if getattr(app.state, VERSIONING_SETTINGS_STATE_KEY, None) is None:
102
+ setattr(app.state, VERSIONING_SETTINGS_STATE_KEY, load_versioning_settings())
103
+ versioning_settings = getattr(app.state, VERSIONING_SETTINGS_STATE_KEY, None)
104
+
105
+ if not version_mapping:
106
+ raise ValueError("version_mapping cannot be empty. Provide at least one version mapping.")
107
+
108
+ validate_version_mapping_keys(version_mapping)
109
+ _propagate_state_to_versioned_apps(app, version_mapping)
110
+
111
+ app_name = load_app_name(app_name)
112
+ prefix = resolve_prefix(prefix)
113
+
114
+ logger.info(
115
+ "Configuring versioned API: app_name=%s, prefix=%s, versions=%s",
116
+ app_name,
117
+ prefix,
118
+ [str(k) for k in version_mapping],
119
+ )
120
+
121
+ if default_version is None:
122
+ default_version = UNVERSIONED
123
+
124
+ if path_param_dependencies is None:
125
+ path_param_dependencies = []
126
+ else:
127
+ path_param_dependencies = list(path_param_dependencies)
128
+
129
+ if hit_id_header is None:
130
+ hit_id_header = HIT_ID_HEADER_NAME
131
+
132
+ if app_id_header is None:
133
+ app_id_header = APP_ID_HEADER_NAME
134
+
135
+ resolved_handler_map = _resolve_exception_handlers(
136
+ exception_handler_provider=exception_handler_provider,
137
+ version_mapping=version_mapping,
138
+ exception_handlers=ex_handlers,
139
+ )
140
+
141
+ documented_error_statuses = dependency_wiring._documented_error_statuses_from_handlers(
142
+ resolved_handler_map=resolved_handler_map,
143
+ )
144
+
145
+ docs._register_custom_docs(
146
+ app=app,
147
+ version_mapping=version_mapping,
148
+ prefix=prefix,
149
+ app_name=app_name,
150
+ hit_id_header=hit_id_header,
151
+ app_id_header=app_id_header,
152
+ default_version=default_version,
153
+ strict_version_matching=strict_version_matching,
154
+ include_info_endpoints=include_info_endpoints,
155
+ build_tag=getattr(versioning_settings, "build_tag", None),
156
+ include_root_favicon_alias=include_root_favicon_alias,
157
+ swagger_plugins=swagger_plugins,
158
+ )
159
+
160
+ if include_actuator_endpoints:
161
+ register_actuator_router(app, plugins=actuator_plugins)
162
+
163
+ dependency_wiring._mount_and_wire_versions(
164
+ app=app,
165
+ version_mapping=version_mapping,
166
+ prefix=prefix,
167
+ path_param_dependencies=path_param_dependencies,
168
+ documented_error_statuses=documented_error_statuses,
169
+ )
170
+ _apply_middleware(
171
+ app,
172
+ prefix,
173
+ version_mapping,
174
+ middleware,
175
+ default_version,
176
+ hit_id_header,
177
+ app_id_header,
178
+ strict_version_matching,
179
+ )
180
+ _apply_exception_handlers(app, resolved_handler_map)
181
+
182
+ setattr(app.state, _VERSIONING_CONFIGURED_KEY, True)
183
+
184
+
185
+ def create_versioned_app(
186
+ version_mapping: VersionMap,
187
+ *,
188
+ title: str | None = None,
189
+ lifespan: VersionedAppLifespan | None = None,
190
+ app_state: VersionedAppState | None = None,
191
+ configure_app: VersionedAppConfigurer | None = None,
192
+ default_version: VersionKey | None = None,
193
+ app_name: str | None = None,
194
+ middleware: list[Middleware] | None = None,
195
+ ex_handlers: list[ExHandler] | None = None,
196
+ exception_handler_provider: ExceptionHandlerProvider | None = None,
197
+ prefix: str | None = None,
198
+ hit_id_header: str | None = None,
199
+ app_id_header: str | None = None,
200
+ path_param_dependencies: PathParamDependencies | None = None,
201
+ current_user_claims_provider: Callable[[], Any] | None = None,
202
+ strict_version_matching: bool = False,
203
+ include_info_endpoints: bool = True,
204
+ include_root_favicon_alias: bool = True,
205
+ include_actuator_endpoints: bool = True,
206
+ actuator_plugins: list[ActuatorPlugin] | None = None,
207
+ swagger_plugins: list | None = None,
208
+ ) -> FastAPI:
209
+ """Create and configure a versioned root FastAPI app."""
210
+ app_kwargs: dict[str, Any] = {}
211
+ app_kwargs["title"] = title or load_app_name()
212
+ if lifespan is not None:
213
+ app_kwargs["lifespan"] = lifespan
214
+
215
+ # Disable FastAPI's built-in /docs — the versioning framework registers
216
+ # its own per-version OpenAPI schemas at /openapi/{version}.json and a
217
+ # custom Swagger UI at /swagger-ui/.
218
+ # Disable FastAPI's built-in /docs and /redoc — the versioning framework
219
+ # registers its own per-version equivalents at /swagger-ui/ and /redoc.
220
+ app_kwargs.setdefault("docs_url", None)
221
+ app_kwargs.setdefault("redoc_url", None)
222
+
223
+ app = FastAPI(**app_kwargs)
224
+
225
+ if app_state is not None:
226
+ for state_key, state_value in app_state.items():
227
+ setattr(app.state, state_key, state_value)
228
+
229
+ if configure_app is not None:
230
+ configure_app(app)
231
+
232
+ configure_versioned_api(
233
+ app=app,
234
+ version_mapping=version_mapping,
235
+ default_version=default_version,
236
+ app_name=app_name,
237
+ middleware=middleware,
238
+ ex_handlers=ex_handlers,
239
+ exception_handler_provider=exception_handler_provider,
240
+ prefix=prefix,
241
+ hit_id_header=hit_id_header,
242
+ app_id_header=app_id_header,
243
+ path_param_dependencies=path_param_dependencies,
244
+ current_user_claims_provider=current_user_claims_provider,
245
+ strict_version_matching=strict_version_matching,
246
+ include_info_endpoints=include_info_endpoints,
247
+ include_root_favicon_alias=include_root_favicon_alias,
248
+ include_actuator_endpoints=include_actuator_endpoints,
249
+ actuator_plugins=actuator_plugins,
250
+ swagger_plugins=swagger_plugins,
251
+ )
252
+
253
+ return app
254
+
255
+
256
+ def _apply_middleware(
257
+ app: FastAPI,
258
+ prefix: str,
259
+ version_mapping: VersionMap,
260
+ middleware: list[Middleware] | None = None,
261
+ default_version: VersionKey | None = None,
262
+ hit_id_header: str = HIT_ID_HEADER_NAME,
263
+ app_id_header: str = APP_ID_HEADER_NAME,
264
+ strict_version_matching: bool = False,
265
+ ) -> None:
266
+ _register_middleware(app, middleware)
267
+
268
+ app.add_middleware(
269
+ VersionDispatchMiddleware,
270
+ prefix=prefix,
271
+ version_mapping=version_mapping,
272
+ default_version=default_version,
273
+ hit_id_header=hit_id_header,
274
+ app_id_header=app_id_header,
275
+ strict_version_matching=strict_version_matching,
276
+ )
277
+
278
+
279
+ def _register_middleware(
280
+ app: FastAPI,
281
+ middleware: list[Middleware] | None,
282
+ ) -> None:
283
+ if middleware is None:
284
+ return
285
+
286
+ for m in middleware:
287
+ if isinstance(m, tuple):
288
+ cls, kwargs = m
289
+ app.add_middleware(cls, **(kwargs or {})) # type: ignore[arg-type]
290
+ elif isinstance(m, type):
291
+ app.add_middleware(m) # type: ignore[arg-type]
292
+ else:
293
+ raise TypeError(
294
+ f"middleware entries must be a class or (class, kwargs) tuple, got {type(m).__name__}"
295
+ )
296
+
297
+ logger.debug("Registered %d user-provided middleware entries", len(middleware))
298
+
299
+
300
+ def _is_builtin_exception_handler(handler: ExceptionHandler) -> bool:
301
+ return getattr(handler, "__module__", "").startswith("fastapi.")
302
+
303
+
304
+ def _resolve_exception_handlers(
305
+ *,
306
+ exception_handler_provider: ExceptionHandlerProvider,
307
+ version_mapping: VersionMap,
308
+ exception_handlers: list[ExHandler] | None,
309
+ ) -> dict[int | type[Exception], ExceptionHandler]:
310
+ resolved_handler_map = dict(exception_handler_provider())
311
+
312
+ per_version_handlers = _collect_sub_app_exception_handlers(version_mapping)
313
+ for exc_key, version_handlers in per_version_handlers.items():
314
+ fallback = resolved_handler_map.get(exc_key)
315
+ resolved_handler_map[exc_key] = _make_version_scoped_handler(version_handlers, fallback)
316
+
317
+ if exception_handlers is not None:
318
+ for ex in exception_handlers:
319
+ resolved_handler_map[ex[0]] = ex[1]
320
+
321
+ return resolved_handler_map
322
+
323
+
324
+ def _make_version_scoped_handler(
325
+ version_handlers: dict[str, ExceptionHandler],
326
+ fallback: ExceptionHandler | None,
327
+ ) -> ExceptionHandler:
328
+ async def handler(request: Any, exc: Any) -> Any:
329
+ version = get_api_version()
330
+ version_handler = version_handlers.get(version) if version else None
331
+ if version_handler is not None:
332
+ result = version_handler(request, exc)
333
+ return await result if inspect.isawaitable(result) else result
334
+ if fallback is not None:
335
+ result = fallback(request, exc)
336
+ return await result if inspect.isawaitable(result) else result
337
+ raise exc
338
+
339
+ return handler
340
+
341
+
342
+ def _collect_sub_app_exception_handlers(
343
+ version_mapping: VersionMap,
344
+ ) -> dict[int | type[Exception], dict[str, ExceptionHandler]]:
345
+ collected: dict[int | type[Exception], dict[str, ExceptionHandler]] = {}
346
+
347
+ for version_key, versioned_app in version_mapping.items():
348
+ normalized = normalize_version(version_key)
349
+ for exc_key, handler in versioned_app.exception_handlers.items():
350
+ if _is_builtin_exception_handler(handler):
351
+ continue
352
+ if exc_key not in collected:
353
+ collected[exc_key] = {}
354
+ collected[exc_key][normalized] = handler
355
+
356
+ if collected:
357
+ logger.debug(
358
+ "Collected %d version-scoped exception handler(s) from sub-apps: %s",
359
+ len(collected),
360
+ [str(k) for k in collected],
361
+ )
362
+
363
+ return collected
364
+
365
+
366
+ def _apply_exception_handlers(
367
+ app: FastAPI,
368
+ resolved_handler_map: dict[int | type[Exception], ExceptionHandler],
369
+ ) -> None:
370
+ for exception_key, handler in resolved_handler_map.items():
371
+ app.add_exception_handler(exception_key, handler)
372
+
373
+ logger.debug(
374
+ "Registered %d exception handlers: %s",
375
+ len(resolved_handler_map),
376
+ [str(k) for k in resolved_handler_map],
377
+ )
378
+
379
+
380
+ def _propagate_state_to_versioned_apps(
381
+ app: FastAPI,
382
+ version_mapping: VersionMap,
383
+ ) -> None:
384
+ if not len(app.state):
385
+ return
386
+
387
+ for versioned_app in version_mapping.values():
388
+ for key in app.state:
389
+ if not hasattr(versioned_app.state, key):
390
+ setattr(versioned_app.state, key, app.state[key])
391
+
392
+
393
+ __all__ = (
394
+ "VersionMap",
395
+ "configure_versioned_api",
396
+ "create_versioned_app",
397
+ "default_exception_handlers_provider",
398
+ "get_current_user_claims",
399
+ )
@@ -0,0 +1,98 @@
1
+ """Per-version ReDoc UI route registration."""
2
+
3
+ from html import escape
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.responses import HTMLResponse
7
+
8
+
9
+ def _register_redoc_routes(
10
+ app: FastAPI,
11
+ *,
12
+ version_list: list[str],
13
+ default_version: str | None = None,
14
+ ) -> None:
15
+ """Register per-version ReDoc UI at ``/redoc`` with a version query param.
16
+
17
+ Usage::
18
+
19
+ /redoc → latest (or default) version
20
+ /redoc?version=2025-06-20 → specific version
21
+
22
+ Points to existing ``/openapi/{version}.json`` routes registered by
23
+ :func:`_docs._register_openapi_json_routes`.
24
+ """
25
+
26
+ _SECURITY_HEADERS = {
27
+ "X-Content-Type-Options": "nosniff",
28
+ "X-Frame-Options": "DENY",
29
+ }
30
+
31
+ def _build_redoc_html(version: str, versions: list[str]) -> str:
32
+ title = escape(f"ReDoc - {version}")
33
+ openapi_url = f"/openapi/{version}.json"
34
+ options = "\n".join(
35
+ f' <option value="{v}"{" selected" if v == version else ""}>{escape(v)}</option>'
36
+ for v in versions
37
+ )
38
+ return f"""<!DOCTYPE html>
39
+ <html><head>
40
+ <title>{title}</title>
41
+ <meta charset="utf-8"/>
42
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
43
+ <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
44
+ rel="stylesheet">
45
+ <style>
46
+ body {{ margin: 0; padding: 0; }}
47
+ .version-bar {{
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 12px;
51
+ padding: 8px 16px;
52
+ background: #32329f;
53
+ color: #fff;
54
+ font-family: Montserrat, sans-serif;
55
+ font-size: 14px;
56
+ }}
57
+ .version-bar select {{
58
+ padding: 4px 8px;
59
+ border-radius: 4px;
60
+ border: 1px solid #5757c0;
61
+ background: #fff;
62
+ font-size: 14px;
63
+ cursor: pointer;
64
+ }}
65
+ .version-bar a {{
66
+ color: #ccc;
67
+ text-decoration: none;
68
+ margin-left: auto;
69
+ font-size: 13px;
70
+ }}
71
+ .version-bar a:hover {{ color: #fff; }}
72
+ </style>
73
+ </head><body>
74
+ <div class="version-bar">
75
+ <label for="api-version"><strong>API Version:</strong></label>
76
+ <select id="api-version" onchange="switchVersion(this.value)">
77
+ {options}
78
+ </select>
79
+ <a href="/swagger-ui/index.html">← Swagger UI</a>
80
+ </div>
81
+ <redoc spec-url="{openapi_url}" id="redoc-container"></redoc>
82
+ <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
83
+ <script>
84
+ function switchVersion(v) {{
85
+ const url = new URL(window.location);
86
+ url.searchParams.set('version', v);
87
+ window.location.href = url.toString();
88
+ }}
89
+ </script>
90
+ </body></html>"""
91
+
92
+ @app.get("/redoc", include_in_schema=False)
93
+ async def redoc_ui(version: str | None = None) -> HTMLResponse:
94
+ resolved = version or default_version or (version_list[-1] if version_list else "latest")
95
+ return HTMLResponse(
96
+ _build_redoc_html(resolved.lower(), version_list),
97
+ headers=_SECURITY_HEADERS,
98
+ )