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