Skip to main content

eero_api/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue, COOKIE};
2use serde::de::DeserializeOwned;
3use serde::Serialize;
4
5use crate::credential::file::FileStore;
6use crate::credential::CredentialStore;
7use crate::error::{Error, Result};
8use crate::types::envelope::ApiResponse;
9
10const BASE_URL: &str = "https://api-user.e2ro.com/2.2";
11const USER_AGENT: &str = "eero-api-rs/0.1";
12
13/// Client for the eero WiFi router API.
14pub struct EeroClient {
15    pub(crate) http: reqwest::Client,
16    base_url: String,
17    credentials: Box<dyn CredentialStore>,
18}
19
20/// Builder for constructing an [`EeroClient`].
21pub struct ClientBuilder {
22    base_url: String,
23    credentials: Option<Box<dyn CredentialStore>>,
24}
25
26impl ClientBuilder {
27    pub fn new() -> Self {
28        Self {
29            base_url: BASE_URL.to_string(),
30            credentials: None,
31        }
32    }
33
34    /// Set the API base URL (for testing or alternative endpoints).
35    pub fn base_url(mut self, url: impl Into<String>) -> Self {
36        self.base_url = url.into();
37        self
38    }
39
40    /// Set the credential store implementation.
41    pub fn credential_store(mut self, store: Box<dyn CredentialStore>) -> Self {
42        self.credentials = Some(store);
43        self
44    }
45
46    /// Build the client.
47    pub fn build(self) -> Result<EeroClient> {
48        let credentials = match self.credentials {
49            Some(c) => c,
50            None => Box::new(FileStore::new()?),
51        };
52
53        let http = reqwest::Client::builder()
54            .user_agent(USER_AGENT)
55            .build()
56            .map_err(Error::Http)?;
57
58        Ok(EeroClient {
59            http,
60            base_url: self.base_url,
61            credentials,
62        })
63    }
64}
65
66impl Default for ClientBuilder {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl EeroClient {
73    /// Create a new client with default settings.
74    pub fn new() -> Result<Self> {
75        ClientBuilder::new().build()
76    }
77
78    /// Access the credential store.
79    pub fn credentials(&self) -> &dyn CredentialStore {
80        &*self.credentials
81    }
82
83    /// Build a full URL from a path (e.g. `/login`).
84    pub(crate) fn url(&self, path: &str) -> String {
85        format!("{}{}", self.base_url, path)
86    }
87
88    /// Build a full URL from an eero resource URL.
89    ///
90    /// Resource URLs from the API look like `/2.2/networks/12345`. If the url
91    /// already starts with `http`, return it as-is. Otherwise prepend the base
92    /// host.
93    pub(crate) fn resource_url(&self, resource: &str) -> String {
94        if resource.starts_with("http") {
95            resource.to_string()
96        } else {
97            let scheme_end = self.base_url.find("://").map(|i| i + 3).unwrap_or(0);
98            let origin_end = self.base_url[scheme_end..]
99                .find('/')
100                .map(|i| scheme_end + i)
101                .unwrap_or(self.base_url.len());
102            format!("{}{resource}", &self.base_url[..origin_end])
103        }
104    }
105
106    pub(crate) async fn session_headers(&self) -> Result<HeaderMap> {
107        let mut headers = HeaderMap::new();
108        let token = self.credentials.get_session_token().await?;
109        let token = match token {
110            Some(t) => {
111                tracing::debug!("using session token");
112                Some(t)
113            }
114            None => {
115                let ut = self.credentials.get_user_token().await?;
116                if ut.is_some() {
117                    tracing::debug!("no session token, falling back to user token");
118                } else {
119                    tracing::debug!("no session token or user token found");
120                }
121                ut
122            }
123        };
124        if let Some(token) = token {
125            tracing::debug!("attaching cookie");
126            headers.insert(
127                COOKIE,
128                HeaderValue::from_str(&format!("s={token}"))
129                    .map_err(|e| Error::CredentialStore(e.to_string()))?,
130            );
131        } else {
132            tracing::debug!("no session token available");
133        }
134        Ok(headers)
135    }
136
137    /// Extract response data, returning an error for non-success status codes.
138    pub fn unwrap_response<T>(response: ApiResponse<T>) -> Result<T> {
139        let code = response.meta.code;
140        if (200..300).contains(&code) {
141            response
142                .data
143                .ok_or_else(|| Error::Api {
144                    code,
145                    message: "response contained no data".into(),
146                })
147        } else {
148            Err(Error::Api {
149                code,
150                message: response
151                    .meta
152                    .error
153                    .unwrap_or_else(|| "unknown error".into()),
154            })
155        }
156    }
157
158    /// Perform an authenticated GET request.
159    #[tracing::instrument(skip(self))]
160    pub(crate) async fn get<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
161        let headers = self.session_headers().await?;
162        tracing::debug!(request_headers = ?headers, "GET request");
163        let resp = self
164            .http
165            .get(url)
166            .headers(headers)
167            .send()
168            .await?;
169        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "GET response");
170        let text = resp.text().await?;
171        tracing::debug!(body = %text, "GET response body");
172        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
173        Self::unwrap_response(parsed)
174    }
175
176    /// Perform an authenticated POST request with a JSON body.
177    #[tracing::instrument(skip(self, body))]
178    pub(crate) async fn post<B: Serialize, T: DeserializeOwned>(
179        &self,
180        url: &str,
181        body: &B,
182    ) -> Result<T> {
183        let headers = self.session_headers().await?;
184        let body_json = serde_json::to_string(body)?;
185        tracing::debug!(request_headers = ?headers, body = %body_json, "POST request");
186        let resp = self
187            .http
188            .post(url)
189            .headers(headers)
190            .header("content-type", "application/json")
191            .body(body_json)
192            .send()
193            .await?;
194        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "POST response");
195        let text = resp.text().await?;
196        tracing::debug!(body = %text, "POST response body");
197        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
198        Self::unwrap_response(parsed)
199    }
200
201    /// Perform an authenticated POST request without a body.
202    #[tracing::instrument(skip(self))]
203    pub(crate) async fn post_empty<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
204        let headers = self.session_headers().await?;
205        tracing::debug!(request_headers = ?headers, "POST (empty) request");
206        let resp = self
207            .http
208            .post(url)
209            .headers(headers)
210            .send()
211            .await?;
212        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "POST (empty) response");
213        let text = resp.text().await?;
214        tracing::debug!(body = %text, "POST (empty) response body");
215        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
216        Self::unwrap_response(parsed)
217    }
218
219    /// Perform an authenticated PUT request with a JSON body.
220    #[tracing::instrument(skip(self, body))]
221    pub(crate) async fn put<B: Serialize, T: DeserializeOwned>(
222        &self,
223        url: &str,
224        body: &B,
225    ) -> Result<T> {
226        let headers = self.session_headers().await?;
227        let body_json = serde_json::to_string(body)?;
228        tracing::debug!(request_headers = ?headers, body = %body_json, "PUT request");
229        let resp = self
230            .http
231            .put(url)
232            .headers(headers)
233            .header("content-type", "application/json")
234            .body(body_json)
235            .send()
236            .await?;
237        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "PUT response");
238        let text = resp.text().await?;
239        tracing::debug!(body = %text, "PUT response body");
240        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
241        Self::unwrap_response(parsed)
242    }
243
244    /// Perform an authenticated DELETE request.
245    #[tracing::instrument(skip(self))]
246    pub(crate) async fn delete<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
247        let headers = self.session_headers().await?;
248        tracing::debug!(request_headers = ?headers, "DELETE request");
249        let resp = self
250            .http
251            .delete(url)
252            .headers(headers)
253            .send()
254            .await?;
255        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "DELETE response");
256        let text = resp.text().await?;
257        tracing::debug!(body = %text, "DELETE response body");
258        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
259        Self::unwrap_response(parsed)
260    }
261
262    /// Access the raw reqwest HTTP client (for advanced use cases).
263    pub fn http_client(&self) -> &reqwest::Client {
264        &self.http
265    }
266}