1use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
8use reqwest::{Method, Response, StatusCode};
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11use url::Url;
12
13use crate::errors::HeyoError;
14
15const DEFAULT_BASE_URL: &str = "https://server.heyo.computer";
16const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
17
18pub const DEFAULT_LOCAL_BASE_URL: &str = "http://127.0.0.1:34099";
22
23#[derive(Debug, Default, Clone)]
26pub struct HeyoClientOptions {
27 pub api_key: Option<String>,
29 pub base_url: Option<String>,
31 pub timeout: Option<Duration>,
33}
34
35#[derive(Debug, Default, Clone)]
37pub struct RequestOptions {
38 pub timeout: Option<Duration>,
39 pub query: Vec<(String, String)>,
41}
42
43#[derive(Clone)]
44pub struct HeyoClient {
45 inner: Arc<Inner>,
46}
47
48struct Inner {
49 api_key: Option<String>,
51 base_url: String,
52 http: reqwest::Client,
53 default_timeout: Duration,
54 _tunnel: Option<TunnelGuard>,
58}
59
60struct TunnelGuard(tokio::task::JoinHandle<()>);
63
64impl Drop for TunnelGuard {
65 fn drop(&mut self) {
66 self.0.abort();
67 }
68}
69
70impl HeyoClient {
71 pub fn new(opts: HeyoClientOptions) -> Result<Self, HeyoError> {
72 let api_key = opts
77 .api_key
78 .or_else(|| std::env::var("HEYO_API_KEY").ok())
79 .filter(|k| !k.is_empty());
80 let base_url = opts
81 .base_url
82 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
83 .trim_end_matches('/')
84 .to_string();
85 let http = reqwest::Client::builder()
86 .build()
87 .map_err(|e| HeyoError::Connection(e.to_string()))?;
88 Ok(Self {
89 inner: Arc::new(Inner {
90 api_key,
91 base_url,
92 http,
93 default_timeout: opts.timeout.unwrap_or(DEFAULT_TIMEOUT),
94 _tunnel: None,
95 }),
96 })
97 }
98
99 pub fn local() -> Result<Self, HeyoError> {
105 Self::local_at(DEFAULT_LOCAL_BASE_URL)
106 }
107
108 pub fn local_at(base_url: impl Into<String>) -> Result<Self, HeyoError> {
111 Self::new(HeyoClientOptions {
112 base_url: Some(base_url.into()),
113 api_key: None,
114 timeout: None,
115 })
116 }
117
118 pub async fn connect_p2p(
130 ticket: &str,
131 relay: Option<&str>,
132 api_key: Option<String>,
133 ) -> Result<Self, HeyoError> {
134 let proxy = crate::proxy::Client::connect(ticket, 0, relay)
135 .await
136 .map_err(|e| HeyoError::Connection(format!("iroh P2P connect failed: {e}")))?;
137 let local = proxy
138 .local_addr()
139 .map_err(|e| HeyoError::Connection(format!("tunnel local_addr: {e}")))?;
140 let base_url = format!("http://{}", local);
141 let handle = tokio::spawn(async move {
142 let _ = proxy.run().await;
146 });
147 let http = reqwest::Client::builder()
148 .build()
149 .map_err(|e| HeyoError::Connection(e.to_string()))?;
150 Ok(Self {
151 inner: Arc::new(Inner {
152 api_key: api_key.filter(|k| !k.is_empty()),
153 base_url,
154 http,
155 default_timeout: DEFAULT_TIMEOUT,
156 _tunnel: Some(TunnelGuard(handle)),
157 }),
158 })
159 }
160
161 pub fn base_url(&self) -> &str {
162 &self.inner.base_url
163 }
164
165 #[allow(dead_code)]
166 pub(crate) fn api_key(&self) -> Option<&str> {
167 self.inner.api_key.as_deref()
168 }
169
170 pub async fn request<T: DeserializeOwned>(
172 &self,
173 method: Method,
174 path: &str,
175 body: Option<&(impl Serialize + ?Sized)>,
176 opts: RequestOptions,
177 ) -> Result<T, HeyoError> {
178 let bytes = self.request_bytes(method, path, body, opts).await?;
179 if bytes.is_empty() {
180 return serde_json::from_slice::<T>(b"null").map_err(|e| {
184 HeyoError::api(0, format!("empty response body could not be parsed: {}", e))
185 });
186 }
187 serde_json::from_slice::<T>(&bytes)
188 .map_err(|e| HeyoError::api(0, format!("invalid JSON response: {}", e)))
189 }
190
191 pub async fn request_bytes(
194 &self,
195 method: Method,
196 path: &str,
197 body: Option<&(impl Serialize + ?Sized)>,
198 opts: RequestOptions,
199 ) -> Result<Vec<u8>, HeyoError> {
200 let response = self.raw_request(method, path, body, opts).await?;
201 self.consume_response(response, path).await
202 }
203
204 pub async fn raw_request(
208 &self,
209 method: Method,
210 path: &str,
211 body: Option<&(impl Serialize + ?Sized)>,
212 opts: RequestOptions,
213 ) -> Result<Response, HeyoError> {
214 let url = self.build_url(path, &opts.query)?;
215 let mut headers = HeaderMap::new();
216 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
217 if let Some(key) = &self.inner.api_key {
218 let auth = format!("Bearer {}", key);
219 headers.insert(
220 AUTHORIZATION,
221 HeaderValue::from_str(&auth)
222 .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
223 );
224 }
225 let mut builder = self
226 .inner
227 .http
228 .request(method, url)
229 .headers(headers)
230 .timeout(opts.timeout.unwrap_or(self.inner.default_timeout));
231 if let Some(body) = body {
232 builder = builder
233 .header(CONTENT_TYPE, "application/json")
234 .json(body);
235 }
236 builder
237 .send()
238 .await
239 .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
240 }
241
242 pub async fn put_bytes(
245 &self,
246 path: &str,
247 body: Vec<u8>,
248 content_type: &str,
249 opts: RequestOptions,
250 ) -> Result<Response, HeyoError> {
251 let url = self.build_url(path, &opts.query)?;
252 let mut headers = HeaderMap::new();
253 if let Some(key) = &self.inner.api_key {
254 let auth = format!("Bearer {}", key);
255 headers.insert(
256 AUTHORIZATION,
257 HeaderValue::from_str(&auth)
258 .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
259 );
260 }
261 headers.insert(
262 CONTENT_TYPE,
263 HeaderValue::from_str(content_type)
264 .map_err(|e| HeyoError::api(0, format!("invalid content-type: {}", e)))?,
265 );
266 self.inner
267 .http
268 .request(Method::PUT, url)
269 .headers(headers)
270 .timeout(opts.timeout.unwrap_or(self.inner.default_timeout))
271 .body(body)
272 .send()
273 .await
274 .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
275 }
276
277 pub(crate) fn ws_url(&self, path: &str) -> Result<String, HeyoError> {
280 let http_url = self.build_url(path, &[])?;
281 let mut parsed = Url::parse(&http_url)
282 .map_err(|e| HeyoError::Connection(format!("bad URL {}: {}", http_url, e)))?;
283 let scheme = match parsed.scheme() {
284 "https" => "wss",
285 "http" => "ws",
286 other => return Err(HeyoError::Connection(format!("unsupported scheme {}", other))),
287 };
288 parsed
289 .set_scheme(scheme)
290 .map_err(|_| HeyoError::Connection("could not swap to ws scheme".into()))?;
291 Ok(parsed.to_string())
292 }
293
294 pub(crate) fn ws_authorization(&self) -> String {
295 match &self.inner.api_key {
298 Some(key) => format!("Bearer {}", key),
299 None => String::new(),
300 }
301 }
302
303 fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<String, HeyoError> {
304 let clean = if path.starts_with('/') {
305 path.to_string()
306 } else {
307 format!("/{}", path)
308 };
309 let mut url = Url::parse(&format!("{}{}", self.inner.base_url, clean))
310 .map_err(|e| HeyoError::api(0, format!("bad URL {}{}: {}", self.inner.base_url, clean, e)))?;
311 if !query.is_empty() {
312 let mut pairs = url.query_pairs_mut();
313 for (k, v) in query {
314 pairs.append_pair(k, v);
315 }
316 }
317 Ok(url.to_string())
318 }
319
320 async fn consume_response(
321 &self,
322 response: Response,
323 path: &str,
324 ) -> Result<Vec<u8>, HeyoError> {
325 let status = response.status();
326 let bytes = response
327 .bytes()
328 .await
329 .map_err(|e| HeyoError::api(0, format!("read body for {}: {}", path, e)))?;
330 if status.is_success() {
331 if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
332 return Ok(Vec::new());
333 }
334 return Ok(bytes.to_vec());
335 }
336
337 let mut message = format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
338 let mut parsed_body: Option<serde_json::Value> = None;
339 if !bytes.is_empty() {
340 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
341 if let Some(m) = v.get("message").and_then(|x| x.as_str()) {
342 message = m.to_string();
343 } else if let Some(e) = v.get("error").and_then(|x| x.as_str()) {
344 message = e.to_string();
345 }
346 parsed_body = Some(v);
347 } else if let Ok(text) = std::str::from_utf8(&bytes) {
348 message = text.to_string();
349 }
350 }
351
352 let with_path = format!("{} (calling {})", message, path);
353 Err(match status {
354 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => HeyoError::Authentication,
355 StatusCode::NOT_FOUND => HeyoError::NotFound(with_path),
356 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
357 HeyoError::InvalidArgument(with_path)
358 }
359 _ => HeyoError::api_with_body(status.as_u16(), with_path, parsed_body),
360 })
361 }
362}
363
364impl std::fmt::Debug for HeyoClient {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 f.debug_struct("HeyoClient")
367 .field("base_url", &self.inner.base_url)
368 .field("default_timeout", &self.inner.default_timeout)
369 .finish_non_exhaustive()
370 }
371}