mod error;
pub mod models;
#[cfg(test)]
mod tests;
pub use crate::error::ApiError;
use base64::engine::general_purpose::STANDARD as base64_std;
use base64::Engine;
use models::{
Feed, FeedCreateInput, FeedMoveInput, FeedRenameInput, FeedsResponse, Folder, FolderName,
FoldersResponse, Item, ItemMarkAllInput, ItemMarkInput, ItemQueryInput, ItemType,
ItemUpdatedQueryInput, ItemsResponse, Version,
};
use reqwest::{header::AUTHORIZATION, Client, Response, StatusCode};
use serde::Deserialize;
use url::Url;
type FolderID = i64;
type FeedID = i64;
type ItemID = i64;
pub struct NextcloudNewsApi {
base_uri: Url,
auth: String,
}
impl NextcloudNewsApi {
pub fn new(url: &Url, username: String, password: String) -> Result<Self, ApiError> {
Ok(Self {
base_uri: Self::sanitize_base_url(url)?,
auth: Self::generate_basic_auth(&username, &password),
})
}
fn sanitize_base_url(url: &Url) -> Result<Url, ApiError> {
let url_str = url.as_str();
let new_url = if !url_str.ends_with('/') {
Url::parse(&format!("{url_str}/"))?
} else {
url.clone()
};
Ok(new_url)
}
fn parse_error(response: &Response, expected_status: StatusCode) -> Result<(), ApiError> {
let status = response.status();
if status != expected_status {
log::error!("unexpected http status {}", status);
Err(ApiError::from(status))
} else {
Ok(())
}
}
async fn deserialize<T: for<'a> Deserialize<'a>>(
response: Response,
expected_status: Option<StatusCode>,
) -> Result<T, ApiError> {
if let Some(expected_status) = expected_status {
Self::parse_error(&response, expected_status)?;
}
let json = response.text().await?;
let result: T =
serde_json::from_str(&json).map_err(|source| ApiError::Json { source, json })?;
Ok(result)
}
fn get_api_1_2_uri(&self) -> Result<Url, ApiError> {
Ok(self.base_uri.join("index.php/apps/news/api/v1-2/")?)
}
fn get_api_1_3_uri(&self) -> Result<Url, ApiError> {
Ok(self.base_uri.join("index.php/apps/news/api/v1-3/")?)
}
fn generate_basic_auth(username: &str, password: &str) -> String {
let auth = format!("{}:{}", username, password);
let auth = base64_std.encode(auth);
format!("Basic {}", auth)
}
pub async fn get_version(&self, client: &Client) -> Result<Version, ApiError> {
let version = self.get_version_1_3(client).await;
if let Ok(version) = version {
Ok(version)
} else {
self.get_version_1_2(client).await
}
}
async fn get_version_1_3(&self, client: &Client) -> Result<Version, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("version")?;
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::deserialize(response, Some(StatusCode::OK)).await
}
async fn get_version_1_2(&self, client: &Client) -> Result<Version, ApiError> {
let api_url = self.get_api_1_2_uri()?.join("version")?;
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::deserialize(response, Some(StatusCode::OK)).await
}
pub async fn get_folders(&self, client: &Client) -> Result<Vec<Folder>, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("folders")?;
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
let FoldersResponse { folders } = Self::deserialize(response, Some(StatusCode::OK)).await?;
Ok(folders)
}
pub async fn create_folder(&self, client: &Client, name: &str) -> Result<Folder, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("folders")?;
let input = FolderName { name: name.into() };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
let FoldersResponse { folders } = Self::deserialize(response, Some(StatusCode::OK)).await?;
let folder = match folders.into_iter().nth(0) {
Some(folder) => folder,
None => return Err(ApiError::Input),
};
Ok(folder)
}
pub async fn delete_folder(
&self,
client: &Client,
folder_id: FolderID,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("folders/{}", folder_id))?;
let response = client
.delete(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn rename_folder(
&self,
client: &Client,
folder_id: FolderID,
new_name: &str,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("folders/{}", folder_id))?;
let input = FolderName {
name: new_name.into(),
};
let response = client
.put(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_folder(
&self,
client: &Client,
folder_id: FolderID,
newest_item_id: ItemID,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("folders/{}/read", folder_id))?;
let input = ItemMarkAllInput { newest_item_id };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn get_feeds(&self, client: &Client) -> Result<Vec<Feed>, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("feeds")?;
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
let FeedsResponse { feeds, .. } = Self::deserialize(response, Some(StatusCode::OK)).await?;
Ok(feeds)
}
pub async fn create_feed(
&self,
client: &Client,
url: &str,
folder_id: Option<FolderID>,
) -> Result<Feed, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("feeds")?;
let input = FeedCreateInput {
url: url.into(),
folder_id,
};
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
let FeedsResponse { feeds, .. } = Self::deserialize(response, Some(StatusCode::OK)).await?;
let feed = match feeds.into_iter().nth(0) {
Some(feed) => feed,
None => return Err(ApiError::Input),
};
Ok(feed)
}
pub async fn delete_feed(&self, client: &Client, feed_id: FeedID) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("feeds/{}", feed_id))?;
let response = client
.delete(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn move_feed(
&self,
client: &Client,
feed_id: FeedID,
folder_id: Option<FolderID>,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("feeds/{}/move", feed_id))?;
let input = FeedMoveInput { folder_id };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn rename_feed(
&self,
client: &Client,
feed_id: FeedID,
new_name: &str,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("feeds/{}/rename", feed_id))?;
let input = FeedRenameInput {
feed_title: new_name.into(),
};
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_feed(
&self,
client: &Client,
feed_id: FeedID,
newest_item_id: ItemID,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("feeds/{}/read", feed_id))?;
let input = ItemMarkAllInput { newest_item_id };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn get_items(
&self,
client: &Client,
batch_size: i64,
offset: Option<u64>,
item_type: Option<ItemType>,
id: Option<u64>,
get_read: Option<bool>,
oldest_first: Option<bool>,
) -> Result<Vec<Item>, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items")?;
let input = ItemQueryInput {
batch_size,
offset,
item_type,
id,
get_read,
oldest_first,
};
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
let ItemsResponse { items, .. } = Self::deserialize(response, Some(StatusCode::OK)).await?;
Ok(items)
}
pub async fn get_updated_items(
&self,
client: &Client,
last_modified: u64,
item_type: Option<ItemType>,
id: Option<u64>,
) -> Result<Vec<Item>, ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/updated")?;
let input = ItemUpdatedQueryInput {
last_modified,
item_type,
id,
};
let response = client
.get(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
let ItemsResponse { items, .. } = Self::deserialize(response, Some(StatusCode::OK)).await?;
Ok(items)
}
pub async fn mark_item_read(&self, client: &Client, item_id: ItemID) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("items/{}/read", item_id))?;
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_items_read(
&self,
client: &Client,
item_ids: Vec<ItemID>,
) -> Result<(), ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/read/multiple")?;
let input = ItemMarkInput { item_ids };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_item_unread(&self, client: &Client, item_id: ItemID) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("items/{}/unread", item_id))?;
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_items_unread(
&self,
client: &Client,
item_ids: Vec<ItemID>,
) -> Result<(), ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/unread/multiple")?;
let input = ItemMarkInput { item_ids };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_item_starred(
&self,
client: &Client,
item_id: ItemID,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("items/{}/star", item_id))?;
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_items_starred(
&self,
client: &Client,
item_ids: Vec<ItemID>,
) -> Result<(), ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/star/multiple")?;
let input = ItemMarkInput { item_ids };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_item_unstarred(
&self,
client: &Client,
item_id: ItemID,
) -> Result<(), ApiError> {
let api_url = self
.get_api_1_3_uri()?
.join(&format!("items/{}/unstar", item_id))?;
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_items_unstarred(
&self,
client: &Client,
item_ids: Vec<ItemID>,
) -> Result<(), ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/unstar/multiple")?;
let input = ItemMarkInput { item_ids };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
pub async fn mark_all_items_read(
&self,
client: &Client,
newest_item_id: ItemID,
) -> Result<(), ApiError> {
let api_url = self.get_api_1_3_uri()?.join("items/read")?;
let input = ItemMarkAllInput { newest_item_id };
let response = client
.post(api_url)
.header(AUTHORIZATION, self.auth.clone())
.json(&input)
.send()
.await?;
Self::parse_error(&response, StatusCode::OK)?;
Ok(())
}
}