horto_os_ui_mcp/
api_client.rs1use 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#[derive(Debug, Clone)]
13pub struct StatusApiClient {
14 base: String,
15 token: Option<String>,
16 http: reqwest::Client,
17}
18
19impl StatusApiClient {
20 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 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 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 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}