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,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
|
+
)
|