feedbin_api 0.2.0

Rust implementation of the Feedbin REST API
Documentation
mod error;
pub mod models;
#[cfg(test)]
mod tests;

pub use crate::error::ApiError;
use crate::models::{
    cache::Cache,
    cache::CacheRequestResponse,
    cache::CacheResult,
    entry::Entry,
    entry::UpdateEntryStarredInput,
    entry::UpdateEntryUnreadInput,
    icon::Icon,
    subscription::Subscription,
    subscription::SubscriptionMode,
    subscription::UpdateSubscriptionInput,
    subscription::{CreateSubscriptionInput, CreateSubscriptionResult},
    tagging::CreateTaggingInput,
    tagging::DeleteTagInput,
    tagging::RenameTagInput,
    tagging::Tagging,
};
use chrono::{DateTime, Utc};
use models::SubscriptionOption;
use reqwest::header::{CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
use serde::Deserialize;
use url::Url;

pub type FeedID = u64;
pub type EntryID = u64;
pub type SubscriptionID = u64;
pub type TaggingID = u64;

pub struct FeedbinApi {
    base_url: Url,
    username: String,
    password: String,
}

impl FeedbinApi {
    pub fn new<S: Into<String>>(base_url: &Url, username: S, password: S) -> Self {
        FeedbinApi {
            base_url: base_url.clone(),
            username: username.into(),
            password: password.into(),
        }
    }

    pub fn with_base_url(&self, base_url: &Url) -> Self {
        FeedbinApi {
            base_url: base_url.clone(),
            username: self.username.clone(),
            password: self.password.clone(),
        }
    }

    pub fn with_password<S: Into<String>>(&self, password: S) -> Self {
        FeedbinApi {
            base_url: self.base_url.clone(),
            username: self.username.clone(),
            password: password.into(),
        }
    }

    fn build_url(&self, path: &str) -> Url {
        self.base_url.clone().join(path).unwrap() // We control all path inputs so failure is impossible
    }

    async fn deserialize<T: for<'a> Deserialize<'a>>(response: Response) -> Result<T, ApiError> {
        let json = response.text().await?;
        let result: T = serde_json::from_str(&json).map_err(|source| ApiError::Json {
            source,
            json: json.into(),
        })?;
        Ok(result)
    }

    async fn request<F: FnOnce(RequestBuilder) -> RequestBuilder>(
        &self,
        client: &Client,
        method: Method,
        path: &str,
        f: F,
    ) -> Result<Response, ApiError> {
        let url = self.build_url(path);
        let request = client
            .request(method, url)
            .basic_auth(&self.username, Some(&self.password));

        let request = f(request);
        let response = request.send().await?;

        match response.status().as_u16() {
            401 => Err(ApiError::InvalidLogin),
            403 => Err(ApiError::AccessDenied),
            200 | 201 | 204 | 302 | 404 => Ok(response),
            _ => Err(ApiError::ServerIsBroken),
        }
    }

    async fn get(
        &self,
        client: &Client,
        path: &str,
        cache: Option<Cache>,
    ) -> Result<CacheRequestResponse<Response>, ApiError> {
        // HEAD request to check for NOT MODIFIED
        if let Some(cache) = cache {
            let response = client
                .request(Method::GET, self.build_url(path))
                .basic_auth(&self.username, Some(&self.password))
                .header(IF_MODIFIED_SINCE, cache.last_modified)
                .header(IF_NONE_MATCH, cache.etag)
                .send()
                .await?;

            if response.status() == StatusCode::NOT_MODIFIED {
                return Ok(CacheRequestResponse::NotModified);
            }
        }

        let response = self.request(client, Method::GET, path, |req| req).await?;

        // extract http cache (etag, last_modified)
        if let Some(etag) = response.headers().get(ETAG) {
            if let Some(last_modified) = response.headers().get(LAST_MODIFIED) {
                if let Ok(etag) = etag.to_str() {
                    if let Ok(last_modified) = last_modified.to_str() {
                        let cache = Cache {
                            etag: etag.into(),
                            last_modified: last_modified.into(),
                        };
                        return Ok(CacheRequestResponse::Modified(CacheResult {
                            value: response,
                            cache: Some(cache),
                        }));
                    }
                }
            }
        }

        Ok(CacheRequestResponse::Modified(CacheResult {
            value: response,
            cache: None,
        }))
    }

    async fn delete_with_body<F: FnOnce(RequestBuilder) -> RequestBuilder>(
        &self,
        client: &Client,
        path: &str,
        f: F,
    ) -> Result<Response, ApiError> {
        self.request(client, Method::DELETE, path, f).await
    }

    async fn delete(&self, client: &Client, path: &str) -> Result<Response, ApiError> {
        self.request(client, Method::DELETE, path, |req| req).await
    }

    async fn post<F: FnOnce(RequestBuilder) -> RequestBuilder>(
        &self,
        client: &Client,
        path: &str,
        f: F,
    ) -> Result<Response, ApiError> {
        self.request(client, Method::POST, path, f).await
    }

    pub async fn is_authenticated(&self, client: &Client) -> Result<bool, ApiError> {
        match self.get(client, "/v2/authentication.json", None).await {
            Err(err) => match err {
                ApiError::InvalidLogin => Ok(false),
                _ => Err(err),
            },
            Ok(CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _,
            })) => match response.status().as_u16() {
                200 => Ok(true),
                _ => Err(ApiError::ServerIsBroken),
            },
            Ok(CacheRequestResponse::NotModified) => Err(ApiError::InvalidCaching),
        }
    }

    pub async fn is_reachable(&self, client: &Client) -> Result<bool, ApiError> {
        match self.is_authenticated(client).await {
            Ok(_) => Ok(true),
            Err(err) => match err {
                ApiError::ServerIsBroken => Ok(true),
                ApiError::Network(_) => Ok(false),
                _ => Err(err),
            },
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#entries
    #[allow(clippy::too_many_arguments)]
    pub async fn get_entries(
        &self,
        client: &Client,
        page: Option<u32>,
        since: Option<DateTime<Utc>>,
        ids: Option<&[EntryID]>,
        starred: Option<bool>,
        enclosure: Option<bool>,
        extended: bool,
    ) -> Result<Vec<Entry>, ApiError> {
        let mut api_endpoint = String::from("/v2/entries.json");
        if page.is_some()
            || since.is_some()
            || ids.is_some()
            || starred.is_some()
            || enclosure.is_some()
            || extended
        {
            api_endpoint.push('?');
        }
        if let Some(page) = page {
            api_endpoint.push_str(&format!("page={}", page));
        }
        if let Some(since) = since {
            if page.is_some() {
                api_endpoint.push('&');
            }
            api_endpoint.push_str(&format!(
                "since={}",
                since.format("%Y-%m-%dT%H:%M:%S%.f").to_string()
            ));
        }
        if let Some(ids) = ids {
            if page.is_some() || since.is_some() {
                api_endpoint.push('&');
            }
            let id_strings = ids.iter().map(|id| id.to_string()).collect::<Vec<String>>();
            api_endpoint.push_str(&format!("ids={}", id_strings.join(",")));
        }
        if let Some(starred) = starred {
            if page.is_some() || since.is_some() || ids.is_some() {
                api_endpoint.push('&');
            }
            api_endpoint.push_str(&format!("starred={}", starred));
        }
        if let Some(enclosure) = enclosure {
            if page.is_some() || since.is_some() || ids.is_some() || starred.is_some() {
                api_endpoint.push('&');
            }
            api_endpoint.push_str(&format!("include_enclosure={}", enclosure));
        }
        if extended {
            if page.is_some()
                || since.is_some()
                || ids.is_some()
                || starred.is_some()
                || enclosure.is_some()
            {
                api_endpoint.push('&');
            }
            api_endpoint.push_str("mode=extended");
        }

        match self.get(client, &api_endpoint, None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Vec<Entry>>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#get-v2feeds203entriesjson
    pub async fn get_entries_for_feed(
        &self,
        client: &Client,
        feed_id: FeedID,
        cache: Option<Cache>,
    ) -> Result<CacheRequestResponse<Vec<Entry>>, ApiError> {
        let path = format!("/v2/feeds/{}/entries.json", feed_id);
        match self.get(client, &path, cache).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache,
            }) => {
                let res = Self::deserialize::<Vec<Entry>>(response).await?;
                Ok(CacheRequestResponse::Modified(CacheResult {
                    value: res,
                    cache,
                }))
            }
            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#unread-entries
    pub async fn get_unread_entry_ids(&self, client: &Client) -> Result<Vec<EntryID>, ApiError> {
        match self.get(client, "/v2/unread_entries.json", None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Vec<EntryID>>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#create-unread-entries-mark-as-unread
    pub async fn set_entries_unread(
        &self,
        client: &Client,
        entry_ids: &[EntryID],
    ) -> Result<(), ApiError> {
        if entry_ids.len() > 1000 {
            return Err(ApiError::InputSize);
        }
        let input = UpdateEntryUnreadInput {
            unread_entries: entry_ids.into(),
        };
        self.post(client, "/v2/unread_entries.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/unread-entries.md#delete-unread-entries-mark-as-read
    pub async fn set_entries_read(
        &self,
        client: &Client,
        entry_ids: &[EntryID],
    ) -> Result<(), ApiError> {
        if entry_ids.len() > 1000 {
            return Err(ApiError::InputSize);
        }
        let input = UpdateEntryUnreadInput {
            unread_entries: entry_ids.into(),
        };
        self.delete_with_body(client, "/v2/unread_entries.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#get-starred-entries
    pub async fn get_starred_entry_ids(&self, client: &Client) -> Result<Vec<EntryID>, ApiError> {
        match self.get(client, "/v2/starred_entries.json", None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Vec<EntryID>>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#create-starred-entries
    pub async fn set_entries_starred(
        &self,
        client: &Client,
        entry_ids: &[EntryID],
    ) -> Result<(), ApiError> {
        if entry_ids.len() > 1000 {
            return Err(ApiError::InputSize);
        }
        let input = UpdateEntryStarredInput {
            starred_entries: entry_ids.into(),
        };
        self.post(client, "/v2/starred_entries.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/starred-entries.md#delete-starred-entries-unstar
    pub async fn set_entries_unstarred(
        &self,
        client: &Client,
        entry_ids: &[EntryID],
    ) -> Result<(), ApiError> {
        if entry_ids.len() > 1000 {
            return Err(ApiError::InputSize);
        }
        let input = UpdateEntryStarredInput {
            starred_entries: entry_ids.into(),
        };
        self.delete_with_body(client, "/v2/starred_entries.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/entries.md#get-v2entries3648json
    pub async fn get_entry(&self, client: &Client, entry_id: EntryID) -> Result<Entry, ApiError> {
        let path = format!("/v2/entries/{}.json", entry_id);
        match self.get(client, &path, None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Entry>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#get-subscriptions
    pub async fn get_subscriptions(
        &self,
        client: &Client,
        since: Option<DateTime<Utc>>,
        mode: Option<SubscriptionMode>,
        cache: Option<Cache>,
    ) -> Result<CacheRequestResponse<Vec<Subscription>>, ApiError> {
        let mut api_endpoint = String::from("/v2/subscriptions.json");
        if since.is_some() || mode.is_some() {
            api_endpoint.push('?');
        }
        if let Some(since) = since {
            api_endpoint.push_str(&format!(
                "since={}",
                since.format("%Y-%m-%dT%H:%M:%S%.f").to_string()
            ));
        }
        if let Some(mode) = mode {
            if since.is_some() {
                api_endpoint.push('&');
            }
            api_endpoint.push_str(&mode.to_string());
        }

        match self.get(client, &api_endpoint, cache).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache,
            }) => {
                let res = Self::deserialize::<Vec<Subscription>>(response).await?;
                Ok(CacheRequestResponse::Modified(CacheResult {
                    value: res,
                    cache,
                }))
            }
            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#get-subscription
    pub async fn get_subscription(
        &self,
        client: &Client,
        subscription_id: SubscriptionID,
    ) -> Result<Subscription, ApiError> {
        let path = format!("/v2/subscriptions/{}.json", subscription_id);
        match self.get(client, &path, None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Subscription>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#create-subscription
    pub async fn create_subscription<S: Into<String>>(
        &self,
        client: &Client,
        url: S,
    ) -> Result<CreateSubscriptionResult, ApiError> {
        let input = CreateSubscriptionInput {
            feed_url: url.into(),
        };
        let res = self
            .post(client, "/v2/subscriptions.json", |request| {
                request.json(&input)
            })
            .await?;
        match res.status().as_u16() {
            201 => {
                let subscription = Self::deserialize::<Subscription>(res).await?;
                Ok(CreateSubscriptionResult::Created(subscription))
            }
            300 => {
                let options = Self::deserialize::<Vec<SubscriptionOption>>(res).await?;
                Ok(CreateSubscriptionResult::MultipleOptions(options))
            }
            303 => {
                let location = res
                    .headers()
                    .get("Location")
                    .ok_or(ApiError::ServerIsBroken)?
                    .to_str()
                    .map_err(|_| ApiError::ServerIsBroken)?;
                let location = Url::parse(location)?;
                Ok(CreateSubscriptionResult::Found(location))
            }
            404 => Ok(CreateSubscriptionResult::NotFound),
            _ => Err(ApiError::from(ApiError::ServerIsBroken)),
        }
    }

    pub async fn delete_subscription(
        &self,
        client: &Client,
        subscription_id: SubscriptionID,
    ) -> Result<(), ApiError> {
        let path = format!("/v2/subscriptions/{}.json", subscription_id);
        self.delete(client, &path).await.map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/subscriptions.md#update-subscription
    pub async fn update_subscription<S: Into<String>>(
        &self,
        client: &Client,
        subscription_id: SubscriptionID,
        title: S,
    ) -> Result<(), ApiError> {
        let input = UpdateSubscriptionInput {
            title: title.into(),
        };
        let path = format!("/v2/subscriptions/{}/update.json", subscription_id);
        self.post(client, &path, |request| request.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#get-taggings
    pub async fn get_taggings(
        &self,
        client: &Client,
        cache: Option<Cache>,
    ) -> Result<CacheRequestResponse<Vec<Tagging>>, ApiError> {
        match self.get(client, "/v2/taggings.json", cache).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache,
            }) => {
                let res = Self::deserialize::<Vec<Tagging>>(response).await?;
                Ok(CacheRequestResponse::Modified(CacheResult {
                    value: res,
                    cache,
                }))
            }
            CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#get-tagging
    pub async fn get_tagging(
        &self,
        client: &Client,
        tagging_id: TaggingID,
    ) -> Result<Tagging, ApiError> {
        let path = format!("/v2/taggings/{}.json", tagging_id);
        match self.get(client, &path, None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Tagging>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#create-tagging
    pub async fn create_tagging(
        &self,
        client: &Client,
        feed_id: FeedID,
        name: &str,
    ) -> Result<(), ApiError> {
        let input = CreateTaggingInput {
            feed_id,
            name: name.into(),
        };
        self.post(client, "/v2/taggings.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/taggings.md#delete-tagging
    pub async fn delete_tagging(
        &self,
        client: &Client,
        tagging_id: TaggingID,
    ) -> Result<(), ApiError> {
        let path = format!("/v2/taggings/{}.json", tagging_id);
        self.delete(client, &path).await.map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/tags.md
    pub async fn rename_tag(
        &self,
        client: &Client,
        old_name: &str,
        new_name: &str,
    ) -> Result<(), ApiError> {
        let input = RenameTagInput {
            old_name: old_name.into(),
            new_name: new_name.into(),
        };
        self.post(client, "/v2/tags.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/tags.md#delete-v2tagsjson
    pub async fn delete_tag(&self, client: &Client, name: &str) -> Result<(), ApiError> {
        let input = DeleteTagInput { name: name.into() };
        self.delete_with_body(client, "/v2/tags.json", |r| r.json(&input))
            .await
            .map(|_| ())
    }

    // https://github.com/feedbin/feedbin-api/blob/master/content/icons.md#get-v2iconsjson
    pub async fn get_icons(&self, client: &Client) -> Result<Vec<Icon>, ApiError> {
        match self.get(client, "/v2/icons.json", None).await? {
            CacheRequestResponse::Modified(CacheResult {
                value: response,
                cache: _cache,
            }) => Self::deserialize::<Vec<Icon>>(response).await,
            CacheRequestResponse::NotModified => Err(ApiError::InvalidCaching),
        }
    }

    pub async fn import_opml(&self, client: &Client, opml: &str) -> Result<(), ApiError> {
        self.post(client, "/v2/imports.json", |req_builder| {
            req_builder
                .header(CONTENT_TYPE, "text/xml")
                .body(opml.to_owned())
        })
        .await
        .map(|_| ())
    }
}