//! # Elefren: API Wrapper around the Mastodon API. //! //! Most of the api is documented on [Mastodon's website](https://docs.joinmastodon.org/client/intro/) //! #![deny(unused_must_use)] #[macro_use] extern crate log; #[macro_use] extern crate thiserror; use std::borrow::Cow; use std::ops; pub use isolang::Language; use reqwest::multipart; use entities::prelude::*; pub use errors::{Error, Result}; use helpers::deserialise_response; use serde::{Serialize,Deserialize}; use requests::{ AddFilterRequest, AddPushRequest, StatusesRequest, UpdateCredsRequest, UpdatePushRequest, }; use streaming::EventReader; pub use streaming::StreamKind; pub use crate::{ data::AppData, media_builder::MediaBuilder, page::Page, registration::Registration, scopes::Scopes, status_builder::NewStatus, status_builder::StatusBuilder, }; #[macro_use] mod macros; /// Registering your App pub mod apps; /// Contains the struct that holds the client auth data pub mod data; /// Entities returned from the API pub mod entities; /// Errors pub mod errors; /// Collection of helpers for serializing/deserializing `Data` objects pub mod helpers; /// Constructing media attachments for a status. pub mod media_builder; /// Handling multiple pages of entities. pub mod page; /// Registering your app. pub mod registration; /// Requests pub mod requests; /// OAuth Scopes pub mod scopes; /// Constructing a status pub mod status_builder; /// Client that doesn't need auth pub mod unauth; /// Streaming API pub mod streaming; pub mod debug; /// Your mastodon application client, handles all requests to and from Mastodon. #[derive(Clone, Debug)] pub struct FediClient { http_client: reqwest::Client, /// Raw data about your mastodon instance. pub data: AppData, } impl From for FediClient { /// Creates a mastodon instance from the data struct. fn from(data: AppData) -> FediClient { let mut builder = ClientBuilder::new(); builder.data(data); builder.build().expect("We know `data` is present, so this should be fine") } } #[derive(Serialize,Deserialize,Clone,Copy,PartialEq)] #[serde(rename_all="snake_case")] pub enum SearchType { Accounts, Hashtags, Statuses } impl FediClient { methods![get, put, post, delete,]; pub fn route(&self, url: &str) -> String { format!("{}{}", self.base, url) } /// # Low-level API for extending /// Send a request and get response pub async fn send(&self, req: reqwest::RequestBuilder) -> Result { let request = req.bearer_auth(&self.token).build()?; Ok(self.http_client.execute(request).await?) } /// Open streaming API of the given kind pub async fn open_streaming_api<'k>(&self, kind: StreamKind<'k>) -> Result { let mut url: url::Url = self.route(&"/api/v1/streaming").parse()?; // let mut url: url::Url = self.route(&format!("/api/v1/streaming/{}", kind.get_url_fragment())).parse()?; { let mut qpm = url.query_pairs_mut(); qpm.append_pair("access_token", &self.token); qpm.append_pair("stream", kind.get_stream_name()); for (k, v) in kind.get_query_params() { qpm.append_pair(k, v); } } streaming::do_open_streaming(url.as_str()).await } route_v1_paged!((get) favourites: "favourites" => Status); route_v1_paged!((get) blocks: "blocks" => Account); route_v1_paged!((get) domain_blocks: "domain_blocks" => String); route_v1_paged!((get) follow_requests: "follow_requests" => Account); route_v1_paged!((get) get_home_timeline: "timelines/home" => Status); route_v1_paged!((get) get_local_timeline: "timelines/public?local=true" => Status); route_v1_paged!((get) get_federated_timeline: "timelines/public?local=false" => Status); route_v1_paged!((get) get_emojis: "custom_emojis" => Emoji); route_v1_paged!((get) mutes: "mutes" => Account); route_v1_paged!((get) notifications: "notifications" => Notification); route_v1_paged!((get) reports: "reports" => Report); route_v1_paged!((get (q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] limit: Option, following: bool,)) search_accounts: "accounts/search" => Account); route_v1_paged!((get) get_endorsements: "endorsements" => Account); route_v1_paged_id!((get) followers: "accounts/{}/followers" => Account); route_v1_paged_id!((get) following: "accounts/{}/following" => Account); route_v1_paged_id!((get) reblogged_by: "statuses/{}/reblogged_by" => Account); route_v1_paged_id!((get) favourited_by: "statuses/{}/favourited_by" => Account); route_v1!((delete (domain: String,)) unblock_domain: "domain_blocks" => Empty); route_v1!((get) instance: "instance" => Instance); route_v1!((get) verify_credentials: "accounts/verify_credentials" => Account); route_v1!((post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report); route_v1!((post (domain: String,)) block_domain: "domain_blocks" => Empty); route_v1!((post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty); route_v1!((post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty); route_v1!((get (q: &'a str, resolve: bool,)) search: "search" => SearchResult); route_v1!((post (uri: Cow<'static, str>,)) follows: "follows" => Account); route_v1!((post) clear_notifications: "notifications/clear" => Empty); route_v1!((post (id: &str,)) dismiss_notification: "notifications/dismiss" => Empty); route_v1!((get) get_push_subscription: "push/subscription" => Subscription); route_v1!((delete) delete_push_subscription: "push/subscription" => Empty); route_v1!((get) get_filters: "filters" => Vec); route_v1!((get) get_follow_suggestions: "suggestions" => Vec); route_v2!((get ( q: &'a str, resolve: bool, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "type")] type_: Option, limit: Option, following: bool, )) search_v2: "search" => SearchResultV2); route_v1_id!((get) get_account: "accounts/{}" => Account); route_v1_id!((post) follow: "accounts/{}/follow" => Relationship); route_v1_id!((post) unfollow: "accounts/{}/unfollow" => Relationship); route_v1_id!((post) block: "accounts/{}/block" => Relationship); route_v1_id!((post) unblock: "accounts/{}/unblock" => Relationship); route_v1_id!((get) mute: "accounts/{}/mute" => Relationship); route_v1_id!((get) unmute: "accounts/{}/unmute" => Relationship); route_v1_id!((get) get_notification: "notifications/{}" => Notification); route_v1_id!((get) get_status: "statuses/{}" => Status); route_v1_id!((get) get_context: "statuses/{}/context" => Context); route_v1_id!((get) get_card: "statuses/{}/card" => Card); route_v1_id!((post) reblog: "statuses/{}/reblog" => Status); route_v1_id!((post) unreblog: "statuses/{}/unreblog" => Status); route_v1_id!((post) favourite: "statuses/{}/favourite" => Status); route_v1_id!((post) unfavourite: "statuses/{}/unfavourite" => Status); route_v1_id!((delete) delete_status: "statuses/{}" => Empty); route_v1_id!((get) get_filter: "filters/{}" => Filter); route_v1_id!((delete) delete_filter: "filters/{}" => Empty); route_v1_id!((delete) delete_from_suggestions: "suggestions/{}" => Empty); route_v1_id!((post) endorse_user: "accounts/{}/pin" => Relationship); route_v1_id!((post) unendorse_user: "accounts/{}/unpin" => Relationship); /// POST /api/v1/filters pub async fn add_filter(&self, request: &mut AddFilterRequest) -> Result { let url = self.route("/api/v1/filters"); let response = self.send(self.http_client.post(&url).json(&request)).await?; let status = response.status(); if status.is_client_error() { return Err(Error::Client(status)); } else if status.is_server_error() { return Err(Error::Server(status)); } deserialise_response(response).await } /// PUT /api/v1/filters/:id pub async fn update_filter(&self, id: &str, request: &mut AddFilterRequest) -> Result { let url = self.route(&format!("/api/v1/filters/{}", id)); let response = self.send(self.http_client.put(&url).json(&request)).await?; let status = response.status(); if status.is_client_error() { return Err(Error::Client(status)); } else if status.is_server_error() { return Err(Error::Server(status)); } deserialise_response(response).await } /// PATCH /api/v1/accounts/update_credentials pub async fn update_credentials(&self, builder: &mut UpdateCredsRequest) -> Result { let changes = builder.build()?; let url = self.route("/api/v1/accounts/update_credentials"); let response = self.send(self.http_client.patch(&url).json(&changes)).await?; let status = response.status(); if status.is_client_error() { return Err(Error::Client(status)); } else if status.is_server_error() { return Err(Error::Server(status)); } deserialise_response(response).await } /// Post a new status to the account. pub async fn new_status(&self, status: NewStatus) -> Result { let response = self.send(self.http_client.post(&self.route("/api/v1/statuses")).json(&status)).await?; deserialise_response(response).await } /// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or /// federated. pub async fn get_hashtag_timeline<'a>(&'a self, hashtag: &str, local: bool) -> Result> { let base = "/api/v1/timelines/tag/"; let url = if local { self.route(&format!("{}{}?local=1", base, hashtag)) } else { self.route(&format!("{}{}", base, hashtag)) }; Page::new(self, self.send(self.http_client.get(&url)).await?).await } /// Get statuses of a single account by id. Optionally only with pictures /// and or excluding replies. pub async fn statuses<'a, 'b: 'a, S>(&'b self, id: &'b str, request: S) -> Result> where S: Into>>, { let mut url = format!("{}/api/v1/accounts/{}/statuses", self.base, id); if let Some(request) = request.into() { url = format!("{}{}", url, request.to_querystring()?); } let response = self.send(self.http_client.get(&url)).await?; Page::new(self, response).await } /// Returns the client account's relationship to a list of other accounts. /// Such as whether they follow them or vice versa. pub async fn relationships<'a>(&'a self, ids: &[&str]) -> Result> { let mut url = self.route("/api/v1/accounts/relationships?"); if ids.len() == 1 { url += "id="; url += ids[0]; } else { for id in ids { url += "id[]="; url += id; url += "&"; } url.pop(); } let response = self.send(self.http_client.get(&url)).await?; Page::new(self, response).await } /// Add a push notifications subscription pub async fn add_push_subscription(&self, request: &AddPushRequest) -> Result { let request = request.build()?; let response = self.send(self.http_client.post(&self.route("/api/v1/push/subscription")).json(&request)).await?; deserialise_response(response).await } /// Update the `data` portion of the push subscription associated with this /// access token pub async fn update_push_data(&self, request: &UpdatePushRequest) -> Result { let request = request.build(); let response = self.send(self.http_client.put(&self.route("/api/v1/push/subscription")).json(&request)).await?; deserialise_response(response).await } /// Get all accounts that follow the authenticated user pub async fn follows_me<'a>(&'a self) -> Result> { let me = self.verify_credentials().await?; self.followers(&me.id).await } /// Get all accounts that the authenticated user follows pub async fn followed_by_me<'a>(&'a self) -> Result> { let me = self.verify_credentials().await?; self.following(&me.id).await } /// returns events that are relevant to the authorized user, i.e. home /// timeline & notifications pub async fn streaming_user(&self) -> Result { self.open_streaming_api(StreamKind::User).await } /// returns all public statuses pub async fn streaming_public(&self) -> Result { self.open_streaming_api(StreamKind::Public).await } /// Returns all local statuses pub async fn streaming_local(&self) -> Result { self.open_streaming_api(StreamKind::PublicLocal).await } /// Returns all public statuses for a particular hashtag pub async fn streaming_public_hashtag(&self, hashtag: &str) -> Result { self.open_streaming_api(StreamKind::Hashtag(hashtag)).await } /// Returns all local statuses for a particular hashtag pub async fn streaming_local_hashtag(&self, hashtag: &str) -> Result { self.open_streaming_api(StreamKind::HashtagLocal(hashtag)).await } /// Returns statuses for a list pub async fn streaming_list(&self, list_id: &str) -> Result { self.open_streaming_api(StreamKind::List(list_id)).await } /// Returns all direct messages pub async fn streaming_direct(&self) -> Result { self.open_streaming_api(StreamKind::Direct).await } /// Upload some media to the server for possible attaching to a new status /// /// Upon successful upload of a media attachment, the server will assign it an id. To actually /// use the attachment in a new status, you can use the `media_ids` field of /// [`StatusBuilder`] pub async fn media(&self, media: MediaBuilder) -> Result { use media_builder::MediaBuilderData; let mut form = multipart::Form::new(); form = match media.data { MediaBuilderData::Reader(reader) => { let mut part = multipart::Part::stream(reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(reader))); if let Some(filename) = media.filename { part = part.file_name(filename); } if let Some(mimetype) = media.mimetype { part = part.mime_str(&mimetype)?; } form.part("file", part) } MediaBuilderData::File(file) => { let f = tokio::fs::OpenOptions::new().read(true).open(file).await?; let part = multipart::Part::stream(reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(f))); form.part("file", part) } }; if let Some(description) = media.description { form = form.text("description", description); } if let Some((x, y)) = media.focus { form = form.text("focus", format!("{},{}", x, y)); } let response = self.send(self.http_client.post(&self.route("/api/v1/media")).multipart(form)).await?; deserialise_response(response).await } } impl ops::Deref for FediClient { type Target = AppData; fn deref(&self) -> &Self::Target { &self.data } } struct ClientBuilder { http_client: Option, data: Option, } impl ClientBuilder { pub fn new() -> Self { ClientBuilder { http_client: None, data: None, } } pub fn http_client(&mut self, client: reqwest::Client) -> &mut Self { self.http_client = Some(client); self } pub fn data(&mut self, data: AppData) -> &mut Self { self.data = Some(data); self } pub fn build(self) -> Result { Ok(if let Some(data) = self.data { FediClient { http_client: self.http_client.unwrap_or_else(reqwest::Client::new), data, } } else { return Err(Error::MissingField("missing field 'data'")); }) } }