//! `mission_client` — REST client to the external `missions` API. //! //! Public surface (per `module-layout.md`): [`MissionClient`], //! [`MissionClientHandle`], the typed [`Mission`] DTO, [`FetchError`], and //! [`MissionClientOptions`]. //! //! Real implementation tasks: AZ-644 (pull + schema, this file), AZ-645 //! (middle-waypoint POST), AZ-646 (mapobjects pull), AZ-647 (mapobjects push). mod internal; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use serde::{Deserialize, Serialize}; use shared::error::{AutopilotError, Result}; use shared::health::ComponentHealth; use shared::models::mapobject::MapObjectsBundle; use shared::models::mission::{Coordinate, Geofence, MissionItem}; use uuid::Uuid; use internal::missions_api::HttpClient; const NAME: &str = "mission_client"; /// Mission DTO returned by `pull_mission`. Shape matches the JSON wire schema /// in `shared/contracts/mission-schema.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Mission { pub mission_id: Uuid, pub schema_version: String, pub items: Vec, pub geofences: Vec, pub return_point: Coordinate, } /// Errors surfaced by `MissionClientHandle::pull_mission`. #[derive(Debug, thiserror::Error)] pub enum FetchError { /// JSON body did not match the bundled `mission-schema`. Includes a /// size-capped sample of the raw body for offline analysis. #[error("mission schema invalid: {}", messages.join("; "))] SchemaInvalid { messages: Vec, sample: String, }, /// Non-retryable HTTP or transport-level error (4xx, malformed URL, etc.). #[error("permanent fetch failure: {0}")] Permanent(String), /// Retried up to `max_attempts` without success. #[error("max retries exceeded after {attempts} attempts")] MaxRetriesExceeded { attempts: u32 }, /// Local bug (deserialisation after schema validation succeeded, etc.). #[error("internal error: {0}")] Internal(String), } impl From for AutopilotError { fn from(e: FetchError) -> Self { match e { FetchError::SchemaInvalid { messages, .. } => { AutopilotError::Validation(messages.join("; ")) } FetchError::Permanent(s) => AutopilotError::Network(s), FetchError::MaxRetriesExceeded { attempts } => { AutopilotError::Network(format!("max retries exceeded after {attempts} attempts")) } FetchError::Internal(s) => AutopilotError::Internal(s), } } } /// Tunables for the missions-API client. AZ-644 §NFR defaults: 5 attempts, /// 200 ms base / 5 s cap, 5 s startup-fetch budget. #[derive(Debug, Clone)] pub struct MissionClientOptions { pub endpoint: String, pub bearer_token: Option, pub max_attempts: u32, pub backoff_base: Duration, pub backoff_cap: Duration, pub request_timeout: Duration, pub connect_timeout: Duration, } impl MissionClientOptions { pub fn new(endpoint: impl Into) -> Self { Self { endpoint: endpoint.into(), bearer_token: None, max_attempts: 5, backoff_base: Duration::from_millis(200), backoff_cap: Duration::from_secs(5), request_timeout: Duration::from_secs(5), connect_timeout: Duration::from_secs(2), } } } #[derive(Debug, Default)] struct ClientState { last_fetch_unix_s: AtomicU64, fetch_errors_total: AtomicU64, last_schema_version: std::sync::Mutex>, last_connection_state: std::sync::Mutex, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ConnectionState { #[default] Unknown, Ok, Error, } impl ConnectionState { fn label(&self) -> &'static str { match self { Self::Unknown => "unknown", Self::Ok => "ok", Self::Error => "error", } } } /// Public client. Build once at startup and pass the [`MissionClientHandle`] /// to other components. #[derive(Debug)] pub struct MissionClient { options: MissionClientOptions, http: HttpClient, state: Arc, } impl MissionClient { pub fn new(options: MissionClientOptions) -> std::result::Result { let http = HttpClient::new(&options)?; Ok(Self { options, http, state: Arc::new(ClientState::default()), }) } pub fn handle(&self) -> MissionClientHandle { MissionClientHandle { options: self.options.clone(), http: self.http.clone(), state: self.state.clone(), } } } /// Clonable handle. Each clone shares the same HTTP client and counters. #[derive(Debug, Clone)] pub struct MissionClientHandle { options: MissionClientOptions, http: HttpClient, state: Arc, } impl MissionClientHandle { /// Fetch + validate a mission by id. Implements bounded exponential /// backoff and rejects schema-invalid responses without a silent downcast. pub async fn pull_mission(&self, mission_id: &str) -> std::result::Result { match self.http.pull_mission_raw(mission_id, &self.options).await { Ok(value) => { let mission: Mission = serde_json::from_value(value) .map_err(|e| FetchError::Internal(format!("deserialise mission: {e}")))?; self.state .last_fetch_unix_s .store(now_unix_s(), Ordering::Relaxed); *self.state.last_schema_version.lock().unwrap() = Some(mission.schema_version.clone()); *self.state.last_connection_state.lock().unwrap() = ConnectionState::Ok; Ok(mission) } Err(e) => { self.state .fetch_errors_total .fetch_add(1, Ordering::Relaxed); *self.state.last_connection_state.lock().unwrap() = ConnectionState::Error; Err(e) } } } pub async fn post_middle_waypoint(&self, _mission_id: &str, _at: Coordinate) -> Result<()> { Err(AutopilotError::NotImplemented( "mission_client::post_middle_waypoint (AZ-645)", )) } pub async fn pull_mapobjects(&self, _mission_id: &str) -> Result { Err(AutopilotError::NotImplemented( "mission_client::pull_mapobjects (AZ-646)", )) } pub async fn push_mapobjects(&self, _bundle: MapObjectsBundle) -> Result<()> { Err(AutopilotError::NotImplemented( "mission_client::push_mapobjects (AZ-647)", )) } pub fn health(&self) -> ComponentHealth { let conn = *self.state.last_connection_state.lock().unwrap(); let last_fetch = self.state.last_fetch_unix_s.load(Ordering::Relaxed); let errors = self.state.fetch_errors_total.load(Ordering::Relaxed); let schema_version = self .state .last_schema_version .lock() .unwrap() .clone() .unwrap_or_else(|| "none".to_owned()); let detail = format!( "last_fetch_ts={} fetch_errors_total={} schema_version={} connection_state={}", if last_fetch == 0 { "none".into() } else { last_fetch.to_string() }, errors, schema_version, conn.label(), ); match conn { ConnectionState::Ok => ComponentHealth::green(NAME), ConnectionState::Error => ComponentHealth::red(NAME, detail), ConnectionState::Unknown => ComponentHealth::yellow(NAME, detail), } } } fn now_unix_s() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn fetch_error_maps_to_autopilot_error() { let e: AutopilotError = FetchError::Permanent("boom".into()).into(); match e { AutopilotError::Network(s) => assert!(s.contains("boom")), other => panic!("expected Network, got {other:?}"), } } }