1mod error;
2pub mod models;
3#[cfg(test)]
4mod tests;
5
6pub use crate::error::ApiError;
7
8use crate::models::{
9 Category, CategoryInput, Entry, EntryBatch, EntryStateUpdate, EntryStatus, FavIcon, Feed,
10 FeedCreation, FeedCreationResponse, FeedDiscovery, FeedModification, MinifluxError, OrderBy,
11 OrderDirection, User, UserCreation, UserModification,
12};
13use base64::engine::general_purpose::STANDARD as base64_std;
14use base64::Engine;
15use log::error;
16use reqwest::{header::AUTHORIZATION, Client, StatusCode};
17use serde::{Deserialize, Serialize};
18use url::Url;
19
20type FeedID = i64;
21type CategoryID = i64;
22type EntryID = i64;
23type UserID = i64;
24type IconID = i64;
25type EnclosureID = i64;
26
27enum ApiAuth {
28 Basic(String),
29 Token(String),
30}
31
32pub struct MinifluxApi {
33 base_uri: Url,
34 auth: ApiAuth,
35}
36
37impl MinifluxApi {
38 pub fn new(url: &Url, username: String, password: String) -> Self {
43 MinifluxApi {
44 base_uri: url.clone(),
45 auth: ApiAuth::Basic(Self::generate_basic_auth(&username, &password)),
46 }
47 }
48
49 pub fn new_from_token(url: &Url, token: String) -> Self {
53 MinifluxApi {
54 base_uri: url.clone(),
55 auth: ApiAuth::Token(token),
56 }
57 }
58
59 fn generate_basic_auth(username: &str, password: &str) -> String {
60 let auth = format!("{}:{}", username, password);
61 let auth = base64_std.encode(auth);
62 format!("Basic {}", auth)
63 }
64
65 async fn send_request<T: Serialize>(
66 &self,
67 client: reqwest::RequestBuilder,
68 json_content: Option<T>,
69 ) -> Result<reqwest::Response, ApiError> {
70 let mut headered_client = match &self.auth {
71 ApiAuth::Basic(auth) => client.header(AUTHORIZATION, auth.clone()),
72 ApiAuth::Token(auth) => client.header("X-Auth-Token", auth.clone()),
73 };
74 if let Some(json_content) = json_content {
75 headered_client = headered_client.json(&json_content);
76 }
77 let response = headered_client.send().await?;
78 Ok(response)
79 }
80
81 fn deserialize<T: for<'a> Deserialize<'a>>(json: &str) -> Result<T, ApiError> {
82 let result: T = serde_json::from_str(json).map_err(|source| ApiError::Json {
83 source,
84 json: json.into(),
85 })?;
86 Ok(result)
87 }
88
89 async fn parse_error(
90 response: reqwest::Response,
91 expected_http: StatusCode,
92 ) -> Result<String, ApiError> {
93 let status = response.status();
94 let response = response.text().await?;
95 if status != expected_http {
96 let error = Self::deserialize::<MinifluxError>(&response)?;
97 error!("Miniflux API: {}", error.error_message);
98 return Err(ApiError::Miniflux(error));
99 }
100 Ok(response)
101 }
102
103 pub async fn discover_subscription(
106 &self,
107 url: Url,
108 client: &Client,
109 ) -> Result<Vec<Feed>, ApiError> {
110 let api_url = self.base_uri.clone().join("v1/discover")?;
111 let content = FeedDiscovery {
112 url: url.to_string(),
113 };
114 let response = self
115 .send_request(client.post(api_url), Some(content))
116 .await?;
117
118 let response = Self::parse_error(response, StatusCode::OK).await?;
119 let feeds = Self::deserialize::<Vec<Feed>>(&response)?;
120 Ok(feeds)
121 }
122
123 pub async fn get_feeds(&self, client: &Client) -> Result<Vec<Feed>, ApiError> {
125 let api_url = self.base_uri.clone().join("v1/feeds")?;
126 let response = self.send_request::<()>(client.get(api_url), None).await?;
127 let response = Self::parse_error(response, StatusCode::OK).await?;
128 let feeds = Self::deserialize::<Vec<Feed>>(&response)?;
129 Ok(feeds)
130 }
131
132 pub async fn get_feed(&self, id: FeedID, client: &Client) -> Result<Feed, ApiError> {
134 let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
135
136 let response = self.send_request::<()>(client.get(api_url), None).await?;
137 let response = Self::parse_error(response, StatusCode::OK).await?;
138 let feed = Self::deserialize::<Feed>(&response)?;
139 Ok(feed)
140 }
141
142 pub async fn get_feed_icon(&self, id: FeedID, client: &Client) -> Result<FavIcon, ApiError> {
144 let api_url = self
145 .base_uri
146 .clone()
147 .join(&format!("v1/feeds/{}/icon", id))?;
148 let response = self.send_request::<()>(client.get(api_url), None).await?;
149 let response = Self::parse_error(response, StatusCode::OK).await?;
150 let icon = Self::deserialize::<FavIcon>(&response)?;
151 Ok(icon)
152 }
153
154 pub async fn create_feed(
158 &self,
159 feed_url: &Url,
160 category_id: CategoryID,
161 client: &Client,
162 ) -> Result<FeedID, ApiError> {
163 let api_url = self.base_uri.clone().join("v1/feeds")?;
164 let content = FeedCreation {
165 feed_url: feed_url.to_string(),
166 category_id,
167 };
168 let response = self
169 .send_request(client.post(api_url), Some(content))
170 .await?;
171
172 let response = Self::parse_error(response, StatusCode::CREATED).await?;
173 let response = Self::deserialize::<FeedCreationResponse>(&response)?;
174 Ok(response.feed_id)
175 }
176
177 #[allow(clippy::too_many_arguments)]
182 pub async fn update_feed(
183 &self,
184 id: FeedID,
185 title: Option<&str>,
186 category_id: Option<CategoryID>,
187 feed_url: Option<&str>,
188 site_url: Option<&str>,
189 username: Option<&str>,
190 password: Option<&str>,
191 user_agent: Option<&str>,
192 client: &Client,
193 ) -> Result<Feed, ApiError> {
194 let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
195 let content = FeedModification {
196 title: title.map(|t| t.into()),
197 category_id,
198 feed_url: feed_url.map(|t| t.into()),
199 site_url: site_url.map(|t| t.into()),
200 username: username.map(|t| t.into()),
201 password: password.map(|t| t.into()),
202 scraper_rules: None,
203 rewrite_rules: None,
204 crawler: None,
205 user_agent: user_agent.map(|t| t.into()),
206 disabled: None,
207 };
208 let response = self
209 .send_request(client.put(api_url), Some(content))
210 .await?;
211 let response = Self::parse_error(response, StatusCode::CREATED).await?;
212 let feed = Self::deserialize::<Feed>(&response)?;
213 Ok(feed)
214 }
215
216 pub async fn refresh_feed_synchronous(
219 &self,
220 id: FeedID,
221 client: &Client,
222 ) -> Result<(), ApiError> {
223 let api_url = self
224 .base_uri
225 .clone()
226 .join(&format!("v1/feeds/{}/refresh", id))?;
227 let response = self.send_request::<()>(client.put(api_url), None).await?;
228 let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
229 Ok(())
230 }
231
232 pub async fn delete_feed(&self, id: FeedID, client: &Client) -> Result<(), ApiError> {
234 let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
235 let response = self
236 .send_request::<()>(client.delete(api_url), None)
237 .await?;
238 let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
239 Ok(())
240 }
241
242 pub async fn get_feed_entry(
244 &self,
245 feed_id: FeedID,
246 entry_id: EntryID,
247 client: &Client,
248 ) -> Result<Entry, ApiError> {
249 let api_url = self
250 .base_uri
251 .clone()
252 .join(&format!("v1/feeds/{}/entries/{}", feed_id, entry_id))?;
253 let response = self.send_request::<()>(client.get(api_url), None).await?;
254 let response = Self::parse_error(response, StatusCode::OK).await?;
255 let entry = Self::deserialize::<Entry>(&response)?;
256 Ok(entry)
257 }
258
259 pub async fn get_entry(&self, id: EntryID, client: &Client) -> Result<Entry, ApiError> {
261 let api_url = self.base_uri.clone().join(&format!("v1/entries/{}", id))?;
262 let response = self.send_request::<()>(client.get(api_url), None).await?;
263 let response = Self::parse_error(response, StatusCode::OK).await?;
264 let entry = Self::deserialize::<Entry>(&response)?;
265 Ok(entry)
266 }
267
268 #[allow(clippy::too_many_arguments)]
270 pub async fn get_entries(
271 &self,
272 status: Option<EntryStatus>,
273 offset: Option<i64>,
274 limit: Option<i64>,
275 order: Option<OrderBy>,
276 direction: Option<OrderDirection>,
277 before: Option<i64>,
278 after: Option<i64>,
279 before_entry_id: Option<EntryID>,
280 after_entry_id: Option<EntryID>,
281 starred: Option<bool>,
282 client: &Client,
283 ) -> Result<Vec<Entry>, ApiError> {
284 let mut api_url = self.base_uri.clone().join("v1/entries")?;
285 {
286 let mut query_pairs = api_url.query_pairs_mut();
287 query_pairs.clear();
288
289 if let Some(status) = status {
290 query_pairs.append_pair("status", status.into());
291 }
292
293 if let Some(offset) = offset {
294 query_pairs.append_pair("offset", &offset.to_string());
295 }
296
297 if let Some(limit) = limit {
298 query_pairs.append_pair("limit", &limit.to_string());
299 }
300
301 if let Some(order) = order {
302 query_pairs.append_pair("order", order.into());
303 }
304
305 if let Some(direction) = direction {
306 query_pairs.append_pair("direction", direction.into());
307 }
308
309 if let Some(before) = before {
310 query_pairs.append_pair("before", &before.to_string());
311 }
312
313 if let Some(after) = after {
314 query_pairs.append_pair("after", &after.to_string());
315 }
316
317 if let Some(before_entry_id) = before_entry_id {
318 query_pairs.append_pair("before_entry_id", &before_entry_id.to_string());
319 }
320
321 if let Some(after_entry_id) = after_entry_id {
322 query_pairs.append_pair("after_entry_id", &after_entry_id.to_string());
323 }
324
325 if let Some(starred) = starred {
326 query_pairs.append_pair("starred", &starred.to_string());
327 }
328 }
329
330 let response = self.send_request::<()>(client.get(api_url), None).await?;
331 let response = Self::parse_error(response, StatusCode::OK).await?;
332 let batch = Self::deserialize::<EntryBatch>(&response)?;
333 Ok(batch.entries)
334 }
335
336 #[allow(clippy::too_many_arguments)]
339 pub async fn get_feed_entries(
340 &self,
341 id: FeedID,
342 status: Option<EntryStatus>,
343 offset: Option<i64>,
344 limit: Option<i64>,
345 order: Option<OrderBy>,
346 direction: Option<OrderDirection>,
347 before: Option<i64>,
348 after: Option<i64>,
349 before_entry_id: Option<EntryID>,
350 after_entry_id: Option<EntryID>,
351 starred: Option<bool>,
352 client: &Client,
353 ) -> Result<Vec<Entry>, ApiError> {
354 let mut api_url = self
355 .base_uri
356 .clone()
357 .join(&format!("v1/feeds/{}/entries", id))?;
358 {
359 let mut query_pairs = api_url.query_pairs_mut();
360 query_pairs.clear();
361
362 if let Some(status) = status {
363 query_pairs.append_pair("status", status.into());
364 }
365
366 if let Some(offset) = offset {
367 query_pairs.append_pair("offset", &offset.to_string());
368 }
369
370 if let Some(limit) = limit {
371 query_pairs.append_pair("limit", &limit.to_string());
372 }
373
374 if let Some(order) = order {
375 query_pairs.append_pair("order", order.into());
376 }
377
378 if let Some(direction) = direction {
379 query_pairs.append_pair("direction", direction.into());
380 }
381
382 if let Some(before) = before {
383 query_pairs.append_pair("before", &before.to_string());
384 }
385
386 if let Some(after) = after {
387 query_pairs.append_pair("after", &after.to_string());
388 }
389
390 if let Some(before_entry_id) = before_entry_id {
391 query_pairs.append_pair("before_entry_id", &before_entry_id.to_string());
392 }
393
394 if let Some(after_entry_id) = after_entry_id {
395 query_pairs.append_pair("after_entry_id", &after_entry_id.to_string());
396 }
397
398 if let Some(starred) = starred {
399 query_pairs.append_pair("starred", &starred.to_string());
400 }
401 }
402
403 let response = self.send_request::<()>(client.get(api_url), None).await?;
404 let response = Self::parse_error(response, StatusCode::OK).await?;
405 let batch = Self::deserialize::<EntryBatch>(&response)?;
406 Ok(batch.entries)
407 }
408
409 pub async fn update_entries_status(
411 &self,
412 ids: Vec<FeedID>,
413 status: EntryStatus,
414 client: &Client,
415 ) -> Result<(), ApiError> {
416 let api_url = self.base_uri.clone().join("v1/entries")?;
417 let status: &str = status.into();
418 let content = EntryStateUpdate {
419 entry_ids: ids,
420 status: status.to_owned(),
421 };
422 let response = self
423 .send_request(client.put(api_url), Some(content))
424 .await?;
425 let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
426 Ok(())
427 }
428
429 pub async fn toggle_bookmark(&self, id: EntryID, client: &Client) -> Result<(), ApiError> {
431 let api_url = self
432 .base_uri
433 .clone()
434 .join(&format!("v1/entries/{}/bookmark", id))?;
435 let response = self.send_request::<()>(client.put(api_url), None).await?;
436 let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
437 Ok(())
438 }
439
440 pub async fn get_categories(&self, client: &Client) -> Result<Vec<Category>, ApiError> {
442 let api_url = self.base_uri.clone().join("v1/categories")?;
443 let response = self.send_request::<()>(client.get(api_url), None).await?;
444 let response = Self::parse_error(response, StatusCode::OK).await?;
445 let categories = Self::deserialize::<Vec<Category>>(&response)?;
446 Ok(categories)
447 }
448
449 pub async fn create_category(
451 &self,
452 title: &str,
453 client: &Client,
454 ) -> Result<Category, ApiError> {
455 let api_url = self.base_uri.clone().join("v1/categories")?;
456 let content = CategoryInput {
457 title: title.to_owned(),
458 };
459 let response = self
460 .send_request(client.post(api_url), Some(content))
461 .await?;
462 let response = Self::parse_error(response, StatusCode::CREATED).await?;
463 let category = Self::deserialize::<Category>(&response)?;
464 Ok(category)
465 }
466
467 pub async fn update_category(
469 &self,
470 id: CategoryID,
471 title: &str,
472 client: &Client,
473 ) -> Result<Category, ApiError> {
474 let api_url = self
475 .base_uri
476 .clone()
477 .join(&format!("v1/categories/{}", id))?;
478 let content = CategoryInput {
479 title: title.to_owned(),
480 };
481 let response = self
482 .send_request(client.put(api_url), Some(content))
483 .await?;
484 let response = Self::parse_error(response, StatusCode::CREATED).await?;
485 let category = Self::deserialize::<Category>(&response)?;
486 Ok(category)
487 }
488
489 pub async fn delete_category(&self, id: CategoryID, client: &Client) -> Result<(), ApiError> {
491 let api_url = self
492 .base_uri
493 .clone()
494 .join(&format!("v1/categories/{}", id))?;
495 let response = self
496 .send_request::<()>(client.delete(api_url), None)
497 .await?;
498 let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
499 Ok(())
500 }
501
502 pub async fn export_opml(&self, client: &Client) -> Result<String, ApiError> {
505 let api_url = self.base_uri.clone().join("v1/export")?;
506 let response = self.send_request::<()>(client.get(api_url), None).await?;
507 let response = Self::parse_error(response, StatusCode::OK).await?;
508 Ok(response)
509 }
510
511 pub async fn import_opml(&self, opml: &str, client: &Client) -> Result<(), ApiError> {
514 let api_url = self.base_uri.clone().join("v1/import")?;
515 let response = match &self.auth {
516 ApiAuth::Basic(auth) => {
517 client
518 .get(api_url)
519 .header(AUTHORIZATION, auth.clone())
520 .body(opml.to_owned())
521 .send()
522 .await?
523 }
524 ApiAuth::Token(auth) => {
525 client
526 .get(api_url)
527 .header("X-Auth-Token", auth.clone())
528 .body(opml.to_owned())
529 .send()
530 .await?
531 }
532 };
533 let _ = Self::parse_error(response, StatusCode::CREATED).await?;
534 Ok(())
535 }
536
537 pub async fn create_user(
540 &self,
541 username: &str,
542 password: &str,
543 is_admin: bool,
544 client: &Client,
545 ) -> Result<User, ApiError> {
546 let api_url = self.base_uri.clone().join("v1/users")?;
547 let content = UserCreation {
548 username: username.to_owned(),
549 password: password.to_owned(),
550 is_admin,
551 };
552 let response = self
553 .send_request(client.post(api_url), Some(content))
554 .await?;
555 let response = Self::parse_error(response, StatusCode::CREATED).await?;
556 let user = Self::deserialize::<User>(&response)?;
557 Ok(user)
558 }
559
560 #[allow(clippy::too_many_arguments)]
563 pub async fn update_user(
564 &self,
565 id: UserID,
566 username: Option<String>,
567 password: Option<String>,
568 is_admin: Option<bool>,
569 theme: Option<String>,
570 language: Option<String>,
571 timezone: Option<String>,
572 entry_sorting_direction: Option<String>,
573 client: &Client,
574 ) -> Result<User, ApiError> {
575 let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
576 let content = UserModification {
577 username,
578 password,
579 is_admin,
580 theme,
581 language,
582 timezone,
583 entry_sorting_direction,
584 };
585 let response = self
586 .send_request(client.put(api_url), Some(content))
587 .await?;
588 let response = Self::parse_error(response, StatusCode::OK).await?;
589 let user = Self::deserialize::<User>(&response)?;
590 Ok(user)
591 }
592
593 pub async fn get_current_user(&self, client: &Client) -> Result<User, ApiError> {
596 let api_url = self.base_uri.clone().join("v1/me")?;
597 let response = self.send_request::<()>(client.get(api_url), None).await?;
598 let response = Self::parse_error(response, StatusCode::OK).await?;
599 let user = Self::deserialize::<User>(&response)?;
600 Ok(user)
601 }
602
603 pub async fn get_user_by_id(&self, id: UserID, client: &Client) -> Result<User, ApiError> {
606 let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
607 let response = self.send_request::<()>(client.post(api_url), None).await?;
608 let response = Self::parse_error(response, StatusCode::OK).await?;
609 let user = Self::deserialize::<User>(&response)?;
610 Ok(user)
611 }
612
613 pub async fn get_user_by_name(
616 &self,
617 username: &str,
618 client: &Client,
619 ) -> Result<User, ApiError> {
620 let api_url = self
621 .base_uri
622 .clone()
623 .join(&format!("v1/users/{}", username))?;
624 let response = self.send_request::<()>(client.post(api_url), None).await?;
625 let response = Self::parse_error(response, StatusCode::OK).await?;
626 let user = Self::deserialize::<User>(&response)?;
627 Ok(user)
628 }
629
630 pub async fn delete_user(&self, id: UserID, client: &Client) -> Result<(), ApiError> {
633 let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
634 let response = self
635 .send_request::<()>(client.delete(api_url), None)
636 .await?;
637 let _ = Self::parse_error(response, StatusCode::OK).await?;
638 Ok(())
639 }
640
641 pub async fn healthcheck(&self, client: &Client) -> Result<(), ApiError> {
643 let api_url = self.base_uri.clone().join("healthcheck")?;
644 let response = self.send_request::<()>(client.get(api_url), None).await?;
645 let _ = Self::parse_error(response, StatusCode::OK).await?;
646 Ok(())
647 }
648}