Skip to main content

homeassistant_cli/api/
mod.rs

1pub mod entities;
2pub mod events;
3pub mod services;
4pub mod types;
5pub mod websocket;
6
7pub use types::*;
8
9use std::fmt;
10
11#[derive(Debug)]
12pub enum HaError {
13    /// 401/403 from HA API.
14    Auth(String),
15    /// 404 — entity, service, or resource not found.
16    NotFound(String),
17    /// Missing or invalid config/input.
18    InvalidInput(String),
19    /// Could not reach Home Assistant.
20    Connection(String),
21    /// Non-2xx response.
22    Api { status: u16, message: String },
23    /// Network/TLS error from reqwest.
24    Http(reqwest::Error),
25    /// Destructive command requires --yes but stdin is not a TTY.
26    ConfirmationRequired(String),
27    /// Resource exists with a different configuration than requested.
28    Conflict(String),
29    /// Any other error.
30    Other(String),
31}
32
33impl HaError {
34    /// Stable, snake_case kind identifier used in the structured error envelope.
35    /// Consumers branch on this field without parsing the message.
36    pub fn error_kind(&self) -> &str {
37        match self {
38            HaError::Auth(_) => "auth",
39            HaError::NotFound(_) => "not_found",
40            HaError::InvalidInput(_) => "invalid_input",
41            HaError::Connection(_) => "connection",
42            HaError::Api { .. } => "api_error",
43            HaError::Http(_) => "http_error",
44            HaError::ConfirmationRequired(_) => "confirmation_required",
45            HaError::Conflict(_) => "conflict",
46            HaError::Other(_) => "error",
47        }
48    }
49
50    /// Optional actionable hint for the error envelope. May be null in JSON.
51    pub fn error_hint(&self) -> Option<&str> {
52        match self {
53            HaError::Auth(_) => Some("Run `ha init` to set up or refresh credentials."),
54            HaError::ConfirmationRequired(_) => {
55                Some("Re-run with --yes to bypass the confirmation prompt.")
56            }
57            HaError::Connection(_) => {
58                Some("Check that Home Assistant is reachable and the URL is correct.")
59            }
60            _ => None,
61        }
62    }
63}
64
65impl fmt::Display for HaError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            HaError::Auth(msg) => write!(f, "Authentication failed: {msg}"),
69            HaError::NotFound(msg) => write!(f, "Not found: {msg}"),
70            HaError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
71            HaError::Connection(url) => {
72                write!(f, "Could not connect to Home Assistant at {url}")
73            }
74            HaError::Api { status, message } => write!(f, "API error {status}: {message}"),
75            HaError::Http(e) => write!(f, "HTTP error: {e}"),
76            HaError::ConfirmationRequired(msg) => write!(f, "{msg}"),
77            HaError::Conflict(msg) => write!(f, "Conflict: {msg}"),
78            HaError::Other(msg) => write!(f, "{msg}"),
79        }
80    }
81}
82
83impl std::error::Error for HaError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            HaError::Http(e) => Some(e),
87            _ => None,
88        }
89    }
90}
91
92impl From<std::io::Error> for HaError {
93    fn from(e: std::io::Error) -> Self {
94        HaError::Other(e.to_string())
95    }
96}
97
98impl From<reqwest::Error> for HaError {
99    fn from(e: reqwest::Error) -> Self {
100        if e.is_connect() || e.is_timeout() {
101            HaError::Connection(
102                e.url()
103                    .map(|u| u.to_string())
104                    .unwrap_or_else(|| "unknown".into()),
105            )
106        } else {
107            HaError::Http(e)
108        }
109    }
110}
111
112/// HTTP client for the Home Assistant REST API.
113pub struct HaClient {
114    pub base_url: String,
115    token: String,
116    pub(crate) client: reqwest::Client,
117}
118
119impl HaClient {
120    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
121        Self {
122            base_url: base_url.into().trim_end_matches('/').to_owned(),
123            token: token.into(),
124            client: reqwest::Client::builder()
125                .timeout(std::time::Duration::from_secs(30))
126                .build()
127                .expect("build reqwest client"),
128        }
129    }
130
131    pub fn token(&self) -> &str {
132        &self.token
133    }
134
135    /// Returns a GET request builder pre-configured with Bearer auth.
136    pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
137        self.client
138            .get(format!("{}{}", self.base_url, path))
139            .bearer_auth(&self.token)
140    }
141
142    /// Returns a POST request builder pre-configured with Bearer auth.
143    pub fn post(&self, path: &str) -> reqwest::RequestBuilder {
144        self.client
145            .post(format!("{}{}", self.base_url, path))
146            .bearer_auth(&self.token)
147    }
148
149    /// Validate the connection by calling GET /api/
150    pub async fn validate(&self) -> Result<String, HaError> {
151        let resp = self.get("/api/").send().await?;
152        match resp.status().as_u16() {
153            200 => {
154                let body: serde_json::Value = resp.json().await?;
155                Ok(body["message"]
156                    .as_str()
157                    .unwrap_or("API running.")
158                    .to_owned())
159            }
160            401 | 403 => Err(HaError::Auth("Invalid token".into())),
161            status => Err(HaError::Api {
162                status,
163                message: resp.text().await.unwrap_or_default(),
164            }),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::error::Error;
173
174    #[test]
175    fn error_kind_returns_snake_case_identifiers() {
176        assert_eq!(HaError::Auth("x".into()).error_kind(), "auth");
177        assert_eq!(HaError::NotFound("x".into()).error_kind(), "not_found");
178        assert_eq!(
179            HaError::InvalidInput("x".into()).error_kind(),
180            "invalid_input"
181        );
182        assert_eq!(HaError::Connection("x".into()).error_kind(), "connection");
183        assert_eq!(
184            HaError::Api {
185                status: 500,
186                message: "x".into()
187            }
188            .error_kind(),
189            "api_error"
190        );
191        assert_eq!(HaError::Other("x".into()).error_kind(), "error");
192        assert_eq!(
193            HaError::ConfirmationRequired("x".into()).error_kind(),
194            "confirmation_required"
195        );
196        assert_eq!(HaError::Conflict("x".into()).error_kind(), "conflict");
197    }
198
199    #[test]
200    fn auth_error_hint_suggests_init() {
201        let err = HaError::Auth("expired".into());
202        assert!(
203            err.error_hint().unwrap_or("").contains("ha init"),
204            "auth hint must mention ha init"
205        );
206    }
207
208    #[test]
209    fn confirmation_required_hint_mentions_yes_flag() {
210        let err = HaError::ConfirmationRequired("delete requires confirmation".into());
211        assert!(
212            err.error_hint().unwrap_or("").contains("--yes"),
213            "confirmation_required hint must mention --yes"
214        );
215    }
216
217    #[test]
218    fn auth_error_display_includes_guidance() {
219        let err = HaError::Auth("401 Unauthorized".into());
220        let msg = err.to_string();
221        assert!(msg.contains("Authentication failed"));
222    }
223
224    #[test]
225    fn not_found_display_includes_entity() {
226        let err = HaError::NotFound("light.missing".into());
227        assert!(err.to_string().contains("light.missing"));
228    }
229
230    #[test]
231    fn connection_error_mentions_url() {
232        let err = HaError::Connection("http://ha.local:8123".into());
233        assert!(err.to_string().contains("http://ha.local:8123"));
234    }
235
236    #[test]
237    fn http_error_source_is_reqwest() {
238        let rt = tokio::runtime::Runtime::new().unwrap();
239        let reqwest_err = rt.block_on(async {
240            reqwest::Client::new()
241                .get("http://127.0.0.1:1")
242                .send()
243                .await
244                .unwrap_err()
245        });
246        let api_err = HaError::Http(reqwest_err);
247        assert!(api_err.source().is_some());
248    }
249
250    #[test]
251    fn ha_client_new_trims_trailing_slash() {
252        let client = HaClient::new("http://ha.local:8123/", "token");
253        assert_eq!(client.base_url, "http://ha.local:8123");
254    }
255}