Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,6 @@
## 2025-11-11
- [x] Pin Reticulum (RNS) to 1.0.2 and LXMF to 0.9.2 in project dependencies.
- [x] Centralise payload conversion utilities and refactor EmergencyManagement client and gateway to use them.

## 2025-11-12
- [x] Introduce FastAPI integration helpers for LXMF configuration, dependencies, and command routing.
104 changes: 104 additions & 0 deletions docs/fastapi_integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# FastAPI Integration Helpers

The `reticulum_openapi.integrations.fastapi` package centralises shared
infrastructure for building LXMF-aware FastAPI applications. It provides
configuration models, lifecycle management utilities, reusable command execution
helpers, and diagnostics that were previously embedded inside the Emergency
Management example services.

## Configuration Settings

Use `LXMFClientSettings` together with `create_settings_loader` (or
`load_lxmf_client_settings`) to populate LXMF client configuration from JSON
files or environment variables. Values mirror the historical
`north_api.config.NorthAPIClientSettings` fields:

```python
from pathlib import Path
from reticulum_openapi.integrations.fastapi import create_settings_loader

loader = create_settings_loader(
default_path=Path("./client_config.json"),
env_json_var="NORTH_API_CONFIG_JSON",
env_path_var="NORTH_API_CONFIG_PATH",
)
settings = loader()
```

All values are normalised (paths are stripped, RPC keys lower-cased) and an
optional `require_server_identity` flag enforces mandatory server identity
configuration when desired.

## Managing LXMF Clients

`LXMFClientManager` wraps client instantiation and lifecycle management. It
produces a singleton LXMF client, handles optional announce broadcasts, and can
attach notification bridges during FastAPI startup/shutdown events:

```python
from fastapi import FastAPI
from reticulum_openapi.integrations.fastapi import LXMFClientManager
from reticulum_openapi.integrations.fastapi import LXMFClientSettings

settings = LXMFClientSettings(server_identity_hash="001122...")
manager = LXMFClientManager(lambda: settings)
app = FastAPI()
manager.register_events(app)
```

The manager exposes `get_client()` for dependency injection and
`get_server_identity()` for resolving default server targets. The Emergency
Management northbound API now consumes this helper directly.

## Link Management and Interface Status

`LinkManager` tracks LXMF link attempts, retries connections with backoff, and
records structured status suitable for status endpoints. Applications can start
or stop the retry loop during FastAPI lifecycle events, and the resulting
`LinkStatus` model integrates with diagnostics endpoints. The
`gather_interface_status()` helper inspects active Reticulum interfaces and is
used by the Emergency Management gateway to publish interface metadata.

## Command Execution Contexts

To remove repetitive boilerplate from FastAPI routes, the integration package
introduces `CommandSpec` and `create_command_context_dependency`. A command
context resolves the destination server identity (via query parameter, header,
or configured default), handles dataclass payload preparation, and translates
common LXMF errors into HTTP responses:

```python
from typing import Annotated, Dict
from fastapi import Depends, FastAPI
from reticulum_openapi.integrations.fastapi import (
CommandSpec,
LXMFCommandContext,
LXMFClientManager,
create_command_context_dependency,
)

manager = LXMFClientManager(loader)
command_specs = {
"eam:create": CommandSpec(command="CreateEmergencyActionMessage")
}
CommandContext = Annotated[
LXMFCommandContext,
Depends(create_command_context_dependency(manager, command_specs)),
]

@app.post("/emergency-action-messages")
async def create_eam(payload: Dict[str, str], context: CommandContext):
return await context.execute("eam:create", body=payload)
```

Routes simply supply command metadata and optional payload overrides. The
Emergency Management gateway has been refactored to use this shared context for
all LXMF interactions.

## Example Adoption

Both the Emergency Management FastAPI gateway and the northbound API now rely on
`reticulum_openapi.integrations.fastapi` for configuration, dependency
injection, link status tracking, and command execution. Tests under
`tests/integrations/fastapi/` cover the integration points, ensuring lifecycle
hooks and status reporting remain functional across applications.
128 changes: 20 additions & 108 deletions examples/EmergencyManagement/client/north_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,129 +2,41 @@

from __future__ import annotations

import json
import os
from functools import lru_cache
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Optional

from dotenv import load_dotenv
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from reticulum_openapi.integrations.fastapi import LXMFClientSettings
from reticulum_openapi.integrations.fastapi import create_settings_loader
from reticulum_openapi.integrations.fastapi import load_lxmf_client_settings


load_dotenv()

CONFIG_JSON_ENV_VAR = "NORTH_API_CONFIG_JSON"
CONFIG_PATH_ENV_VAR = "NORTH_API_CONFIG_PATH"
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "client_config.json"


class NorthAPIClientSettings(BaseModel):
"""Pydantic model describing LXMF client configuration values."""

server_identity_hash: str = Field(..., min_length=1)
client_display_name: str = Field("EmergencyClient", min_length=1)
request_timeout_seconds: float = Field(300.0, ge=0.0)
lxmf_config_path: Optional[str] = None
lxmf_storage_path: Optional[str] = None
shared_instance_rpc_key: Optional[str] = None

@field_validator("server_identity_hash")
def _validate_server_identity_hash(cls, value: str) -> str:
"""Ensure the server identity hash contains hexadecimal characters."""

cleaned = value.strip()
if not cleaned:
raise ValueError("server_identity_hash cannot be empty")
return cleaned.lower()

@field_validator("client_display_name")
def _validate_display_name(cls, value: str) -> str:
"""Ensure the display name is not blank."""

cleaned = value.strip()
if not cleaned:
raise ValueError("client_display_name cannot be empty")
return cleaned

@field_validator("lxmf_config_path", "lxmf_storage_path", mode="before")
def _normalise_optional_paths(cls, value: Optional[str]) -> Optional[str]:
"""Return ``None`` when optional string values are empty."""

if value is None:
return None
cleaned = str(value).strip()
return cleaned or None

@field_validator("shared_instance_rpc_key", mode="before")
def _validate_shared_instance_rpc_key(
cls, value: Optional[str]
) -> Optional[str]:
"""Normalise and validate optional RPC key overrides."""

if value is None:
return None
DEFAULT_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "client_config.json"
)

cleaned = str(value).strip()
if not cleaned:
return None
NorthAPIClientSettings = LXMFClientSettings

try:
bytes.fromhex(cleaned)
except ValueError as exc:
raise ValueError(
"shared_instance_rpc_key must be a hexadecimal string"
) from exc

return cleaned.lower()


def _load_config_from_json(raw_json: str) -> Dict[str, Any]:
"""Return configuration data parsed from a raw JSON string."""

try:
return json.loads(raw_json)
except json.JSONDecodeError as exc: # pragma: no cover - defensive logging
raise ValueError("Invalid JSON supplied via environment variable") from exc


def _load_config_from_path(path: Path) -> Dict[str, Any]:
"""Return configuration data parsed from a JSON file."""

if not path.exists():
raise FileNotFoundError(f"Configuration file not found: {path}")
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)


def _resolve_config_source() -> Dict[str, Any]:
"""Return configuration data from environment or default JSON file."""

raw_json = os.getenv(CONFIG_JSON_ENV_VAR)
if raw_json:
return _load_config_from_json(raw_json)

path_override = os.getenv(CONFIG_PATH_ENV_VAR)
config_path = Path(path_override) if path_override else DEFAULT_CONFIG_PATH
return _load_config_from_path(config_path)
_SETTINGS_LOADER = create_settings_loader(
default_path=DEFAULT_CONFIG_PATH,
env_json_var=CONFIG_JSON_ENV_VAR,
env_path_var=CONFIG_PATH_ENV_VAR,
require_server_identity=True,
)


def load_config() -> NorthAPIClientSettings:
"""Create a configuration model populated with LXMF client values."""

data = _resolve_config_source()
return NorthAPIClientSettings(**data)

return load_lxmf_client_settings(
default_path=DEFAULT_CONFIG_PATH,
env_json_var=CONFIG_JSON_ENV_VAR,
env_path_var=CONFIG_PATH_ENV_VAR,
require_server_identity=True,
)

@lru_cache(maxsize=1)
def get_config() -> NorthAPIClientSettings:
"""Return a cached configuration instance for dependency injection."""

return load_config()
get_config = _SETTINGS_LOADER


__all__ = ["NorthAPIClientSettings", "get_config", "load_config"]
64 changes: 11 additions & 53 deletions examples/EmergencyManagement/client/north_api/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,35 @@
"""Dependency wiring for the emergency management north API client."""
"""FastAPI dependencies for the Emergency Management northbound API."""

from __future__ import annotations

import logging
from typing import Annotated, Optional
from typing import Annotated

from fastapi import Depends
from fastapi import FastAPI

from reticulum_openapi.client import LXMFClient
from reticulum_openapi.integrations.fastapi import LXMFClientManager

from .config import NorthAPIClientSettings
from .config import get_config


logger = logging.getLogger(__name__)
_client_instance: Optional[LXMFClient] = None


def _create_client(settings: NorthAPIClientSettings) -> LXMFClient:
"""Instantiate the LXMF client using configuration values."""

return LXMFClient(
config_path=settings.lxmf_config_path,
storage_path=settings.lxmf_storage_path,
display_name=settings.client_display_name,
timeout=settings.request_timeout_seconds,
shared_instance_rpc_key=settings.shared_instance_rpc_key,
)


def startup_client() -> LXMFClient:
"""Initialise the singleton LXMF client instance if required."""

global _client_instance
if _client_instance is None:
settings = get_config()
_client_instance = _create_client(settings)
return _client_instance


def shutdown_client() -> None:
"""Stop the LXMF client instance and release related resources."""

global _client_instance
if _client_instance is None:
return
try:
_client_instance.stop_listening_for_announces()
except Exception: # pragma: no cover - defensive cleanup
logger.debug("Failed to stop announce listener during shutdown", exc_info=True)
_client_instance = None
_client_manager = LXMFClientManager(get_config)


def get_lxmf_client() -> LXMFClient:
"""Return the configured LXMF client instance."""

if _client_instance is None:
raise RuntimeError("LXMF client has not been initialised")
return _client_instance
return _client_manager.get_client()


def get_server_identity_hash() -> str:
"""Return the configured server identity hash without user interaction."""

return get_config().server_identity_hash
identity = _client_manager.get_server_identity()
if identity is None: # pragma: no cover - configuration guard
raise RuntimeError("server_identity_hash must be configured")
return identity


ServerIdentityHash = Annotated[str, Depends(get_server_identity_hash)]
Expand All @@ -73,20 +38,13 @@ def get_server_identity_hash() -> str:
def register_client_events(app: FastAPI) -> None:
"""Attach lifecycle events for creating and shutting down the client."""

@app.on_event("startup")
async def _startup() -> None:
startup_client()

@app.on_event("shutdown")
async def _shutdown() -> None:
shutdown_client()
_client_manager.register_events(app)


__all__ = [
"NorthAPIClientSettings",
"ServerIdentityHash",
"get_lxmf_client",
"get_server_identity_hash",
"register_client_events",
"shutdown_client",
"startup_client",
]
Loading
Loading