mod error;
pub mod models;
#[cfg(test)]
mod tests;
pub use crate::error::{ApiError, ApiErrorKind};
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 failure::ResultExt;
use reqwest::header::{CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
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,
username: username.into(),
password: password.into(),
}
}
pub fn with_base_url(&self, base_url: Url) -> Self {
FeedbinApi {
base_url: base_url,
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()
}
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.context(ApiErrorKind::Network)?;
match response.status().as_u16() {
401 => Err(ApiError::from(ApiErrorKind::InvalidLogin)),
403 => Err(ApiError::from(ApiErrorKind::AccessDenied)),
200 | 201 | 204 | 302 | 404 => Ok(response),
_ => Err(ApiError::from(ApiErrorKind::ServerIsBroken)),
}
}
async fn get(
&self,
client: &Client,
path: &str,
cache: Option<Cache>,
) -> Result<CacheRequestResponse<Response>, ApiError> {
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
.context(ApiErrorKind::Network)?;
if response.status() == StatusCode::NOT_MODIFIED {
return Ok(CacheRequestResponse::NotModified);
}
}
let response = self.request(client, Method::GET, path, |req| req).await?;
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.kind() {
ApiErrorKind::InvalidLogin => Ok(false),
_ => Err(err),
},
Ok(CacheRequestResponse::Modified(CacheResult {
value: response,
cache: _,
})) => match response.status().as_u16() {
200 => Ok(true),
_ => Err(ApiErrorKind::ServerIsBroken)?,
},
Ok(CacheRequestResponse::NotModified) => Err(ApiErrorKind::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.kind() {
ApiErrorKind::ServerIsBroken => Ok(true),
ApiErrorKind::Network => Ok(false),
_ => Err(err),
},
}
}
pub async fn get_entries(
&self,
client: &Client,
page: Option<u32>,
since: Option<DateTime<Utc>>,
ids: Option<&[EntryID]>,
starred: Option<bool>,
enclosure: Option<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()
{
api_endpoint.push_str("?");
}
if let Some(page) = page {
api_endpoint.push_str(&format!("page={}", page));
}
if let Some(since) = since {
if page.is_some() {
api_endpoint.push_str("&");
}
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_str("&");
}
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_str("&");
}
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_str("&");
}
api_endpoint.push_str(&format!("include_enclosure={}", enclosure));
}
match self.get(client, &api_endpoint, None).await? {
CacheRequestResponse::Modified(CacheResult {
value: response,
cache: _cache,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
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,
}) => response
.json()
.await
.map(|res| {
CacheRequestResponse::Modified(CacheResult {
value: res,
cache: cache,
})
})
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
}
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
pub async fn set_entries_unread(
&self,
client: &Client,
entry_ids: &[EntryID],
) -> Result<(), ApiError> {
if entry_ids.len() > 1000 {
return Err(ApiErrorKind::InputSize.into());
}
let input = UpdateEntryUnreadInput {
unread_entries: entry_ids.into(),
};
self.post(client, "/v2/unread_entries.json", |r| r.json(&input))
.await
.map(|_| ())
}
pub async fn set_entries_read(
&self,
client: &Client,
entry_ids: &[EntryID],
) -> Result<(), ApiError> {
if entry_ids.len() > 1000 {
return Err(ApiErrorKind::InputSize.into());
}
let input = UpdateEntryUnreadInput {
unread_entries: entry_ids.into(),
};
self.delete_with_body(client, "/v2/unread_entries.json", |r| r.json(&input))
.await
.map(|_| ())
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
pub async fn set_entries_starred(
&self,
client: &Client,
entry_ids: &[EntryID],
) -> Result<(), ApiError> {
if entry_ids.len() > 1000 {
return Err(ApiErrorKind::InputSize.into());
}
let input = UpdateEntryStarredInput {
starred_entries: entry_ids.into(),
};
self.post(client, "/v2/starred_entries.json", |r| r.json(&input))
.await
.map(|_| ())
}
pub async fn set_entries_unstarred(
&self,
client: &Client,
entry_ids: &[EntryID],
) -> Result<(), ApiError> {
if entry_ids.len() > 1000 {
return Err(ApiErrorKind::InputSize.into());
}
let input = UpdateEntryStarredInput {
starred_entries: entry_ids.into(),
};
self.delete_with_body(client, "/v2/starred_entries.json", |r| r.json(&input))
.await
.map(|_| ())
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
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_str("?");
}
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_str("&");
}
api_endpoint.push_str(&mode.to_string());
}
match self.get(client, &api_endpoint, cache).await? {
CacheRequestResponse::Modified(CacheResult {
value: response,
cache,
}) => response
.json()
.await
.map(|res| CacheRequestResponse::Modified(CacheResult { value: res, cache }))
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
}
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
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 = res.json().await.context(ApiErrorKind::ServerIsBroken)?;
Ok(CreateSubscriptionResult::Created(subscription))
}
300 => {
let options = res.json().await.context(ApiErrorKind::ServerIsBroken)?;
Ok(CreateSubscriptionResult::MultipleOptions(options))
}
303 => {
let location = res
.headers()
.get("Location")
.ok_or(ApiErrorKind::ServerIsBroken)?
.to_str()
.context(ApiErrorKind::ServerIsBroken)?;
let location = Url::parse(location).context(ApiErrorKind::ServerIsBroken)?;
Ok(CreateSubscriptionResult::Found(location))
}
404 => Ok(CreateSubscriptionResult::NotFound),
_ => Err(ApiError::from(ApiErrorKind::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(|_| ())
}
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(|_| ())
}
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,
}) => response
.json()
.await
.map(|res| CacheRequestResponse::Modified(CacheResult { value: res, cache }))
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Ok(CacheRequestResponse::NotModified),
}
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::InvalidCaching)?,
}
}
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(|_| ())
}
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(|_| ())
}
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(|_| ())
}
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(|_| ())
}
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,
}) => response
.json()
.await
.context(ApiErrorKind::ServerIsBroken)
.map_err(ApiError::from),
CacheRequestResponse::NotModified => Err(ApiErrorKind::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(|_| ())
}
}