miniflux_api/
lib.rs

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    /// Create a new instance of the MinifluxApi.
39    /// - `url`: url of the hosted Miniflux instance (e.g. `https://reader.miniflux.app/`)
40    /// - `username`: user existing on said Miniflux instance
41    /// - `password`: password of said user
42    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    /// Create a new instance of the MinifluxApi using Token Auth.
50    /// - `url`: url of the hosted Miniflux instance (e.g. `https://reader.miniflux.app/`)
51    /// - `token`: token generated by Miniflux instance
52    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    /// Try to find all available feeds (RSS/Atom) for a given website url.
104    /// - `url`: url of a website with possible feeds (e.g. `http://example.org`)
105    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    /// Get all subscribed feeds.
124    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    /// Get a specific feed by id.
133    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    /// Get the FavIcon for a specific feed.
143    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    /// Subscribe to a feed.
155    /// - `feed_url`: url to a RSS or Atom feed (e.g. `http://example.org/feed.atom`)
156    /// - `category_id`: Miniflux internal id of a category the feed should be created in
157    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    /// Update title and/or move feed to a different category.
178    /// - `id`: Miniflux internal id of the feed to alter
179    /// - `title`: new title of the feed
180    /// - `category_id`: new parent category id
181    #[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    /// Refresh the contents of a feed synchronous on Miniflux.
217    /// This operation can block the Miniflux instance for hundrets of milliseconds.
218    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    /// Unsubscribe from a feed.
233    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    /// Get a single specific entry (= article) from a feed.
243    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    /// Get a single specific entry (= article).
260    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    /// Get a batch of entries (= articles).
269    #[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    /// Get a batch of entries (= articles) from a specific feed.
337    /// The field comments_url is available since Miniflux v2.0.5.
338    #[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    /// Update the read status of a batch of entries (= articles).
410    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    /// Toggle the starred status of an entry (= article)
430    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    /// Get all categories
441    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    /// Create a new empty category
450    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    /// Rename a existing cagegory
468    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    /// Delete a existing category
490    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    /// Serialize all categories and subscribed feeds into a OPML string.
503    /// This API call is available since Miniflux v2.0.1.
504    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    /// Parse OPML string, create all contained categories and subscribe to all contained feeds.
512    /// This API call is available since Miniflux v2.0.7.
513    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    /// Create a new user on the Miniflux instance.
538    /// You must be an administrator to create users.
539    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    /// Update details and/or credentials of a user.
561    /// You must be an administrator to update users.
562    #[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    /// Get the user specified when this struct was created.
594    /// This API endpoint is available since Miniflux v2.0.8.
595    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    /// Get a specific user of the Miniflux instance.
604    /// You must be an administrator to fetch users.
605    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    /// Try to get a user by its `username`.
614    /// You must be an administrator to fetch users.
615    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    /// Delete a user.
631    /// You must be an administrator to delete users.
632    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    /// The healthcheck endpoint is useful for monitoring and load-balancer configuration.
642    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}