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