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