feedbin_api/
lib.rs

1mod error;
2pub mod models;
3#[cfg(test)]
4mod tests;
5
6pub use crate::error::ApiError;
7use crate::models::{
8    cache::Cache,
9    cache::CacheRequestResponse,
10    cache::CacheResult,
11    entry::Entry,
12    entry::UpdateEntryStarredInput,
13    entry::UpdateEntryUnreadInput,
14    icon::Icon,
15    subscription::Subscription,
16    subscription::SubscriptionMode,
17    subscription::UpdateSubscriptionInput,
18    subscription::{CreateSubscriptionInput, CreateSubscriptionResult},
19    tagging::CreateTaggingInput,
20    tagging::DeleteTagInput,
21    tagging::RenameTagInput,
22    tagging::Tagging,
23};
24use chrono::{DateTime, Utc};
25use core::marker::Sized;
26use models::SubscriptionOption;
27use reqwest::header::{CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
28use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use url::Url;
32
33pub type FeedID = u64;
34pub type EntryID = u64;
35pub type SubscriptionID = u64;
36pub type TaggingID = u64;
37
38const EMPTY_PARAMS: Option<&HashMap<String, String>> = None;
39
40pub struct FeedbinApi {
41    base_url: Url,
42    username: String,
43    password: String,
44}
45
46impl FeedbinApi {
47    pub fn new<S: Into<String>>(base_url: &Url, username: S, password: S) -> Self {
48        FeedbinApi {
49            base_url: base_url.clone(),
50            username: username.into(),
51            password: password.into(),
52        }
53    }
54
55    pub fn with_base_url(&self, base_url: &Url) -> Self {
56        FeedbinApi {
57            base_url: base_url.clone(),
58            username: self.username.clone(),
59            password: self.password.clone(),
60        }
61    }
62
63    pub fn with_password<S: Into<String>>(&self, password: S) -> Self {
64        FeedbinApi {
65            base_url: self.base_url.clone(),
66            username: self.username.clone(),
67            password: password.into(),
68        }
69    }
70
71    fn build_url(&self, path: &str) -> Url {
72        self.base_url.clone().join(path).unwrap() // We control all path inputs so failure is impossible
73    }
74
75    async fn deserialize<T: for<'a> Deserialize<'a>>(response: Response) -> Result<T, ApiError> {
76        let json = response.text().await?;
77        let result: T =
78            serde_json::from_str(&json).map_err(|source| ApiError::Json { source, json })?;
79        Ok(result)
80    }
81
82    async fn request<F: FnOnce(RequestBuilder) -> RequestBuilder, T: Serialize + ?Sized>(
83        &self,
84        client: &Client,
85        method: Method,
86        path: &str,
87        params: Option<&T>,
88        f: F,
89    ) -> Result<Response, ApiError> {
90        let url = self.build_url(path);
91        let request = client
92            .request(method, url)
93            .basic_auth(&self.username, Some(&self.password));
94
95        let request = match params {
96            Some(params) => request.query(params),
97            None => request,
98        };
99
100        let request = f(request);
101        let response = request.send().await?;
102
103        match response.status().as_u16() {
104            401 => Err(ApiError::InvalidLogin),
105            403 => Err(ApiError::AccessDenied),
106            200 | 201 | 204 | 302 | 404 => Ok(response),
107            _ => Err(ApiError::ServerIsBroken),
108        }
109    }
110
111    async fn get<T: Serialize + ?Sized>(
112        &self,
113        client: &Client,
114        path: &str,
115        params: Option<&T>,
116        cache: Option<Cache>,
117    ) -> Result<CacheRequestResponse<Response>, ApiError> {
118        // HEAD request to check for NOT MODIFIED
119        if let Some(cache) = cache {
120            let response = client
121                .request(Method::GET, self.build_url(path))
122                .basic_auth(&self.username, Some(&self.password))
123                .header(IF_MODIFIED_SINCE, cache.last_modified)
124                .header(IF_NONE_MATCH, cache.etag)
125                .send()
126                .await?;
127
128            if response.status() == StatusCode::NOT_MODIFIED {
129                return Ok(CacheRequestResponse::NotModified);
130            }
131        }
132
133        let response = self
134            .request(client, Method::GET, path, params, |req| req)
135            .await?;
136
137        // extract http cache (etag, last_modified)
138        if let Some(etag) = response.headers().get(ETAG) {
139            if let Some(last_modified) = response.headers().get(LAST_MODIFIED) {
140                if let Ok(etag) = etag.to_str() {
141                    if let Ok(last_modified) = last_modified.to_str() {
142                        let cache = Cache {
143                            etag: etag.into(),
144                            last_modified: last_modified.into(),
145                        };
146                        return Ok(CacheRequestResponse::Modified(CacheResult {
147                            value: response,
148                            cache: Some(cache),
149                        }));
150                    }
151                }
152            }
153        }
154
155        Ok(CacheRequestResponse::Modified(CacheResult {
156            value: response,
157            cache: None,
158        }))
159    }
160
161    async fn delete_with_body<F: FnOnce(RequestBuilder) -> RequestBuilder>(
162        &self,
163        client: &Client,
164        path: &str,
165        f: F,
166    ) -> Result<Response, ApiError> {
167        self.request(client, Method::DELETE, path, EMPTY_PARAMS, f)
168            .await
169    }
170
171    async fn delete(&self, client: &Client, path: &str) -> Result<Response, ApiError> {
172        self.request(client, Method::DELETE, path, EMPTY_PARAMS, |req| req)
173            .await
174    }
175
176    async fn post<F: FnOnce(RequestBuilder) -> RequestBuilder>(
177        &self,
178        client: &Client,
179        path: &str,
180        f: F,
181    ) -> Result<Response, ApiError> {
182        self.request(client, Method::POST, path, EMPTY_PARAMS, f)
183            .await
184    }
185
186    pub async fn is_authenticated(&self, client: &Client) -> Result<bool, ApiError> {
187        match self
188            .get(client, "/v2/authentication.json", EMPTY_PARAMS, None)
189            .await
190        {
191            Err(err) => match err {
192                ApiError::InvalidLogin => Ok(false),
193                _ => Err(err),
194            },
195            Ok(CacheRequestResponse::Modified(CacheResult {
196                value: response,
197                cache: _,
198            })) => match response.status().as_u16() {
199                200 => Ok(true),
200                _ => Err(ApiError::ServerIsBroken),
201            },
202            Ok(CacheRequestResponse::NotModified) => Err(ApiError::InvalidCaching),
203        }
204    }
205
206    pub async fn is_reachable(&self, client: &Client) -> Result<bool, ApiError> {
207        match self.is_authenticated(client).await {
208            Ok(_) => Ok(true),
209            Err(err) => match err {
210                ApiError::ServerIsBroken => Ok(true),
211                ApiError::Network(_) => Ok(false),
212                _ => Err(err),
213            },
214        }
215    }
216
217    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#entries
218    #[allow(clippy::too_many_arguments)]
219    pub async fn get_entries(
220        &self,
221        client: &Client,
222        page: Option<u32>,
223        since: Option<DateTime<Utc>>,
224        ids: Option<&[EntryID]>,
225        starred: Option<bool>,
226        enclosure: Option<bool>,
227        extended: bool,
228    ) -> Result<Vec<Entry>, ApiError> {
229        let api_endpoint = "/v2/entries.json";
230        let mut params = HashMap::new();
231
232        if let Some(page) = page {
233            params.insert(String::from("page"), page.to_string());
234        }
235        if let Some(since) = since {
236            params.insert(
237                String::from("since"),
238                since.format("%Y-%m-%dT%H:%M:%S%.f").to_string(),
239            );
240        }
241        if let Some(ids) = ids {
242            let id_strings = ids.iter().map(|id| id.to_string()).collect::<Vec<String>>();
243            params.insert(String::from("ids"), id_strings.join(","));
244        }
245        if let Some(starred) = starred {
246            params.insert(String::from("starred"), starred.to_string());
247        }
248        if let Some(enclosure) = enclosure {
249            params.insert(String::from("include_enclosure"), enclosure.to_string());
250        }
251        if extended {
252            params.insert(String::from("mode"), String::from("extended"));
253        }
254
255        match self.get(client, api_endpoint, Some(&params), None).await? {
256            CacheRequestResponse::Modified(CacheResult {
257                value: response,
258                cache: _cache,
259            }) => Self::deserialize::<Vec<Entry>>(response).await,
260            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
261        }
262    }
263
264    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#get-v2feeds203entriesjson
265    pub async fn get_entries_for_feed(
266        &self,
267        client: &Client,
268        feed_id: FeedID,
269        cache: Option<Cache>,
270    ) -> Result<CacheRequestResponse<Vec<Entry>>, ApiError> {
271        let path = format!("/v2/feeds/{}/entries.json", feed_id);
272        match self.get(client, &path, EMPTY_PARAMS, cache).await? {
273            CacheRequestResponse::Modified(CacheResult {
274                value: response,
275                cache,
276            }) => {
277                let res = Self::deserialize::<Vec<Entry>>(response).await?;
278                Ok(CacheRequestResponse::Modified(CacheResult {
279                    value: res,
280                    cache,
281                }))
282            }
283            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
284        }
285    }
286
287    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#unread-entries
288    pub async fn get_unread_entry_ids(&self, client: &Client) -> Result<Vec<EntryID>, ApiError> {
289        match self
290            .get(client, "/v2/unread_entries.json", EMPTY_PARAMS, None)
291            .await?
292        {
293            CacheRequestResponse::Modified(CacheResult {
294                value: response,
295                cache: _cache,
296            }) => Self::deserialize::<Vec<EntryID>>(response).await,
297            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
298        }
299    }
300
301    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#create-unread-entries-mark-as-unread
302    pub async fn set_entries_unread(
303        &self,
304        client: &Client,
305        entry_ids: &[EntryID],
306    ) -> Result<(), ApiError> {
307        if entry_ids.len() > 1000 {
308            return Err(ApiError::InputSize);
309        }
310        let input = UpdateEntryUnreadInput {
311            unread_entries: entry_ids.into(),
312        };
313        self.post(client, "/v2/unread_entries.json", |r| r.json(&input))
314            .await
315            .map(|_| ())
316    }
317
318    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#delete-unread-entries-mark-as-read
319    pub async fn set_entries_read(
320        &self,
321        client: &Client,
322        entry_ids: &[EntryID],
323    ) -> Result<(), ApiError> {
324        if entry_ids.len() > 1000 {
325            return Err(ApiError::InputSize);
326        }
327        let input = UpdateEntryUnreadInput {
328            unread_entries: entry_ids.into(),
329        };
330        self.delete_with_body(client, "/v2/unread_entries.json", |r| r.json(&input))
331            .await
332            .map(|_| ())
333    }
334
335    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#get-starred-entries
336    pub async fn get_starred_entry_ids(&self, client: &Client) -> Result<Vec<EntryID>, ApiError> {
337        match self
338            .get(client, "/v2/starred_entries.json", EMPTY_PARAMS, None)
339            .await?
340        {
341            CacheRequestResponse::Modified(CacheResult {
342                value: response,
343                cache: _cache,
344            }) => Self::deserialize::<Vec<EntryID>>(response).await,
345            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
346        }
347    }
348
349    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#create-starred-entries
350    pub async fn set_entries_starred(
351        &self,
352        client: &Client,
353        entry_ids: &[EntryID],
354    ) -> Result<(), ApiError> {
355        if entry_ids.len() > 1000 {
356            return Err(ApiError::InputSize);
357        }
358        let input = UpdateEntryStarredInput {
359            starred_entries: entry_ids.into(),
360        };
361        self.post(client, "/v2/starred_entries.json", |r| r.json(&input))
362            .await
363            .map(|_| ())
364    }
365
366    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#delete-starred-entries-unstar
367    pub async fn set_entries_unstarred(
368        &self,
369        client: &Client,
370        entry_ids: &[EntryID],
371    ) -> Result<(), ApiError> {
372        if entry_ids.len() > 1000 {
373            return Err(ApiError::InputSize);
374        }
375        let input = UpdateEntryStarredInput {
376            starred_entries: entry_ids.into(),
377        };
378        self.delete_with_body(client, "/v2/starred_entries.json", |r| r.json(&input))
379            .await
380            .map(|_| ())
381    }
382
383    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#get-v2entries3648json
384    pub async fn get_entry(&self, client: &Client, entry_id: EntryID) -> Result<Entry, ApiError> {
385        let path = format!("/v2/entries/{}.json", entry_id);
386        match self.get(client, &path, EMPTY_PARAMS, None).await? {
387            CacheRequestResponse::Modified(CacheResult {
388                value: response,
389                cache: _cache,
390            }) => Self::deserialize::<Entry>(response).await,
391            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
392        }
393    }
394
395    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#get-subscriptions
396    pub async fn get_subscriptions(
397        &self,
398        client: &Client,
399        since: Option<DateTime<Utc>>,
400        mode: Option<SubscriptionMode>,
401        cache: Option<Cache>,
402    ) -> Result<CacheRequestResponse<Vec<Subscription>>, ApiError> {
403        let api_endpoint = "/v2/subscriptions.json";
404        let mut params: HashMap<String, String> = HashMap::new();
405
406        if let Some(since) = since {
407            params.insert(
408                String::from("since"),
409                since.format("%Y-%m-%dT%H:%M:%S%.f").to_string(),
410            );
411        }
412        if let Some(mode) = mode {
413            params.insert(String::from("mode"), mode.to_string());
414        }
415
416        match self.get(client, api_endpoint, Some(&params), cache).await? {
417            CacheRequestResponse::Modified(CacheResult {
418                value: response,
419                cache,
420            }) => {
421                let res = Self::deserialize::<Vec<Subscription>>(response).await?;
422                Ok(CacheRequestResponse::Modified(CacheResult {
423                    value: res,
424                    cache,
425                }))
426            }
427            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
428        }
429    }
430
431    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#get-subscription
432    pub async fn get_subscription(
433        &self,
434        client: &Client,
435        subscription_id: SubscriptionID,
436    ) -> Result<Subscription, ApiError> {
437        let path = format!("/v2/subscriptions/{}.json", subscription_id);
438        match self.get(client, &path, EMPTY_PARAMS, None).await? {
439            CacheRequestResponse::Modified(CacheResult {
440                value: response,
441                cache: _cache,
442            }) => Self::deserialize::<Subscription>(response).await,
443            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
444        }
445    }
446
447    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#create-subscription
448    pub async fn create_subscription<S: Into<String>>(
449        &self,
450        client: &Client,
451        url: S,
452    ) -> Result<CreateSubscriptionResult, ApiError> {
453        let input = CreateSubscriptionInput {
454            feed_url: url.into(),
455        };
456        let res = self
457            .post(client, "/v2/subscriptions.json", |request| {
458                request.json(&input)
459            })
460            .await?;
461        match res.status().as_u16() {
462            201 => {
463                let subscription = Self::deserialize::<Subscription>(res).await?;
464                Ok(CreateSubscriptionResult::Created(subscription))
465            }
466            300 => {
467                let options = Self::deserialize::<Vec<SubscriptionOption>>(res).await?;
468                Ok(CreateSubscriptionResult::MultipleOptions(options))
469            }
470            303 => {
471                let location = res
472                    .headers()
473                    .get("Location")
474                    .ok_or(ApiError::ServerIsBroken)?
475                    .to_str()
476                    .map_err(|_| ApiError::ServerIsBroken)?;
477                let location = Url::parse(location)?;
478                Ok(CreateSubscriptionResult::Found(location))
479            }
480            404 => Ok(CreateSubscriptionResult::NotFound),
481            _ => Err(ApiError::ServerIsBroken),
482        }
483    }
484
485    pub async fn delete_subscription(
486        &self,
487        client: &Client,
488        subscription_id: SubscriptionID,
489    ) -> Result<(), ApiError> {
490        let path = format!("/v2/subscriptions/{}.json", subscription_id);
491        self.delete(client, &path).await.map(|_| ())
492    }
493
494    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#update-subscription
495    pub async fn update_subscription<S: Into<String>>(
496        &self,
497        client: &Client,
498        subscription_id: SubscriptionID,
499        title: S,
500    ) -> Result<(), ApiError> {
501        let input = UpdateSubscriptionInput {
502            title: title.into(),
503        };
504        let path = format!("/v2/subscriptions/{}/update.json", subscription_id);
505        self.post(client, &path, |request| request.json(&input))
506            .await
507            .map(|_| ())
508    }
509
510    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#get-taggings
511    pub async fn get_taggings(
512        &self,
513        client: &Client,
514        cache: Option<Cache>,
515    ) -> Result<CacheRequestResponse<Vec<Tagging>>, ApiError> {
516        match self
517            .get(client, "/v2/taggings.json", EMPTY_PARAMS, cache)
518            .await?
519        {
520            CacheRequestResponse::Modified(CacheResult {
521                value: response,
522                cache,
523            }) => {
524                let res = Self::deserialize::<Vec<Tagging>>(response).await?;
525                Ok(CacheRequestResponse::Modified(CacheResult {
526                    value: res,
527                    cache,
528                }))
529            }
530            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
531        }
532    }
533
534    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#get-tagging
535    pub async fn get_tagging(
536        &self,
537        client: &Client,
538        tagging_id: TaggingID,
539    ) -> Result<Tagging, ApiError> {
540        let path = format!("/v2/taggings/{}.json", tagging_id);
541        match self.get(client, &path, EMPTY_PARAMS, None).await? {
542            CacheRequestResponse::Modified(CacheResult {
543                value: response,
544                cache: _cache,
545            }) => Self::deserialize::<Tagging>(response).await,
546            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
547        }
548    }
549
550    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#create-tagging
551    pub async fn create_tagging(
552        &self,
553        client: &Client,
554        feed_id: FeedID,
555        name: &str,
556    ) -> Result<(), ApiError> {
557        let input = CreateTaggingInput {
558            feed_id,
559            name: name.into(),
560        };
561        self.post(client, "/v2/taggings.json", |r| r.json(&input))
562            .await
563            .map(|_| ())
564    }
565
566    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#delete-tagging
567    pub async fn delete_tagging(
568        &self,
569        client: &Client,
570        tagging_id: TaggingID,
571    ) -> Result<(), ApiError> {
572        let path = format!("/v2/taggings/{}.json", tagging_id);
573        self.delete(client, &path).await.map(|_| ())
574    }
575
576    // https://github.com/feedbin/feedbin-api/blob/master/content/tags.md
577    pub async fn rename_tag(
578        &self,
579        client: &Client,
580        old_name: &str,
581        new_name: &str,
582    ) -> Result<(), ApiError> {
583        let input = RenameTagInput {
584            old_name: old_name.into(),
585            new_name: new_name.into(),
586        };
587        self.post(client, "/v2/tags.json", |r| r.json(&input))
588            .await
589            .map(|_| ())
590    }
591
592    // https://github.com/feedbin/feedbin-api/blob/master/content/tags.md#delete-v2tagsjson
593    pub async fn delete_tag(&self, client: &Client, name: &str) -> Result<(), ApiError> {
594        let input = DeleteTagInput { name: name.into() };
595        self.delete_with_body(client, "/v2/tags.json", |r| r.json(&input))
596            .await
597            .map(|_| ())
598    }
599
600    // https://github.com/feedbin/feedbin-api/blob/master/content/icons.md#get-v2iconsjson
601    pub async fn get_icons(&self, client: &Client) -> Result<Vec<Icon>, ApiError> {
602        match self
603            .get(client, "/v2/icons.json", EMPTY_PARAMS, None)
604            .await?
605        {
606            CacheRequestResponse::Modified(CacheResult {
607                value: response,
608                cache: _cache,
609            }) => Self::deserialize::<Vec<Icon>>(response).await,
610            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
611        }
612    }
613
614    pub async fn import_opml(&self, client: &Client, opml: &str) -> Result<(), ApiError> {
615        self.post(client, "/v2/imports.json", |req_builder| {
616            req_builder
617                .header(CONTENT_TYPE, "text/xml")
618                .body(opml.to_owned())
619        })
620        .await
621        .map(|_| ())
622    }
623}