homeassistant_cli/api/
mod.rs1pub 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 Auth(String),
15 NotFound(String),
17 InvalidInput(String),
19 Connection(String),
21 Api { status: u16, message: String },
23 Http(reqwest::Error),
25 ConfirmationRequired(String),
27 Conflict(String),
29 Other(String),
31}
32
33impl HaError {
34 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 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
112pub 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 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 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 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}