Skip to main content

moltbook_cli/api/
client.rs

1//! The core HTTP client for the Moltbook API.
2//!
3//! This module provides the `MoltbookClient` which handles authentication headers,
4//! rate limit parsing, CAPTCHA detection, and JSON serialization/deserialization
5//! for all API interactions.
6
7use crate::api::error::ApiError;
8use mime_guess::from_path;
9use reqwest::Client;
10use serde::{Serialize, de::DeserializeOwned};
11use serde_json::Value;
12use std::path::PathBuf;
13use std::time::Duration;
14
15/// The default base URL for the Moltbook API.
16const DEFAULT_API_BASE: &str = "https://www.moltbook.com/api/v1";
17
18/// A thread-safe, asynchronous client for the Moltbook API.
19///
20/// Designed to be reused throughout the application lifecycle to benefit from
21/// connection pooling and internal state management.
22pub struct MoltbookClient {
23    client: Client,
24    api_key: String,
25    pub agent_name: String,
26    debug: bool,
27    base_url: String,
28}
29
30impl MoltbookClient {
31    /// Creates a new `MoltbookClient` instance.
32    ///
33    /// # Arguments
34    ///
35    /// * `api_key` - The API key for authentication.
36    /// * `debug` - If true, logs all requests and responses to stderr.
37    pub fn new(api_key: String, agent_name: String, debug: bool) -> Self {
38        Self {
39            client: Client::builder()
40                .timeout(Duration::from_secs(30))
41                .connect_timeout(Duration::from_secs(10))
42                .build()
43                .expect("Failed to build HTTP client"),
44            api_key,
45            agent_name,
46            debug,
47            base_url: DEFAULT_API_BASE.to_string(),
48        }
49    }
50
51    /// Overrides the default base URL (useful for testing).
52    pub fn with_base_url(mut self, base_url: String) -> Self {
53        self.base_url = base_url;
54        self
55    }
56
57    /// Performs a GET request to the specified endpoint.
58    ///
59    /// # Errors
60    ///
61    /// Returns `ApiError` if the network fails, the API returns an error, or parsing fails.
62    pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, ApiError> {
63        let url = format!("{}{}", self.base_url, endpoint);
64
65        if self.debug {
66            eprintln!("GET {}", url);
67        }
68
69        let response = self
70            .client
71            .get(&url)
72            .header("Authorization", format!("Bearer {}", self.api_key))
73            .send()
74            .await?;
75
76        self.handle_response(response).await
77    }
78
79    /// Performs a POST request with a JSON body.
80    ///
81    /// # Errors
82    ///
83    /// Returns `ApiError` if the network fails, the API returns an error, or serialization/parsing fails.
84    pub async fn post<T: DeserializeOwned>(
85        &self,
86        endpoint: &str,
87        body: &impl Serialize,
88    ) -> Result<T, ApiError> {
89        let url = format!("{}{}", self.base_url, endpoint);
90
91        if self.debug {
92            eprintln!("POST {}", url);
93            eprintln!(
94                "Body: {}",
95                serde_json::to_string_pretty(&body).unwrap_or_default()
96            );
97        }
98
99        let response = self
100            .client
101            .post(&url)
102            .header("Authorization", format!("Bearer {}", self.api_key))
103            .header("Content-Type", "application/json")
104            .json(body)
105            .send()
106            .await?;
107
108        self.handle_response(response).await
109    }
110
111    /// Performs an unauthenticated POST request with a JSON body.
112    pub async fn post_unauth<T: DeserializeOwned>(
113        &self,
114        endpoint: &str,
115        body: &impl Serialize,
116    ) -> Result<T, ApiError> {
117        let url = format!("{}{}", self.base_url, endpoint);
118
119        if self.debug {
120            eprintln!("POST (unauth) {}", url);
121            eprintln!(
122                "Body: {}",
123                serde_json::to_string_pretty(&body).unwrap_or_default()
124            );
125        }
126
127        let response = self
128            .client
129            .post(&url)
130            .header("Content-Type", "application/json")
131            .json(body)
132            .send()
133            .await?;
134
135        self.handle_response(response).await
136    }
137
138    /// Uploads a file using multipart/form-data.
139    ///
140    /// Typically used for avatar updates.
141    ///
142    /// # Errors
143    ///
144    /// Returns `ApiError` if the file cannot be read or the upload fails.
145    pub async fn post_file<T: DeserializeOwned>(
146        &self,
147        endpoint: &str,
148        file_path: PathBuf,
149    ) -> Result<T, ApiError> {
150        let url = format!("{}{}", self.base_url, endpoint);
151
152        let file_name = file_path
153            .file_name()
154            .unwrap_or_default()
155            .to_string_lossy()
156            .to_string();
157
158        let file_contents = std::fs::read(&file_path).map_err(ApiError::IoError)?;
159
160        let mime_type = from_path(&file_path).first_or_octet_stream();
161        let part = reqwest::multipart::Part::bytes(file_contents)
162            .file_name(file_name)
163            .mime_str(mime_type.as_ref())?;
164        let form = reqwest::multipart::Form::new().part("file", part);
165
166        if self.debug {
167            eprintln!("POST (File) {}", url);
168            eprintln!("File: {:?}", file_path);
169        }
170
171        let response = self
172            .client
173            .post(&url)
174            .header("Authorization", format!("Bearer {}", self.api_key))
175            .multipart(form)
176            .send()
177            .await?;
178
179        self.handle_response(response).await
180    }
181
182    /// Performs a PATCH request with a JSON body.
183    pub async fn patch<T: DeserializeOwned>(
184        &self,
185        endpoint: &str,
186        body: &impl Serialize,
187    ) -> Result<T, ApiError> {
188        let url = format!("{}{}", self.base_url, endpoint);
189
190        if self.debug {
191            eprintln!("PATCH {}", url);
192            eprintln!(
193                "Body: {}",
194                serde_json::to_string_pretty(&body).unwrap_or_default()
195            );
196        }
197
198        let response = self
199            .client
200            .patch(&url)
201            .header("Authorization", format!("Bearer {}", self.api_key))
202            .header("Content-Type", "application/json")
203            .json(body)
204            .send()
205            .await?;
206
207        self.handle_response(response).await
208    }
209
210    /// Performs a DELETE request to the specified endpoint.
211    pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, ApiError> {
212        let url = format!("{}{}", self.base_url, endpoint);
213
214        if self.debug {
215            eprintln!("DELETE {}", url);
216        }
217
218        let response = self
219            .client
220            .delete(&url)
221            .header("Authorization", format!("Bearer {}", self.api_key))
222            .send()
223            .await?;
224
225        self.handle_response(response).await
226    }
227
228    /// Unified handler for API responses, managing errors and parsing.
229    ///
230    /// This method specifically handles:
231    /// - HTTP 429 Rate Limiting with retry extraction.
232    /// - CAPTCHA required status.
233    /// - Flattened API errors (error message + hint).
234    /// - General JSON deserialization.
235    async fn handle_response<T: DeserializeOwned>(
236        &self,
237        response: reqwest::Response,
238    ) -> Result<T, ApiError> {
239        let status = response.status();
240        let text = response.text().await?;
241
242        if self.debug {
243            eprintln!("Response Status: {}", status);
244            eprintln!("Response Body: {}", text);
245        }
246
247        if status.as_u16() == 429 {
248            if let Ok(json) = serde_json::from_str::<Value>(&text) {
249                if let Some(retry) = json.get("retry_after_minutes").and_then(|v| v.as_u64()) {
250                    return Err(ApiError::RateLimited(format!("{} minutes", retry)));
251                }
252                if let Some(retry) = json.get("retry_after_seconds").and_then(|v| v.as_u64()) {
253                    return Err(ApiError::RateLimited(format!("{} seconds", retry)));
254                }
255            }
256            return Err(ApiError::RateLimited("Wait before retrying".to_string()));
257        }
258
259        if !status.is_success() {
260            if let Ok(json) = serde_json::from_str::<Value>(&text) {
261                let error = json
262                    .get("error")
263                    .and_then(|v| v.as_str())
264                    .unwrap_or("Unknown error");
265
266                if error == "captcha_required" {
267                    let token = json
268                        .get("token")
269                        .and_then(|v| v.as_str())
270                        .unwrap_or("unknown_token");
271                    return Err(ApiError::CaptchaRequired(token.to_string()));
272                }
273
274                let hint = json.get("hint").and_then(|v| v.as_str()).unwrap_or("");
275                return Err(ApiError::MoltbookError(error.to_string(), hint.to_string()));
276            }
277            return Err(ApiError::MoltbookError(format!("HTTP {}", status), text));
278        }
279
280        serde_json::from_str(&text).map_err(ApiError::ParseError)
281    }
282}