feat(map): add guarded map gateway BFF
This commit is contained in:
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"bleak==3.0.2",
|
||||
"fastapi>=0.116,<1",
|
||||
"foxglove-sdk==0.25.3",
|
||||
"httpx>=0.28,<1",
|
||||
"lz4>=4.4,<5",
|
||||
"missioncore-plugin-sdk",
|
||||
"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.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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "click"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "httptools"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
@@ -287,6 +324,7 @@ dependencies = [
|
||||
{ name = "bleak" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "foxglove-sdk" },
|
||||
{ name = "httpx" },
|
||||
{ name = "lz4" },
|
||||
{ name = "missioncore-plugin-sdk" },
|
||||
{ name = "paho-mqtt" },
|
||||
@@ -311,6 +349,7 @@ requires-dist = [
|
||||
{ name = "bleak", specifier = "==3.0.2" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||
|
||||
Reference in New Issue
Block a user