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 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 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 assert!(!json.contains("readPermission"));
453 }
454}