feat(map): add guarded map gateway BFF
This commit is contained in:
@@ -42,6 +42,12 @@ from k1link.web.e30_review_api import build_e30_review_router
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.map_api import (
|
||||
MapGatewayConfiguration,
|
||||
MapGatewayProxy,
|
||||
build_map_router,
|
||||
)
|
||||
from k1link.web.map_view_api import build_map_view_router
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
STATE_READ_ACTION_ID,
|
||||
@@ -151,6 +157,7 @@ session_perception_epoch_store = (
|
||||
if _ffprobe is not None
|
||||
else None
|
||||
)
|
||||
map_gateway_proxy = MapGatewayProxy(MapGatewayConfiguration.from_environment())
|
||||
|
||||
|
||||
def _prepare_recorded_media_for_launch(
|
||||
@@ -291,6 +298,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||
yield
|
||||
finally:
|
||||
await map_gateway_proxy.close()
|
||||
if reconciler is not None:
|
||||
reconciler.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
@@ -417,6 +425,12 @@ app.include_router(
|
||||
root_provider=lambda: session_store.data_dir / "ui-environment"
|
||||
)
|
||||
)
|
||||
app.include_router(build_map_router(map_gateway_proxy))
|
||||
app.include_router(
|
||||
build_map_view_router(
|
||||
root_provider=lambda: session_store.data_dir / "map-view",
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_polygon_router(
|
||||
root_provider=lambda: configured_polygon_runs_root()
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from typing import Annotated, Any, Final
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL"
|
||||
MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1"
|
||||
MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1"
|
||||
MAP_PAGE_VERSION: Final = "0.1.0"
|
||||
CESIUM_RENDERER_VERSION: Final = "1.143.0"
|
||||
MAP_GATEWAY_SUBJECT: Final = "mission-core-loopback-operator"
|
||||
ALLOWED_ION_ASSET_IDS: Final = frozenset({1, 2, 96188})
|
||||
SAFE_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$")
|
||||
MAX_JSON_RESPONSE_BYTES: Final = 1024 * 1024
|
||||
MAX_CACHE_TARGET_LENGTH: Final = 8192
|
||||
|
||||
REQUEST_HEADER_ALLOWLIST: Final = frozenset(
|
||||
{
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"if-modified-since",
|
||||
"if-none-match",
|
||||
"range",
|
||||
}
|
||||
)
|
||||
RESPONSE_HEADER_ALLOWLIST: Final = frozenset(
|
||||
{
|
||||
"accept-ranges",
|
||||
"cache-control",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"content-range",
|
||||
"content-type",
|
||||
"etag",
|
||||
"expires",
|
||||
"last-modified",
|
||||
"vary",
|
||||
}
|
||||
)
|
||||
SENSITIVE_JSON_KEYS: Final = frozenset(
|
||||
{
|
||||
"access_token",
|
||||
"accesstoken",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"bing_key",
|
||||
"bingkey",
|
||||
"cookie",
|
||||
"master_token",
|
||||
"mastertoken",
|
||||
"secret",
|
||||
"set_cookie",
|
||||
"setcookie",
|
||||
"token",
|
||||
}
|
||||
)
|
||||
CREDENTIAL_QUERY_KEYS: Final = frozenset(
|
||||
{
|
||||
"access_token",
|
||||
"access-token",
|
||||
"authorization",
|
||||
"credential",
|
||||
"key",
|
||||
"sig",
|
||||
"signature",
|
||||
"token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MapGatewayConfiguration:
|
||||
internal_url: str | None
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> MapGatewayConfiguration:
|
||||
raw_url = os.environ.get(MAP_GATEWAY_URL_ENV, "").strip()
|
||||
if not raw_url:
|
||||
return cls(internal_url=None)
|
||||
return cls(internal_url=_validate_internal_url(raw_url))
|
||||
|
||||
|
||||
class MapGatewayProxy:
|
||||
"""Narrow same-origin proxy to the shared Platform Map Gateway."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
configuration: MapGatewayConfiguration,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._configuration = configuration
|
||||
self._owns_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(
|
||||
connect=15.0,
|
||||
read=30.0,
|
||||
write=15.0,
|
||||
pool=15.0,
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=48,
|
||||
max_keepalive_connections=16,
|
||||
keepalive_expiry=30.0,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return self._configuration.internal_url is not None
|
||||
|
||||
def runtime_configuration(self) -> Response:
|
||||
if not self.configured:
|
||||
return _safe_error_response(
|
||||
503,
|
||||
"map_gateway_not_configured",
|
||||
retryable=False,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schema_version": MAP_RUNTIME_SCHEMA_VERSION,
|
||||
"map_page_version": MAP_PAGE_VERSION,
|
||||
"renderer": {
|
||||
"id": "cesium",
|
||||
"version": CESIUM_RENDERER_VERSION,
|
||||
},
|
||||
"gateway": {
|
||||
"configured": True,
|
||||
"health_url": "/api/v1/map/gateway/health",
|
||||
"asset_endpoint_template": (
|
||||
"/api/v1/map/gateway/ion/assets/{asset_id}/endpoint"
|
||||
),
|
||||
"cache_proxy_prefix": "/api/v1/map/gateway/cache?url=",
|
||||
},
|
||||
"assets": {
|
||||
"imagery": 2,
|
||||
"terrain": 1,
|
||||
"buildings": 96188,
|
||||
},
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def proxy_json(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
upstream_path: str,
|
||||
) -> Response:
|
||||
upstream = await self._send(request, upstream_path=upstream_path)
|
||||
try:
|
||||
payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES)
|
||||
except MapGatewayPayloadTooLarge:
|
||||
return _safe_error_response(
|
||||
502,
|
||||
"map_gateway_invalid_response",
|
||||
retryable=False,
|
||||
)
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
|
||||
if upstream.status_code >= 400:
|
||||
return _safe_upstream_error(upstream.status_code, payload)
|
||||
|
||||
try:
|
||||
document = _decode_safe_json(upstream, payload)
|
||||
except ValueError:
|
||||
return _safe_error_response(
|
||||
502,
|
||||
"map_gateway_invalid_response",
|
||||
retryable=False,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=document,
|
||||
status_code=upstream.status_code,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
async def proxy_cache(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
target_url: str,
|
||||
) -> Response:
|
||||
_validate_cache_target(target_url)
|
||||
upstream = await self._send(
|
||||
request,
|
||||
upstream_path="/api/map/cache",
|
||||
query={"url": target_url},
|
||||
)
|
||||
if upstream.status_code >= 400:
|
||||
try:
|
||||
payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES)
|
||||
except MapGatewayPayloadTooLarge:
|
||||
payload = b""
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
return _safe_upstream_error(upstream.status_code, payload)
|
||||
|
||||
response_headers = _filtered_response_headers(upstream.headers)
|
||||
if request.method == "HEAD" or upstream.status_code in {204, 304}:
|
||||
await upstream.aclose()
|
||||
return Response(
|
||||
status_code=upstream.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
async def stream_body() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
status_code=upstream.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
upstream_path: str,
|
||||
query: Mapping[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
_require_loopback_request(request)
|
||||
base_url = self._configuration.internal_url
|
||||
if base_url is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"schema_version": MAP_SCHEMA_VERSION,
|
||||
"code": "map_gateway_not_configured",
|
||||
"retryable": False,
|
||||
},
|
||||
)
|
||||
|
||||
headers = {
|
||||
name: value
|
||||
for name, value in request.headers.items()
|
||||
if name.lower() in REQUEST_HEADER_ALLOWLIST
|
||||
}
|
||||
headers["x-nodedc-user-id"] = MAP_GATEWAY_SUBJECT
|
||||
upstream_request = self._client.build_request(
|
||||
request.method,
|
||||
f"{base_url}{upstream_path}",
|
||||
params=query,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
return await self._client.send(upstream_request, stream=True)
|
||||
except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ReadTimeout) as exc:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail={
|
||||
"schema_version": MAP_SCHEMA_VERSION,
|
||||
"code": "map_gateway_headers_timeout",
|
||||
"retryable": True,
|
||||
},
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"schema_version": MAP_SCHEMA_VERSION,
|
||||
"code": "map_gateway_unavailable",
|
||||
"retryable": True,
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
class MapGatewayPayloadTooLarge(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_internal_url(raw_url: str) -> str:
|
||||
parsed = urlsplit(raw_url)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(f"{MAP_GATEWAY_URL_ENV} must be an HTTP(S) origin")
|
||||
path = parsed.path.rstrip("/")
|
||||
if path:
|
||||
raise ValueError(f"{MAP_GATEWAY_URL_ENV} must not include a path")
|
||||
return raw_url.rstrip("/")
|
||||
|
||||
|
||||
def _validate_cache_target(target_url: str) -> None:
|
||||
if len(target_url) > MAX_CACHE_TARGET_LENGTH:
|
||||
raise HTTPException(status_code=422, detail="Map resource URL is too long.")
|
||||
parsed = urlsplit(target_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Map resource URL must be a credential-free HTTPS URL.",
|
||||
)
|
||||
if any(
|
||||
key.lower() in CREDENTIAL_QUERY_KEYS
|
||||
for key, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Map resource URL must not contain credential parameters.",
|
||||
)
|
||||
|
||||
|
||||
def _require_loopback_request(request: Request) -> None:
|
||||
client = request.client
|
||||
if client is None:
|
||||
raise HTTPException(status_code=403, detail="Map access requires a local session.")
|
||||
try:
|
||||
is_loopback = ip_address(client.host).is_loopback
|
||||
except ValueError:
|
||||
is_loopback = client.host == "localhost"
|
||||
if not is_loopback:
|
||||
raise HTTPException(status_code=403, detail="Map access requires a local session.")
|
||||
|
||||
|
||||
async def _read_bounded(response: httpx.Response, limit: int) -> bytes:
|
||||
payload = bytearray()
|
||||
async for chunk in response.aiter_raw():
|
||||
payload.extend(chunk)
|
||||
if len(payload) > limit:
|
||||
raise MapGatewayPayloadTooLarge
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
def _decode_safe_json(response: httpx.Response, payload: bytes) -> dict[str, Any]:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" not in content_type.lower():
|
||||
raise ValueError("Map Gateway response is not JSON")
|
||||
document = httpx.Response(
|
||||
status_code=response.status_code,
|
||||
content=payload,
|
||||
headers={"content-type": content_type},
|
||||
).json()
|
||||
if not isinstance(document, dict) or _contains_sensitive_json(document):
|
||||
raise ValueError("Map Gateway response violates the public contract")
|
||||
return document
|
||||
|
||||
|
||||
def _contains_sensitive_json(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for raw_key, child in value.items():
|
||||
key = str(raw_key).replace("-", "_").lower()
|
||||
if key in SENSITIVE_JSON_KEYS or _contains_sensitive_json(child):
|
||||
return True
|
||||
return False
|
||||
if isinstance(value, list):
|
||||
return any(_contains_sensitive_json(child) for child in value)
|
||||
return False
|
||||
|
||||
|
||||
def _safe_upstream_error(status_code: int, payload: bytes) -> JSONResponse:
|
||||
code = "map_gateway_request_failed"
|
||||
try:
|
||||
parsed = httpx.Response(
|
||||
status_code=status_code,
|
||||
content=payload,
|
||||
headers={"content-type": "application/json"},
|
||||
).json()
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
candidate = parsed.get("code")
|
||||
if not isinstance(candidate, str):
|
||||
error = parsed.get("error")
|
||||
if isinstance(error, dict):
|
||||
candidate = error.get("code")
|
||||
if isinstance(candidate, str) and SAFE_ERROR_CODE.fullmatch(candidate):
|
||||
code = candidate
|
||||
return _safe_error_response(
|
||||
status_code,
|
||||
code,
|
||||
retryable=status_code >= 500 or status_code == 429,
|
||||
)
|
||||
|
||||
|
||||
def _safe_error_response(
|
||||
status_code: int,
|
||||
code: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"schema_version": MAP_SCHEMA_VERSION,
|
||||
"code": code,
|
||||
"retryable": retryable,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _filtered_response_headers(headers: httpx.Headers) -> dict[str, str]:
|
||||
return {
|
||||
name: value
|
||||
for name, value in headers.items()
|
||||
if name.lower() in RESPONSE_HEADER_ALLOWLIST
|
||||
}
|
||||
|
||||
|
||||
def build_map_router(service: MapGatewayProxy) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/map", tags=["map"])
|
||||
|
||||
@router.get("/runtime-config")
|
||||
async def runtime_config(request: Request) -> Response:
|
||||
_require_loopback_request(request)
|
||||
return service.runtime_configuration()
|
||||
|
||||
@router.get("/gateway/health")
|
||||
async def gateway_health(request: Request) -> Response:
|
||||
return await service.proxy_json(request, upstream_path="/healthz")
|
||||
|
||||
@router.get("/gateway/ion/assets/{asset_id}/endpoint")
|
||||
async def ion_asset_endpoint(
|
||||
request: Request,
|
||||
asset_id: Annotated[int, Path()],
|
||||
) -> Response:
|
||||
if asset_id not in ALLOWED_ION_ASSET_IDS:
|
||||
return _safe_error_response(
|
||||
404,
|
||||
"cesium_asset_not_allowed",
|
||||
retryable=False,
|
||||
)
|
||||
return await service.proxy_json(
|
||||
request,
|
||||
upstream_path=f"/api/map/ion/assets/{asset_id}/endpoint",
|
||||
)
|
||||
|
||||
@router.api_route("/gateway/cache", methods=["GET", "HEAD"])
|
||||
async def map_cache(
|
||||
request: Request,
|
||||
url: Annotated[str, Query(min_length=1, max_length=MAX_CACHE_TARGET_LENGTH)],
|
||||
) -> Response:
|
||||
if len(request.query_params.getlist("url")) != 1:
|
||||
raise HTTPException(status_code=422, detail="One map resource URL is required.")
|
||||
return await service.proxy_cache(request, target_url=url)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v1"] = "missioncore.map-view/v1"
|
||||
MapInspectorSection = Literal[
|
||||
"base-terrain",
|
||||
"atmosphere-light",
|
||||
"buildings",
|
||||
"targets",
|
||||
"grid-lod",
|
||||
"camera",
|
||||
"tile-cache",
|
||||
"selection",
|
||||
]
|
||||
|
||||
|
||||
class StrictMapModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MapCamera(StrictMapModel):
|
||||
longitude: float = Field(ge=-180.0, le=180.0)
|
||||
latitude: float = Field(ge=-90.0, le=90.0)
|
||||
height: float = Field(ge=1.0, le=100_000_000.0)
|
||||
heading: float = Field(ge=-360.0, le=360.0)
|
||||
pitch: float = Field(ge=-90.0, le=90.0)
|
||||
roll: float = Field(ge=-360.0, le=360.0)
|
||||
|
||||
|
||||
class MapVisualSettings(StrictMapModel):
|
||||
atmosphere_enabled: bool = True
|
||||
lighting_enabled: bool = True
|
||||
monochrome_enabled: bool = False
|
||||
terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0)
|
||||
buildings_maximum_screen_space_error: float = Field(
|
||||
default=16.0,
|
||||
ge=1.0,
|
||||
le=64.0,
|
||||
)
|
||||
camera_animation_enabled: bool = True
|
||||
|
||||
|
||||
class MapLayerVisibility(StrictMapModel):
|
||||
imagery: bool = True
|
||||
terrain: bool = True
|
||||
buildings: bool = True
|
||||
grid: bool = False
|
||||
targets: bool = True
|
||||
|
||||
|
||||
class MapCacheIntent(StrictMapModel):
|
||||
enabled: bool = True
|
||||
no_overwrite: bool = True
|
||||
|
||||
|
||||
class MapViewContent(StrictMapModel):
|
||||
camera: MapCamera | None = None
|
||||
visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings)
|
||||
map_height: int = Field(default=720, ge=420, le=2160)
|
||||
inspector_open_sections: list[MapInspectorSection] = Field(
|
||||
default_factory=list,
|
||||
max_length=1,
|
||||
)
|
||||
cache_intent: MapCacheIntent = Field(default_factory=MapCacheIntent)
|
||||
selected_subject_id: str | None = Field(
|
||||
default=None,
|
||||
max_length=160,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$",
|
||||
)
|
||||
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_selection_section(self) -> MapViewContent:
|
||||
if self.selected_subject_id is None and "selection" in self.inspector_open_sections:
|
||||
raise ValueError("selection section requires a selected stable subject")
|
||||
return self
|
||||
|
||||
|
||||
class MapViewPut(StrictMapModel):
|
||||
revision: int = Field(ge=0)
|
||||
view: MapViewContent
|
||||
|
||||
|
||||
class MapViewDocument(MapViewPut):
|
||||
schema_version: Literal["missioncore.map-view/v1"] = MAP_VIEW_SCHEMA_VERSION
|
||||
|
||||
|
||||
def default_map_view() -> MapViewDocument:
|
||||
return MapViewDocument(
|
||||
revision=0,
|
||||
view=MapViewContent(),
|
||||
)
|
||||
|
||||
|
||||
class MapViewStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root.expanduser().resolve()
|
||||
self.document_path = self.root / "view.json"
|
||||
self._lock = Lock()
|
||||
|
||||
def read(self) -> MapViewDocument:
|
||||
with self._lock:
|
||||
return self._read_unlocked()
|
||||
|
||||
def save(self, request: MapViewPut) -> MapViewDocument:
|
||||
with self._lock:
|
||||
current = self._read_unlocked()
|
||||
if request.revision != current.revision:
|
||||
raise RuntimeError("map view revision changed")
|
||||
document = MapViewDocument(
|
||||
revision=current.revision + 1,
|
||||
view=request.view,
|
||||
)
|
||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = self.document_path.with_suffix(".json.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(
|
||||
document.model_dump(mode="json"),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, self.document_path)
|
||||
return document
|
||||
|
||||
def _read_unlocked(self) -> MapViewDocument:
|
||||
if not self.document_path.is_file():
|
||||
return default_map_view()
|
||||
try:
|
||||
return MapViewDocument.model_validate_json(
|
||||
self.document_path.read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise RuntimeError("map view is corrupt") from exc
|
||||
|
||||
|
||||
def build_map_view_router(root_provider: Callable[[], Path]) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/map/view", tags=["map"])
|
||||
|
||||
def store() -> MapViewStore:
|
||||
return MapViewStore(root_provider())
|
||||
|
||||
@router.get("")
|
||||
def get_map_view() -> MapViewDocument:
|
||||
try:
|
||||
return store().read()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Сохранённое состояние карты повреждено.",
|
||||
) from exc
|
||||
|
||||
@router.put("")
|
||||
def put_map_view(request: MapViewPut) -> MapViewDocument:
|
||||
try:
|
||||
return store().save(request)
|
||||
except RuntimeError as exc:
|
||||
if "revision changed" in str(exc):
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Состояние карты было изменено в другом окне.",
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Сохранённое состояние карты повреждено.",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user