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