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 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 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 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}