Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
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 Proxmox-compatible 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,138 @@
|
||||
"""OpenAPI tag resolution for contract-driven routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_NODE_SECTION_LABELS: dict[str, str] = {
|
||||
"qemu": "QEMU",
|
||||
"lxc": "LXC",
|
||||
"ceph": "Ceph",
|
||||
"storage": "Storage",
|
||||
"sdn": "SDN",
|
||||
"firewall": "Firewall",
|
||||
"apt": "APT",
|
||||
"certificates": "Certificates",
|
||||
"scan": "Scan",
|
||||
"network": "Network",
|
||||
"services": "Services",
|
||||
"capabilities": "Capabilities",
|
||||
"hardware": "Hardware",
|
||||
"replication": "Replication",
|
||||
"tasks": "Tasks",
|
||||
"subscription": "Subscription",
|
||||
"vzdump": "Backup",
|
||||
"disks": "Disks",
|
||||
"config": "Config",
|
||||
"dns": "DNS",
|
||||
"hosts": "Hosts",
|
||||
"status": "Status",
|
||||
"time": "Time",
|
||||
"aplinfo": "Appliance",
|
||||
}
|
||||
|
||||
_CLUSTER_SECTION_LABELS: dict[str, str] = {
|
||||
"sdn": "SDN",
|
||||
"firewall": "Firewall",
|
||||
"notifications": "Notifications",
|
||||
"ha": "HA",
|
||||
"mapping": "Mapping",
|
||||
"acme": "ACME",
|
||||
"config": "Config",
|
||||
"ceph": "Ceph",
|
||||
"jobs": "Jobs",
|
||||
"metrics": "Metrics",
|
||||
"qemu": "QEMU",
|
||||
"backup": "Backup",
|
||||
"bulk-action": "Bulk Action",
|
||||
"replication": "Replication",
|
||||
"backup-info": "Backup Info",
|
||||
"options": "Options",
|
||||
"log": "Log",
|
||||
"nextid": "Next ID",
|
||||
"resources": "Resources",
|
||||
"status": "Status",
|
||||
"tasks": "Tasks",
|
||||
}
|
||||
|
||||
_VSPHERE_TAG_DESCRIPTIONS: dict[str, str] = {
|
||||
"vSphere REST": "vSphere Automation REST inventory and lifecycle APIs.",
|
||||
"vSphere REST surface": "Additional vSphere REST surface stubs.",
|
||||
"vSphere SOAP": "vSphere Web Services (SOAP) SDK endpoints.",
|
||||
"vSphere PBM": "Storage Policy Based Management (PBM) SOAP endpoints.",
|
||||
"vSphere Platform": "Appliance, CIS session, and platform helpers.",
|
||||
"vSphere Tagging": "CIS tagging categories and tags.",
|
||||
"vSphere Content": "Content library stubs.",
|
||||
"vSphere NFC": "NFC file transfer stubs.",
|
||||
"vSphere Tasks": "vSphere task polling helpers.",
|
||||
"vSphere VM Ext": "Extended VM operations beyond the core REST surface.",
|
||||
"vSphere Inventory Ext": "Extended inventory and folder helpers.",
|
||||
"vSphere Appliance": "vCenter appliance management stubs.",
|
||||
"vSphere Legacy REST": "Legacy vSphere REST compatibility stubs.",
|
||||
}
|
||||
|
||||
|
||||
def contract_openapi_tag(path: str) -> str:
|
||||
"""Map a semantic contract path to a Swagger UI category."""
|
||||
|
||||
parts = [part for part in path.strip("/").split("/") if part]
|
||||
if not parts or parts == ["version"]:
|
||||
return "Core"
|
||||
root = parts[0]
|
||||
if root == "access":
|
||||
return "Access"
|
||||
if root == "nodes":
|
||||
if len(parts) >= 3 and parts[1] == "{node}":
|
||||
section = parts[2]
|
||||
label = _NODE_SECTION_LABELS.get(section, section.replace("-", " ").title())
|
||||
return f"Nodes · {label}"
|
||||
return "Nodes"
|
||||
if root == "cluster":
|
||||
if len(parts) >= 2:
|
||||
section = parts[1]
|
||||
label = _CLUSTER_SECTION_LABELS.get(section, section.replace("-", " ").title())
|
||||
return f"Cluster · {label}"
|
||||
return "Cluster"
|
||||
if root == "storage":
|
||||
return "Storage"
|
||||
if root == "pools":
|
||||
return "Pools"
|
||||
return root.replace("-", " ").title()
|
||||
|
||||
|
||||
def contract_openapi_tags(path: str, renderer: str) -> list[str]:
|
||||
"""Return OpenAPI tags for a contract route, including the API renderer."""
|
||||
|
||||
renderer_label = "API2 JSON" if renderer == "json" else "API2 ExtJS"
|
||||
return [contract_openapi_tag(path), renderer_label]
|
||||
|
||||
|
||||
def _pve_openapi_tag_descriptions() -> dict[str, str]:
|
||||
descriptions: dict[str, str] = {
|
||||
"Core": "Version and global simulator metadata.",
|
||||
"Access": "Authentication, users, groups, roles, ACLs, and API tokens.",
|
||||
"Nodes": "Node inventory and node-level endpoints without a resource section.",
|
||||
"Storage": "Cluster-wide and node storage definitions and content.",
|
||||
"Pools": "Resource pools and membership.",
|
||||
"API2 JSON": "Proxmox `/api2/json` renderer routes.",
|
||||
"API2 ExtJS": "Proxmox `/api2/extjs` renderer routes.",
|
||||
}
|
||||
for label in _NODE_SECTION_LABELS.values():
|
||||
descriptions.setdefault(f"Nodes · {label}", f"Node-level {label} API.")
|
||||
for label in _CLUSTER_SECTION_LABELS.values():
|
||||
descriptions.setdefault(f"Cluster · {label}", f"Cluster-level {label} API.")
|
||||
return descriptions
|
||||
|
||||
|
||||
def openapi_tag_metadata(*, include_pve: bool = False) -> list[dict[str, str]]:
|
||||
"""Descriptions shown in Swagger UI for each tag group.
|
||||
|
||||
Proxmox `/api2/*` tag groups are omitted unless ``include_pve`` is true,
|
||||
so the default vSphere plane does not show empty legacy sections in `/docs`.
|
||||
"""
|
||||
|
||||
descriptions: dict[str, str] = {
|
||||
"Simulator": "Health checks, compatibility reports, and the web console.",
|
||||
**_VSPHERE_TAG_DESCRIPTIONS,
|
||||
}
|
||||
if include_pve:
|
||||
descriptions.update(_pve_openapi_tag_descriptions())
|
||||
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Contract-driven dynamic route and semantic handler registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.errors import ApiError, ContractValidationError
|
||||
from app.api.openapi import contract_openapi_tags
|
||||
from app.config import Settings
|
||||
from app.contracts.examples import schema_example
|
||||
from app.contracts.model import Method, Schema, Snapshot
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
|
||||
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
|
||||
|
||||
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
|
||||
FallbackMode = Literal["error", "schema-default", "fixture"]
|
||||
|
||||
|
||||
class RouteCollisionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HandlerRegistry:
|
||||
_handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)
|
||||
|
||||
def register(self, path: str, verb: str, handler: Handler) -> None:
|
||||
key = (path, verb.upper())
|
||||
if key in self._handlers:
|
||||
raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
|
||||
self._handlers[key] = handler
|
||||
|
||||
def get(self, path: str, verb: str) -> Handler | None:
|
||||
return self._handlers.get((path, verb.upper()))
|
||||
|
||||
def keys(self) -> frozenset[tuple[str, str]]:
|
||||
return frozenset(self._handlers)
|
||||
|
||||
|
||||
def register_contract_routes(
|
||||
app: FastAPI,
|
||||
snapshot: Snapshot,
|
||||
handlers: HandlerRegistry,
|
||||
fallback: FallbackMode = "error",
|
||||
*,
|
||||
existing: set[tuple[str, str, str]] | None = None,
|
||||
require_handler: bool = False,
|
||||
allow_existing: bool = False,
|
||||
) -> set[tuple[str, str, str]]:
|
||||
"""Register `/api2/{json,extjs}` routes for a contract snapshot.
|
||||
|
||||
When ``allow_existing`` is true, path/verb pairs already present in
|
||||
``existing`` are skipped (used to merge older majors onto a primary contract).
|
||||
When ``require_handler`` is true, only methods with a registered semantic
|
||||
handler are added — used for legacy-path aliases.
|
||||
"""
|
||||
|
||||
seen = existing if existing is not None else set()
|
||||
for contract_path in snapshot.paths:
|
||||
for contract_method in contract_path.methods:
|
||||
if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
|
||||
continue
|
||||
for renderer in ("json", "extjs"):
|
||||
route = f"/api2/{renderer}{contract_path.path}"
|
||||
key = (route, contract_method.verb, renderer)
|
||||
if key in seen:
|
||||
if allow_existing:
|
||||
continue
|
||||
raise RouteCollisionError(
|
||||
f"duplicate contract route: {contract_method.verb} {route}"
|
||||
)
|
||||
seen.add(key)
|
||||
implemented = handlers.get(contract_path.path, contract_method.verb) is not None
|
||||
endpoint = _endpoint(
|
||||
contract_path.path,
|
||||
contract_method,
|
||||
renderer,
|
||||
handlers,
|
||||
fallback,
|
||||
)
|
||||
app.add_api_route(
|
||||
route,
|
||||
endpoint,
|
||||
methods=[contract_method.verb],
|
||||
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
|
||||
tags=cast(
|
||||
list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
|
||||
),
|
||||
openapi_extra={
|
||||
"x-proxmox-method-checksum": contract_method.checksum,
|
||||
"x-proxmox-implementation": "implemented" if implemented else "unsupported",
|
||||
},
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def register_legacy_handler_routes(
|
||||
app: FastAPI,
|
||||
handlers: HandlerRegistry,
|
||||
store_root: Path,
|
||||
fallback: FallbackMode = "error",
|
||||
*,
|
||||
primary_version: str | None = None,
|
||||
existing: set[tuple[str, str, str]] | None = None,
|
||||
) -> set[tuple[str, str, str]]:
|
||||
"""Expose handler-backed paths declared only in older cached contracts."""
|
||||
|
||||
seen = existing if existing is not None else set()
|
||||
if not store_root.is_dir():
|
||||
return seen
|
||||
for revision_dir in sorted(store_root.iterdir()):
|
||||
snapshot_path = revision_dir / "snapshot.json"
|
||||
if not snapshot_path.is_file():
|
||||
continue
|
||||
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
|
||||
if primary_version and snapshot.source_version == primary_version:
|
||||
continue
|
||||
seen = register_contract_routes(
|
||||
app,
|
||||
snapshot,
|
||||
handlers,
|
||||
fallback,
|
||||
existing=seen,
|
||||
require_handler=True,
|
||||
allow_existing=True,
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _endpoint(
|
||||
semantic_path: str,
|
||||
method: Method,
|
||||
renderer: str,
|
||||
handlers: HandlerRegistry,
|
||||
fallback: FallbackMode,
|
||||
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
||||
async def dispatch(request: Request) -> JSONResponse:
|
||||
inputs = await _parse_inputs(request, method)
|
||||
await _authenticate(request, semantic_path, method, inputs)
|
||||
handler = handlers.get(semantic_path, method.verb)
|
||||
if handler is not None:
|
||||
data = await handler(request, inputs)
|
||||
elif fallback == "schema-default":
|
||||
data = schema_example(method.returns)
|
||||
elif fallback == "fixture" and "fixture" in method.extra:
|
||||
data = method.extra["fixture"]
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=501,
|
||||
content={"data": None, "errors": "handler pending for this contract method"},
|
||||
)
|
||||
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
|
||||
response = JSONResponse(content)
|
||||
if semantic_path == "/access/ticket" and isinstance(data, dict):
|
||||
ticket = data.get("ticket")
|
||||
if isinstance(ticket, str):
|
||||
response.set_cookie(
|
||||
"PVEAuthCookie", ticket, httponly=True, samesite="strict", path="/"
|
||||
)
|
||||
return response
|
||||
|
||||
return dispatch
|
||||
|
||||
|
||||
async def _authenticate(
|
||||
request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
|
||||
) -> None:
|
||||
if semantic_path in {"/version", "/access/ticket"}:
|
||||
return
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
token_privileges: frozenset[str] | None = None
|
||||
principal: str
|
||||
if authorization.startswith("PVEAPIToken="):
|
||||
database = cast(AsyncpgDatabase, request.app.state.database)
|
||||
try:
|
||||
parsed_token = parse_api_token(authorization)
|
||||
except ValueError as error:
|
||||
raise ApiError(401, "authentication failure") from error
|
||||
row = await database.pool.fetchrow(
|
||||
"""SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
|
||||
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||
WHERE p.name=$1 AND t.token_id=$2
|
||||
AND (t.expires_at IS NULL OR t.expires_at > now())""",
|
||||
parsed_token.principal,
|
||||
parsed_token.token_id,
|
||||
)
|
||||
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
|
||||
raise ApiError(401, "authentication failure")
|
||||
principal = str(row["name"])
|
||||
token_privileges = (
|
||||
frozenset(str(item) for item in row["privileges"])
|
||||
if bool(row["privilege_separation"])
|
||||
else None
|
||||
)
|
||||
else:
|
||||
ticket = request.cookies.get("PVEAuthCookie")
|
||||
if ticket is None:
|
||||
raise ApiError(401, "authentication required")
|
||||
settings = cast(Settings, request.app.state.settings)
|
||||
key = settings.ticket_signing_key.get_secret_value().encode()
|
||||
try:
|
||||
claims = verify_ticket(ticket, key)
|
||||
except ValueError as error:
|
||||
raise ApiError(401, "authentication failure") from error
|
||||
principal = claims.principal
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
csrf_value = request.headers.get("CSRFPreventionToken", "")
|
||||
if not verify_csrf(ticket, csrf_value, key):
|
||||
raise ApiError(403, "invalid CSRF prevention token")
|
||||
request.state.principal = principal
|
||||
if principal == "root@pam" and token_privileges is None:
|
||||
return
|
||||
database = cast(AsyncpgDatabase, request.app.state.database)
|
||||
await _authorize(database, principal, token_privileges, semantic_path, method, inputs)
|
||||
|
||||
|
||||
async def _authorize(
|
||||
database: AsyncpgDatabase,
|
||||
principal: str,
|
||||
token_privileges: frozenset[str] | None,
|
||||
semantic_path: str,
|
||||
method: Method,
|
||||
inputs: dict[str, Any],
|
||||
) -> None:
|
||||
values = cast(dict[str, Any], inputs["values"])
|
||||
requirement = requirement_from_contract(
|
||||
method.permissions, {name: str(value) for name, value in values.items()}
|
||||
)
|
||||
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
|
||||
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
|
||||
if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
|
||||
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
|
||||
if requirement is None:
|
||||
return
|
||||
rows = await database.pool.fetch(
|
||||
"""SELECT a.path, a.propagate, r.privileges
|
||||
FROM acl_entries a JOIN roles r ON r.name=a.role_name
|
||||
JOIN principals p ON p.id=a.principal_id WHERE p.name=$1
|
||||
UNION ALL
|
||||
SELECT a.path, a.propagate, r.privileges
|
||||
FROM group_acl_entries a JOIN roles r ON r.name=a.role_name
|
||||
JOIN identity_group_members m ON m.group_id=a.group_id
|
||||
JOIN principals p ON p.id=m.principal_id WHERE p.name=$1""",
|
||||
principal,
|
||||
)
|
||||
entries = tuple(
|
||||
AclEntry(
|
||||
principal,
|
||||
str(row["path"]),
|
||||
frozenset(str(item) for item in row["privileges"]),
|
||||
bool(row["propagate"]),
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
if not authorize(
|
||||
principal,
|
||||
requirement.path,
|
||||
requirement.privileges,
|
||||
entries,
|
||||
token_privileges=token_privileges,
|
||||
require_all=requirement.require_all,
|
||||
):
|
||||
raise ApiError(403, "permission check failed")
|
||||
|
||||
|
||||
async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
|
||||
supplied: dict[str, Any] = dict(request.query_params)
|
||||
supplied.update(request.path_params)
|
||||
if request.method not in {"GET", "DELETE"}:
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip()
|
||||
if content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError as exc:
|
||||
raise ContractValidationError({"body": "invalid JSON"}) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise ContractValidationError({"body": "expected an object"})
|
||||
supplied.update(body)
|
||||
elif content_type == "application/x-www-form-urlencoded":
|
||||
supplied.update(dict(parse_qsl((await request.body()).decode())))
|
||||
|
||||
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
|
||||
indexed = {
|
||||
re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
|
||||
for name, definition in definitions.items()
|
||||
if "[n]" in name
|
||||
}
|
||||
errors: dict[str, str] = {}
|
||||
parsed: dict[str, Any] = {}
|
||||
for name, definition in definitions.items():
|
||||
if "[n]" in name:
|
||||
continue
|
||||
if name not in supplied:
|
||||
if definition.optional:
|
||||
if definition.default is not None:
|
||||
parsed[name] = definition.default
|
||||
continue
|
||||
errors[name] = "property is missing and it is not optional"
|
||||
continue
|
||||
try:
|
||||
parsed[name] = _coerce(supplied[name], definition)
|
||||
except (TypeError, ValueError) as exc:
|
||||
errors[name] = str(exc)
|
||||
indexed_names: set[str] = set()
|
||||
for name in supplied.keys() - definitions.keys():
|
||||
indexed_definition = next(
|
||||
(candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
|
||||
)
|
||||
if indexed_definition is not None:
|
||||
indexed_names.add(name)
|
||||
try:
|
||||
parsed[name] = _coerce(supplied[name], indexed_definition)
|
||||
except (TypeError, ValueError) as exc:
|
||||
errors[name] = str(exc)
|
||||
for name in supplied.keys() - definitions.keys() - indexed_names:
|
||||
if name not in request.path_params:
|
||||
errors[name] = "property is not defined in schema"
|
||||
if errors:
|
||||
raise ContractValidationError(dict(sorted(errors.items())))
|
||||
# Path params are always available to handlers even when omitted from the
|
||||
# method property schema (common for Proxmox nested resources).
|
||||
for name, value in request.path_params.items():
|
||||
parsed.setdefault(name, value)
|
||||
return {
|
||||
"values": parsed,
|
||||
"path": dict(request.path_params),
|
||||
"provided": tuple(sorted(supplied)),
|
||||
}
|
||||
|
||||
|
||||
def _coerce(value: Any, schema: Schema) -> Any:
|
||||
if schema.type == "integer":
|
||||
parsed: Any = int(value)
|
||||
elif schema.type == "number":
|
||||
parsed = float(value)
|
||||
elif schema.type == "boolean":
|
||||
if isinstance(value, bool):
|
||||
parsed = value
|
||||
elif str(value).lower() in {"1", "true", "yes", "on"}:
|
||||
parsed = True
|
||||
elif str(value).lower() in {"0", "false", "no", "off"}:
|
||||
parsed = False
|
||||
else:
|
||||
raise ValueError("expected a boolean")
|
||||
elif schema.type == "string" or schema.type is None:
|
||||
parsed = str(value)
|
||||
else:
|
||||
parsed = value
|
||||
if schema.enum and parsed not in schema.enum:
|
||||
raise ValueError("value is not in the allowed enumeration")
|
||||
if isinstance(parsed, int | float):
|
||||
if schema.minimum is not None and parsed < schema.minimum:
|
||||
raise ValueError(f"value must be at least {schema.minimum}")
|
||||
if schema.maximum is not None and parsed > schema.maximum:
|
||||
raise ValueError(f"value must be at most {schema.maximum}")
|
||||
if isinstance(parsed, str):
|
||||
if schema.min_length is not None and len(parsed) < schema.min_length:
|
||||
raise ValueError(f"value is shorter than {schema.min_length}")
|
||||
if schema.max_length is not None and len(parsed) > schema.max_length:
|
||||
raise ValueError(f"value is longer than {schema.max_length}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _schema_default(schema: Schema) -> Any:
|
||||
return schema_example(schema)
|
||||
Reference in New Issue
Block a user