haply 1.3.1

Haply Robotics Client Library for the Inverse Service
Documentation
//! Core HTTP client — struct, private helpers, system/session/settings endpoints.

use crate::device_model::{
    Config, ProfileConfig, ProfileInfo, SessionInfo, VerseGripDuplicateMode, VersionResponse,
};
use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Serialize;

use super::types::{ApiResponse, DevicesResponse, ExpertStatusResponse, SessionListData};

/// HTTP client implementation for the Haply Inverse Service.
pub struct InverseHttpClient {
    pub(crate) client: Client,
    pub(crate) base_url: String,
    pub(crate) verse_grip_mode: VerseGripDuplicateMode,
}

impl InverseHttpClient {
    /// Create a new HTTP client with default options (`PreferCustom` dedup mode).
    pub fn new(base_url: &str) -> Self {
        Self::with_options(base_url, VerseGripDuplicateMode::default())
    }

    /// Create a new HTTP client with a specific verse grip duplicate mode.
    pub fn with_options(base_url: &str, verse_grip_mode: VerseGripDuplicateMode) -> Self {
        Self {
            client: Client::new(),
            base_url: base_url.trim_end_matches('/').to_string(),
            verse_grip_mode,
        }
    }

    // ============================================================
    // Private helpers
    // ============================================================

    pub(crate) fn config_url(&self, device_type: &str, id: &str, setting: &str) -> String {
        format!(
            "{}/{}/{}/config/{}",
            self.base_url, device_type, id, setting
        )
    }

    pub(crate) fn device_url(&self, device_type: &str, id: &str, path: &str) -> String {
        format!("{}/{}/{}/{}", self.base_url, device_type, id, path)
    }

    // Canonical crate format for session-scoped routes is `?session=<expr>`.
    // Some service compatibility paths may also accept `session_id`.
    pub(crate) fn append_session(url: &mut String, session: Option<&str>) {
        if let Some(s) = session {
            url.push_str("?session=");
            url.push_str(s);
        }
    }

    pub(crate) async fn request_envelope<T: DeserializeOwned>(
        &self,
        request: RequestBuilder,
    ) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
        let resp = request.send().await?;
        let status = resp.status();
        let envelope: ApiResponse = resp.json().await?;
        if envelope.ok {
            let data_value = envelope
                .data
                .ok_or_else(|| format!("ok=true but no data field (HTTP {})", status))?;
            let typed: T = serde_json::from_value(data_value)?;
            Ok(typed)
        } else {
            Err(envelope
                .error
                .unwrap_or_else(|| format!("HTTP {}", status))
                .into())
        }
    }

    pub(crate) async fn request_envelope_void(
        &self,
        request: RequestBuilder,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let resp = request.send().await?;
        let status = resp.status();
        let envelope: ApiResponse = resp.json().await?;
        if envelope.ok {
            Ok(())
        } else {
            Err(envelope
                .error
                .unwrap_or_else(|| format!("HTTP {}", status))
                .into())
        }
    }

    pub(crate) async fn get_device_config<T: DeserializeOwned>(
        &self,
        device_type: &str,
        id: &str,
        setting: &str,
        session: Option<&str>,
    ) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
        let mut url = self.config_url(device_type, id, setting);
        Self::append_session(&mut url, session);
        self.request_envelope(self.client.get(&url)).await
    }

    pub(crate) async fn set_device_config<B: Serialize>(
        &self,
        method: Method,
        device_type: &str,
        id: &str,
        setting: &str,
        session: Option<&str>,
        body: &B,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let mut url = self.config_url(device_type, id, setting);
        Self::append_session(&mut url, session);
        self.request_envelope_void(self.client.request(method, &url).json(body))
            .await
    }

    pub(crate) async fn delete_device_config(
        &self,
        device_type: &str,
        id: &str,
        setting: &str,
        session: Option<&str>,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let mut url = self.config_url(device_type, id, setting);
        Self::append_session(&mut url, session);
        self.request_envelope_void(self.client.delete(&url)).await
    }

    // ============================================================
    // System endpoints
    // ============================================================

    pub async fn get_version(
        &self,
    ) -> Result<VersionResponse, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/version", self.base_url);
        let resp = self
            .client
            .get(&url)
            .send()
            .await?
            .json::<VersionResponse>()
            .await?;
        Ok(resp)
    }

    pub async fn get_expert_status(
        &self,
    ) -> Result<ExpertStatusResponse, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/expert/status", self.base_url);
        let resp = self
            .client
            .get(&url)
            .send()
            .await?
            .json::<ExpertStatusResponse>()
            .await?;
        Ok(resp)
    }

    pub async fn get_devices(
        &self,
    ) -> Result<Vec<Config>, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/devices", self.base_url);
        let resp = self
            .client
            .get(&url)
            .send()
            .await?
            .json::<DevicesResponse>()
            .await?;
        Self::map_devices_response(resp, self.verse_grip_mode)
    }

    pub async fn get_devices_for_session(
        &self,
        session: &str,
    ) -> Result<Vec<Config>, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/devices?session={}", self.base_url, session);
        let resp = self
            .client
            .get(&url)
            .send()
            .await?
            .json::<DevicesResponse>()
            .await?;
        Self::map_devices_response(resp, self.verse_grip_mode)
    }

    fn map_devices_response(
        mut resp: DevicesResponse,
        mode: VerseGripDuplicateMode,
    ) -> Result<Vec<Config>, Box<dyn std::error::Error + Send + Sync>> {
        // Apply verse grip dedup on the raw response
        match mode {
            VerseGripDuplicateMode::PreferCustom => {
                let custom_ids: Vec<String> = resp.custom_verse_grip.iter()
                    .map(|d| d.device_id.clone()).collect();
                resp.wireless_verse_grip.retain(|d| !custom_ids.contains(&d.device_id));
            }
            VerseGripDuplicateMode::PreferWireless => {
                let wireless_ids: Vec<String> = resp.wireless_verse_grip.iter()
                    .map(|d| d.device_id.clone()).collect();
                resp.custom_verse_grip.retain(|d| !wireless_ids.contains(&d.device_id));
            }
            VerseGripDuplicateMode::KeepBoth => {}
        }

        let mut configs: Vec<Config> = Vec::new();
        for d in resp.inverse3 {
            let mut config = d.config.ok_or("Missing config for Inverse3 device")?;
            config.id = d.device_id.clone();
            config.device_info.id = d.device_id;
            configs.push(Config::DeviceConfig(config));
        }
        for d in resp.verse_grip {
            let mut config = d.config.ok_or("Missing config for Verse Grip device")?;
            config.id = d.device_id;
            configs.push(Config::VGConfig(config));
        }
        for d in resp.wireless_verse_grip {
            let mut config = d
                .config
                .ok_or("Missing config for Wireless Verse Grip device")?;
            config.id = d.device_id;
            configs.push(Config::WVGConfig(config));
        }
        for d in resp.custom_verse_grip {
            let mut config = d
                .config
                .ok_or("Missing config for Custom Verse Grip device")?;
            config.id = d.device_id;
            configs.push(Config::WVGConfig(config));
        }
        Ok(configs)
    }

    // ============================================================
    // Session endpoints
    // ============================================================

    pub async fn get_sessions(
        &self,
    ) -> Result<SessionListData, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/sessions", self.base_url);
        self.request_envelope(self.client.get(&url)).await
    }

    pub async fn get_session(
        &self,
        id: u64,
    ) -> Result<SessionInfo, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/sessions/{}", self.base_url, id);
        self.request_envelope(self.client.get(&url)).await
    }

    pub async fn get_session_profile(
        &self,
        id: u64,
    ) -> Result<ProfileInfo, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/sessions/{}/profile", self.base_url, id);
        self.request_envelope(self.client.get(&url)).await
    }

    pub async fn set_session_profile(
        &self,
        id: u64,
        profile: &ProfileConfig,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/sessions/{}/profile", self.base_url, id);
        self.request_envelope_void(self.client.post(&url).json(profile))
            .await
    }

    pub async fn delete_session_profile(
        &self,
        id: u64,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/sessions/{}/profile", self.base_url, id);
        self.request_envelope_void(self.client.delete(&url)).await
    }

    // ============================================================
    // Settings endpoints
    // ============================================================

    pub async fn get_settings(
        &self,
    ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/settings", self.base_url);
        self.request_envelope(self.client.get(&url)).await
    }

    pub async fn set_settings(
        &self,
        settings: &serde_json::Value,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/settings", self.base_url);
        self.request_envelope_void(self.client.post(&url).json(settings))
            .await
    }

    pub async fn get_setting(
        &self,
        key: &str,
    ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/settings/{}", self.base_url, key);
        let resp = self
            .client
            .get(&url)
            .send()
            .await?
            .json::<serde_json::Value>()
            .await?;
        Ok(resp)
    }

    pub async fn set_setting(
        &self,
        key: &str,
        value: &serde_json::Value,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/settings/{}", self.base_url, key);
        self.request_envelope_void(self.client.post(&url).json(value))
            .await
    }

    pub async fn delete_setting(
        &self,
        key: &str,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let url = format!("{}/settings/{}", self.base_url, key);
        self.request_envelope_void(self.client.delete(&url)).await
    }
}