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
13pub struct EeroClient {
15 pub(crate) http: reqwest::Client,
16 base_url: String,
17 credentials: Box<dyn CredentialStore>,
18}
19
20pub 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 pub fn base_url(mut self, url: impl Into<String>) -> Self {
36 self.base_url = url.into();
37 self
38 }
39
40 pub fn credential_store(mut self, store: Box<dyn CredentialStore>) -> Self {
42 self.credentials = Some(store);
43 self
44 }
45
46 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 pub fn new() -> Result<Self> {
75 ClientBuilder::new().build()
76 }
77
78 pub fn credentials(&self) -> &dyn CredentialStore {
80 &*self.credentials
81 }
82
83 pub(crate) fn url(&self, path: &str) -> String {
85 format!("{}{}", self.base_url, path)
86 }
87
88 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 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 #[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 #[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 #[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 #[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 #[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 pub fn http_client(&self) -> &reqwest::Client {
264 &self.http
265 }
266}