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,120 @@
|
|
|
1
|
+
"""Default actuator plugin that serves build-time service metadata."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, FastAPI
|
|
10
|
+
|
|
11
|
+
from .base import ActuatorLink, BaseActuatorPlugin
|
|
12
|
+
|
|
13
|
+
_DEFAULT_INFO_PATH = "service_info.json"
|
|
14
|
+
_INFO_SECTIONS = ("git", "build")
|
|
15
|
+
|
|
16
|
+
# Nested key-order schema — mirrors the JSON shape so preferred insertion
|
|
17
|
+
# order is readable at a glance. ``None`` leaves mean "no child ordering".
|
|
18
|
+
_KEY_ORDER: dict[str, dict | None] = {
|
|
19
|
+
"git": {
|
|
20
|
+
"branch": None,
|
|
21
|
+
"commit": {
|
|
22
|
+
"time": None,
|
|
23
|
+
"message": {"full": None, "short": None},
|
|
24
|
+
"id": {"describe": None, "abbrev": None, "full": None},
|
|
25
|
+
"user": {"email": None, "name": None},
|
|
26
|
+
},
|
|
27
|
+
"build": {
|
|
28
|
+
"version": None,
|
|
29
|
+
"user": {"name": None, "email": None},
|
|
30
|
+
"host": None,
|
|
31
|
+
},
|
|
32
|
+
"dirty": None,
|
|
33
|
+
"tags": None,
|
|
34
|
+
"total": {"commit": {"count": None}},
|
|
35
|
+
"closest": {"tag": {"commit": {"count": None}, "name": None}},
|
|
36
|
+
"remote": {"origin": {"url": None}},
|
|
37
|
+
},
|
|
38
|
+
"build": {
|
|
39
|
+
"artifact": None,
|
|
40
|
+
"name": None,
|
|
41
|
+
"time": None,
|
|
42
|
+
"version": None,
|
|
43
|
+
"group": None,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reorder(value: Any, order: dict | None = None) -> Any:
|
|
49
|
+
"""Recursively reorder dict keys according to *order*, preserving extras."""
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
result: dict[str, Any] = {}
|
|
52
|
+
if order:
|
|
53
|
+
for key in order:
|
|
54
|
+
if key in value:
|
|
55
|
+
result[key] = _reorder(value[key], order[key])
|
|
56
|
+
for key, child in value.items():
|
|
57
|
+
if key not in result:
|
|
58
|
+
result[key] = _reorder(child)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
return [_reorder(item) for item in value]
|
|
63
|
+
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_info_payload(loaded: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
payload: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
for section in _INFO_SECTIONS:
|
|
71
|
+
section_value = loaded.get(section)
|
|
72
|
+
if isinstance(section_value, dict):
|
|
73
|
+
payload[section] = section_value
|
|
74
|
+
|
|
75
|
+
return dict(_reorder(payload, _KEY_ORDER))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InfoActuatorPlugin(BaseActuatorPlugin):
|
|
79
|
+
"""Expose Spring-style ``/actuator/info`` from a baked JSON file."""
|
|
80
|
+
|
|
81
|
+
name = "info"
|
|
82
|
+
|
|
83
|
+
def register(
|
|
84
|
+
self,
|
|
85
|
+
router: APIRouter,
|
|
86
|
+
*,
|
|
87
|
+
app: FastAPI,
|
|
88
|
+
prefix: str,
|
|
89
|
+
) -> Mapping[str, ActuatorLink]:
|
|
90
|
+
path = self._resolve_info_path()
|
|
91
|
+
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
@router.get("/info", include_in_schema=False)
|
|
96
|
+
async def info() -> dict[str, Any]:
|
|
97
|
+
return self._load_info(path)
|
|
98
|
+
|
|
99
|
+
return {"info": self.build_link(prefix=prefix, route_path="/info")}
|
|
100
|
+
|
|
101
|
+
def _resolve_info_path(self) -> Path:
|
|
102
|
+
path_value = os.getenv("ACTUATOR_INFO_PATH")
|
|
103
|
+
return Path(path_value or _DEFAULT_INFO_PATH)
|
|
104
|
+
|
|
105
|
+
def _load_info(self, path: Path) -> dict[str, Any]:
|
|
106
|
+
def _load() -> dict[str, Any]:
|
|
107
|
+
if not path.exists():
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
except (json.JSONDecodeError, OSError):
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
if isinstance(loaded, dict):
|
|
116
|
+
return _build_info_payload(loaded)
|
|
117
|
+
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
return self.memoized_payload(cache_key=str(path), loader=_load)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Actuator Tools
|
|
2
|
+
|
|
3
|
+
This folder contains build-time tools that generate metadata consumed by
|
|
4
|
+
runtime actuator plugins.
|
|
5
|
+
|
|
6
|
+
Current tool:
|
|
7
|
+
|
|
8
|
+
- `generate_service_info_from_git.py`
|
|
9
|
+
- Source: git repository metadata
|
|
10
|
+
- Output: `ACTUATOR_INFO_OUTPUT_PATH` (defaults to `service_info.json`)
|
|
11
|
+
- Runtime consumer: `InfoActuatorPlugin` (`/actuator/info`)
|
|
12
|
+
|
|
13
|
+
This script is Docker-build-only. It exits unless
|
|
14
|
+
`ACTUATOR_INFO_DOCKER_BUILD=1` is set.
|
|
15
|
+
|
|
16
|
+
Use this script during Docker build stages where `.git` is available, then copy
|
|
17
|
+
`service_info.json` into the runtime image.
|
|
18
|
+
|
|
19
|
+
## Docker Build Pattern
|
|
20
|
+
|
|
21
|
+
Expected usage is a multi-stage Docker build that generates `service_info.json`
|
|
22
|
+
while `.git` is still available, then copies that file into the runtime image.
|
|
23
|
+
|
|
24
|
+
The CI pipeline injects `BUILD_VERSION` as a Docker build-arg. Pass it through
|
|
25
|
+
to the script via `--build-version` so the release version is recorded in
|
|
26
|
+
`build.version` and `git.build.version`. The argument is optional — when
|
|
27
|
+
omitted the script falls back to `git describe --tags --always`.
|
|
28
|
+
|
|
29
|
+
Example (drop-in for a consuming repository):
|
|
30
|
+
|
|
31
|
+
```dockerfile
|
|
32
|
+
FROM python:3.13-slim AS service-info
|
|
33
|
+
ARG BUILD_VERSION=""
|
|
34
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
|
35
|
+
WORKDIR /app
|
|
36
|
+
|
|
37
|
+
# Install runtime dependencies for the service, including api_versioning.
|
|
38
|
+
# Use the install flow your repository already uses.
|
|
39
|
+
COPY pyproject.toml poetry.lock* ./
|
|
40
|
+
RUN pip install --no-cache-dir poetry \
|
|
41
|
+
&& poetry config virtualenvs.create false \
|
|
42
|
+
&& poetry install --only main --no-interaction --no-ansi
|
|
43
|
+
|
|
44
|
+
COPY .git ./.git
|
|
45
|
+
RUN ACTUATOR_INFO_DOCKER_BUILD=1 \
|
|
46
|
+
python -m api_versioning.fastapi.versioning.actuator.tools.generate_service_info_from_git \
|
|
47
|
+
--build-version="${BUILD_VERSION}"
|
|
48
|
+
|
|
49
|
+
FROM base AS runtime
|
|
50
|
+
COPY --from=service-info /app/service_info.json ./
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If you place the file somewhere else at runtime, set `ACTUATOR_INFO_PATH`
|
|
54
|
+
so `InfoActuatorPlugin` can resolve it.
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Generate service metadata from git for the actuator ``/actuator/info`` endpoint.
|
|
2
|
+
|
|
3
|
+
Run this script at build time to write a JSON file consumed by
|
|
4
|
+
``InfoActuatorPlugin``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_DEFAULT_OUTPUT_PATH = "service_info.json"
|
|
16
|
+
_DOCKER_BUILD_ENV = "ACTUATOR_INFO_DOCKER_BUILD"
|
|
17
|
+
_DOCKER_BUILD_ENV_VALUE = "1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _git(*cmd: str, default: str = "") -> str:
|
|
21
|
+
try:
|
|
22
|
+
return subprocess.check_output(["git", *cmd], stderr=subprocess.DEVNULL, text=True).strip()
|
|
23
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
24
|
+
return default
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iso_utc(value: str) -> str:
|
|
28
|
+
if not value:
|
|
29
|
+
return ""
|
|
30
|
+
try:
|
|
31
|
+
parsed = datetime.fromisoformat(value)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return ""
|
|
34
|
+
return parsed.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_output_path() -> Path:
|
|
38
|
+
return Path(os.getenv("ACTUATOR_INFO_OUTPUT_PATH", _DEFAULT_OUTPUT_PATH))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_build_version(build_version: str) -> str:
|
|
42
|
+
return build_version.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _assert_docker_build_context() -> None:
|
|
46
|
+
if os.getenv(_DOCKER_BUILD_ENV) != _DOCKER_BUILD_ENV_VALUE:
|
|
47
|
+
raise SystemExit(
|
|
48
|
+
"generate_service_info_from_git.py is build-only and must run from Docker "
|
|
49
|
+
f"with {_DOCKER_BUILD_ENV}={_DOCKER_BUILD_ENV_VALUE}."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_info(build_version: str = "") -> dict[str, Any]:
|
|
54
|
+
normalized_build_version = _normalize_build_version(build_version)
|
|
55
|
+
commit_time = _iso_utc(_git("log", "-1", "--format=%cI"))
|
|
56
|
+
commit_full = _git("rev-parse", "HEAD")
|
|
57
|
+
commit_abbrev = _git("rev-parse", "--short", "HEAD")
|
|
58
|
+
remote_url = _git("remote", "get-url", "origin")
|
|
59
|
+
service_name = remote_url.rsplit("/", 1)[-1].removesuffix(".git") if remote_url else ""
|
|
60
|
+
|
|
61
|
+
resolved_version = normalized_build_version or _git("describe", "--tags", "--always")
|
|
62
|
+
|
|
63
|
+
git_info: dict[str, Any] = {
|
|
64
|
+
"branch": _git("rev-parse", "--abbrev-ref", "HEAD"),
|
|
65
|
+
"commit": {
|
|
66
|
+
"time": commit_time,
|
|
67
|
+
"message": {
|
|
68
|
+
"full": _git("log", "-1", "--format=%B"),
|
|
69
|
+
"short": _git("log", "-1", "--format=%s"),
|
|
70
|
+
},
|
|
71
|
+
"id": {
|
|
72
|
+
"abbrev": commit_abbrev,
|
|
73
|
+
"full": commit_full,
|
|
74
|
+
"describe": _git("describe", "--tags"),
|
|
75
|
+
},
|
|
76
|
+
"user": {
|
|
77
|
+
"name": _git("log", "-1", "--format=%an"),
|
|
78
|
+
"email": _git("log", "-1", "--format=%ae"),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"dirty": str(bool(_git("status", "--porcelain"))).lower(),
|
|
82
|
+
"remote": {"origin": {"url": remote_url}},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if normalized_build_version:
|
|
86
|
+
git_info["build"] = {"version": normalized_build_version}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"git": git_info,
|
|
90
|
+
"build": {
|
|
91
|
+
"artifact": service_name,
|
|
92
|
+
"name": service_name,
|
|
93
|
+
"group": "com.example",
|
|
94
|
+
"version": resolved_version,
|
|
95
|
+
"time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
parser = argparse.ArgumentParser(
|
|
102
|
+
description="Generate service metadata from git for InfoActuatorPlugin."
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--build-version",
|
|
106
|
+
default="",
|
|
107
|
+
help=(
|
|
108
|
+
"Explicit release version injected by the CI pipeline "
|
|
109
|
+
'(e.g. --build-version="${BUILD_VERSION}"). '
|
|
110
|
+
"Populates build.version and git.build.version. "
|
|
111
|
+
"Falls back to `git describe --tags --always` when omitted."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
args = parser.parse_args()
|
|
115
|
+
_assert_docker_build_context()
|
|
116
|
+
|
|
117
|
+
info = _build_info(build_version=args.build_version)
|
|
118
|
+
output_path = _resolve_output_path()
|
|
119
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
output_path.write_text(json.dumps(info, indent=2), encoding="utf-8")
|
|
121
|
+
abbrev = info.get("git", {}).get("commit", {}).get("id", {}).get("abbrev", "")
|
|
122
|
+
print(f"Wrote {output_path} (commit {abbrev})")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException, Request, status
|
|
7
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from csrd.context import get_api_version
|
|
11
|
+
from csrd.context.middleware._logging import REQUEST_SCOPE_KEY
|
|
12
|
+
from csrd.models.errors import APIErrorResponse, APIVersion, ErrorMeta
|
|
13
|
+
from csrd.versioning._constants import API_VERSION_HEADER_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _display_api_version(version: str | None) -> str | None:
|
|
17
|
+
if version is None:
|
|
18
|
+
return None
|
|
19
|
+
if str(version).lower() == "unv":
|
|
20
|
+
return "Unversioned"
|
|
21
|
+
return version
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_VERSION_SEGMENT_RE = re.compile(
|
|
25
|
+
r"(unv|unversioned|latest|v\d+(?:[._-]\d+)*)(?:/|$)",
|
|
26
|
+
flags=re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_served_api_version(request: Request) -> str | None:
|
|
31
|
+
# 1. Scope key set by VersionDispatchMiddleware
|
|
32
|
+
scope = getattr(request, "scope", None)
|
|
33
|
+
if isinstance(scope, dict):
|
|
34
|
+
scoped_version = scope.get("api_version")
|
|
35
|
+
if isinstance(scoped_version, str):
|
|
36
|
+
return _display_api_version(scoped_version)
|
|
37
|
+
|
|
38
|
+
# 2. Contextvar set by set_api_version_context()
|
|
39
|
+
ctx_version = get_api_version()
|
|
40
|
+
if ctx_version is not None:
|
|
41
|
+
return _display_api_version(ctx_version)
|
|
42
|
+
|
|
43
|
+
# 3. Path params resolved by FastAPI routing
|
|
44
|
+
path_params = getattr(request, "path_params", None)
|
|
45
|
+
if isinstance(path_params, dict):
|
|
46
|
+
version = path_params.get("version")
|
|
47
|
+
if isinstance(version, str):
|
|
48
|
+
return _display_api_version(version)
|
|
49
|
+
|
|
50
|
+
# 4. Best-effort path parsing (prefix-agnostic)
|
|
51
|
+
path = getattr(getattr(request, "url", None), "path", "")
|
|
52
|
+
if isinstance(path, str):
|
|
53
|
+
for segment in path.strip("/").split("/"):
|
|
54
|
+
if _VERSION_SEGMENT_RE.fullmatch(segment):
|
|
55
|
+
return _display_api_version(segment)
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _api_version_from_request(request: Request) -> APIVersion:
|
|
61
|
+
requested = None
|
|
62
|
+
headers = getattr(request, "headers", None)
|
|
63
|
+
if headers is not None:
|
|
64
|
+
requested_value = headers.get(API_VERSION_HEADER_NAME)
|
|
65
|
+
requested = requested_value if isinstance(requested_value, str) else None
|
|
66
|
+
|
|
67
|
+
served = _get_served_api_version(request)
|
|
68
|
+
return APIVersion(served=served, requested=requested)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_request_id(request: Request):
|
|
72
|
+
try:
|
|
73
|
+
return request.scope[REQUEST_SCOPE_KEY]["hit_id"]
|
|
74
|
+
except KeyError:
|
|
75
|
+
return str(uuid.uuid4())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
79
|
+
return JSONResponse(
|
|
80
|
+
status_code=exc.status_code,
|
|
81
|
+
content=APIErrorResponse(
|
|
82
|
+
meta=ErrorMeta(
|
|
83
|
+
status=exc.status_code,
|
|
84
|
+
error=exc.detail or "Unknown Error",
|
|
85
|
+
method=request.method,
|
|
86
|
+
path=request.url.path,
|
|
87
|
+
request_id=_get_request_id(request),
|
|
88
|
+
timestamp=datetime.datetime.now(UTC),
|
|
89
|
+
api_version=_api_version_from_request(request),
|
|
90
|
+
),
|
|
91
|
+
errors=[],
|
|
92
|
+
).as_dict(by_alias=True, mode="json"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
97
|
+
return JSONResponse(
|
|
98
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
99
|
+
content=APIErrorResponse(
|
|
100
|
+
meta=ErrorMeta(
|
|
101
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
102
|
+
error="Validation Error",
|
|
103
|
+
method=request.method,
|
|
104
|
+
path=request.url.path,
|
|
105
|
+
request_id=_get_request_id(request),
|
|
106
|
+
timestamp=datetime.datetime.now(UTC),
|
|
107
|
+
api_version=_api_version_from_request(request),
|
|
108
|
+
),
|
|
109
|
+
errors=[],
|
|
110
|
+
).as_dict(by_alias=True, mode="json"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
EXCEPTION_HANDLERS = {
|
|
115
|
+
# the HTTPException is being intercepted for 404s likely in the instana middleware
|
|
116
|
+
# force a 404 to be handled by the http_exception_handler
|
|
117
|
+
404: http_exception_handler,
|
|
118
|
+
HTTPException: http_exception_handler,
|
|
119
|
+
RequestValidationError: request_validation_exception_handler,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
__all__ = (
|
|
123
|
+
"EXCEPTION_HANDLERS",
|
|
124
|
+
"http_exception_handler",
|
|
125
|
+
"request_validation_exception_handler",
|
|
126
|
+
)
|
csrd/versioning/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Swagger UI plugin system for extending the custom docs rendering pipeline."""
|
|
2
|
+
|
|
3
|
+
from ._base import (
|
|
4
|
+
SchemaContext,
|
|
5
|
+
SwaggerPlugin,
|
|
6
|
+
SwaggerPluginContribution,
|
|
7
|
+
apply_schema_patchers,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SchemaContext",
|
|
12
|
+
"SwaggerPlugin",
|
|
13
|
+
"SwaggerPluginContribution",
|
|
14
|
+
"apply_schema_patchers",
|
|
15
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Swagger UI plugin protocol, contribution dataclass, and resolution logic."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from csrd.versioning._types import VersionKey
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SchemaContext:
|
|
18
|
+
"""Read-only metadata passed to schema patchers."""
|
|
19
|
+
|
|
20
|
+
app: FastAPI
|
|
21
|
+
"""The versioned sub-app whose schema is being built."""
|
|
22
|
+
|
|
23
|
+
version_key: VersionKey
|
|
24
|
+
"""The version key (e.g. ``Versions.V1``) being rendered."""
|
|
25
|
+
|
|
26
|
+
prefix: str
|
|
27
|
+
"""Normalised API prefix, e.g. ``"/api"``."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class SwaggerPluginContribution:
|
|
32
|
+
"""Static data returned by :meth:`SwaggerPlugin.contribute`."""
|
|
33
|
+
|
|
34
|
+
extra_css: str | None = None
|
|
35
|
+
extra_js: str = ""
|
|
36
|
+
bundle_plugins: tuple[str, ...] = ()
|
|
37
|
+
schema_patcher: Callable[[dict, SchemaContext], dict] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class SwaggerPlugin(Protocol):
|
|
42
|
+
"""Protocol for Swagger UI plugins."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
def contribute(self) -> SwaggerPluginContribution: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_css_class_names(css: str) -> set[str]:
|
|
50
|
+
return set(re.findall(r"\.([\w-]+)", css))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _warn_css_conflicts(
|
|
54
|
+
contributions: dict[str, SwaggerPluginContribution],
|
|
55
|
+
) -> None:
|
|
56
|
+
plugin_classes: dict[str, set[str]] = {}
|
|
57
|
+
for name, contrib in contributions.items():
|
|
58
|
+
if contrib.extra_css:
|
|
59
|
+
plugin_classes[name] = _extract_css_class_names(contrib.extra_css)
|
|
60
|
+
|
|
61
|
+
names = list(plugin_classes.keys())
|
|
62
|
+
for i, name_a in enumerate(names):
|
|
63
|
+
for name_b in names[i + 1 :]:
|
|
64
|
+
overlap = plugin_classes[name_a] & plugin_classes[name_b]
|
|
65
|
+
if overlap:
|
|
66
|
+
logger.warning(
|
|
67
|
+
"SwaggerPlugins '%s' and '%s' both define CSS rules for: %s",
|
|
68
|
+
name_a,
|
|
69
|
+
name_b,
|
|
70
|
+
", ".join(f".{cls}" for cls in sorted(overlap)),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_swagger_plugins(
|
|
75
|
+
global_plugins: list[SwaggerPlugin] | None,
|
|
76
|
+
per_version_plugins: list[SwaggerPlugin] | None = None,
|
|
77
|
+
) -> tuple[SwaggerPlugin, ...]:
|
|
78
|
+
|
|
79
|
+
if global_plugins is not None and len(global_plugins) == 0:
|
|
80
|
+
return ()
|
|
81
|
+
|
|
82
|
+
resolved: dict[str, SwaggerPlugin] = {p.name: p for p in _default_swagger_plugins()}
|
|
83
|
+
if global_plugins:
|
|
84
|
+
for p in global_plugins:
|
|
85
|
+
resolved[p.name] = p
|
|
86
|
+
|
|
87
|
+
if per_version_plugins is not None and len(per_version_plugins) == 0:
|
|
88
|
+
return ()
|
|
89
|
+
if per_version_plugins:
|
|
90
|
+
for p in per_version_plugins:
|
|
91
|
+
resolved[p.name] = p
|
|
92
|
+
|
|
93
|
+
return tuple(resolved.values())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_swagger_plugins() -> list[SwaggerPlugin]:
|
|
97
|
+
from .file_upload import FileUploadExamplesPlugin
|
|
98
|
+
|
|
99
|
+
return [FileUploadExamplesPlugin()]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _collect_contributions(
|
|
103
|
+
plugins: tuple[SwaggerPlugin, ...],
|
|
104
|
+
) -> dict[str, SwaggerPluginContribution]:
|
|
105
|
+
contributions: dict[str, SwaggerPluginContribution] = {}
|
|
106
|
+
for plugin in plugins:
|
|
107
|
+
try:
|
|
108
|
+
contributions[plugin.name] = plugin.contribute()
|
|
109
|
+
except Exception:
|
|
110
|
+
logger.warning(
|
|
111
|
+
"SwaggerPlugin '%s' failed in contribute()",
|
|
112
|
+
plugin.name,
|
|
113
|
+
exc_info=True,
|
|
114
|
+
)
|
|
115
|
+
return contributions
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _aggregate_css(contributions: dict[str, SwaggerPluginContribution]) -> str:
|
|
119
|
+
parts = [c.extra_css for c in contributions.values() if c.extra_css]
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _aggregate_js(contributions: dict[str, SwaggerPluginContribution]) -> str:
|
|
124
|
+
parts = [c.extra_js for c in contributions.values() if c.extra_js]
|
|
125
|
+
return "\n".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _aggregate_bundle_plugins(
|
|
129
|
+
contributions: dict[str, SwaggerPluginContribution],
|
|
130
|
+
) -> str:
|
|
131
|
+
names: list[str] = []
|
|
132
|
+
for c in contributions.values():
|
|
133
|
+
names.extend(c.bundle_plugins)
|
|
134
|
+
return ", ".join(names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collect_schema_patchers(
|
|
138
|
+
contributions: dict[str, SwaggerPluginContribution],
|
|
139
|
+
) -> list[Callable[[dict, SchemaContext], dict]]:
|
|
140
|
+
return [c.schema_patcher for c in contributions.values() if c.schema_patcher]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def apply_schema_patchers(
|
|
144
|
+
schema: dict,
|
|
145
|
+
ctx: SchemaContext,
|
|
146
|
+
patchers: list[Callable[[dict, SchemaContext], dict]],
|
|
147
|
+
) -> dict:
|
|
148
|
+
for patcher in patchers:
|
|
149
|
+
try:
|
|
150
|
+
schema = patcher(schema, ctx)
|
|
151
|
+
except Exception:
|
|
152
|
+
logger.warning("schema_patcher from plugin failed", exc_info=True)
|
|
153
|
+
return schema
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""File Upload Examples — built-in Swagger UI plugin."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from .._base import SwaggerPluginContribution
|
|
6
|
+
from ._body_factory import file_upload_body, unwrap_json_schema
|
|
7
|
+
from ._schema_patcher import patch_file_upload_examples
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FileUploadExamplesPlugin",
|
|
11
|
+
"file_upload_body",
|
|
12
|
+
"unwrap_json_schema",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
_PACKAGE = "csrd.versioning.swagger_plugins.file_upload"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileUploadExamplesPlugin:
|
|
19
|
+
"""Built-in plugin that adds file upload example dropdowns to Swagger UI."""
|
|
20
|
+
|
|
21
|
+
name = "file_upload_examples"
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
pkg = files(_PACKAGE)
|
|
25
|
+
self._js = pkg.joinpath("file_upload_plugin.js").read_text(encoding="utf-8")
|
|
26
|
+
css_path = pkg.joinpath("file_upload_plugin.css")
|
|
27
|
+
self._css = css_path.read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
def contribute(self) -> SwaggerPluginContribution:
|
|
30
|
+
return SwaggerPluginContribution(
|
|
31
|
+
extra_css=self._css,
|
|
32
|
+
extra_js=self._js,
|
|
33
|
+
bundle_plugins=("FileUploadExamplesPlugin",),
|
|
34
|
+
schema_patcher=patch_file_upload_examples,
|
|
35
|
+
)
|