1use reqwest::Client;
2use serde::{de::DeserializeOwned, Serialize};
3
4const DEFAULT_HTTP_URL: &str = "http://127.0.0.1:7878";
5
6pub fn discover_origin_url(cli_url: Option<String>) -> String {
11 if let Some(url) = cli_url {
12 return url;
13 }
14
15 DEFAULT_HTTP_URL.to_string()
16}
17
18#[derive(Clone)]
20pub struct OriginClient {
21 client: Client,
22 base_url: String,
23}
24
25impl OriginClient {
26 pub fn new(base_url: String) -> Self {
27 Self {
28 client: Client::new(),
29 base_url,
30 }
31 }
32
33 pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
35 let url = format!("{}{}", self.base_url, path);
36 let resp = self
37 .client
38 .get(&url)
39 .send()
40 .await
41 .map_err(|e| OriginError::Unreachable(e.to_string()))?;
42
43 if !resp.status().is_success() {
44 let status = resp.status().as_u16();
45 let body = resp.text().await.unwrap_or_default();
46 return Err(OriginError::Api { status, body });
47 }
48
49 let bytes = resp.bytes().await.map_err(|e| {
50 OriginError::Deserialize(format!("failed to read response body: {e:#}"))
51 })?;
52
53 serde_json::from_slice::<R>(&bytes).map_err(|e| {
54 let preview = std::str::from_utf8(&bytes)
55 .unwrap_or("<non-utf8>")
56 .chars()
57 .take(512)
58 .collect::<String>();
59 OriginError::Deserialize(format!("{e} (body preview: {preview})"))
60 })
61 }
62
63 pub async fn post<B: Serialize, R: DeserializeOwned>(
65 &self,
66 path: &str,
67 body: &B,
68 ) -> Result<R, OriginError> {
69 let url = format!("{}{}", self.base_url, path);
70 let resp = self
71 .client
72 .post(&url)
73 .json(body)
74 .send()
75 .await
76 .map_err(|e| OriginError::Unreachable(e.to_string()))?;
77
78 if !resp.status().is_success() {
79 let status = resp.status().as_u16();
80 let body = resp.text().await.unwrap_or_default();
81 return Err(OriginError::Api { status, body });
82 }
83
84 let bytes = resp.bytes().await.map_err(|e| {
87 OriginError::Deserialize(format!("failed to read response body: {e:#}"))
88 })?;
89
90 serde_json::from_slice::<R>(&bytes).map_err(|e| {
91 let preview = std::str::from_utf8(&bytes)
94 .unwrap_or("<non-utf8>")
95 .chars()
96 .take(512)
97 .collect::<String>();
98 OriginError::Deserialize(format!("{e} (body preview: {preview})"))
99 })
100 }
101
102 pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
104 let url = format!("{}{}", self.base_url, path);
105 let resp = self
106 .client
107 .delete(&url)
108 .send()
109 .await
110 .map_err(|e| OriginError::Unreachable(e.to_string()))?;
111
112 if !resp.status().is_success() {
113 let status = resp.status().as_u16();
114 let body = resp.text().await.unwrap_or_default();
115 return Err(OriginError::Api { status, body });
116 }
117
118 let bytes = resp.bytes().await.map_err(|e| {
119 OriginError::Deserialize(format!("failed to read response body: {e:#}"))
120 })?;
121
122 serde_json::from_slice::<R>(&bytes).map_err(|e| {
123 let preview = std::str::from_utf8(&bytes)
124 .unwrap_or("<non-utf8>")
125 .chars()
126 .take(512)
127 .collect::<String>();
128 OriginError::Deserialize(format!("{e} (body preview: {preview})"))
129 })
130 }
131}
132
133#[derive(Debug, thiserror::Error)]
134pub enum OriginError {
135 #[error("Origin is not reachable: {0}")]
136 Unreachable(String),
137
138 #[error("Origin API error (HTTP {status}): {body}")]
139 Api { status: u16, body: String },
140
141 #[error("Failed to parse Origin response: {0}")]
142 Deserialize(String),
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_discover_url_prefers_cli_flag() {
151 let url = discover_origin_url(Some("http://localhost:9999".into()));
152 assert_eq!(url, "http://localhost:9999");
153 }
154
155 #[test]
156 fn test_discover_url_falls_back_to_http() {
157 let url = discover_origin_url(None);
159 assert_eq!(url, "http://127.0.0.1:7878");
160 }
161}