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,203 @@
|
|
|
1
|
+
"""Version-dispatch middleware for FastAPI.
|
|
2
|
+
|
|
3
|
+
Intercepts inbound requests, resolves the target API version from request
|
|
4
|
+
headers, rewrites the ASGI ``path`` to the version-keyed mount, and manages
|
|
5
|
+
request-scoped context variables for the duration of each request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
14
|
+
|
|
15
|
+
from csrd.context import (
|
|
16
|
+
reset_api_version_context,
|
|
17
|
+
reset_headers_context,
|
|
18
|
+
set_api_version_context,
|
|
19
|
+
set_headers_context,
|
|
20
|
+
)
|
|
21
|
+
from csrd.context.middleware import REQUEST_SCOPE_KEY
|
|
22
|
+
from csrd.versioning._constants import (
|
|
23
|
+
API_VERSION_HEADER_NAME,
|
|
24
|
+
APP_ID_HEADER_NAME,
|
|
25
|
+
HIT_ID_HEADER_NAME,
|
|
26
|
+
)
|
|
27
|
+
from csrd.versioning._core import map_version_path, normalize_prefix, resolve_version
|
|
28
|
+
from csrd.versioning._types import VersionKey, VersionMap
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_DOCS_PREFIXES = ("/swagger-ui", "/openapi", "/_info", "/actuator", "/docs", "/redoc")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ensure_request_scope(
|
|
36
|
+
request: Request,
|
|
37
|
+
*,
|
|
38
|
+
hit_id_header: str = HIT_ID_HEADER_NAME,
|
|
39
|
+
app_id_header: str = APP_ID_HEADER_NAME,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Ensure request scope has request context even without logging middleware."""
|
|
42
|
+
scope = request.scope.get(REQUEST_SCOPE_KEY)
|
|
43
|
+
if scope is None:
|
|
44
|
+
scope = {}
|
|
45
|
+
request.scope[REQUEST_SCOPE_KEY] = scope
|
|
46
|
+
|
|
47
|
+
hit_id = request.headers.get(hit_id_header)
|
|
48
|
+
app_id = request.headers.get(app_id_header)
|
|
49
|
+
if "hit_id" not in scope:
|
|
50
|
+
scope["hit_id"] = hit_id or str(uuid.uuid4())
|
|
51
|
+
if "app_id" not in scope and app_id is not None:
|
|
52
|
+
scope["app_id"] = app_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _map_version_endpoint(request: Request, version: str, prefix: str) -> str:
|
|
56
|
+
"""Rewrite an incoming path to include resolved version under the API prefix."""
|
|
57
|
+
return map_version_path(str(request.url.path), version=version, prefix=prefix)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_version(
|
|
61
|
+
request: Request,
|
|
62
|
+
*,
|
|
63
|
+
version_mapping: VersionMap | None = None,
|
|
64
|
+
default_version: VersionKey | None = None,
|
|
65
|
+
strict: bool = False,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Resolve request version using explicit, deterministic fallback precedence."""
|
|
68
|
+
requested_version = request.headers.get(API_VERSION_HEADER_NAME)
|
|
69
|
+
return resolve_version(
|
|
70
|
+
requested_version=requested_version,
|
|
71
|
+
version_mapping=version_mapping,
|
|
72
|
+
default_version=default_version,
|
|
73
|
+
strict=strict,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _should_dispatch_request(request: Request, prefix: str) -> bool:
|
|
78
|
+
"""Return True when request path is handled by version-dispatch middleware."""
|
|
79
|
+
normalized_prefix = normalize_prefix(prefix)
|
|
80
|
+
path = request.url.path
|
|
81
|
+
|
|
82
|
+
if any(path == p or path.startswith(f"{p}/") for p in _DOCS_PREFIXES) or path == "/":
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
if normalized_prefix == "/":
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
return path == normalized_prefix or path.startswith(f"{normalized_prefix}/")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _send_json_error(
|
|
92
|
+
send: Send,
|
|
93
|
+
scope: Scope,
|
|
94
|
+
*,
|
|
95
|
+
status_code: int,
|
|
96
|
+
detail: str,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Send a JSON error response using the raw ASGI ``send`` callable."""
|
|
99
|
+
body = json.dumps({"detail": detail}).encode("utf-8")
|
|
100
|
+
headers: list[list[bytes]] = [
|
|
101
|
+
[b"content-type", b"application/json"],
|
|
102
|
+
[b"content-length", str(len(body)).encode("ascii")],
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
for header_name, header_value in scope.get("headers", []):
|
|
106
|
+
if header_name == b"origin":
|
|
107
|
+
headers.append([b"access-control-allow-origin", header_value])
|
|
108
|
+
headers.append([b"vary", b"origin"])
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
await send(
|
|
112
|
+
{
|
|
113
|
+
"type": "http.response.start",
|
|
114
|
+
"status": status_code,
|
|
115
|
+
"headers": headers,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class VersionDispatchMiddleware:
|
|
122
|
+
"""Raw ASGI middleware for version dispatch.
|
|
123
|
+
|
|
124
|
+
Unlike ``BaseHTTPMiddleware`` / ``@app.middleware("http")``, this does
|
|
125
|
+
**not** buffer the response body, so ``StreamingResponse`` and SSE
|
|
126
|
+
endpoints work correctly through versioned routes.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
app: ASGIApp,
|
|
132
|
+
*,
|
|
133
|
+
prefix: str,
|
|
134
|
+
version_mapping: VersionMap,
|
|
135
|
+
default_version: VersionKey | None = None,
|
|
136
|
+
hit_id_header: str = HIT_ID_HEADER_NAME,
|
|
137
|
+
app_id_header: str = APP_ID_HEADER_NAME,
|
|
138
|
+
strict_version_matching: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
self.app = app
|
|
141
|
+
self.prefix = normalize_prefix(prefix)
|
|
142
|
+
self.version_mapping = version_mapping
|
|
143
|
+
self.default_version = default_version
|
|
144
|
+
self.hit_id_header = hit_id_header
|
|
145
|
+
self.app_id_header = app_id_header
|
|
146
|
+
self.strict_version_matching = strict_version_matching
|
|
147
|
+
|
|
148
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
149
|
+
if scope["type"] != "http":
|
|
150
|
+
await self.app(scope, receive, send)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
request = Request(scope)
|
|
154
|
+
|
|
155
|
+
if not _should_dispatch_request(request, self.prefix):
|
|
156
|
+
await self.app(scope, receive, send)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
_ensure_request_scope(
|
|
160
|
+
request,
|
|
161
|
+
hit_id_header=self.hit_id_header,
|
|
162
|
+
app_id_header=self.app_id_header,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
version = _get_version(
|
|
167
|
+
request,
|
|
168
|
+
version_mapping=self.version_mapping,
|
|
169
|
+
default_version=self.default_version,
|
|
170
|
+
strict=self.strict_version_matching,
|
|
171
|
+
)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
await _send_json_error(send, scope, status_code=400, detail=str(exc))
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
token = set_api_version_context(version)
|
|
177
|
+
scope["api_version"] = version
|
|
178
|
+
try:
|
|
179
|
+
headers_token = set_headers_context(request.headers)
|
|
180
|
+
try:
|
|
181
|
+
path = _map_version_endpoint(request, version, self.prefix)
|
|
182
|
+
logger.debug(
|
|
183
|
+
"Version dispatch: %s -> %s (resolved version=%s)",
|
|
184
|
+
request.url.path,
|
|
185
|
+
path,
|
|
186
|
+
version,
|
|
187
|
+
)
|
|
188
|
+
scope["path"] = path
|
|
189
|
+
original_raw = scope.get("raw_path", b"")
|
|
190
|
+
prefix_bytes = self.prefix.rstrip("/").encode("utf-8")
|
|
191
|
+
version_segment = b"/" + version.encode("utf-8")
|
|
192
|
+
if prefix_bytes == b"":
|
|
193
|
+
raw_remainder = original_raw
|
|
194
|
+
else:
|
|
195
|
+
raw_remainder = original_raw[len(prefix_bytes) :]
|
|
196
|
+
if raw_remainder == b"/":
|
|
197
|
+
raw_remainder = b""
|
|
198
|
+
scope["raw_path"] = prefix_bytes + version_segment + raw_remainder
|
|
199
|
+
await self.app(scope, receive, send)
|
|
200
|
+
finally:
|
|
201
|
+
reset_headers_context(headers_token)
|
|
202
|
+
finally:
|
|
203
|
+
reset_api_version_context(token)
|
csrd/versioning/_docs.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Custom Swagger UI, per-version OpenAPI JSON, and introspection endpoint registration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from html import escape
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from fastapi import APIRouter, FastAPI
|
|
15
|
+
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
16
|
+
from fastapi.routing import APIRoute
|
|
17
|
+
|
|
18
|
+
from csrd.versioning._constants import API_VERSION_HEADER_NAME, HTTP_METHODS
|
|
19
|
+
from csrd.versioning._core import (
|
|
20
|
+
normalize_prefix as _normalize_prefix,
|
|
21
|
+
)
|
|
22
|
+
from csrd.versioning._core import (
|
|
23
|
+
normalize_unversioned_label as _normalize_unv,
|
|
24
|
+
)
|
|
25
|
+
from csrd.versioning._core import (
|
|
26
|
+
normalize_version as _normalize_version,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ._fastapi_types import VersionKey, VersionMap
|
|
30
|
+
from ._redoc import _register_redoc_routes
|
|
31
|
+
from ._swagger_ui_version import (
|
|
32
|
+
SWAGGER_UI_CSS_SRI,
|
|
33
|
+
SWAGGER_UI_JS_SRI,
|
|
34
|
+
SWAGGER_UI_VERSION,
|
|
35
|
+
)
|
|
36
|
+
from .swagger_plugins._base import (
|
|
37
|
+
SchemaContext,
|
|
38
|
+
SwaggerPlugin,
|
|
39
|
+
_aggregate_bundle_plugins,
|
|
40
|
+
_aggregate_css,
|
|
41
|
+
_aggregate_js,
|
|
42
|
+
_collect_contributions,
|
|
43
|
+
_collect_schema_patchers,
|
|
44
|
+
_resolve_swagger_plugins,
|
|
45
|
+
_warn_css_conflicts,
|
|
46
|
+
apply_schema_patchers,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_TEMPLATE_PACKAGE = "csrd.versioning.templates"
|
|
52
|
+
_INLINE_ASSET_LINE_THRESHOLD = 2000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_template(template_name: str) -> str:
|
|
56
|
+
"""Load a template or asset file from package templates."""
|
|
57
|
+
return files(_TEMPLATE_PACKAGE).joinpath(template_name).read_text(encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_version_options(version_list: list[str]) -> str:
|
|
61
|
+
"""Build HTML <option> entries for version select."""
|
|
62
|
+
return "\n".join(
|
|
63
|
+
f'<option value="{escape(version.lower())}">{escape(version)}</option>'
|
|
64
|
+
for version in version_list
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_swagger_ui_html(
|
|
69
|
+
*,
|
|
70
|
+
version_list: list[str],
|
|
71
|
+
app_name: str,
|
|
72
|
+
hit_id_header: str,
|
|
73
|
+
app_id_header: str,
|
|
74
|
+
default_version: str | None = None,
|
|
75
|
+
build_tag: str | None = None,
|
|
76
|
+
plugin_css: str = "",
|
|
77
|
+
plugin_js: str = "",
|
|
78
|
+
bundle_plugins: str = "",
|
|
79
|
+
) -> str:
|
|
80
|
+
html_template = _load_template("swagger_ui.html")
|
|
81
|
+
inline_css = _load_template("swagger_ui.css")
|
|
82
|
+
inline_js = _load_template("swagger_ui.js")
|
|
83
|
+
|
|
84
|
+
inline_lines = inline_css.count("\n") + inline_js.count("\n")
|
|
85
|
+
if inline_lines > _INLINE_ASSET_LINE_THRESHOLD:
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Inlined Swagger UI assets total %d lines (threshold: %d). "
|
|
88
|
+
"Consider serving CSS/JS as static files with Cache-Control headers.",
|
|
89
|
+
inline_lines,
|
|
90
|
+
_INLINE_ASSET_LINE_THRESHOLD,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
version_options = _build_version_options(version_list)
|
|
94
|
+
|
|
95
|
+
swagger_config_json = json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"appName": app_name,
|
|
98
|
+
"appIdHeader": app_id_header,
|
|
99
|
+
"hitIdHeader": hit_id_header,
|
|
100
|
+
"versionHeader": API_VERSION_HEADER_NAME,
|
|
101
|
+
"defaultVersion": default_version,
|
|
102
|
+
}
|
|
103
|
+
).replace("</", "<\\/")
|
|
104
|
+
|
|
105
|
+
build_tag_value = (build_tag or "").strip()
|
|
106
|
+
build_tag_segment = ""
|
|
107
|
+
if build_tag_value:
|
|
108
|
+
build_tag_segment = (
|
|
109
|
+
f'<span class="docs-footer-build-tag">V: {escape(build_tag_value)}</span>'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
bundle_plugins_value = f", {bundle_plugins}" if bundle_plugins else ""
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
html_template.replace("__SWAGGER_UI_VERSION__", SWAGGER_UI_VERSION)
|
|
116
|
+
.replace("__SRI_CSS__", SWAGGER_UI_CSS_SRI)
|
|
117
|
+
.replace("__SRI_JS__", SWAGGER_UI_JS_SRI)
|
|
118
|
+
.replace("__INLINE_CSS__", inline_css)
|
|
119
|
+
.replace("__PLUGIN_CSS__", plugin_css)
|
|
120
|
+
.replace("__PLUGIN_JS__", plugin_js)
|
|
121
|
+
.replace("__INLINE_JS__", inline_js)
|
|
122
|
+
.replace("__VERSIONS_OPTIONS__", version_options)
|
|
123
|
+
.replace("__SWAGGER_CONFIG_JSON__", swagger_config_json)
|
|
124
|
+
.replace("__BUILD_TAG_SEGMENT__", build_tag_segment)
|
|
125
|
+
.replace("__BUNDLE_PLUGINS__", bundle_plugins_value)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _deduplicate_operation_ids(app: FastAPI) -> None:
|
|
130
|
+
"""Ensure OpenAPI operation ids are unique within an app."""
|
|
131
|
+
operation_ids: set[str] = set()
|
|
132
|
+
for route in app.routes:
|
|
133
|
+
if isinstance(route, APIRoute):
|
|
134
|
+
base_id = route.operation_id or route.name
|
|
135
|
+
if base_id not in operation_ids:
|
|
136
|
+
route.operation_id = base_id
|
|
137
|
+
operation_ids.add(base_id)
|
|
138
|
+
else:
|
|
139
|
+
counter = 2
|
|
140
|
+
while f"{base_id}_{counter}" in operation_ids:
|
|
141
|
+
counter += 1
|
|
142
|
+
deduped_id = f"{base_id}_{counter}"
|
|
143
|
+
route.operation_id = deduped_id
|
|
144
|
+
operation_ids.add(deduped_id)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_path_param_order(path: str) -> dict[str, int]:
|
|
148
|
+
"""Return path-parameter names mapped to their declaration order."""
|
|
149
|
+
names = re.findall(r"{([^{}]+)}", path)
|
|
150
|
+
return {name: i for i, name in enumerate(names)}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sort_openapi_path_params_inplace(schema: dict[str, Any]) -> None:
|
|
154
|
+
"""Sort OpenAPI operation parameters so path params follow URL template order."""
|
|
155
|
+
paths = schema.get("paths", {})
|
|
156
|
+
for path, path_item in paths.items():
|
|
157
|
+
order = _extract_path_param_order(path)
|
|
158
|
+
|
|
159
|
+
def _key(
|
|
160
|
+
item: tuple[int, dict[str, Any]], order: dict[str, int] = order
|
|
161
|
+
) -> tuple[int, int, int]:
|
|
162
|
+
idx, p = item
|
|
163
|
+
loc = p.get("in")
|
|
164
|
+
name = p.get("name", "")
|
|
165
|
+
if loc == "path":
|
|
166
|
+
return (0, order.get(name, 10_000), idx)
|
|
167
|
+
return (1, 0, idx)
|
|
168
|
+
|
|
169
|
+
for method, op in path_item.items():
|
|
170
|
+
if method not in HTTP_METHODS:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
params: list[dict[str, Any]] = op.get("parameters", [])
|
|
174
|
+
if not params:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
indexed = list(enumerate(params))
|
|
178
|
+
op["parameters"] = [p for _, p in sorted(indexed, key=_key)]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _normalized_version_labels(version_mapping: VersionMap) -> list[str]:
|
|
182
|
+
"""Return display labels for configured versions."""
|
|
183
|
+
labels = [_normalize_unv(version) for version in version_mapping]
|
|
184
|
+
|
|
185
|
+
def _key(label: str) -> tuple[int, tuple[int, ...], str]:
|
|
186
|
+
normalized = _normalize_version(label)
|
|
187
|
+
if normalized == "unv":
|
|
188
|
+
return (0, (), "")
|
|
189
|
+
|
|
190
|
+
numeric_parts = tuple(int(part) for part in re.findall(r"\d+", normalized))
|
|
191
|
+
if numeric_parts:
|
|
192
|
+
return (2, numeric_parts, "")
|
|
193
|
+
|
|
194
|
+
return (1, (), normalized)
|
|
195
|
+
|
|
196
|
+
return sorted(labels, key=_key)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _build_version_openapi_schema(
|
|
200
|
+
versioned_app: FastAPI,
|
|
201
|
+
*,
|
|
202
|
+
prefix: str,
|
|
203
|
+
version_key: VersionKey,
|
|
204
|
+
schema_patchers: list | None = None,
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
"""Build a per-version OpenAPI schema with normalized paths/version info."""
|
|
207
|
+
_deduplicate_operation_ids(versioned_app)
|
|
208
|
+
|
|
209
|
+
normalized_prefix = _normalize_prefix(prefix)
|
|
210
|
+
openapi_schema = copy.deepcopy(versioned_app.openapi())
|
|
211
|
+
existing_paths = dict(openapi_schema.get("paths", {}))
|
|
212
|
+
prefixed_paths: dict[str, Any] = {}
|
|
213
|
+
|
|
214
|
+
for path, path_item in existing_paths.items():
|
|
215
|
+
if path.startswith(normalized_prefix):
|
|
216
|
+
prefixed_paths[path] = path_item
|
|
217
|
+
else:
|
|
218
|
+
prefixed_paths[f"{normalized_prefix}{path}"] = path_item
|
|
219
|
+
|
|
220
|
+
openapi_schema["paths"] = prefixed_paths
|
|
221
|
+
openapi_schema["info"]["version"] = _normalize_unv(version_key)
|
|
222
|
+
_sort_openapi_path_params_inplace(openapi_schema)
|
|
223
|
+
|
|
224
|
+
if schema_patchers:
|
|
225
|
+
ctx = SchemaContext(
|
|
226
|
+
app=versioned_app,
|
|
227
|
+
version_key=version_key,
|
|
228
|
+
prefix=normalized_prefix,
|
|
229
|
+
)
|
|
230
|
+
openapi_schema = apply_schema_patchers(openapi_schema, ctx, schema_patchers)
|
|
231
|
+
|
|
232
|
+
return openapi_schema
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_openapi_doc_endpoint(
|
|
236
|
+
*,
|
|
237
|
+
version_mapping: VersionMap,
|
|
238
|
+
version_key: VersionKey,
|
|
239
|
+
prefix: str,
|
|
240
|
+
schema_patchers: list | None = None,
|
|
241
|
+
) -> Callable[[], Awaitable[dict[str, Any]]]:
|
|
242
|
+
cached_schema: dict[str, Any] | None = None
|
|
243
|
+
|
|
244
|
+
async def _doc_endpoint() -> dict[str, Any]:
|
|
245
|
+
nonlocal cached_schema
|
|
246
|
+
if cached_schema is None:
|
|
247
|
+
versioned_app = version_mapping[version_key]
|
|
248
|
+
cached_schema = _build_version_openapi_schema(
|
|
249
|
+
versioned_app,
|
|
250
|
+
prefix=prefix,
|
|
251
|
+
version_key=version_key,
|
|
252
|
+
schema_patchers=schema_patchers,
|
|
253
|
+
)
|
|
254
|
+
return copy.deepcopy(cached_schema)
|
|
255
|
+
|
|
256
|
+
return _doc_endpoint
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _register_swagger_ui_routes(
|
|
260
|
+
app: FastAPI,
|
|
261
|
+
*,
|
|
262
|
+
version_list: list[str],
|
|
263
|
+
app_name: str,
|
|
264
|
+
hit_id_header: str,
|
|
265
|
+
app_id_header: str,
|
|
266
|
+
default_version: str | None = None,
|
|
267
|
+
build_tag: str | None = None,
|
|
268
|
+
include_root_favicon_alias: bool = True,
|
|
269
|
+
plugin_css: str = "",
|
|
270
|
+
plugin_js: str = "",
|
|
271
|
+
bundle_plugins: str = "",
|
|
272
|
+
) -> None:
|
|
273
|
+
_SECURITY_HEADERS = {
|
|
274
|
+
"X-Content-Type-Options": "nosniff",
|
|
275
|
+
"X-Frame-Options": "DENY",
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def _has_get_route(path: str) -> bool:
|
|
279
|
+
return any(
|
|
280
|
+
isinstance(route, APIRoute) and route.path == path and "GET" in route.methods
|
|
281
|
+
for route in app.routes
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@app.get("/", include_in_schema=False)
|
|
285
|
+
async def redirect_to_docs() -> RedirectResponse:
|
|
286
|
+
return RedirectResponse(url="/swagger-ui/index.html")
|
|
287
|
+
|
|
288
|
+
@app.get("/docs", include_in_schema=False)
|
|
289
|
+
async def redirect_docs_to_swagger() -> RedirectResponse:
|
|
290
|
+
return RedirectResponse(url="/swagger-ui/index.html")
|
|
291
|
+
|
|
292
|
+
@app.get("/swagger-ui", include_in_schema=False)
|
|
293
|
+
async def redirect_to_docs_() -> RedirectResponse:
|
|
294
|
+
return RedirectResponse(url="/swagger-ui/index.html")
|
|
295
|
+
|
|
296
|
+
_cached_html = _render_swagger_ui_html(
|
|
297
|
+
version_list=version_list,
|
|
298
|
+
app_name=app_name,
|
|
299
|
+
hit_id_header=hit_id_header,
|
|
300
|
+
app_id_header=app_id_header,
|
|
301
|
+
default_version=default_version,
|
|
302
|
+
build_tag=build_tag,
|
|
303
|
+
plugin_css=plugin_css,
|
|
304
|
+
plugin_js=plugin_js,
|
|
305
|
+
bundle_plugins=bundle_plugins,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@app.get("/swagger-ui/index.html", include_in_schema=False)
|
|
309
|
+
async def custom_docs() -> HTMLResponse:
|
|
310
|
+
return HTMLResponse(_cached_html, headers=_SECURITY_HEADERS)
|
|
311
|
+
|
|
312
|
+
_favicon_bytes = files(_TEMPLATE_PACKAGE).joinpath("favicon.png").read_bytes()
|
|
313
|
+
|
|
314
|
+
@app.get("/swagger-ui/favicon.png", include_in_schema=False)
|
|
315
|
+
async def favicon() -> Response:
|
|
316
|
+
return Response(
|
|
317
|
+
content=_favicon_bytes,
|
|
318
|
+
media_type="image/png",
|
|
319
|
+
headers={
|
|
320
|
+
**_SECURITY_HEADERS,
|
|
321
|
+
"Cache-Control": "public, max-age=86400",
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if include_root_favicon_alias and not _has_get_route("/favicon.ico"):
|
|
326
|
+
|
|
327
|
+
@app.get("/favicon.ico", include_in_schema=False)
|
|
328
|
+
async def root_favicon() -> Response:
|
|
329
|
+
return Response(
|
|
330
|
+
content=_favicon_bytes,
|
|
331
|
+
media_type="image/png",
|
|
332
|
+
headers={
|
|
333
|
+
**_SECURITY_HEADERS,
|
|
334
|
+
"Cache-Control": "public, max-age=86400",
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _register_openapi_json_routes(
|
|
340
|
+
app: FastAPI,
|
|
341
|
+
*,
|
|
342
|
+
version_mapping: VersionMap,
|
|
343
|
+
prefix: str,
|
|
344
|
+
schema_patchers: list | None = None,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Register ``/openapi/{version}.json`` routes for each configured version."""
|
|
347
|
+
doc_router = APIRouter(prefix="/openapi")
|
|
348
|
+
|
|
349
|
+
for api in version_mapping:
|
|
350
|
+
normalized_label = _normalize_unv(api)
|
|
351
|
+
url = f"/{normalized_label.lower()}.json"
|
|
352
|
+
name = f"version_{normalized_label.replace('-', '_')}"
|
|
353
|
+
|
|
354
|
+
doc_router.add_api_route(
|
|
355
|
+
url,
|
|
356
|
+
_build_openapi_doc_endpoint(
|
|
357
|
+
version_mapping=version_mapping,
|
|
358
|
+
version_key=api,
|
|
359
|
+
prefix=prefix,
|
|
360
|
+
schema_patchers=schema_patchers,
|
|
361
|
+
),
|
|
362
|
+
methods=["GET"],
|
|
363
|
+
name=name,
|
|
364
|
+
include_in_schema=False,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
app.include_router(doc_router)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _register_info_endpoints(
|
|
371
|
+
app: FastAPI,
|
|
372
|
+
*,
|
|
373
|
+
version_list: list[str],
|
|
374
|
+
prefix: str,
|
|
375
|
+
app_name: str,
|
|
376
|
+
default_version: VersionKey | None,
|
|
377
|
+
strict_version_matching: bool,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Register ``/_info`` and ``/_info/health`` introspection routes."""
|
|
380
|
+
|
|
381
|
+
@app.get("/_info", include_in_schema=False)
|
|
382
|
+
async def versioning_info() -> dict[str, Any]:
|
|
383
|
+
return {
|
|
384
|
+
"app_name": app_name,
|
|
385
|
+
"prefix": prefix,
|
|
386
|
+
"versions": version_list,
|
|
387
|
+
"default_version": (
|
|
388
|
+
_normalize_unv(default_version) if default_version is not None else None
|
|
389
|
+
),
|
|
390
|
+
"strict_version_matching": strict_version_matching,
|
|
391
|
+
"docs": "/swagger-ui/index.html",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@app.get("/_info/health", include_in_schema=False)
|
|
395
|
+
async def versioning_health() -> dict[str, Any]:
|
|
396
|
+
health_path = f"{_normalize_prefix(prefix)}/health"
|
|
397
|
+
|
|
398
|
+
async def _probe(label: str, client: httpx.AsyncClient) -> tuple[str, dict]:
|
|
399
|
+
try:
|
|
400
|
+
r = await client.get(
|
|
401
|
+
health_path,
|
|
402
|
+
headers={API_VERSION_HEADER_NAME: label.lower()},
|
|
403
|
+
)
|
|
404
|
+
if r.status_code == 200:
|
|
405
|
+
try:
|
|
406
|
+
body = r.json()
|
|
407
|
+
except Exception:
|
|
408
|
+
body = None
|
|
409
|
+
return label, {"status": "ok", "code": 200, "response": body}
|
|
410
|
+
elif r.status_code == 404:
|
|
411
|
+
return label, {"status": "not_found", "code": 404, "response": None}
|
|
412
|
+
else:
|
|
413
|
+
return label, {
|
|
414
|
+
"status": "error",
|
|
415
|
+
"code": r.status_code,
|
|
416
|
+
"response": None,
|
|
417
|
+
}
|
|
418
|
+
except Exception:
|
|
419
|
+
logger.warning("Health probe failed for version %s", label, exc_info=True)
|
|
420
|
+
return label, {"status": "error", "code": 0, "response": None}
|
|
421
|
+
|
|
422
|
+
transport = httpx.ASGITransport(app=app)
|
|
423
|
+
async with httpx.AsyncClient(
|
|
424
|
+
transport=transport, base_url="http://internal", timeout=1.0
|
|
425
|
+
) as client:
|
|
426
|
+
pairs = await asyncio.gather(*(_probe(label, client) for label in version_list))
|
|
427
|
+
|
|
428
|
+
return {label: result for label, result in pairs}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _register_custom_docs(
|
|
432
|
+
app: FastAPI,
|
|
433
|
+
version_mapping: VersionMap,
|
|
434
|
+
prefix: str,
|
|
435
|
+
app_name: str,
|
|
436
|
+
hit_id_header: str,
|
|
437
|
+
app_id_header: str,
|
|
438
|
+
default_version: "VersionKey | None" = None,
|
|
439
|
+
strict_version_matching: bool = False,
|
|
440
|
+
include_info_endpoints: bool = True,
|
|
441
|
+
build_tag: str | None = None,
|
|
442
|
+
include_root_favicon_alias: bool = True,
|
|
443
|
+
swagger_plugins: list[SwaggerPlugin] | None = None,
|
|
444
|
+
) -> None:
|
|
445
|
+
version_list = _normalized_version_labels(version_mapping)
|
|
446
|
+
|
|
447
|
+
resolved_plugins = _resolve_swagger_plugins(swagger_plugins)
|
|
448
|
+
contributions = _collect_contributions(resolved_plugins)
|
|
449
|
+
_warn_css_conflicts(contributions)
|
|
450
|
+
|
|
451
|
+
plugin_css = _aggregate_css(contributions)
|
|
452
|
+
plugin_js = _aggregate_js(contributions)
|
|
453
|
+
bundle_plugins = _aggregate_bundle_plugins(contributions)
|
|
454
|
+
schema_patchers = _collect_schema_patchers(contributions)
|
|
455
|
+
|
|
456
|
+
_register_swagger_ui_routes(
|
|
457
|
+
app,
|
|
458
|
+
version_list=version_list,
|
|
459
|
+
app_name=app_name,
|
|
460
|
+
hit_id_header=hit_id_header,
|
|
461
|
+
app_id_header=app_id_header,
|
|
462
|
+
default_version=(
|
|
463
|
+
_normalize_unv(default_version).lower() if default_version is not None else None
|
|
464
|
+
),
|
|
465
|
+
build_tag=build_tag,
|
|
466
|
+
include_root_favicon_alias=include_root_favicon_alias,
|
|
467
|
+
plugin_css=plugin_css,
|
|
468
|
+
plugin_js=plugin_js,
|
|
469
|
+
bundle_plugins=bundle_plugins,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if include_info_endpoints:
|
|
473
|
+
_register_info_endpoints(
|
|
474
|
+
app,
|
|
475
|
+
version_list=version_list,
|
|
476
|
+
prefix=prefix,
|
|
477
|
+
app_name=app_name,
|
|
478
|
+
default_version=default_version,
|
|
479
|
+
strict_version_matching=strict_version_matching,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
_register_openapi_json_routes(
|
|
483
|
+
app,
|
|
484
|
+
version_mapping=version_mapping,
|
|
485
|
+
prefix=prefix,
|
|
486
|
+
schema_patchers=schema_patchers or None,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
_register_redoc_routes(
|
|
490
|
+
app,
|
|
491
|
+
version_list=version_list,
|
|
492
|
+
default_version=(
|
|
493
|
+
_normalize_unv(default_version).lower() if default_version is not None else None
|
|
494
|
+
),
|
|
495
|
+
)
|