Skip to main content

horto_os_ui_mcp/
api_client.rs

1//! HTTP client for horto-os-ui-status-api day-2 routes.
2
3use anyhow::{bail, Context, Result};
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
5use serde_json::Value;
6
7use crate::config::McpSettings;
8
9const CONFIRM_BACKUP_ETC: &str = "backup-etc";
10
11/// Thin client over status-api.
12#[derive(Debug, Clone)]
13pub struct StatusApiClient {
14    base: String,
15    token: Option<String>,
16    http: reqwest::Client,
17}
18
19impl StatusApiClient {
20    /// Build from MCP settings.
21    ///
22    /// # Errors
23    ///
24    /// Returns when the HTTP client cannot be built.
25    pub fn from_settings(settings: &McpSettings) -> Result<Self> {
26        let base = settings.status_api_url.trim_end_matches('/').to_owned();
27        let http = reqwest::Client::builder()
28            .timeout(std::time::Duration::from_secs(60))
29            .build()
30            .context("build reqwest client")?;
31        Ok(Self {
32            base,
33            token: settings.api_token.clone(),
34            http,
35        })
36    }
37
38    fn auth_headers(&self) -> Result<HeaderMap> {
39        let mut headers = HeaderMap::new();
40        if let Some(token) = &self.token {
41            let value = HeaderValue::from_str(&format!("Bearer {token}"))
42                .context("invalid HORTO_API_TOKEN for Authorization header")?;
43            headers.insert(AUTHORIZATION, value);
44        }
45        Ok(headers)
46    }
47
48    /// `GET /health`.
49    ///
50    /// # Errors
51    ///
52    /// Returns on transport or non-success HTTP status.
53    pub async fn health(&self) -> Result<Value> {
54        let url = format!("{}/health", self.base);
55        let res = self.http.get(&url).send().await.context("GET /health")?;
56        let status = res.status();
57        let body = res.text().await.context("read /health body")?;
58        if !status.is_success() {
59            bail!("GET /health -> {status}: {body}");
60        }
61        serde_json::from_str(&body).context("parse /health JSON")
62    }
63
64    /// `GET /v1/status`.
65    ///
66    /// # Errors
67    ///
68    /// Returns on transport or non-success HTTP status.
69    pub async fn status(&self) -> Result<Value> {
70        let url = format!("{}/v1/status", self.base);
71        let res = self
72            .http
73            .get(&url)
74            .headers(self.auth_headers()?)
75            .send()
76            .await
77            .context("GET /v1/status")?;
78        let status = res.status();
79        let body = res.text().await.context("read /v1/status body")?;
80        if !status.is_success() {
81            bail!("GET /v1/status -> {status}: {body}");
82        }
83        serde_json::from_str(&body).context("parse /v1/status JSON")
84    }
85
86    /// Confirmed `POST /v1/backup/etc`.
87    ///
88    /// # Errors
89    ///
90    /// Returns when confirm is wrong, token missing, or the API rejects the call.
91    pub async fn backup_etc(&self, confirm: &str) -> Result<Value> {
92        if confirm != CONFIRM_BACKUP_ETC {
93            bail!("confirm must be exactly `{CONFIRM_BACKUP_ETC}`");
94        }
95        if self
96            .token
97            .as_ref()
98            .is_none_or(std::string::String::is_empty)
99        {
100            bail!("HORTO_API_TOKEN required for backup_etc");
101        }
102        let url = format!("{}/v1/backup/etc", self.base);
103        let mut headers = self.auth_headers()?;
104        headers.insert(
105            "X-Horto-Confirm",
106            HeaderValue::from_static(CONFIRM_BACKUP_ETC),
107        );
108        let res = self
109            .http
110            .post(&url)
111            .headers(headers)
112            .send()
113            .await
114            .context("POST /v1/backup/etc")?;
115        let status = res.status();
116        let body = res.text().await.context("read backup body")?;
117        if !status.is_success() {
118            bail!("POST /v1/backup/etc -> {status}: {body}");
119        }
120        if body.trim().is_empty() {
121            return Ok(serde_json::json!({ "ok": true }));
122        }
123        serde_json::from_str(&body).or_else(|_| Ok(serde_json::json!({ "ok": true, "raw": body })))
124    }
125}