feat(map): add guarded map gateway BFF
This commit is contained in:
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"bleak==3.0.2",
|
"bleak==3.0.2",
|
||||||
"fastapi>=0.116,<1",
|
"fastapi>=0.116,<1",
|
||||||
"foxglove-sdk==0.25.3",
|
"foxglove-sdk==0.25.3",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
"lz4>=4.4,<5",
|
"lz4>=4.4,<5",
|
||||||
"missioncore-plugin-sdk",
|
"missioncore-plugin-sdk",
|
||||||
"paho-mqtt>=2.1,<3",
|
"paho-mqtt>=2.1,<3",
|
||||||
|
|||||||
@@ -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.environment_api import build_environment_router
|
||||||
from k1link.web.laboratory_api import build_laboratory_router
|
from k1link.web.laboratory_api import build_laboratory_router
|
||||||
from k1link.web.lidar_api import build_lidar_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_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
STATE_READ_ACTION_ID,
|
STATE_READ_ACTION_ID,
|
||||||
@@ -151,6 +157,7 @@ session_perception_epoch_store = (
|
|||||||
if _ffprobe is not None
|
if _ffprobe is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
map_gateway_proxy = MapGatewayProxy(MapGatewayConfiguration.from_environment())
|
||||||
|
|
||||||
|
|
||||||
def _prepare_recorded_media_for_launch(
|
def _prepare_recorded_media_for_launch(
|
||||||
@@ -291,6 +298,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
await map_gateway_proxy.close()
|
||||||
if reconciler is not None:
|
if reconciler is not None:
|
||||||
reconciler.cancel()
|
reconciler.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
@@ -417,6 +425,12 @@ app.include_router(
|
|||||||
root_provider=lambda: session_store.data_dir / "ui-environment"
|
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(
|
app.include_router(
|
||||||
build_polygon_router(
|
build_polygon_router(
|
||||||
root_provider=lambda: configured_polygon_runs_root()
|
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
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.web.map_api import (
|
||||||
|
MAP_GATEWAY_URL_ENV,
|
||||||
|
MapGatewayConfiguration,
|
||||||
|
MapGatewayProxy,
|
||||||
|
build_map_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncContent(httpx.AsyncByteStream):
|
||||||
|
def __init__(self, content: bytes) -> None:
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
yield self._content
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
query: str = "",
|
||||||
|
headers: list[tuple[bytes, bytes]] | None = None,
|
||||||
|
client: tuple[str, int] = ("127.0.0.1", 8000),
|
||||||
|
) -> Request:
|
||||||
|
async def receive() -> dict[str, Any]:
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": method,
|
||||||
|
"scheme": "http",
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode("ascii"),
|
||||||
|
"query_string": query.encode("ascii"),
|
||||||
|
"headers": headers or [],
|
||||||
|
"client": client,
|
||||||
|
"server": ("127.0.0.1", 8000),
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||||
|
for route in router.routes:
|
||||||
|
if (
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path == path
|
||||||
|
and route.methods is not None
|
||||||
|
and method in route.methods
|
||||||
|
):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"{method} {path} route is missing")
|
||||||
|
|
||||||
|
|
||||||
|
def _response_body(response: JSONResponse) -> bytes:
|
||||||
|
return bytes(response.body)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(
|
||||||
|
handler: Callable[[httpx.Request], httpx.Response],
|
||||||
|
) -> tuple[MapGatewayProxy, list[httpx.Request]]:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
def recording_handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
response = handler(request)
|
||||||
|
if response.is_stream_consumed:
|
||||||
|
return httpx.Response(
|
||||||
|
response.status_code,
|
||||||
|
headers=response.headers,
|
||||||
|
stream=_AsyncContent(response.content),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(transport=httpx.MockTransport(recording_handler))
|
||||||
|
return (
|
||||||
|
MapGatewayProxy(
|
||||||
|
MapGatewayConfiguration("http://map-gateway.internal:18103"),
|
||||||
|
client=client,
|
||||||
|
),
|
||||||
|
requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_is_fail_closed_and_rejects_non_origin_values(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv(MAP_GATEWAY_URL_ENV, raising=False)
|
||||||
|
assert MapGatewayConfiguration.from_environment().internal_url is None
|
||||||
|
|
||||||
|
monkeypatch.setenv(MAP_GATEWAY_URL_ENV, "http://user:pass@gateway/path?token=no")
|
||||||
|
with pytest.raises(ValueError, match="HTTP\\(S\\) origin"):
|
||||||
|
MapGatewayConfiguration.from_environment()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin() -> None:
|
||||||
|
service, requests = _service(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
json={"ok": True, "rendererVersion": "1.143.0"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
router = build_map_router(service)
|
||||||
|
endpoint = _endpoint(router, "/api/v1/map/runtime-config", "GET")
|
||||||
|
|
||||||
|
response = asyncio.run(endpoint(request=_request("/api/v1/map/runtime-config")))
|
||||||
|
|
||||||
|
assert isinstance(response, JSONResponse)
|
||||||
|
assert response.status_code == 200
|
||||||
|
document = json.loads(_response_body(response))
|
||||||
|
assert document["schema_version"] == "missioncore.map-runtime/v1"
|
||||||
|
assert document["map_page_version"] == "0.1.0"
|
||||||
|
assert document["renderer"] == {"id": "cesium", "version": "1.143.0"}
|
||||||
|
assert document["gateway"]["cache_proxy_prefix"].startswith("/api/v1/map/")
|
||||||
|
assert document["assets"] == {
|
||||||
|
"imagery": 2,
|
||||||
|
"terrain": 1,
|
||||||
|
"buildings": 96188,
|
||||||
|
}
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert not requests
|
||||||
|
assert "map-gateway.internal" not in _response_body(response).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_config_is_fail_closed_when_gateway_is_not_configured() -> None:
|
||||||
|
service = MapGatewayProxy(MapGatewayConfiguration(None))
|
||||||
|
response = service.runtime_configuration()
|
||||||
|
|
||||||
|
assert isinstance(response, JSONResponse)
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert json.loads(_response_body(response))["code"] == "map_gateway_not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_json_contract_rejects_secret_material() -> None:
|
||||||
|
service, _ = _service(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
json={"ok": True, "accessToken": "must-not-cross-bff"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
service.proxy_json(
|
||||||
|
_request("/api/v1/map/runtime-config"),
|
||||||
|
upstream_path="/api/map/runtime-config",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(response, JSONResponse)
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert json.loads(_response_body(response))["code"] == "map_gateway_invalid_response"
|
||||||
|
assert b"must-not-cross-bff" not in _response_body(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_stream_forwards_only_allowlisted_headers_and_preserves_range() -> None:
|
||||||
|
service, requests = _service(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
206,
|
||||||
|
headers={
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
"content-range": "bytes 0-3/10",
|
||||||
|
"etag": '"tile-v1"',
|
||||||
|
"set-cookie": "forbidden=1",
|
||||||
|
"x-internal-secret": "forbidden",
|
||||||
|
},
|
||||||
|
content=b"tile",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = asyncio.run(
|
||||||
|
service.proxy_cache(
|
||||||
|
_request(
|
||||||
|
"/api/v1/map/gateway/cache",
|
||||||
|
headers=[
|
||||||
|
(b"range", b"bytes=0-3"),
|
||||||
|
(b"authorization", b"Bearer browser-secret"),
|
||||||
|
(b"x-nodedc-user-id", b"browser-forgery"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target_url="https://assets.cesium.com/tile.bin?nodedc_cache_profile=live",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(response, StreamingResponse)
|
||||||
|
|
||||||
|
async def read_body() -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk))
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
assert asyncio.run(read_body()) == b"tile"
|
||||||
|
assert response.headers["content-range"] == "bytes 0-3/10"
|
||||||
|
assert response.headers["etag"] == '"tile-v1"'
|
||||||
|
assert "set-cookie" not in response.headers
|
||||||
|
assert "x-internal-secret" not in response.headers
|
||||||
|
assert requests[0].headers["range"] == "bytes=0-3"
|
||||||
|
assert "authorization" not in requests[0].headers
|
||||||
|
assert requests[0].headers["x-nodedc-user-id"] == "mission-core-loopback-operator"
|
||||||
|
assert requests[0].url.params["url"].startswith("https://assets.cesium.com/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_rejects_credentials_non_https_and_non_loopback_clients() -> None:
|
||||||
|
service, requests = _service(
|
||||||
|
lambda request: httpx.Response(200, content=b"unused")
|
||||||
|
)
|
||||||
|
unsafe_targets = (
|
||||||
|
"http://assets.cesium.com/tile.bin",
|
||||||
|
"https://user:pass@assets.cesium.com/tile.bin",
|
||||||
|
"https://assets.cesium.com/tile.bin#secret",
|
||||||
|
"https://assets.cesium.com/tile.bin?access_token=forbidden",
|
||||||
|
)
|
||||||
|
for target in unsafe_targets:
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
service.proxy_cache(
|
||||||
|
_request("/api/v1/map/gateway/cache"),
|
||||||
|
target_url=target,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 422
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
service.proxy_cache(
|
||||||
|
_request(
|
||||||
|
"/api/v1/map/gateway/cache",
|
||||||
|
client=("192.0.2.10", 1234),
|
||||||
|
),
|
||||||
|
target_url="https://assets.cesium.com/tile.bin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
assert not requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_route_is_a_fixed_allowlist() -> None:
|
||||||
|
service, requests = _service(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
json={"ok": True, "assetId": 1, "credentialMode": "gateway"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
router = build_map_router(service)
|
||||||
|
endpoint = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/map/gateway/ion/assets/{asset_id}/endpoint",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected = asyncio.run(
|
||||||
|
endpoint(
|
||||||
|
request=_request("/api/v1/map/gateway/ion/assets/42/endpoint"),
|
||||||
|
asset_id=42,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accepted = asyncio.run(
|
||||||
|
endpoint(
|
||||||
|
request=_request("/api/v1/map/gateway/ion/assets/1/endpoint"),
|
||||||
|
asset_id=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(rejected, JSONResponse)
|
||||||
|
assert rejected.status_code == 404
|
||||||
|
assert json.loads(_response_body(rejected))["code"] == "cesium_asset_not_allowed"
|
||||||
|
assert isinstance(accepted, JSONResponse)
|
||||||
|
assert len(requests) == 1
|
||||||
|
assert requests[0].url.path.endswith("/api/map/ion/assets/1/endpoint")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_errors_preserve_only_safe_code() -> None:
|
||||||
|
service, _ = _service(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
504,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
json={
|
||||||
|
"code": "map_upstream_timeout",
|
||||||
|
"detail": "private-host.internal?token=forbidden",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
service.proxy_cache(
|
||||||
|
_request("/api/v1/map/gateway/cache"),
|
||||||
|
target_url="https://assets.cesium.com/tile.bin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(response, JSONResponse)
|
||||||
|
assert response.status_code == 504
|
||||||
|
assert json.loads(_response_body(response)) == {
|
||||||
|
"schema_version": "missioncore.map-gateway/v1",
|
||||||
|
"code": "map_upstream_timeout",
|
||||||
|
"retryable": True,
|
||||||
|
}
|
||||||
|
assert b"private-host" not in _response_body(response)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from k1link.web.map_view_api import (
|
||||||
|
MapCamera,
|
||||||
|
MapViewContent,
|
||||||
|
MapViewPut,
|
||||||
|
MapViewStore,
|
||||||
|
default_map_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_map_view_is_honestly_unlocated_and_cache_safe() -> None:
|
||||||
|
document = default_map_view()
|
||||||
|
|
||||||
|
assert document.schema_version == "missioncore.map-view/v1"
|
||||||
|
assert document.revision == 0
|
||||||
|
assert document.view.camera is None
|
||||||
|
assert document.view.selected_subject_id is None
|
||||||
|
assert document.view.layer_visibility.imagery is True
|
||||||
|
assert document.view.layer_visibility.targets is True
|
||||||
|
assert document.view.cache_intent.enabled is True
|
||||||
|
assert document.view.cache_intent.no_overwrite is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_view_round_trip_uses_optimistic_revision(tmp_path: Path) -> None:
|
||||||
|
store = MapViewStore(tmp_path / "mission-data" / "map-view")
|
||||||
|
initial = store.read()
|
||||||
|
view = initial.view.model_copy(
|
||||||
|
update={
|
||||||
|
"camera": MapCamera(
|
||||||
|
longitude=37.6176,
|
||||||
|
latitude=55.7558,
|
||||||
|
height=2500.0,
|
||||||
|
heading=12.0,
|
||||||
|
pitch=-48.0,
|
||||||
|
roll=0.0,
|
||||||
|
),
|
||||||
|
"inspector_open_sections": ["camera"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = store.save(MapViewPut(revision=initial.revision, view=view))
|
||||||
|
restored = MapViewStore(store.root).read()
|
||||||
|
|
||||||
|
assert saved.revision == 1
|
||||||
|
assert restored == saved
|
||||||
|
assert restored.view.camera is not None
|
||||||
|
assert restored.view.camera.longitude == pytest.approx(37.6176)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="revision changed"):
|
||||||
|
store.save(MapViewPut(revision=0, view=view))
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_view_contract_cannot_store_secret_or_provider_fields() -> None:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"camera": None,
|
||||||
|
"visual_settings": {},
|
||||||
|
"map_height": 720,
|
||||||
|
"inspector_open_sections": [],
|
||||||
|
"cache_intent": {"enabled": True, "no_overwrite": True},
|
||||||
|
"selected_subject_id": None,
|
||||||
|
"layer_visibility": {},
|
||||||
|
"token": "forbidden",
|
||||||
|
"gateway_url": "http://private.internal",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MapViewContent.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_inspector_requires_real_stable_subject() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="selected stable subject"):
|
||||||
|
MapViewContent(inspector_open_sections=["selection"])
|
||||||
|
|
||||||
|
selected = MapViewContent(
|
||||||
|
selected_subject_id="map.moving_object/device-006",
|
||||||
|
inspector_open_sections=["selection"],
|
||||||
|
)
|
||||||
|
assert selected.selected_subject_id == "map.moving_object/device-006"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_document_contains_no_runtime_or_credential_material(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = MapViewStore(tmp_path / "map-view")
|
||||||
|
initial = store.read()
|
||||||
|
store.save(MapViewPut(revision=0, view=initial.view))
|
||||||
|
|
||||||
|
payload = json.loads(store.document_path.read_text(encoding="utf-8"))
|
||||||
|
serialized = json.dumps(payload).lower()
|
||||||
|
assert "token" not in serialized
|
||||||
|
assert "gateway" not in serialized
|
||||||
|
assert "provider" not in serialized
|
||||||
|
assert "cesium" not in serialized
|
||||||
@@ -66,6 +66,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.2"
|
version = "8.4.2"
|
||||||
@@ -146,6 +155,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -161,6 +183,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.18"
|
||||||
@@ -287,6 +324,7 @@ dependencies = [
|
|||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "foxglove-sdk" },
|
{ name = "foxglove-sdk" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "lz4" },
|
{ name = "lz4" },
|
||||||
{ name = "missioncore-plugin-sdk" },
|
{ name = "missioncore-plugin-sdk" },
|
||||||
{ name = "paho-mqtt" },
|
{ name = "paho-mqtt" },
|
||||||
@@ -311,6 +349,7 @@ requires-dist = [
|
|||||||
{ name = "bleak", specifier = "==3.0.2" },
|
{ name = "bleak", specifier = "==3.0.2" },
|
||||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||||
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
|
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
|
||||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user