Initial release of the oVirt/RHV Engine API simulator.
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""HTTP adapters."""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Base external error representation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""A safe error intended for the Engine API boundary."""
|
||||
|
||||
def __init__(
|
||||
self, status_code: int, message: str, errors: dict[str, str] | None = None
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class ContractValidationError(ApiError):
|
||||
def __init__(self, errors: dict[str, str]) -> None:
|
||||
super().__init__(400, "parameter verification failed", errors)
|
||||
|
||||
|
||||
async def api_error_handler(_request: Request, exc: Exception) -> JSONResponse:
|
||||
if not isinstance(exc, ApiError):
|
||||
raise TypeError("api_error_handler received an incompatible exception")
|
||||
body: dict[str, Any] = {"data": None, "message": exc.message}
|
||||
if exc.errors is not None:
|
||||
body["errors"] = exc.errors
|
||||
return JSONResponse(status_code=exc.status_code, content=body)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log internal failures and return a stable non-FastAPI error envelope."""
|
||||
|
||||
logger.exception(
|
||||
"unhandled request error",
|
||||
extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path},
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"data": None,
|
||||
"errors": {"internal": "internal server error"},
|
||||
}
|
||||
return JSONResponse(status_code=500, content=body)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Request correlation and access logging middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RequestHandler = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a bounded request ID and log one structured completion event."""
|
||||
|
||||
def __init__(self, app: object, header_name: str) -> None:
|
||||
super().__init__(app) # type: ignore[arg-type]
|
||||
self._header_name = header_name
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
|
||||
supplied = request.headers.get(self._header_name, "")
|
||||
request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.monotonic()
|
||||
response = await call_next(request)
|
||||
response.headers[self._header_name] = request_id
|
||||
logger.info(
|
||||
"request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,94 @@
|
||||
"""OpenAPI tag resolution for Engine API and simulator routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_COLLECTION_LABELS: dict[str, str] = {
|
||||
"vms": "VMs",
|
||||
"disks": "Disks",
|
||||
"hosts": "Hosts",
|
||||
"clusters": "Clusters",
|
||||
"datacenters": "Data Centers",
|
||||
"networks": "Networks",
|
||||
"vnicprofiles": "vNIC Profiles",
|
||||
"storagedomains": "Storage Domains",
|
||||
"storageconnections": "Storage Connections",
|
||||
"templates": "Templates",
|
||||
"users": "Users",
|
||||
"groups": "Groups",
|
||||
"roles": "Roles",
|
||||
"permissions": "Permissions",
|
||||
"domains": "Domains",
|
||||
"events": "Events",
|
||||
"jobs": "Jobs",
|
||||
"tags": "Tags",
|
||||
"bookmarks": "Bookmarks",
|
||||
"affinitylabels": "Affinity Labels",
|
||||
"instancetypes": "Instance Types",
|
||||
"macpools": "MAC Pools",
|
||||
"schedulingpolicies": "Scheduling Policies",
|
||||
"schedulingpolicyunits": "Scheduling Policy Units",
|
||||
"clusterlevels": "Cluster Levels",
|
||||
"icons": "Icons",
|
||||
"operatingsystems": "Operating Systems",
|
||||
"networkfilters": "Network Filters",
|
||||
"vmpools": "VM Pools",
|
||||
"katelloerrata": "Katello Errata",
|
||||
"externalhostproviders": "External Host Providers",
|
||||
"openstacknetworkproviders": "OpenStack Network Providers",
|
||||
"openstackimageproviders": "OpenStack Image Providers",
|
||||
"openstackvolumeproviders": "OpenStack Volume Providers",
|
||||
"imagetransfers": "Image Transfers",
|
||||
"options": "Options",
|
||||
}
|
||||
|
||||
|
||||
def contract_openapi_tag(path: str) -> str:
|
||||
"""Map an Engine API path to a Swagger UI category."""
|
||||
|
||||
parts = [part for part in path.strip("/").split("/") if part]
|
||||
if parts[:2] == ["ovirt-engine", "api"]:
|
||||
parts = parts[2:]
|
||||
if parts and parts[0] in {"v3", "v4"}:
|
||||
parts = parts[1:]
|
||||
if not parts:
|
||||
return "engine"
|
||||
root = parts[0]
|
||||
return _COLLECTION_LABELS.get(root, root.replace("-", " ").title())
|
||||
|
||||
|
||||
def contract_openapi_tags(path: str, renderer: str | None = None) -> list[str]:
|
||||
"""Return OpenAPI tags for a contract route.
|
||||
|
||||
``renderer`` is accepted for call-site compatibility; Engine API has a
|
||||
single representation surface.
|
||||
"""
|
||||
|
||||
del renderer
|
||||
return [contract_openapi_tag(path)]
|
||||
|
||||
|
||||
def openapi_tag_metadata() -> list[dict[str, str]]:
|
||||
"""Descriptions shown in Swagger UI for each tag group."""
|
||||
|
||||
tags: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "engine",
|
||||
"description": "oVirt Engine REST API root under `/ovirt-engine/api`.",
|
||||
},
|
||||
{
|
||||
"name": "sso",
|
||||
"description": "Engine SSO OAuth2 token endpoints.",
|
||||
},
|
||||
{
|
||||
"name": "Simulator",
|
||||
"description": "Health checks, compatibility reports, and the web console.",
|
||||
},
|
||||
]
|
||||
for label in sorted(set(_COLLECTION_LABELS.values())):
|
||||
tags.append(
|
||||
{
|
||||
"name": label,
|
||||
"description": f"Engine {label} collection under `/ovirt-engine/api`.",
|
||||
}
|
||||
)
|
||||
return tags
|
||||
Reference in New Issue
Block a user