mod error;
pub mod models;
#[cfg(test)]
mod tests;
pub use crate::error::ApiError;
use crate::models::{
Category, CategoryInput, Entry, EntryBatch, EntryStateUpdate, EntryStatus, FavIcon, Feed,
FeedCreation, FeedCreationResponse, FeedDiscovery, FeedModification, MinifluxError, OrderBy,
OrderDirection, User, UserCreation, UserModification,
};
use base64::engine::general_purpose::STANDARD as base64_std;
use base64::Engine;
use log::error;
use reqwest::{header::AUTHORIZATION, Client, StatusCode};
use serde::{Deserialize, Serialize};
use url::Url;
type FeedID = i64;
type CategoryID = i64;
type EntryID = i64;
type UserID = i64;
type IconID = i64;
type EnclosureID = i64;
enum ApiAuth {
Basic(String),
Token(String),
}
pub struct MinifluxApi {
base_uri: Url,
auth: ApiAuth,
}
impl MinifluxApi {
pub fn new(url: &Url, username: String, password: String) -> Self {
MinifluxApi {
base_uri: url.clone(),
auth: ApiAuth::Basic(Self::generate_basic_auth(&username, &password)),
}
}
pub fn new_from_token(url: &Url, token: String) -> Self {
MinifluxApi {
base_uri: url.clone(),
auth: ApiAuth::Token(token),
}
}
fn generate_basic_auth(username: &str, password: &str) -> String {
let auth = format!("{}:{}", username, password);
let auth = base64_std.encode(auth);
format!("Basic {}", auth)
}
async fn send_request<T: Serialize>(
&self,
client: reqwest::RequestBuilder,
json_content: Option<T>,
) -> Result<reqwest::Response, ApiError> {
let mut headered_client = match &self.auth {
ApiAuth::Basic(auth) => client.header(AUTHORIZATION, auth.clone()),
ApiAuth::Token(auth) => client.header("X-Auth-Token", auth.clone()),
};
if let Some(json_content) = json_content {
headered_client = headered_client.json(&json_content);
}
let response = headered_client.send().await?;
Ok(response)
}
fn deserialize<T: for<'a> Deserialize<'a>>(json: &str) -> Result<T, ApiError> {
let result: T = serde_json::from_str(json).map_err(|source| ApiError::Json {
source,
json: json.into(),
})?;
Ok(result)
}
async fn parse_error(
response: reqwest::Response,
expected_http: StatusCode,
) -> Result<String, ApiError> {
let status = response.status();
let response = response.text().await?;
if status != expected_http {
let error = Self::deserialize::<MinifluxError>(&response)?;
error!("Miniflux API: {}", error.error_message);
return Err(ApiError::Miniflux(error));
}
Ok(response)
}
pub async fn discover_subscription(
&self,
url: Url,
client: &Client,
) -> Result<Vec<Feed>, ApiError> {
let api_url = self.base_uri.clone().join("v1/discover")?;
let content = FeedDiscovery {
url: url.to_string(),
};
let response = self
.send_request(client.post(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let feeds = Self::deserialize::<Vec<Feed>>(&response)?;
Ok(feeds)
}
pub async fn get_feeds(&self, client: &Client) -> Result<Vec<Feed>, ApiError> {
let api_url = self.base_uri.clone().join("v1/feeds")?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let feeds = Self::deserialize::<Vec<Feed>>(&response)?;
Ok(feeds)
}
pub async fn get_feed(&self, id: FeedID, client: &Client) -> Result<Feed, ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let feed = Self::deserialize::<Feed>(&response)?;
Ok(feed)
}
pub async fn get_feed_icon(&self, id: FeedID, client: &Client) -> Result<FavIcon, ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/feeds/{}/icon", id))?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let icon = Self::deserialize::<FavIcon>(&response)?;
Ok(icon)
}
pub async fn create_feed(
&self,
feed_url: &Url,
category_id: CategoryID,
client: &Client,
) -> Result<FeedID, ApiError> {
let api_url = self.base_uri.clone().join("v1/feeds")?;
let content = FeedCreation {
feed_url: feed_url.to_string(),
category_id,
};
let response = self
.send_request(client.post(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::CREATED).await?;
let response = Self::deserialize::<FeedCreationResponse>(&response)?;
Ok(response.feed_id)
}
#[allow(clippy::too_many_arguments)]
pub async fn update_feed(
&self,
id: FeedID,
title: Option<&str>,
category_id: Option<CategoryID>,
feed_url: Option<&str>,
site_url: Option<&str>,
username: Option<&str>,
password: Option<&str>,
user_agent: Option<&str>,
client: &Client,
) -> Result<Feed, ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
let content = FeedModification {
title: title.map(|t| t.into()),
category_id,
feed_url: feed_url.map(|t| t.into()),
site_url: site_url.map(|t| t.into()),
username: username.map(|t| t.into()),
password: password.map(|t| t.into()),
scraper_rules: None,
rewrite_rules: None,
crawler: None,
user_agent: user_agent.map(|t| t.into()),
disabled: None,
};
let response = self
.send_request(client.put(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::CREATED).await?;
let feed = Self::deserialize::<Feed>(&response)?;
Ok(feed)
}
pub async fn refresh_feed_synchronous(
&self,
id: FeedID,
client: &Client,
) -> Result<(), ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/feeds/{}/refresh", id))?;
let response = self.send_request::<()>(client.put(api_url), None).await?;
let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
Ok(())
}
pub async fn delete_feed(&self, id: FeedID, client: &Client) -> Result<(), ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/feeds/{}", id))?;
let response = self
.send_request::<()>(client.delete(api_url), None)
.await?;
let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
Ok(())
}
pub async fn get_feed_entry(
&self,
feed_id: FeedID,
entry_id: EntryID,
client: &Client,
) -> Result<Entry, ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/feeds/{}/entries/{}", feed_id, entry_id))?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let entry = Self::deserialize::<Entry>(&response)?;
Ok(entry)
}
pub async fn get_entry(&self, id: EntryID, client: &Client) -> Result<Entry, ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/entries/{}", id))?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let entry = Self::deserialize::<Entry>(&response)?;
Ok(entry)
}
#[allow(clippy::too_many_arguments)]
pub async fn get_entries(
&self,
status: Option<EntryStatus>,
offset: Option<i64>,
limit: Option<i64>,
order: Option<OrderBy>,
direction: Option<OrderDirection>,
before: Option<i64>,
after: Option<i64>,
before_entry_id: Option<EntryID>,
after_entry_id: Option<EntryID>,
starred: Option<bool>,
client: &Client,
) -> Result<Vec<Entry>, ApiError> {
let mut api_url = self.base_uri.clone().join("v1/entries")?;
{
let mut query_pairs = api_url.query_pairs_mut();
query_pairs.clear();
if let Some(status) = status {
query_pairs.append_pair("status", status.into());
}
if let Some(offset) = offset {
query_pairs.append_pair("offset", &offset.to_string());
}
if let Some(limit) = limit {
query_pairs.append_pair("limit", &limit.to_string());
}
if let Some(order) = order {
query_pairs.append_pair("order", order.into());
}
if let Some(direction) = direction {
query_pairs.append_pair("direction", direction.into());
}
if let Some(before) = before {
query_pairs.append_pair("before", &before.to_string());
}
if let Some(after) = after {
query_pairs.append_pair("after", &after.to_string());
}
if let Some(before_entry_id) = before_entry_id {
query_pairs.append_pair("before_entry_id", &before_entry_id.to_string());
}
if let Some(after_entry_id) = after_entry_id {
query_pairs.append_pair("after_entry_id", &after_entry_id.to_string());
}
if let Some(starred) = starred {
query_pairs.append_pair("starred", &starred.to_string());
}
}
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let batch = Self::deserialize::<EntryBatch>(&response)?;
Ok(batch.entries)
}
#[allow(clippy::too_many_arguments)]
pub async fn get_feed_entries(
&self,
id: FeedID,
status: Option<EntryStatus>,
offset: Option<i64>,
limit: Option<i64>,
order: Option<OrderBy>,
direction: Option<OrderDirection>,
before: Option<i64>,
after: Option<i64>,
before_entry_id: Option<EntryID>,
after_entry_id: Option<EntryID>,
starred: Option<bool>,
client: &Client,
) -> Result<Vec<Entry>, ApiError> {
let mut api_url = self
.base_uri
.clone()
.join(&format!("v1/feeds/{}/entries", id))?;
{
let mut query_pairs = api_url.query_pairs_mut();
query_pairs.clear();
if let Some(status) = status {
query_pairs.append_pair("status", status.into());
}
if let Some(offset) = offset {
query_pairs.append_pair("offset", &offset.to_string());
}
if let Some(limit) = limit {
query_pairs.append_pair("limit", &limit.to_string());
}
if let Some(order) = order {
query_pairs.append_pair("order", order.into());
}
if let Some(direction) = direction {
query_pairs.append_pair("direction", direction.into());
}
if let Some(before) = before {
query_pairs.append_pair("before", &before.to_string());
}
if let Some(after) = after {
query_pairs.append_pair("after", &after.to_string());
}
if let Some(before_entry_id) = before_entry_id {
query_pairs.append_pair("before_entry_id", &before_entry_id.to_string());
}
if let Some(after_entry_id) = after_entry_id {
query_pairs.append_pair("after_entry_id", &after_entry_id.to_string());
}
if let Some(starred) = starred {
query_pairs.append_pair("starred", &starred.to_string());
}
}
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let batch = Self::deserialize::<EntryBatch>(&response)?;
Ok(batch.entries)
}
pub async fn update_entries_status(
&self,
ids: Vec<FeedID>,
status: EntryStatus,
client: &Client,
) -> Result<(), ApiError> {
let api_url = self.base_uri.clone().join("v1/entries")?;
let status: &str = status.into();
let content = EntryStateUpdate {
entry_ids: ids,
status: status.to_owned(),
};
let response = self
.send_request(client.put(api_url), Some(content))
.await?;
let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
Ok(())
}
pub async fn toggle_bookmark(&self, id: EntryID, client: &Client) -> Result<(), ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/entries/{}/bookmark", id))?;
let response = self.send_request::<()>(client.put(api_url), None).await?;
let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
Ok(())
}
pub async fn get_categories(&self, client: &Client) -> Result<Vec<Category>, ApiError> {
let api_url = self.base_uri.clone().join("v1/categories")?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let categories = Self::deserialize::<Vec<Category>>(&response)?;
Ok(categories)
}
pub async fn create_category(
&self,
title: &str,
client: &Client,
) -> Result<Category, ApiError> {
let api_url = self.base_uri.clone().join("v1/categories")?;
let content = CategoryInput {
title: title.to_owned(),
};
let response = self
.send_request(client.post(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::CREATED).await?;
let category = Self::deserialize::<Category>(&response)?;
Ok(category)
}
pub async fn update_category(
&self,
id: CategoryID,
title: &str,
client: &Client,
) -> Result<Category, ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/categories/{}", id))?;
let content = CategoryInput {
title: title.to_owned(),
};
let response = self
.send_request(client.put(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::CREATED).await?;
let category = Self::deserialize::<Category>(&response)?;
Ok(category)
}
pub async fn delete_category(&self, id: CategoryID, client: &Client) -> Result<(), ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/categories/{}", id))?;
let response = self
.send_request::<()>(client.delete(api_url), None)
.await?;
let _ = Self::parse_error(response, StatusCode::NO_CONTENT).await?;
Ok(())
}
pub async fn export_opml(&self, client: &Client) -> Result<String, ApiError> {
let api_url = self.base_uri.clone().join("v1/export")?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
Ok(response)
}
pub async fn import_opml(&self, opml: &str, client: &Client) -> Result<(), ApiError> {
let api_url = self.base_uri.clone().join("v1/import")?;
let response = match &self.auth {
ApiAuth::Basic(auth) => {
client
.get(api_url)
.header(AUTHORIZATION, auth.clone())
.body(opml.to_owned())
.send()
.await?
}
ApiAuth::Token(auth) => {
client
.get(api_url)
.header("X-Auth-Token", auth.clone())
.body(opml.to_owned())
.send()
.await?
}
};
let _ = Self::parse_error(response, StatusCode::CREATED).await?;
Ok(())
}
pub async fn create_user(
&self,
username: &str,
password: &str,
is_admin: bool,
client: &Client,
) -> Result<User, ApiError> {
let api_url = self.base_uri.clone().join("v1/users")?;
let content = UserCreation {
username: username.to_owned(),
password: password.to_owned(),
is_admin,
};
let response = self
.send_request(client.post(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::CREATED).await?;
let user = Self::deserialize::<User>(&response)?;
Ok(user)
}
#[allow(clippy::too_many_arguments)]
pub async fn update_user(
&self,
id: UserID,
username: Option<String>,
password: Option<String>,
is_admin: Option<bool>,
theme: Option<String>,
language: Option<String>,
timezone: Option<String>,
entry_sorting_direction: Option<String>,
client: &Client,
) -> Result<User, ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
let content = UserModification {
username,
password,
is_admin,
theme,
language,
timezone,
entry_sorting_direction,
};
let response = self
.send_request(client.put(api_url), Some(content))
.await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let user = Self::deserialize::<User>(&response)?;
Ok(user)
}
pub async fn get_current_user(&self, client: &Client) -> Result<User, ApiError> {
let api_url = self.base_uri.clone().join("v1/me")?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let user = Self::deserialize::<User>(&response)?;
Ok(user)
}
pub async fn get_user_by_id(&self, id: UserID, client: &Client) -> Result<User, ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
let response = self.send_request::<()>(client.post(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let user = Self::deserialize::<User>(&response)?;
Ok(user)
}
pub async fn get_user_by_name(
&self,
username: &str,
client: &Client,
) -> Result<User, ApiError> {
let api_url = self
.base_uri
.clone()
.join(&format!("v1/users/{}", username))?;
let response = self.send_request::<()>(client.post(api_url), None).await?;
let response = Self::parse_error(response, StatusCode::OK).await?;
let user = Self::deserialize::<User>(&response)?;
Ok(user)
}
pub async fn delete_user(&self, id: UserID, client: &Client) -> Result<(), ApiError> {
let api_url = self.base_uri.clone().join(&format!("v1/users/{}", id))?;
let response = self
.send_request::<()>(client.delete(api_url), None)
.await?;
let _ = Self::parse_error(response, StatusCode::OK).await?;
Ok(())
}
pub async fn healthcheck(&self, client: &Client) -> Result<(), ApiError> {
let api_url = self.base_uri.clone().join("healthcheck")?;
let response = self.send_request::<()>(client.get(api_url), None).await?;
let _ = Self::parse_error(response, StatusCode::OK).await?;
Ok(())
}
}