hackmd_api_client_rs/
lib.rs

1pub mod error;
2pub mod types;
3
4pub use error::{ApiError, Result};
5pub use types::*;
6
7use crate::error::{
8    HttpResponseError, InternalServerError, MissingRequiredArgument,
9    TooManyRequestsError,
10};
11use reqwest::{header, Client as HttpClient, Response, StatusCode, Url};
12use std::time::Duration;
13
14const DEFAULT_BASE_URL: &str = "https://api.hackmd.io/v1";
15
16#[derive(Clone)]
17pub struct ApiClientOptions {
18    pub wrap_response_errors: bool,
19    pub timeout: Option<Duration>,
20    pub retry_config: Option<RetryConfig>,
21}
22
23impl Default for ApiClientOptions {
24    fn default() -> Self {
25        Self {
26            wrap_response_errors: true,
27            timeout: Some(Duration::from_secs(30)),
28            retry_config: Some(RetryConfig {
29                max_retries: 3,
30                base_delay: Duration::from_millis(100),
31            }),
32        }
33    }
34}
35
36#[derive(Clone)]
37pub struct RetryConfig {
38    pub max_retries: u32,
39    pub base_delay: Duration,
40}
41
42pub struct ApiClient {
43    http_client: HttpClient,
44    base_url: Url,
45    options: ApiClientOptions,
46}
47
48impl ApiClient {
49    pub fn new(access_token: &str) -> Result<Self> {
50        Self::with_options(access_token, None, None)
51    }
52
53    pub fn with_base_url(access_token: &str, base_url: &str) -> Result<Self> {
54        Self::with_options(access_token, Some(base_url), None)
55    }
56
57    pub fn with_options(
58        access_token: &str,
59        base_url: Option<&str>,
60        options: Option<ApiClientOptions>,
61    ) -> Result<Self> {
62        if access_token.is_empty() {
63            return Err(ApiError::MissingRequiredArgument(
64                MissingRequiredArgument {
65                    message: "Missing access token when creating HackMD client".to_string(),
66                },
67            ));
68        }
69
70        let options = options.unwrap_or_default();
71
72        let mut headers = header::HeaderMap::new();
73        headers.insert(
74            header::AUTHORIZATION,
75            header::HeaderValue::from_str(&format!("Bearer {}", access_token))?,
76        );
77        headers.insert(
78            header::CONTENT_TYPE,
79            header::HeaderValue::from_static("application/json"),
80        );
81
82        let mut client_builder = HttpClient::builder().default_headers(headers);
83
84        if let Some(timeout) = options.timeout {
85            client_builder = client_builder.timeout(timeout);
86        }
87
88        let http_client = client_builder.build()?;
89        let base_url = Url::parse(base_url.unwrap_or(DEFAULT_BASE_URL))?;
90
91        Ok(Self {
92            http_client,
93            base_url,
94            options,
95        })
96    }
97
98    async fn handle_response<T>(&self, response: Response) -> Result<T>
99    where
100        T: serde::de::DeserializeOwned,
101    {
102        let status = response.status();
103
104        if !self.options.wrap_response_errors {
105            return if status.is_success() {
106                Ok(response.json().await?)
107            } else {
108                Err(ApiError::Reqwest(
109                    response.error_for_status().unwrap_err(),
110                ))
111            };
112        }
113
114        if status.is_success() {
115            return Ok(response.json().await?);
116        }
117
118        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
119
120        match status {
121            StatusCode::TOO_MANY_REQUESTS => {
122                let user_limit = response
123                    .headers()
124                    .get("x-ratelimit-userlimit")
125                    .and_then(|v| v.to_str().ok())
126                    .and_then(|v| v.parse().ok())
127                    .unwrap_or(0);
128
129                let user_remaining = response
130                    .headers()
131                    .get("x-ratelimit-userremaining")
132                    .and_then(|v| v.to_str().ok())
133                    .and_then(|v| v.parse().ok())
134                    .unwrap_or(0);
135
136                let reset_after = response
137                    .headers()
138                    .get("x-ratelimit-userreset")
139                    .and_then(|v| v.to_str().ok())
140                    .and_then(|v| v.parse().ok());
141
142                Err(ApiError::TooManyRequests(TooManyRequestsError {
143                    message: format!("Too many requests ({} {})", status.as_u16(), status_text),
144                    code: status.as_u16(),
145                    status_text,
146                    user_limit,
147                    user_remaining,
148                    reset_after,
149                }))
150            }
151            _ if status.is_server_error() => {
152                Err(ApiError::InternalServer(InternalServerError {
153                    message: format!("HackMD internal error ({} {})", status.as_u16(), status_text),
154                    code: status.as_u16(),
155                    status_text,
156                }))
157            }
158            _ => Err(ApiError::HttpResponse(HttpResponseError {
159                message: format!(
160                    "Received an error response ({} {}) from HackMD",
161                    status.as_u16(),
162                    status_text
163                ),
164                code: status.as_u16(),
165                status_text,
166            })),
167        }
168    }
169
170    async fn retry_request<F, Fut, T>(&self, operation: F) -> Result<T>
171    where
172        F: Fn() -> Fut,
173        Fut: std::future::Future<Output = Result<T>>,
174    {
175        let retry_config = match &self.options.retry_config {
176            Some(config) => config,
177            None => return operation().await,
178        };
179
180        let mut last_error = None;
181        for attempt in 0..=retry_config.max_retries {
182            match operation().await {
183                Ok(result) => return Ok(result),
184                Err(err) => {
185                    if attempt < retry_config.max_retries && self.is_retryable_error(&err) {
186                        let delay = self.exponential_backoff(attempt, retry_config.base_delay);
187                        tokio::time::sleep(delay).await;
188                        last_error = Some(err);
189                    } else {
190                        return Err(err);
191                    }
192                }
193            }
194        }
195
196        Err(last_error.unwrap())
197    }
198
199    fn is_retryable_error(&self, error: &ApiError) -> bool {
200        match error {
201            ApiError::TooManyRequests(err) => err.user_remaining > 0,
202            ApiError::InternalServer(_) => true,
203            ApiError::Reqwest(req_err) => {
204                req_err.is_timeout() || req_err.is_connect() || req_err.is_request()
205            }
206            _ => false,
207        }
208    }
209
210    fn exponential_backoff(&self, retries: u32, base_delay: Duration) -> Duration {
211        let multiplier = 2_u64.pow(retries);
212        Duration::from_millis(base_delay.as_millis() as u64 * multiplier)
213    }
214
215    // User API methods
216    pub async fn get_me(&self) -> Result<User> {
217        self.retry_request(|| async {
218            let url = self.base_url.join("me")?;
219            let response = self.http_client.get(url).send().await?;
220            self.handle_response(response).await
221        })
222        .await
223    }
224
225    pub async fn get_history(&self) -> Result<Vec<Note>> {
226        self.retry_request(|| async {
227            let url = self.base_url.join("history")?;
228            let response = self.http_client.get(url).send().await?;
229            self.handle_response(response).await
230        })
231        .await
232    }
233
234    pub async fn get_note_list(&self) -> Result<Vec<Note>> {
235        self.retry_request(|| async {
236            let url = self.base_url.join("notes")?;
237            let response = self.http_client.get(url).send().await?;
238            self.handle_response(response).await
239        })
240        .await
241    }
242
243    pub async fn get_note(&self, note_id: &str) -> Result<SingleNote> {
244        self.retry_request(|| async {
245            let url = self.base_url.join(&format!("notes/{}", note_id))?;
246            let response = self.http_client.get(url).send().await?;
247            self.handle_response(response).await
248        })
249        .await
250    }
251
252    pub async fn create_note(&self, payload: &CreateNoteOptions) -> Result<SingleNote> {
253        self.retry_request(|| async {
254            let url = self.base_url.join("notes")?;
255            let response = self.http_client.post(url).json(payload).send().await?;
256            self.handle_response(response).await
257        })
258        .await
259    }
260
261    pub async fn update_note_content(&self, note_id: &str, content: &str) -> Result<SingleNote> {
262        let payload = UpdateNoteOptions {
263            content: Some(content.to_string()),
264            read_permission: None,
265            write_permission: None,
266            permalink: None,
267        };
268        self.update_note(note_id, &payload).await
269    }
270
271    pub async fn update_note(&self, note_id: &str, payload: &UpdateNoteOptions) -> Result<SingleNote> {
272        self.retry_request(|| async {
273            let url = self.base_url.join(&format!("notes/{}", note_id))?;
274            let response = self.http_client.patch(url).json(payload).send().await?;
275            self.handle_response(response).await
276        })
277        .await
278    }
279
280    pub async fn delete_note(&self, note_id: &str) -> Result<()> {
281        self.retry_request(|| async {
282            let url = self.base_url.join(&format!("notes/{}", note_id))?;
283            let response = self.http_client.delete(url).send().await?;
284            let _: serde_json::Value = self.handle_response(response).await?;
285            Ok(())
286        })
287        .await
288    }
289
290    // Team API methods
291    pub async fn get_teams(&self) -> Result<Vec<Team>> {
292        self.retry_request(|| async {
293            let url = self.base_url.join("teams")?;
294            let response = self.http_client.get(url).send().await?;
295            self.handle_response(response).await
296        })
297        .await
298    }
299
300    pub async fn get_team_notes(&self, team_path: &str) -> Result<Vec<Note>> {
301        self.retry_request(|| async {
302            let url = self.base_url.join(&format!("teams/{}/notes", team_path))?;
303            let response = self.http_client.get(url).send().await?;
304            self.handle_response(response).await
305        })
306        .await
307    }
308
309    pub async fn create_team_note(&self, team_path: &str, payload: &CreateNoteOptions) -> Result<SingleNote> {
310        self.retry_request(|| async {
311            let url = self.base_url.join(&format!("teams/{}/notes", team_path))?;
312            let response = self.http_client.post(url).json(payload).send().await?;
313            self.handle_response(response).await
314        })
315        .await
316    }
317
318    pub async fn update_team_note_content(
319        &self,
320        team_path: &str,
321        note_id: &str,
322        content: &str,
323    ) -> Result<()> {
324        let payload = UpdateNoteOptions {
325            content: Some(content.to_string()),
326            read_permission: None,
327            write_permission: None,
328            permalink: None,
329        };
330        self.update_team_note(team_path, note_id, &payload).await
331    }
332
333    pub async fn update_team_note(
334        &self,
335        team_path: &str,
336        note_id: &str,
337        payload: &UpdateNoteOptions,
338    ) -> Result<()> {
339        self.retry_request(|| async {
340            let url = self.base_url.join(&format!("teams/{}/notes/{}", team_path, note_id))?;
341            let response = self.http_client.patch(url).json(payload).send().await?;
342            let _: serde_json::Value = self.handle_response(response).await?;
343            Ok(())
344        })
345        .await
346    }
347
348    pub async fn delete_team_note(&self, team_path: &str, note_id: &str) -> Result<()> {
349        self.retry_request(|| async {
350            let url = self.base_url.join(&format!("teams/{}/notes/{}", team_path, note_id))?;
351            let response = self.http_client.delete(url).send().await?;
352            let _: serde_json::Value = self.handle_response(response).await?;
353            Ok(())
354        })
355        .await
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_api_client_creation() {
365        let client = ApiClient::new("test_token");
366        assert!(client.is_ok());
367    }
368
369    #[test]
370    fn test_api_client_creation_empty_token() {
371        let client = ApiClient::new("");
372        assert!(client.is_err());
373        
374        if let Err(ApiError::MissingRequiredArgument(err)) = client {
375            assert!(err.message.contains("Missing access token"));
376        } else {
377            panic!("Expected MissingRequiredArgument error");
378        }
379    }
380
381    #[test]
382    fn test_api_client_with_base_url() {
383        let client = ApiClient::with_base_url("test_token", "https://api.example.com/v1");
384        assert!(client.is_ok());
385    }
386
387    #[test]
388    fn test_api_client_with_options() {
389        let options = ApiClientOptions {
390            wrap_response_errors: false,
391            timeout: Some(std::time::Duration::from_secs(10)),
392            retry_config: None,
393        };
394        
395        let client = ApiClient::with_options("test_token", None, Some(options));
396        assert!(client.is_ok());
397    }
398
399    #[test]
400    fn test_create_note_options_serialization() {
401        let options = CreateNoteOptions {
402            title: Some("Test Note".to_string()),
403            content: Some("# Test Content".to_string()),
404            read_permission: Some(NotePermissionRole::Owner),
405            write_permission: Some(NotePermissionRole::SignedIn),
406            comment_permission: Some(CommentPermissionType::Owners),
407            permalink: None,
408        };
409
410        let json = serde_json::to_string(&options).unwrap();
411        assert!(json.contains("Test Note"));
412        assert!(json.contains("Test Content"));
413    }
414
415    #[test]
416    fn test_update_note_options_serialization() {
417        let options = UpdateNoteOptions {
418            content: Some("Updated content".to_string()),
419            read_permission: None,
420            write_permission: Some(NotePermissionRole::Guest),
421            permalink: Some("custom-permalink".to_string()),
422        };
423
424        let json = serde_json::to_string(&options).unwrap();
425        assert!(json.contains("Updated content"));
426        assert!(json.contains("guest"));
427        assert!(json.contains("custom-permalink"));
428        // Should not contain null values for None fields
429        assert!(!json.contains("readPermission"));
430    }
431}