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    /// Performs a PATCH request with a JSON body.
141    pub async fn patch<T: DeserializeOwned>(
142        &self,
143        endpoint: &str,
144        body: &impl Serialize,
145    ) -> Result<T, ApiError> {
146        let url = format!("{}{}", API_BASE, endpoint);
147
148        if self.debug {
149            eprintln!("PATCH {}", url);
150            eprintln!(
151                "Body: {}",
152                serde_json::to_string_pretty(&body).unwrap_or_default()
153            );
154        }
155
156        let response = self
157            .client
158            .patch(&url)
159            .header("Authorization", format!("Bearer {}", self.api_key))
160            .header("Content-Type", "application/json")
161            .json(body)
162            .send()
163            .await?;
164
165        self.handle_response(response).await
166    }
167
168    /// Performs a DELETE request to the specified endpoint.
169    pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, ApiError> {
170        let url = format!("{}{}", API_BASE, endpoint);
171
172        if self.debug {
173            eprintln!("DELETE {}", url);
174        }
175
176        let response = self
177            .client
178            .delete(&url)
179            .header("Authorization", format!("Bearer {}", self.api_key))
180            .send()
181            .await?;
182
183        self.handle_response(response).await
184    }
185
186    /// Unified handler for API responses, managing errors and parsing.
187    ///
188    /// This method specifically handles:
189    /// - HTTP 429 Rate Limiting with retry extraction.
190    /// - CAPTCHA required status.
191    /// - Flattened API errors (error message + hint).
192    /// - General JSON deserialization.
193    async fn handle_response<T: DeserializeOwned>(
194        &self,
195        response: reqwest::Response,
196    ) -> Result<T, ApiError> {
197        let status = response.status();
198        let text = response.text().await?;
199
200        if self.debug {
201            eprintln!("Response Status: {}", status);
202            eprintln!("Response Body: {}", text);
203        }
204
205        if status.as_u16() == 429 {
206            if let Ok(json) = serde_json::from_str::<Value>(&text) {
207                if let Some(retry) = json.get("retry_after_minutes").and_then(|v| v.as_u64()) {
208                    return Err(ApiError::RateLimited(format!("{} minutes", retry)));
209                }
210                if let Some(retry) = json.get("retry_after_seconds").and_then(|v| v.as_u64()) {
211                    return Err(ApiError::RateLimited(format!("{} seconds", retry)));
212                }
213            }
214            return Err(ApiError::RateLimited("Wait before retrying".to_string()));
215        }
216
217        if !status.is_success() {
218            if let Ok(json) = serde_json::from_str::<Value>(&text) {
219                let error = json
220                    .get("error")
221                    .and_then(|v| v.as_str())
222                    .unwrap_or("Unknown error");
223
224                if error == "captcha_required" {
225                    let token = json
226                        .get("token")
227                        .and_then(|v| v.as_str())
228                        .unwrap_or("unknown_token");
229                    return Err(ApiError::CaptchaRequired(token.to_string()));
230                }
231
232                let hint = json.get("hint").and_then(|v| v.as_str()).unwrap_or("");
233                return Err(ApiError::MoltbookError(error.to_string(), hint.to_string()));
234            }
235            return Err(ApiError::MoltbookError(format!("HTTP {}", status), text));
236        }
237
238        serde_json::from_str(&text).map_err(ApiError::ParseError)
239    }
240}