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