Skip to main content

origin_mcp/
client.rs

1use reqwest::Client;
2use serde::{de::DeserializeOwned, Serialize};
3
4const DEFAULT_HTTP_URL: &str = "http://127.0.0.1:7878";
5
6/// Discover the Origin server URL.
7/// Priority: CLI flag > HTTP default.
8/// Note: UDS discovery disabled — reqwest doesn't support unix:// URLs natively.
9/// Origin always binds HTTP on 127.0.0.1:7878 alongside UDS, so HTTP is reliable.
10pub 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/// HTTP client for the Origin REST API.
19#[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    /// GET request, deserialize JSON response.
34    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    /// POST request with JSON body, deserialize JSON response.
64    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        // Collect bytes first so that a body-read failure is distinguished
85        // from a JSON parse failure, and the full error chain is preserved.
86        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            // Include the first 512 bytes of the body to aid debugging without
92            // flooding logs with potentially large payloads.
93            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    /// DELETE request, deserialize JSON response.
103    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        // With no CLI flag and no socket, should fall back to default HTTP
158        let url = discover_origin_url(None);
159        assert_eq!(url, "http://127.0.0.1:7878");
160    }
161}