Skip to main content

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::default()),
29        }
30    }
31}
32
33#[derive(Clone)]
34pub struct RetryOptions {
35    pub max_retries: u32,
36    pub base_delay: time::Duration,
37}
38
39impl Default for RetryOptions {
40    fn default() -> Self {
41        Self {
42            max_retries: 3,
43            base_delay: time::Duration::from_millis(100),
44        }
45    }
46}
47
48pub struct ApiClient {
49    http_client: HttpClient,
50    base_url: Url,
51    options: ApiClientOptions,
52}
53
54impl ApiClient {
55    fn missing_required_argument(message: impl Into<String>) -> ApiError {
56        ApiError::MissingRequiredArgument(MissingRequiredArgument {
57            message: message.into(),
58        })
59    }
60
61    fn require_non_empty(value_name: &str, value: &str) -> Result<()> {
62        if value.trim().is_empty() {
63            return Err(Self::missing_required_argument(format!(
64                "Missing {value_name} when calling HackMD API"
65            )));
66        }
67
68        Ok(())
69    }
70
71    fn normalized_base_url(base_url: &str) -> String {
72        if base_url.ends_with('/') {
73            base_url.to_string()
74        } else {
75            format!("{base_url}/")
76        }
77    }
78
79    fn note_url(&self, note_id: &str) -> Result<Url> {
80        Self::require_non_empty("note_id", note_id)?;
81        Ok(self.base_url.join(&format!("notes/{note_id}"))?)
82    }
83
84    fn note_image_url(&self, note_id: &str) -> Result<Url> {
85        Self::require_non_empty("note_id", note_id)?;
86        Ok(self.base_url.join(&format!("notes/{note_id}/images"))?)
87    }
88
89    fn team_notes_url(&self, team_path: &str) -> Result<Url> {
90        Self::require_non_empty("team_path", team_path)?;
91        Ok(self.base_url.join(&format!("teams/{team_path}/notes"))?)
92    }
93
94    fn team_note_url(&self, team_path: &str, note_id: &str) -> Result<Url> {
95        Self::require_non_empty("team_path", team_path)?;
96        Self::require_non_empty("note_id", note_id)?;
97        Ok(self
98            .base_url
99            .join(&format!("teams/{team_path}/notes/{note_id}"))?)
100    }
101
102    fn is_success_status(status: StatusCode) -> bool {
103        status.is_success()
104    }
105
106    pub fn new(access_token: &str) -> Result<Self> {
107        Self::with_options(access_token, None, None)
108    }
109
110    pub fn with_base_url(access_token: &str, base_url: &str) -> Result<Self> {
111        Self::with_options(access_token, Some(base_url), None)
112    }
113
114    pub fn with_options(
115        access_token: &str,
116        base_url: Option<&str>,
117        options: Option<ApiClientOptions>,
118    ) -> Result<Self> {
119        if access_token.trim().is_empty() {
120            return Err(Self::missing_required_argument(
121                "Missing access token when creating HackMD client",
122            ));
123        }
124
125        let options = options.unwrap_or_default();
126
127        let mut headers = header::HeaderMap::new();
128        headers.insert(
129            header::AUTHORIZATION,
130            header::HeaderValue::from_str(&format!("Bearer {}", access_token))?,
131        );
132
133        let mut client_builder = HttpClient::builder().default_headers(headers);
134
135        if let Some(timeout) = options.timeout {
136            client_builder = client_builder.timeout(timeout);
137        }
138
139        let http_client = client_builder.build()?;
140        let base_url = Url::parse(&Self::normalized_base_url(
141            base_url.unwrap_or(DEFAULT_BASE_URL),
142        ))?;
143
144        Ok(Self {
145            http_client,
146            base_url,
147            options,
148        })
149    }
150
151    async fn handle_response<T>(&self, response: Response) -> Result<T>
152    where
153        T: serde::de::DeserializeOwned,
154    {
155        let status = response.status();
156
157        if !self.options.wrap_response_errors {
158            return if status.is_success() {
159                Ok(response.json().await?)
160            } else {
161                Err(ApiError::Reqwest(response.error_for_status().unwrap_err()))
162            };
163        }
164
165        if status.is_success() {
166            return Ok(response.json().await?);
167        }
168
169        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
170
171        match status {
172            StatusCode::TOO_MANY_REQUESTS => {
173                let user_limit = response
174                    .headers()
175                    .get("x-ratelimit-userlimit")
176                    .and_then(|v| v.to_str().ok())
177                    .and_then(|v| v.parse().ok())
178                    .unwrap_or(0);
179
180                let user_remaining = response
181                    .headers()
182                    .get("x-ratelimit-userremaining")
183                    .and_then(|v| v.to_str().ok())
184                    .and_then(|v| v.parse().ok())
185                    .unwrap_or(0);
186
187                let reset_after = response
188                    .headers()
189                    .get("x-ratelimit-userreset")
190                    .and_then(|v| v.to_str().ok())
191                    .and_then(|v| v.parse().ok());
192
193                Err(ApiError::TooManyRequests(TooManyRequestsError {
194                    message: format!("Too many requests ({} {})", status.as_u16(), status_text),
195                    code: status.as_u16(),
196                    status_text,
197                    user_limit,
198                    user_remaining,
199                    reset_after,
200                }))
201            }
202            _ if status.is_server_error() => Err(ApiError::InternalServer(InternalServerError {
203                message: format!(
204                    "HackMD internal error ({} {})",
205                    status.as_u16(),
206                    status_text
207                ),
208                code: status.as_u16(),
209                status_text,
210            })),
211            _ => Err(ApiError::HttpResponse(HttpResponseError {
212                message: format!(
213                    "Received an error response ({} {}) from HackMD",
214                    status.as_u16(),
215                    status_text
216                ),
217                code: status.as_u16(),
218                status_text,
219            })),
220        }
221    }
222
223    async fn handle_empty_response(&self, response: Response) -> Result<()> {
224        if Self::is_success_status(response.status()) {
225            return Ok(());
226        }
227
228        self.handle_response::<Value>(response).await.map(|_| ())
229    }
230
231    async fn retry_request<F, Fut, T>(&self, operation: F) -> Result<T>
232    where
233        F: Fn() -> Fut,
234        Fut: future::Future<Output = Result<T>>,
235    {
236        let retry_options = match &self.options.retry_options {
237            Some(config) => config,
238            None => return operation().await,
239        };
240
241        let mut last_error = None;
242        for attempt in 0..=retry_options.max_retries {
243            match operation().await {
244                Ok(result) => return Ok(result),
245                Err(err) => {
246                    if attempt < retry_options.max_retries && self.is_retryable_error(&err) {
247                        let delay = self.exponential_backoff(attempt, retry_options.base_delay);
248                        tokio::time::sleep(delay).await;
249                        last_error = Some(err);
250                    } else {
251                        return Err(err);
252                    }
253                }
254            }
255        }
256
257        Err(last_error.unwrap())
258    }
259
260    fn is_retryable_error(&self, error: &ApiError) -> bool {
261        match error {
262            ApiError::TooManyRequests(_) => true,
263            ApiError::InternalServer(_) => true,
264            ApiError::Reqwest(req_err) => {
265                req_err.is_timeout() || req_err.is_connect() || req_err.is_request()
266            }
267            _ => false,
268        }
269    }
270
271    fn exponential_backoff(&self, retries: u32, base_delay: time::Duration) -> time::Duration {
272        let multiplier = 2_u64.pow(retries);
273        time::Duration::from_millis(base_delay.as_millis() as u64 * multiplier)
274    }
275
276    // User API methods
277    pub async fn get_me(&self) -> Result<User> {
278        self.retry_request(|| async {
279            let url = self.base_url.join("me")?;
280            let response = self.http_client.get(url).send().await?;
281            self.handle_response(response).await
282        })
283        .await
284    }
285
286    pub async fn get_history(&self, limit: Option<u32>) -> Result<Vec<Note>> {
287        self.retry_request(|| async {
288            let mut url = self.base_url.join("history")?;
289            if let Some(limit_val) = limit {
290                url.query_pairs_mut()
291                    .append_pair("limit", &limit_val.to_string());
292            }
293            let response = self.http_client.get(url).send().await?;
294            self.handle_response(response).await
295        })
296        .await
297    }
298
299    pub async fn get_note_list(&self) -> Result<Vec<Note>> {
300        self.retry_request(|| async {
301            let url = self.base_url.join("notes")?;
302            let response = self.http_client.get(url).send().await?;
303            self.handle_response(response).await
304        })
305        .await
306    }
307
308    pub async fn get_note(&self, note_id: &str) -> Result<SingleNote> {
309        self.retry_request(|| async {
310            let url = self.note_url(note_id)?;
311            let response = self.http_client.get(url).send().await?;
312            self.handle_response(response).await
313        })
314        .await
315    }
316
317    pub async fn create_note(&self, payload: &CreateNoteOptions) -> Result<SingleNote> {
318        self.retry_request(|| async {
319            let url = self.base_url.join("notes")?;
320            let response = self.http_client.post(url).json(payload).send().await?;
321            self.handle_response(response).await
322        })
323        .await
324    }
325
326    pub async fn update_note_content(&self, note_id: &str, content: &str) -> Result<()> {
327        let payload = UpdateNoteOptions {
328            content: Some(content.to_string()),
329            ..Default::default()
330        };
331        self.update_note(note_id, &payload).await
332    }
333
334    pub async fn update_note(&self, note_id: &str, payload: &UpdateNoteOptions) -> Result<()> {
335        self.retry_request(|| async {
336            let url = self.note_url(note_id)?;
337            let response = self.http_client.patch(url).json(payload).send().await?;
338            self.handle_empty_response(response).await
339        })
340        .await
341    }
342
343    pub async fn delete_note(&self, note_id: &str) -> Result<()> {
344        self.retry_request(|| async {
345            let url = self.note_url(note_id)?;
346            let response = self.http_client.delete(url).send().await?;
347            self.handle_empty_response(response).await
348        })
349        .await
350    }
351
352    pub async fn upload_note_image(
353        &self,
354        note_id: &str,
355        image_bytes: bytes::Bytes,
356        file_name: &str,
357        mime_type: &str,
358    ) -> Result<NoteImageUploadResponse> {
359        self.retry_request(|| async {
360            let url = self.note_image_url(note_id)?;
361            let part = reqwest::multipart::Part::stream(image_bytes.clone())
362                .file_name(file_name.to_string())
363                .mime_str(mime_type)?;
364            let form = reqwest::multipart::Form::new().part("image", part);
365            let response = self.http_client.post(url).multipart(form).send().await?;
366            self.handle_response(response).await
367        })
368        .await
369    }
370
371    // Team API methods
372    pub async fn get_teams(&self) -> Result<Vec<Team>> {
373        self.retry_request(|| async {
374            let url = self.base_url.join("teams")?;
375            let response = self.http_client.get(url).send().await?;
376            self.handle_response(response).await
377        })
378        .await
379    }
380
381    pub async fn get_team_notes(&self, team_path: &str) -> Result<Vec<Note>> {
382        self.retry_request(|| async {
383            let url = self.team_notes_url(team_path)?;
384            let response = self.http_client.get(url).send().await?;
385            self.handle_response(response).await
386        })
387        .await
388    }
389
390    pub async fn create_team_note(
391        &self,
392        team_path: &str,
393        payload: &CreateNoteOptions,
394    ) -> Result<SingleNote> {
395        self.retry_request(|| async {
396            let url = self.team_notes_url(team_path)?;
397            let response = self.http_client.post(url).json(payload).send().await?;
398            self.handle_response(response).await
399        })
400        .await
401    }
402
403    pub async fn update_team_note_content(
404        &self,
405        team_path: &str,
406        note_id: &str,
407        content: &str,
408    ) -> Result<()> {
409        let payload = UpdateNoteOptions {
410            content: Some(content.to_string()),
411            ..Default::default()
412        };
413        self.update_team_note(team_path, note_id, &payload).await
414    }
415
416    pub async fn update_team_note(
417        &self,
418        team_path: &str,
419        note_id: &str,
420        payload: &UpdateNoteOptions,
421    ) -> Result<()> {
422        self.retry_request(|| async {
423            let url = self.team_note_url(team_path, note_id)?;
424            let response = self.http_client.patch(url).json(payload).send().await?;
425            self.handle_empty_response(response).await
426        })
427        .await
428    }
429
430    pub async fn delete_team_note(&self, team_path: &str, note_id: &str) -> Result<()> {
431        self.retry_request(|| async {
432            let url = self.team_note_url(team_path, note_id)?;
433            let response = self.http_client.delete(url).send().await?;
434            self.handle_empty_response(response).await
435        })
436        .await
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_api_client_creation() {
446        let client = ApiClient::new("test_token");
447        assert!(client.is_ok());
448    }
449
450    #[test]
451    fn test_api_client_creation_empty_token() {
452        let client = ApiClient::new("   ");
453        assert!(client.is_err());
454
455        if let Err(ApiError::MissingRequiredArgument(err)) = client {
456            assert!(err.message.contains("Missing access token"));
457        } else {
458            panic!("Expected MissingRequiredArgument error");
459        }
460    }
461
462    #[test]
463    fn test_api_client_with_base_url() {
464        let client = ApiClient::with_base_url("test_token", "https://api.example.com/v1")
465            .expect("client should be created");
466
467        assert_eq!(client.base_url.as_str(), "https://api.example.com/v1/");
468    }
469
470    #[test]
471    fn test_api_client_with_options() {
472        let options = ApiClientOptions {
473            wrap_response_errors: false,
474            timeout: Some(time::Duration::from_secs(10)),
475            retry_options: None,
476        };
477
478        let client = ApiClient::with_options("test_token", None, Some(options));
479        assert!(client.is_ok());
480    }
481
482    #[test]
483    fn test_create_note_options_serialization() {
484        let options = CreateNoteOptions {
485            title: Some("Test Note".to_string()),
486            content: Some("# Test Content".to_string()),
487            read_permission: Some(NotePermissionRole::Owner),
488            write_permission: Some(NotePermissionRole::SignedIn),
489            comment_permission: Some(CommentPermissionType::Owners),
490            ..Default::default()
491        };
492
493        let json = serde_json::to_string(&options).unwrap();
494        assert!(json.contains("Test Note"));
495        assert!(json.contains("Test Content"));
496    }
497
498    #[test]
499    fn test_update_note_options_serialization() {
500        let options = UpdateNoteOptions {
501            content: Some("Updated content".to_string()),
502            write_permission: Some(NotePermissionRole::Guest),
503            permalink: Some("custom-permalink".to_string()),
504            ..Default::default()
505        };
506
507        let json = serde_json::to_string(&options).unwrap();
508        assert!(json.contains("Updated content"));
509        assert!(json.contains("guest"));
510        assert!(json.contains("custom-permalink"));
511        // Should not contain null values for None fields
512        assert!(!json.contains("readPermission"));
513    }
514
515    #[test]
516    fn test_note_url_requires_note_id() {
517        let client = ApiClient::new("test_token").unwrap();
518        let error = client.note_url("   ").unwrap_err();
519
520        assert!(matches!(error, ApiError::MissingRequiredArgument(_)));
521    }
522
523    #[test]
524    fn test_team_note_url_requires_team_path() {
525        let client = ApiClient::new("test_token").unwrap();
526        let error = client.team_note_url("", "note-123").unwrap_err();
527
528        assert!(matches!(error, ApiError::MissingRequiredArgument(_)));
529    }
530
531    #[test]
532    fn test_note_and_team_urls_are_composed_from_valid_identifiers() {
533        let client = ApiClient::new("test_token").unwrap();
534
535        assert_eq!(
536            client.note_url("note-123").unwrap().as_str(),
537            "https://api.hackmd.io/v1/notes/note-123"
538        );
539        assert_eq!(
540            client
541                .team_note_url("platform-team", "note-123")
542                .unwrap()
543                .as_str(),
544            "https://api.hackmd.io/v1/teams/platform-team/notes/note-123"
545        );
546    }
547
548    #[test]
549    fn test_rate_limit_errors_are_retryable() {
550        let client = ApiClient::new("test_token").unwrap();
551        let error = ApiError::TooManyRequests(TooManyRequestsError {
552            message: "Too many requests".to_string(),
553            code: 429,
554            status_text: "Too Many Requests".to_string(),
555            user_limit: 60,
556            user_remaining: 0,
557            reset_after: Some(1),
558        });
559
560        assert!(client.is_retryable_error(&error));
561    }
562
563    #[test]
564    fn test_success_status_accepts_all_2xx_codes() {
565        assert!(ApiClient::is_success_status(StatusCode::OK));
566        assert!(ApiClient::is_success_status(StatusCode::ACCEPTED));
567        assert!(ApiClient::is_success_status(StatusCode::NO_CONTENT));
568        assert!(!ApiClient::is_success_status(StatusCode::BAD_REQUEST));
569    }
570
571    #[test]
572    fn test_exponential_backoff_doubles_between_attempts() {
573        let client = ApiClient::new("test_token").unwrap();
574        let base_delay = time::Duration::from_millis(100);
575
576        assert_eq!(client.exponential_backoff(0, base_delay), base_delay);
577        assert_eq!(
578            client.exponential_backoff(1, base_delay),
579            time::Duration::from_millis(200)
580        );
581        assert_eq!(
582            client.exponential_backoff(2, base_delay),
583            time::Duration::from_millis(400)
584        );
585    }
586}