From e69d92f71e969228e6975f18001ff8311c03825e Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Mon, 5 Dec 2022 08:52:48 -0500 Subject: [PATCH] Update client to work asynchronously - Use reqwest's async client - Convert items_iter() to a futures::Stream - make Mastodon client an Arc smart pointer, removing the need for OwnedPage. - remove MastodonClient and HttpSender traits; these can be re-added once async trait fns are stabilized - make EventStream a futures::Stream --- Cargo.toml | 85 ++-- examples/follow_profile.rs | 2 +- examples/follows_me.rs | 2 +- examples/home_timeline.rs | 2 +- examples/print_your_profile.rs | 2 +- examples/search.rs | 2 +- examples/upload_photo.rs | 2 +- src/entities/itemsiter.rs | 91 ++-- src/errors.rs | 56 ++- src/event_stream.rs | 91 ++++ src/helpers/cli.rs | 6 +- src/http_send.rs | 26 - src/lib.rs | 742 ++--------------------------- src/macros.rs | 99 ++-- src/mastodon.rs | 410 ++++++++++++++++ src/page.rs | 144 +++--- src/registration.rs | 161 ++++--- src/requests/push.rs | 24 +- src/requests/update_credentials.rs | 4 +- src/status_builder.rs | 5 +- 20 files changed, 879 insertions(+), 1077 deletions(-) create mode 100644 src/event_stream.rs delete mode 100644 src/http_send.rs create mode 100644 src/mastodon.rs diff --git a/Cargo.toml b/Cargo.toml index 7eec89a..db6978b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,97 +1,70 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies -# -# If you believe there's an error in this file please file an -# issue against the rust-lang/cargo repository. If you're -# editing this file be aware that the upstream Cargo.toml -# will likely look very different (and much more reasonable) - [package] name = "elefren" version = "0.23.0" -authors = ["Aaron Power ", "Paul Woolcock "] +authors = ["Aaron Power ", "Paul Woolcock ", "D. Scott Boggs "] description = "A wrapper around the Mastodon API." readme = "README.md" keywords = ["api", "web", "social", "mastodon", "wrapper"] categories = ["web-programming", "web-programming::http-client", "api-bindings"] license = "MIT/Apache-2.0" edition = "2021" -# TODO setup repo -# repository = "https://github.com/pwoolcoc/elefren.git" +repository = "https://github.com/dscottboggs/elefren.git" [package.metadata.docs.rs] features = ["all"] + +[dependencies] +futures = "0.3.25" +doc-comment = "0.3" +log = "0.4" +serde_json = "1" +serde_qs = "0.4.5" +serde_urlencoded = "0.6.1" +tap-reader = "1" +tungstenite = "0.18" +url = "1" +# Provides parsing for the link header in get_links() in page.rs +hyper-old-types = "0.11.0" + [dependencies.chrono] version = "0.4" features = ["serde"] -[dependencies.doc-comment] -version = "0.3" - [dependencies.envy] version = "0.4" optional = true -# Provides parsing for the link header in get_links() in page.rs -[dependencies.hyper-old-types] -version = "0.11.0" - [dependencies.isolang] version = "2.2" features = ["serde"] -[dependencies.log] -version = "0.4" - [dependencies.reqwest] -version = "0.9" -default-features = false +version = "0.11" +features = ["multipart", "json"] [dependencies.serde] version = "1" features = ["derive"] -[dependencies.serde_json] -version = "1" - -[dependencies.serde_qs] -version = "0.4.5" - -[dependencies.serde_urlencoded] -version = "0.6.1" - -[dependencies.tap-reader] -version = "1" - [dependencies.toml] version = "0.5" optional = true -[dependencies.tungstenite] -version = "0.10.1" - -[dependencies.url] -version = "1" - -[dev-dependencies.indoc] -version = "1.0" - -[dev-dependencies.pretty_env_logger] -version = "0.3.0" - -[dev-dependencies.skeptic] -version = "0.13" - -[dev-dependencies.tempfile] -version = "3" +[dev-dependencies] +tokio-test = "0.4.2" +futures-util = "0.3.25" +indoc = "1.0" +pretty_env_logger = "0.3.0" +skeptic = "0.13" +tempfile = "3" [build-dependencies.skeptic] version = "0.13" +[dev-dependencies.tokio] +version = "1.22.0" +features = ["rt-multi-thread", "macros"] + [features] all = ["toml", "json", "env"] default = ["reqwest/default-tls"] diff --git a/examples/follow_profile.rs b/examples/follow_profile.rs index 84b2998..6d50327 100644 --- a/examples/follow_profile.rs +++ b/examples/follow_profile.rs @@ -5,7 +5,7 @@ extern crate pretty_env_logger; extern crate elefren; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/examples/follows_me.rs b/examples/follows_me.rs index a78ceb7..c491994 100644 --- a/examples/follows_me.rs +++ b/examples/follows_me.rs @@ -4,7 +4,7 @@ extern crate pretty_env_logger; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/examples/home_timeline.rs b/examples/home_timeline.rs index 84531a6..ae279a9 100644 --- a/examples/home_timeline.rs +++ b/examples/home_timeline.rs @@ -4,7 +4,7 @@ extern crate pretty_env_logger; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/examples/print_your_profile.rs b/examples/print_your_profile.rs index cbcd372..cae9f6e 100644 --- a/examples/print_your_profile.rs +++ b/examples/print_your_profile.rs @@ -5,7 +5,7 @@ extern crate pretty_env_logger; extern crate elefren; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/examples/search.rs b/examples/search.rs index 28ef8df..4bd2756 100644 --- a/examples/search.rs +++ b/examples/search.rs @@ -4,7 +4,7 @@ extern crate pretty_env_logger; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/examples/upload_photo.rs b/examples/upload_photo.rs index a2e6688..8ce184c 100644 --- a/examples/upload_photo.rs +++ b/examples/upload_photo.rs @@ -5,7 +5,7 @@ extern crate pretty_env_logger; extern crate elefren; mod register; -use register::MastodonClient; +use register::Mastodon; use std::error; #[cfg(feature = "toml")] diff --git a/src/entities/itemsiter.rs b/src/entities/itemsiter.rs index a8395e7..46511d8 100644 --- a/src/entities/itemsiter.rs +++ b/src/entities/itemsiter.rs @@ -1,27 +1,36 @@ -use crate::{http_send::HttpSend, page::Page}; +use futures::{stream::unfold, Stream}; + +use crate::page::Page; use serde::Deserialize; /// Abstracts away the `next_page` logic into a single stream of items /// -/// ```no_run +/// ```no_run,async /// use elefren::prelude::*; -/// let data = Data::default(); -/// let client = Mastodon::from(data); -/// let statuses = client.statuses("user-id", None).unwrap(); -/// for status in statuses.items_iter() { -/// // do something with `status` -/// } +/// use futures::stream::StreamExt; +/// use futures_util::pin_mut; +/// +/// tokio_test::block_on(async { +/// let data = Data::default(); +/// let client = Mastodon::from(data); +/// let statuses = client.statuses("user-id", None).await.unwrap().items_iter(); +/// statuses.for_each(|status| async move { +/// // Do something with the status +/// }).await; +/// }) /// ``` +/// +/// See documentation for `futures::Stream::StreamExt` for available methods. #[derive(Debug, Clone)] -pub(crate) struct ItemsIter<'a, T: Clone + for<'de> Deserialize<'de>, H: 'a + HttpSend> { - page: Page<'a, T, H>, +pub(crate) struct ItemsIter Deserialize<'de>> { + page: Page, buffer: Vec, cur_idx: usize, use_initial: bool, } -impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> ItemsIter<'a, T, H> { - pub(crate) fn new(page: Page<'a, T, H>) -> ItemsIter<'a, T, H> { +impl<'a, T: Clone + for<'de> Deserialize<'de>> ItemsIter { + pub(crate) fn new(page: Page) -> ItemsIter { ItemsIter { page, buffer: vec![], @@ -34,8 +43,8 @@ impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> ItemsIter<'a, T, H> self.buffer.is_empty() || self.cur_idx == self.buffer.len() } - fn fill_next_page(&mut self) -> Option<()> { - let items = if let Ok(items) = self.page.next_page() { + async fn fill_next_page(&mut self) -> Option<()> { + let items = if let Ok(items) = self.page.next_page().await { items } else { return None; @@ -51,33 +60,39 @@ impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> ItemsIter<'a, T, H> None } } -} -impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> Iterator for ItemsIter<'a, T, H> { - type Item = T; - - fn next(&mut self) -> Option { - if self.use_initial { - if self.page.initial_items.is_empty() || self.cur_idx == self.page.initial_items.len() { - return None; - } - let idx = self.cur_idx; - if self.cur_idx == self.page.initial_items.len() - 1 { - self.cur_idx = 0; - self.use_initial = false; - } else { - self.cur_idx += 1; - } - Some(self.page.initial_items[idx].clone()) - } else { - if self.need_next_page() { - if self.fill_next_page().is_none() { + pub(crate) fn stream(self) -> impl Stream { + unfold(self, |mut this| async move { + if this.use_initial { + if this.page.initial_items.is_empty() + || this.cur_idx == this.page.initial_items.len() + { return None; } + let idx = this.cur_idx; + if this.cur_idx == this.page.initial_items.len() - 1 { + this.cur_idx = 0; + this.use_initial = false; + } else { + this.cur_idx += 1; + } + let item = this.page.initial_items[idx].clone(); + // let item = Box::pin(item); + // pin_mut!(item); + Some((item, this)) + } else { + if this.need_next_page() { + if this.fill_next_page().await.is_none() { + return None; + } + } + let idx = this.cur_idx; + this.cur_idx += 1; + let item = this.buffer[idx].clone(); + // let item = Box::pin(item); + // pin_mut!(item); + Some((item, this)) } - let idx = self.cur_idx; - self.cur_idx += 1; - Some(self.buffer[idx].clone()) - } + }) } } diff --git a/src/errors.rs b/src/errors.rs index b8b4c18..754d274 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,4 +1,4 @@ -use std::{error, fmt, io::Error as IoError}; +use std::{error, fmt, io::Error as IoError, num::TryFromIntError}; #[cfg(feature = "env")] use envy::Error as EnvyError; @@ -12,7 +12,7 @@ use serde_urlencoded::ser::Error as UrlEncodedError; use tomlcrate::de::Error as TomlDeError; #[cfg(feature = "toml")] use tomlcrate::ser::Error as TomlSerError; -use tungstenite::error::Error as WebSocketError; +use tungstenite::{error::Error as WebSocketError, Message as WebSocketMessage}; use url::ParseError as UrlError; /// Convience type over `std::result::Result` with `Error` as the error type. @@ -64,6 +64,14 @@ pub enum Error { SerdeQs(SerdeQsError), /// WebSocket error WebSocket(WebSocketError), + /// An integer conversion was attempted, but the value didn't fit into the + /// target type. + /// + /// At the time of writing, this can only be triggered when a file is + /// larger than the system's usize allows. + IntConversion(TryFromIntError), + /// A stream message was received that wasn't recognized + UnrecognizedStreamMessage(WebSocketMessage), /// Other errors Other(String), } @@ -93,12 +101,13 @@ impl error::Error for Error { Error::Envy(ref e) => e, Error::SerdeQs(ref e) => e, Error::WebSocket(ref e) => e, - + Error::IntConversion(ref e) => e, Error::Client(..) | Error::Server(..) => return None, Error::ClientIdRequired => return None, Error::ClientSecretRequired => return None, Error::AccessTokenRequired => return None, Error::MissingField(_) => return None, + Error::UnrecognizedStreamMessage(_) => return None, Error::Other(..) => return None, }) } @@ -122,7 +131,7 @@ impl fmt::Display for ApiError { impl error::Error for ApiError {} macro_rules! from { - ($($(#[$met:meta])* $typ:ident, $variant:ident,)*) => { + ($($(#[$met:meta])* $typ:ident => $variant:ident,)*) => { $( $(#[$met])* impl From<$typ> for Error { @@ -136,20 +145,22 @@ macro_rules! from { } from! { - HttpError, Http, - IoError, Io, - SerdeError, Serde, - UrlEncodedError, UrlEncoded, - UrlError, Url, - ApiError, Api, - #[cfg(feature = "toml")] TomlSerError, TomlSer, - #[cfg(feature = "toml")] TomlDeError, TomlDe, - HeaderStrError, HeaderStrError, - HeaderParseError, HeaderParseError, - #[cfg(feature = "env")] EnvyError, Envy, - SerdeQsError, SerdeQs, - WebSocketError, WebSocket, - String, Other, + HttpError => Http, + IoError => Io, + SerdeError => Serde, + UrlEncodedError => UrlEncoded, + UrlError => Url, + ApiError => Api, + #[cfg(feature = "toml")] TomlSerError => TomlSer, + #[cfg(feature = "toml")] TomlDeError => TomlDe, + HeaderStrError => HeaderStrError, + HeaderParseError => HeaderParseError, + #[cfg(feature = "env")] EnvyError => Envy, + SerdeQsError => SerdeQs, + WebSocketError => WebSocket, + String => Other, + TryFromIntError => IntConversion, + WebSocketMessage => UnrecognizedStreamMessage, } #[macro_export] @@ -157,8 +168,7 @@ from! { macro_rules! format_err { ( $( $arg:tt )* ) => { { - use elefren::Error; - Error::Other(format!($($arg)*)) + crate::Error::Other(format!($($arg)*)) } } } @@ -177,9 +187,9 @@ mod tests { }; } - #[test] - fn from_http_error() { - let err: HttpError = reqwest::get("not an actual URL").unwrap_err(); + #[tokio::test] + async fn from_http_error() { + let err: HttpError = reqwest::get("not an actual URL").await.unwrap_err(); let err: Error = Error::from(err); assert_is!(err, Error::Http(..)); } diff --git a/src/event_stream.rs b/src/event_stream.rs new file mode 100644 index 0000000..c7645e7 --- /dev/null +++ b/src/event_stream.rs @@ -0,0 +1,91 @@ +use crate::{ + entities::{event::Event, prelude::Notification, status::Status}, + errors::Result, + Error, +}; +use futures::{stream::try_unfold, TryStream}; +use log::debug; +use tungstenite::Message; + +/// Returns a stream of events at the given url location. +pub fn event_stream( + location: impl AsRef, +) -> Result>> { + let (client, response) = tungstenite::connect(location.as_ref())?; + let status = response.status(); + if !status.is_success() { + return Err(Error::Api(crate::ApiError { + error: status.canonical_reason().map(String::from), + error_description: None, + })); + } + Ok(try_unfold(client, |mut client| async move { + let mut lines = vec![]; + loop { + match client.read_message() { + Ok(Message::Text(message)) => { + let line = message.trim().to_string(); + if line.starts_with(":") || line.is_empty() { + continue; + } + lines.push(line); + if let Ok(event) = make_event(&lines) { + lines.clear(); + return Ok(Some((event, client))); + } else { + continue; + } + }, + Ok(Message::Ping(data)) => { + debug!("received ping, ponging (metadata: {data:?})"); + client.write_message(Message::Pong(data))?; + }, + Ok(message) => return Err(message.into()), + Err(err) => return Err(err.into()), + } + } + })) +} + +fn make_event(lines: &[String]) -> Result { + let event; + let data; + if let Some(event_line) = lines.iter().find(|line| line.starts_with("event:")) { + event = event_line[6..].trim().to_string(); + data = lines + .iter() + .find(|line| line.starts_with("data:")) + .map(|x| x[5..].trim().to_string()); + } else { + #[derive(Deserialize)] + struct Message { + pub event: String, + pub payload: Option, + } + let message = serde_json::from_str::(&lines[0])?; + event = message.event; + data = message.payload; + } + let event: &str = &event; + Ok(match event { + "notification" => { + let data = data + .ok_or_else(|| Error::Other("Missing `data` line for notification".to_string()))?; + let notification = serde_json::from_str::(&data)?; + Event::Notification(notification) + }, + "update" => { + let data = + data.ok_or_else(|| Error::Other("Missing `data` line for update".to_string()))?; + let status = serde_json::from_str::(&data)?; + Event::Update(status) + }, + "delete" => { + let data = + data.ok_or_else(|| Error::Other("Missing `data` line for delete".to_string()))?; + Event::Delete(data) + }, + "filters_changed" => Event::FiltersChanged, + _ => return Err(Error::Other(format!("Unknown event `{}`", event))), + }) +} diff --git a/src/helpers/cli.rs b/src/helpers/cli.rs index 25c1e03..5f4bbf0 100644 --- a/src/helpers/cli.rs +++ b/src/helpers/cli.rs @@ -1,10 +1,10 @@ use std::io::{self, BufRead, Write}; -use crate::{errors::Result, http_send::HttpSend, registration::Registered, Mastodon}; +use crate::{errors::Result, registration::Registered, Mastodon}; /// Finishes the authentication process for the given `Registered` object, /// using the command-line -pub fn authenticate(registration: Registered) -> Result> { +pub async fn authenticate(registration: Registered) -> Result { let url = registration.authorize_url()?; let stdout = io::stdout(); @@ -20,5 +20,5 @@ pub fn authenticate(registration: Registered) -> Result Result; - - /// Convenience method so that .build() doesn't have to be called at every - /// call site - fn send(&self, client: &Client, builder: RequestBuilder) -> Result { - let request = builder.build()?; - self.execute(client, request) - } -} - -#[doc(hidden)] -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct HttpSender; - -impl HttpSend for HttpSender { - fn execute(&self, client: &Client, request: Request) -> Result { - Ok(client.execute(request)?) - } -} diff --git a/src/lib.rs b/src/lib.rs index a2ecfc3..790e06a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,43 +1,55 @@ -//! // Elefren: API Wrapper around the Mastodon API. +//! # Elefren: API Wrapper around the Mastodon API. //! //! Most of the api is documented on [Mastodon's website](https://docs.joinmastodon.org/client/intro/) //! //! ```no_run //! use elefren::{helpers::cli, prelude::*}; +//! use futures_util::StreamExt; //! -//! let registration = Registration::new("https://botsin.space") -//! .client_name("elefren_test") -//! .build() -//! .unwrap(); -//! let mastodon = cli::authenticate(registration).unwrap(); +//! tokio_test::block_on(async { +//! let registration = Registration::new("https://botsin.space") +//! .client_name("elefren_test") +//! .build() +//! .await +//! .unwrap(); +//! let mastodon = cli::authenticate(registration).await.unwrap(); //! -//! println!( -//! "{:?}", -//! mastodon -//! .get_home_timeline() -//! .unwrap() -//! .items_iter() -//! .take(100) -//! .collect::>() -//! ); +//! println!( +//! "{:?}", +//! mastodon +//! .get_home_timeline() +//! .await +//! .unwrap() +//! .items_iter() +//! .take(100) +//! .collect::>() +//! .await +//! ); +//! }); //! ``` //! //! Elefren also supports Mastodon's Streaming API: //! -//! // Example +//! ## Example //! //! ```no_run //! use elefren::{prelude::*, entities::event::Event}; +//! use futures_util::TryStreamExt; +//! //! let data = Data::default(); //! let client = Mastodon::from(data); -//! for event in client.streaming_user().unwrap() { -//! match event { -//! Event::Update(ref status) => { /* .. */ }, -//! Event::Notification(ref notification) => { /* .. */ }, -//! Event::Delete(ref id) => { /* .. */ }, -//! Event::FiltersChanged => { /* .. */ }, -//! } -//! } +//! tokio_test::block_on(async { +//! let stream = client.streaming_user().await.unwrap(); +//! stream.try_for_each(|event| async move { +//! match event { +//! Event::Update(ref status) => { /* .. */ }, +//! Event::Notification(ref notification) => { /* .. */ }, +//! Event::Delete(ref id) => { /* .. */ }, +//! Event::FiltersChanged => { /* .. */ }, +//! } +//! Ok(()) +//! }).await.unwrap(); +//! }); //! ``` #![deny( @@ -53,7 +65,7 @@ unused_qualifications )] -#[macro_use] +// #[macro_use] extern crate log; #[macro_use] extern crate doc_comment; @@ -84,20 +96,13 @@ extern crate tempfile; #[cfg_attr(all(test, any(feature = "toml", feature = "json")), macro_use)] extern crate indoc; -use std::{borrow::Cow, io::BufRead, ops}; - -use reqwest::{Client, RequestBuilder, Response}; -use tap_reader::Tap; -use tungstenite::client::AutoStream; - -use entities::prelude::*; -use http_send::{HttpSend, HttpSender}; use page::Page; pub use data::Data; pub use errors::{ApiError, Error, Result}; pub use isolang::Language; -pub use mastodon_client::{MastodonClient, MastodonUnauthenticated}; +pub use mastodon::{Mastodon, MastodonUnauthenticated}; +// pub use mastodon_client::{MastodonClient, MastodonUnauthenticated}; pub use registration::Registration; pub use requests::{ AddFilterRequest, @@ -116,11 +121,10 @@ pub mod data; pub mod entities; /// Errors pub mod errors; +/// Event stream generators +pub mod event_stream; /// Collection of helpers for serializing/deserializing `Data` objects pub mod helpers; -/// Contains trait for converting `reqwest::Request`s to `reqwest::Response`s -pub mod http_send; -mod mastodon_client; /// Handling multiple pages of entities. pub mod page; /// Registering your app. @@ -139,670 +143,12 @@ pub mod prelude { scopes::Scopes, Data, Mastodon, - MastodonClient, + // MastodonClient, NewStatus, Registration, StatusBuilder, StatusesRequest, }; } - -/// Your mastodon application client, handles all requests to and from Mastodon. -#[derive(Clone, Debug)] -pub struct Mastodon { - client: Client, - http_sender: H, - /// Raw data about your mastodon instance. - pub data: Data, -} - -impl Mastodon { - methods![get, post, delete,]; - - fn route(&self, url: &str) -> String { - format!("{}{}", self.base, url) - } - - pub(crate) fn send(&self, req: RequestBuilder) -> Result { - Ok(self - .http_sender - .send(&self.client, req.bearer_auth(&self.token))?) - } -} - -impl From for Mastodon { - /// Creates a mastodon instance from the data struct. - fn from(data: Data) -> Mastodon { - let mut builder = MastodonBuilder::new(HttpSender); - builder.data(data); - builder - .build() - .expect("We know `data` is present, so this should be fine") - } -} - -impl MastodonClient for Mastodon { - type Stream = EventReader; - - paged_routes! { - (get) favourites: "favourites" => Status, - (get) blocks: "blocks" => Account, - (get) domain_blocks: "domain_blocks" => String, - (get) follow_requests: "follow_requests" => Account, - (get) get_home_timeline: "timelines/home" => Status, - (get) get_emojis: "custom_emojis" => Emoji, - (get) mutes: "mutes" => Account, - (get) notifications: "notifications" => Notification, - (get) reports: "reports" => Report, - (get (q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] limit: Option, following: bool,)) search_accounts: "accounts/search" => Account, - (get) get_endorsements: "endorsements" => Account, - } - - paged_routes_with_id! { - (get) followers: "accounts/{}/followers" => Account, - (get) following: "accounts/{}/following" => Account, - (get) reblogged_by: "statuses/{}/reblogged_by" => Account, - (get) favourited_by: "statuses/{}/favourited_by" => Account, - } - - route! { - (delete (domain: String,)) unblock_domain: "domain_blocks" => Empty, - (get) instance: "instance" => Instance, - (get) verify_credentials: "accounts/verify_credentials" => Account, - (post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report, - (post (domain: String,)) block_domain: "domain_blocks" => Empty, - (post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty, - (post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty, - (get (q: &'a str, resolve: bool,)) search: "search" => SearchResult, - (get (local: bool,)) get_public_timeline: "timelines/public" => Vec, - (post (uri: Cow<'static, str>,)) follows: "follows" => Account, - (post multipart (file: Cow<'static, str>,)) media: "media" => Attachment, - (post) clear_notifications: "notifications/clear" => Empty, - (post (id: &str,)) dismiss_notification: "notifications/dismiss" => Empty, - (get) get_push_subscription: "push/subscription" => Subscription, - (delete) delete_push_subscription: "push/subscription" => Empty, - (get) get_filters: "filters" => Vec, - (get) get_follow_suggestions: "suggestions" => Vec, - } - - route_v2! { - (get (q: &'a str, resolve: bool,)) search_v2: "search" => SearchResultV2, - } - - route_id! { - (get) get_account: "accounts/{}" => Account, - (post) follow: "accounts/{}/follow" => Relationship, - (post) unfollow: "accounts/{}/unfollow" => Relationship, - (post) block: "accounts/{}/block" => Relationship, - (post) unblock: "accounts/{}/unblock" => Relationship, - (get) mute: "accounts/{}/mute" => Relationship, - (get) unmute: "accounts/{}/unmute" => Relationship, - (get) get_notification: "notifications/{}" => Notification, - (get) get_status: "statuses/{}" => Status, - (get) get_context: "statuses/{}/context" => Context, - (get) get_card: "statuses/{}/card" => Card, - (post) reblog: "statuses/{}/reblog" => Status, - (post) unreblog: "statuses/{}/unreblog" => Status, - (post) favourite: "statuses/{}/favourite" => Status, - (post) unfavourite: "statuses/{}/unfavourite" => Status, - (delete) delete_status: "statuses/{}" => Empty, - (get) get_filter: "filters/{}" => Filter, - (delete) delete_filter: "filters/{}" => Empty, - (delete) delete_from_suggestions: "suggestions/{}" => Empty, - (post) endorse_user: "accounts/{}/pin" => Relationship, - (post) unendorse_user: "accounts/{}/unpin" => Relationship, - } - - fn add_filter(&self, request: &mut AddFilterRequest) -> Result { - let url = self.route("/api/v1/filters"); - let response = self.send(self.client.post(&url).json(&request))?; - - let status = response.status(); - - if status.is_client_error() { - return Err(Error::Client(status.clone())); - } else if status.is_server_error() { - return Err(Error::Server(status.clone())); - } - - deserialise(response) - } - - /// PUT /api/v1/filters/:id - fn update_filter(&self, id: &str, request: &mut AddFilterRequest) -> Result { - let url = self.route(&format!("/api/v1/filters/{}", id)); - let response = self.send(self.client.put(&url).json(&request))?; - - let status = response.status(); - - if status.is_client_error() { - return Err(Error::Client(status.clone())); - } else if status.is_server_error() { - return Err(Error::Server(status.clone())); - } - - deserialise(response) - } - - 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.client.patch(&url).json(&changes))?; - - let status = response.status(); - - if status.is_client_error() { - return Err(Error::Client(status.clone())); - } else if status.is_server_error() { - return Err(Error::Server(status.clone())); - } - - deserialise(response) - } - - /// Post a new status to the account. - fn new_status(&self, status: NewStatus) -> Result { - let response = self.send( - self.client - .post(&self.route("/api/v1/statuses")) - .json(&status), - )?; - - deserialise(response) - } - - /// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or - /// federated. - fn get_tagged_timeline(&self, hashtag: String, 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)) - }; - - self.get(url) - } - - /// Get statuses of a single account by id. Optionally only with pictures - /// and or excluding replies. - /// - /// // Example - /// - /// ```no_run - /// use elefren::prelude::*; - /// let data = Data::default(); - /// let client = Mastodon::from(data); - /// let statuses = client.statuses("user-id", None).unwrap(); - /// ``` - /// - /// ```no_run - /// use elefren::prelude::*; - /// let data = Data::default(); - /// let client = Mastodon::from(data); - /// let mut request = StatusesRequest::new(); - /// request.only_media(); - /// let statuses = client.statuses("user-id", request).unwrap(); - /// ``` - 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.client.get(&url))?; - - Page::new(self, response) - } - - /// Returns the client account's relationship to a list of other accounts. - /// Such as whether they follow them or vice versa. - fn relationships(&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.client.get(&url))?; - - Page::new(self, response) - } - - /// Add a push notifications subscription - fn add_push_subscription(&self, request: &AddPushRequest) -> Result { - let request = request.build()?; - let response = self.send( - self.client - .post(&self.route("/api/v1/push/subscription")) - .json(&request), - )?; - - deserialise(response) - } - - /// Update the `data` portion of the push subscription associated with this - /// access token - fn update_push_data(&self, request: &UpdatePushRequest) -> Result { - let request = request.build(); - let response = self.send( - self.client - .put(&self.route("/api/v1/push/subscription")) - .json(&request), - )?; - - deserialise(response) - } - - /// Get all accounts that follow the authenticated user - fn follows_me(&self) -> Result> { - let me = self.verify_credentials()?; - Ok(self.followers(&me.id)?) - } - - /// Get all accounts that the authenticated user follows - fn followed_by_me(&self) -> Result> { - let me = self.verify_credentials()?; - Ok(self.following(&me.id)?) - } - - /// returns events that are relevant to the authorized user, i.e. home - /// timeline & notifications - /// - /// // Example - /// - /// ```no_run - /// use elefren::prelude::*; - /// use elefren::entities::event::Event; - /// let data = Data::default(); - /// let client = Mastodon::from(data); - /// for event in client.streaming_user().unwrap() { - /// match event { - /// Event::Update(ref status) => { /* .. */ }, - /// Event::Notification(ref notification) => { /* .. */ }, - /// Event::Delete(ref id) => { /* .. */ }, - /// Event::FiltersChanged => { /* .. */ }, - /// } - /// } - /// ``` - fn streaming_user(&self) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "user"); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// returns all public statuses - fn streaming_public(&self) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "public"); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// Returns all local statuses - fn streaming_local(&self) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "public:local"); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// Returns all public statuses for a particular hashtag - fn streaming_public_hashtag(&self, hashtag: &str) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "hashtag") - .append_pair("tag", hashtag); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// Returns all local statuses for a particular hashtag - fn streaming_local_hashtag(&self, hashtag: &str) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "hashtag:local") - .append_pair("tag", hashtag); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// Returns statuses for a list - fn streaming_list(&self, list_id: &str) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "list") - .append_pair("list", list_id); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } - - /// Returns all direct messages - fn streaming_direct(&self) -> Result { - let mut url: url::Url = self.route("/api/v1/streaming").parse()?; - url.query_pairs_mut() - .append_pair("access_token", &self.token) - .append_pair("stream", "direct"); - let mut url: url::Url = reqwest::get(url.as_str())?.url().as_str().parse()?; - let new_scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), - }; - url.set_scheme(new_scheme) - .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; - - let client = tungstenite::connect(url.as_str())?.0; - - Ok(EventReader(WebSocket(client))) - } -} - -#[derive(Debug)] -/// WebSocket newtype so that EventStream can be implemented without coherency -/// issues -pub struct WebSocket(tungstenite::protocol::WebSocket); - -/// A type that streaming events can be read from -pub trait EventStream { - /// Read a message from this stream - fn read_message(&mut self) -> Result; -} - -impl EventStream for R { - fn read_message(&mut self) -> Result { - let mut buf = String::new(); - self.read_line(&mut buf)?; - Ok(buf) - } -} - -impl EventStream for WebSocket { - fn read_message(&mut self) -> Result { - Ok(self.0.read_message()?.into_text()?) - } -} - -#[derive(Debug)] -/// Iterator that produces events from a mastodon streaming API event stream -pub struct EventReader(R); -impl Iterator for EventReader { - type Item = Event; - - fn next(&mut self) -> Option { - let mut lines = Vec::new(); - loop { - if let Ok(line) = self.0.read_message() { - let line = line.trim().to_string(); - if line.starts_with(":") || line.is_empty() { - continue; - } - lines.push(line); - if let Ok(event) = self.make_event(&lines) { - lines.clear(); - return Some(event); - } else { - continue; - } - } - } - } -} - -impl EventReader { - fn make_event(&self, lines: &[String]) -> Result { - let event; - let data; - if let Some(event_line) = lines.iter().find(|line| line.starts_with("event:")) { - event = event_line[6..].trim().to_string(); - data = lines - .iter() - .find(|line| line.starts_with("data:")) - .map(|x| x[5..].trim().to_string()); - } else { - #[derive(Deserialize)] - struct Message { - pub event: String, - pub payload: Option, - } - let message = serde_json::from_str::(&lines[0])?; - event = message.event; - data = message.payload; - } - let event: &str = &event; - Ok(match event { - "notification" => { - let data = data.ok_or_else(|| { - Error::Other("Missing `data` line for notification".to_string()) - })?; - let notification = serde_json::from_str::(&data)?; - Event::Notification(notification) - }, - "update" => { - let data = - data.ok_or_else(|| Error::Other("Missing `data` line for update".to_string()))?; - let status = serde_json::from_str::(&data)?; - Event::Update(status) - }, - "delete" => { - let data = - data.ok_or_else(|| Error::Other("Missing `data` line for delete".to_string()))?; - Event::Delete(data) - }, - "filters_changed" => Event::FiltersChanged, - _ => return Err(Error::Other(format!("Unknown event `{}`", event))), - }) - } -} - -impl ops::Deref for Mastodon { - type Target = Data; - - fn deref(&self) -> &Self::Target { - &self.data - } -} - -struct MastodonBuilder { - client: Option, - http_sender: H, - data: Option, -} - -impl MastodonBuilder { - pub fn new(sender: H) -> Self { - MastodonBuilder { - http_sender: sender, - client: None, - data: None, - } - } - - pub fn client(&mut self, client: Client) -> &mut Self { - self.client = Some(client); - self - } - - pub fn data(&mut self, data: Data) -> &mut Self { - self.data = Some(data); - self - } - - pub fn build(self) -> Result> { - Ok(if let Some(data) = self.data { - Mastodon { - client: self.client.unwrap_or_else(|| Client::new()), - http_sender: self.http_sender, - data, - } - } else { - return Err(Error::MissingField("missing field 'data'")); - }) - } -} - -/// Client that can make unauthenticated calls to a mastodon instance -#[derive(Clone, Debug)] -pub struct MastodonUnauth { - client: Client, - http_sender: H, - base: url::Url, -} - -impl MastodonUnauth { - /// Create a new unauthenticated client - pub fn new(base: &str) -> Result> { - let base = if base.starts_with("https://") { - base.to_string() - } else { - format!("https://{}", base) - }; - Ok(MastodonUnauth { - client: Client::new(), - http_sender: HttpSender, - base: url::Url::parse(&base)?, - }) - } -} - -impl MastodonUnauth { - fn route(&self, url: &str) -> Result { - Ok(self.base.join(url)?) - } - - fn send(&self, req: RequestBuilder) -> Result { - Ok(self.http_sender.send(&self.client, req)?) - } -} - -impl MastodonUnauthenticated for MastodonUnauth { - /// GET /api/v1/statuses/:id - fn get_status(&self, id: &str) -> Result { - let route = self.route("/api/v1/statuses")?; - let route = route.join(id)?; - let response = self.send(self.client.get(route))?; - deserialise(response) - } - - /// GET /api/v1/statuses/:id/context - fn get_context(&self, id: &str) -> Result { - let route = self.route("/api/v1/statuses")?; - let route = route.join(id)?; - let route = route.join("context")?; - let response = self.send(self.client.get(route))?; - deserialise(response) - } - - /// GET /api/v1/statuses/:id/card - fn get_card(&self, id: &str) -> Result { - let route = self.route("/api/v1/statuses")?; - let route = route.join(id)?; - let route = route.join("card")?; - let response = self.send(self.client.get(route))?; - deserialise(response) - } -} - -// Convert the HTTP response body from JSON. Pass up deserialization errors -// transparently. -fn deserialise serde::Deserialize<'de>>(response: Response) -> Result { - let mut reader = Tap::new(response); - - match serde_json::from_reader(&mut reader) { - Ok(t) => { - debug!("{}", String::from_utf8_lossy(&reader.bytes)); - Ok(t) - }, - // If deserializing into the desired type fails try again to - // see if this is an error response. - Err(e) => { - error!("{}", String::from_utf8_lossy(&reader.bytes)); - if let Ok(error) = serde_json::from_slice(&reader.bytes) { - return Err(Error::Api(error)); - } - Err(e.into()) - }, - } -} +/// The mastodon client +pub mod mastodon; diff --git a/src/macros.rs b/src/macros.rs index c5aa44c..fc4f361 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,14 +1,18 @@ macro_rules! methods { ($($method:ident,)+) => { $( - fn $method serde::Deserialize<'de>>(&self, url: String) - -> Result + async fn $method serde::Deserialize<'de>>(&self, url: impl AsRef) -> Result { - let response = self.send( - self.client.$method(&url) - )?; - - deserialise(response) + let url = url.as_ref(); + Ok( + self.client + .$method(url) + .send() + .await? + .error_for_status()? + .json() + .await? + ) } )+ }; @@ -30,13 +34,11 @@ macro_rules! paged_routes { "client.", stringify!($name), "();\n", "```" ), - fn $name(&self) -> Result> { + pub async fn $name(&self) -> Result> { let url = self.route(concat!("/api/v1/", $url)); - let response = self.send( - self.client.$method(&url) - )?; + let response = self.client.$method(&url).send().await?; - Page::new(self, response) + Page::new(self.clone(), response).await } } @@ -51,7 +53,7 @@ macro_rules! paged_routes { $url, "`\n# Errors\nIf `access_token` is not set." ), - fn $name<'a>(&self, $($param: $typ,)*) -> Result> { + pub async fn $name<'a>(&self, $($param: $typ,)*) -> Result> { use serde_urlencoded; #[derive(Serialize)] @@ -77,11 +79,9 @@ macro_rules! paged_routes { let url = format!(concat!("/api/v1/", $url, "?{}"), &qs); - let response = self.send( - self.client.get(&url) - )?; + let response = self.client.get(&url).send().await?; - Page::new(self, response) + Page::new(self.clone(), response).await } } @@ -99,7 +99,7 @@ macro_rules! route_v2 { $url, "`\n# Errors\nIf `access_token` is not set." ), - fn $name<'a>(&self, $($param: $typ,)*) -> Result<$ret> { + pub async fn $name<'a>(&self, $($param: $typ,)*) -> Result<$ret> { use serde_urlencoded; #[derive(Serialize)] @@ -122,7 +122,7 @@ macro_rules! route_v2 { let url = format!(concat!("/api/v2/", $url, "?{}"), &qs); - Ok(self.get(self.route(&url))?) + self.get(self.route(&url)).await } } @@ -140,19 +140,29 @@ macro_rules! route { "Equivalent to `post /api/v1/", $url, "`\n# Errors\nIf `access_token` is not set."), - fn $name(&self, $($param: $typ,)*) -> Result<$ret> { - use reqwest::multipart::Form; + pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { + use reqwest::multipart::{Form, Part}; + use std::io::Read; let form_data = Form::new() $( - .file(stringify!($param), $param.as_ref())? + .part(stringify!($param), { + let mut file = std::fs::File::open($param.as_ref())?; + let mut data = if let Ok(metadata) = file.metadata() { + Vec::with_capacity(metadata.len().try_into()?) + } else { + vec![] + }; + file.read_to_end(&mut data)?; + Part::bytes(data) + }) )*; - let response = self.send( - self.client - .post(&self.route(concat!("/api/v1/", $url))) - .multipart(form_data) - )?; + let response = self.client + .post(&self.route(concat!("/api/v1/", $url))) + .multipart(form_data) + .send() + .await?; let status = response.status().clone(); @@ -162,7 +172,7 @@ macro_rules! route { return Err(Error::Server(status)); } - deserialise(response) + Ok(response.json().await?) } } @@ -176,7 +186,7 @@ macro_rules! route { $url, "`\n# Errors\nIf `access_token` is not set." ), - fn $name<'a>(&self, $($param: $typ,)*) -> Result<$ret> { + pub async fn $name<'a>(&self, $($param: $typ,)*) -> Result<$ret> { use serde_urlencoded; #[derive(Serialize)] @@ -199,7 +209,7 @@ macro_rules! route { let url = format!(concat!("/api/v1/", $url, "?{}"), &qs); - Ok(self.get(self.route(&url))?) + self.get(self.route(&url)).await } } @@ -213,7 +223,7 @@ macro_rules! route { $url, "`\n# Errors\nIf `access_token` is not set.", ), - fn $name(&self, $($param: $typ,)*) -> Result<$ret> { + pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { let form_data = json!({ $( @@ -221,10 +231,11 @@ macro_rules! route { )* }); - let response = self.send( - self.client.$method(&self.route(concat!("/api/v1/", $url))) - .json(&form_data) - )?; + let response = self.client + .$method(&self.route(concat!("/api/v1/", $url))) + .json(&form_data) + .send() + .await?; let status = response.status().clone(); @@ -234,7 +245,7 @@ macro_rules! route { return Err(Error::Server(status)); } - deserialise(response) + Ok(response.json().await?) } } @@ -255,8 +266,8 @@ macro_rules! route { "client.", stringify!($name), "();\n", "```" ), - fn $name(&self) -> Result<$ret> { - self.$method(self.route(concat!("/api/v1/", $url))) + pub async fn $name(&self) -> Result<$ret> { + self.$method(self.route(concat!("/api/v1/", $url))).await } } @@ -285,8 +296,8 @@ macro_rules! route_id { "# }\n", "```" ), - fn $name(&self, id: &str) -> Result<$ret> { - self.$method(self.route(&format!(concat!("/api/v1/", $url), id))) + pub async fn $name(&self, id: &str) -> Result<$ret> { + self.$method(self.route(&format!(concat!("/api/v1/", $url), id))).await } } )* @@ -309,13 +320,11 @@ macro_rules! paged_routes_with_id { "client.", stringify!($name), "(\"some-id\");\n", "```" ), - fn $name(&self, id: &str) -> Result> { + pub async fn $name(&self, id: &str) -> Result> { let url = self.route(&format!(concat!("/api/v1/", $url), id)); - let response = self.send( - self.client.$method(&url) - )?; + let response = self.client.$method(&url).send().await?; - Page::new(self, response) + Page::new(self.clone(), response).await } } diff --git a/src/mastodon.rs b/src/mastodon.rs new file mode 100644 index 0000000..0d22714 --- /dev/null +++ b/src/mastodon.rs @@ -0,0 +1,410 @@ +use std::{borrow::Cow, ops::Deref, sync::Arc}; + +use crate::{ + entities::{ + account::Account, + prelude::*, + report::Report, + status::{Emoji, Status}, + Empty, + }, + errors::{Error, Result}, + event_stream::event_stream, + AddFilterRequest, + AddPushRequest, + Data, + NewStatus, + Page, + StatusesRequest, + UpdateCredsRequest, + UpdatePushRequest, +}; +use futures::TryStream; +use reqwest::Client; +use url::Url; + +/// The Mastodon client is a smart pointer to this struct +#[derive(Clone, Debug)] +pub struct MastodonClient { + pub(crate) client: Client, + /// Raw data about your mastodon instance. + pub data: Data, +} + +/// Your mastodon application client, handles all requests to and from Mastodon. +#[derive(Debug, Clone)] +pub struct Mastodon(Arc); + +/// A client for making unauthenticated requests to the public API. +#[derive(Clone, Debug)] +pub struct MastodonUnauthenticated { + client: Client, + /// Which Mastodon instance to contact + pub base: Url, +} + +impl From for Mastodon { + /// Creates a mastodon instance from the data struct. + fn from(data: Data) -> Mastodon { + MastodonClient { + client: Client::new(), + data, + } + .into() + } +} +impl Mastodon { + methods![get, post, delete,]; + + paged_routes! { + (get) favourites: "favourites" => Status, + (get) blocks: "blocks" => Account, + (get) domain_blocks: "domain_blocks" => String, + (get) follow_requests: "follow_requests" => Account, + (get) get_home_timeline: "timelines/home" => Status, + (get) get_emojis: "custom_emojis" => Emoji, + (get) mutes: "mutes" => Account, + (get) notifications: "notifications" => Notification, + (get) reports: "reports" => Report, + (get (q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] limit: Option, following: bool,)) search_accounts: "accounts/search" => Account, + (get) get_endorsements: "endorsements" => Account, + } + + paged_routes_with_id! { + (get) followers: "accounts/{}/followers" => Account, + (get) following: "accounts/{}/following" => Account, + (get) reblogged_by: "statuses/{}/reblogged_by" => Account, + (get) favourited_by: "statuses/{}/favourited_by" => Account, + } + + route! { + (delete (domain: String,)) unblock_domain: "domain_blocks" => Empty, + (get) instance: "instance" => Instance, + (get) verify_credentials: "accounts/verify_credentials" => Account, + (post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report, + (post (domain: String,)) block_domain: "domain_blocks" => Empty, + (post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty, + (post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty, + (get (q: &'a str, resolve: bool,)) search: "search" => SearchResult, + (get (local: bool,)) get_public_timeline: "timelines/public" => Vec, + (post (uri: Cow<'static, str>,)) follows: "follows" => Account, + (post multipart (file: Cow<'static, str>,)) media: "media" => Attachment, + (post) clear_notifications: "notifications/clear" => Empty, + (post (id: &str,)) dismiss_notification: "notifications/dismiss" => Empty, + (get) get_push_subscription: "push/subscription" => Subscription, + (delete) delete_push_subscription: "push/subscription" => Empty, + (get) get_filters: "filters" => Vec, + (get) get_follow_suggestions: "suggestions" => Vec, + } + + route_v2! { + (get (q: &'a str, resolve: bool,)) search_v2: "search" => SearchResultV2, + } + + route_id! { + (get) get_account: "accounts/{}" => Account, + (post) follow: "accounts/{}/follow" => Relationship, + (post) unfollow: "accounts/{}/unfollow" => Relationship, + (post) block: "accounts/{}/block" => Relationship, + (post) unblock: "accounts/{}/unblock" => Relationship, + (get) mute: "accounts/{}/mute" => Relationship, + (get) unmute: "accounts/{}/unmute" => Relationship, + (get) get_notification: "notifications/{}" => Notification, + (get) get_status: "statuses/{}" => Status, + (get) get_context: "statuses/{}/context" => Context, + (get) get_card: "statuses/{}/card" => Card, + (post) reblog: "statuses/{}/reblog" => Status, + (post) unreblog: "statuses/{}/unreblog" => Status, + (post) favourite: "statuses/{}/favourite" => Status, + (post) unfavourite: "statuses/{}/unfavourite" => Status, + (delete) delete_status: "statuses/{}" => Empty, + (get) get_filter: "filters/{}" => Filter, + (delete) delete_filter: "filters/{}" => Empty, + (delete) delete_from_suggestions: "suggestions/{}" => Empty, + (post) endorse_user: "accounts/{}/pin" => Relationship, + (post) unendorse_user: "accounts/{}/unpin" => Relationship, + } + + /// Create a new Mastodon Client + pub fn new(client: Client, data: Data) -> Self { + Mastodon(Arc::new(MastodonClient { + client, + data, + })) + } + + fn route(&self, url: &str) -> String { + format!("{}{}", self.data.base, url) + } + + /// POST /api/v1/filters + pub async fn add_filter(&self, request: &mut AddFilterRequest) -> Result { + Ok(self + .client + .post(self.route("/api/v1/filters")) + .json(&request) + .send() + .await? + .json() + .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.client.put(&url).json(&request).send().await?; + + let status = response.status(); + + if status.is_client_error() { + return Err(Error::Client(status.clone())); + } else if status.is_server_error() { + return Err(Error::Server(status.clone())); + } + + Ok(response.json().await?) + } + + /// Update the user 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.client.patch(&url).json(&changes).send().await?; + + let status = response.status(); + + if status.is_client_error() { + return Err(Error::Client(status.clone())); + } else if status.is_server_error() { + return Err(Error::Server(status.clone())); + } + + Ok(response.json().await?) + } + + /// Post a new status to the account. + pub async fn new_status(&self, status: NewStatus) -> Result { + Ok(self + .client + .post(&self.route("/api/v1/statuses")) + .json(&status) + .send() + .await? + .json() + .await?) + } + + /// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or + /// federated. + pub async fn get_tagged_timeline(&self, hashtag: String, 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)) + }; + + self.get(url).await + } + + /// Get statuses of a single account by id. Optionally only with pictures + /// and or excluding replies. + /// + /// // Example + /// + /// ```no_run + /// use elefren::prelude::*; + /// tokio_test::block_on(async { + /// let data = Data::default(); + /// let client = Mastodon::from(data); + /// let statuses = client.statuses("user-id", None).await.unwrap(); + /// }); + /// ``` + /// + /// ```no_run + /// use elefren::prelude::*; + /// tokio_test::block_on(async { + /// let data = Data::default(); + /// let client = Mastodon::from(data); + /// let mut request = StatusesRequest::new(); + /// request.only_media(); + /// let statuses = client.statuses("user-id", request).await.unwrap(); + /// }); + /// ``` + 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.data.base, id); + + if let Some(request) = request.into() { + url = format!("{}{}", url, request.to_querystring()?); + } + + let response = self.client.get(&url).send().await?; + + Page::new(self.clone(), 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(&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.client.get(&url).send().await?; + + Page::new(self.clone(), response).await + } + + /// Add a push notifications subscription + pub async fn add_push_subscription(&self, request: &AddPushRequest) -> Result { + let request = request.build()?; + Ok(self + .client + .post(&self.route("/api/v1/push/subscription")) + .json(&request) + .send() + .await? + .json() + .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(); + Ok(self + .client + .put(&self.route("/api/v1/push/subscription")) + .json(&request) + .send() + .await? + .json() + .await?) + } + + /// Get all accounts that follow the authenticated user + pub async fn follows_me(&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(&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 + /// + /// // Example + /// + /// ```no_run + /// use elefren::prelude::*; + /// use elefren::entities::event::Event; + /// use futures_util::{pin_mut, StreamExt, TryStreamExt}; + /// + /// tokio_test::block_on(async { + /// let data = Data::default(); + /// let client = Mastodon::from(data); + /// let stream = client.streaming_user().await.unwrap(); + /// stream.try_for_each(|event| async move { + /// match event { + /// Event::Update(ref status) => { /* .. */ }, + /// Event::Notification(ref notification) => { /* .. */ }, + /// Event::Delete(ref id) => { /* .. */ }, + /// Event::FiltersChanged => { /* .. */ }, + /// } + /// Ok(()) + /// }).await.unwrap(); + /// }); + /// ``` + pub async fn streaming_user(&self) -> Result> { + let mut url: Url = self.route("/api/v1/streaming").parse()?; + url.query_pairs_mut() + .append_pair("access_token", &self.data.token) + .append_pair("stream", "user"); + let mut url: Url = reqwest::get(url.as_str()).await?.url().as_str().parse()?; + let new_scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + x => return Err(Error::Other(format!("Bad URL scheme: {}", x))), + }; + url.set_scheme(new_scheme) + .map_err(|_| Error::Other("Bad URL scheme!".to_string()))?; + + event_stream(url) + } +} + +impl MastodonUnauthenticated { + methods![get,]; + + /// Create a new client for unauthenticated requests to a given Mastodon + /// instance. + pub fn new(base: impl AsRef) -> Result { + let base = base.as_ref(); + let base = if base.starts_with("https://") { + base.to_string() + } else { + format!("https://{}", base.trim_start_matches("http://")) + }; + Ok(MastodonUnauthenticated { + client: Client::new(), + base: Url::parse(&base)?, + }) + } + + fn route(&self, url: &str) -> Result { + Ok(self.base.join(url)?) + } + + /// GET /api/v1/statuses/:id + pub async fn get_status(&self, id: &str) -> Result { + let route = self.route("/api/v1/statuses")?; + let route = route.join(id)?; + self.get(route.as_str()).await + } + + /// GET /api/v1/statuses/:id/context + pub async fn get_context(&self, id: &str) -> Result { + let route = self.route("/api/v1/statuses")?; + let route = route.join(id)?; + let route = route.join("context")?; + self.get(route.as_str()).await + } + + /// GET /api/v1/statuses/:id/card + pub async fn get_card(&self, id: &str) -> Result { + let route = self.route("/api/v1/statuses")?; + let route = route.join(id)?; + let route = route.join("card")?; + self.get(route.as_str()).await + } +} +impl Deref for Mastodon { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for Mastodon { + fn from(value: MastodonClient) -> Self { + Mastodon(Arc::new(value)) + } +} diff --git a/src/page.rs b/src/page.rs index 0a0c727..69f00e6 100644 --- a/src/page.rs +++ b/src/page.rs @@ -1,11 +1,10 @@ -use super::{deserialise, Mastodon, Result}; -use crate::entities::itemsiter::ItemsIter; +use super::{Mastodon, Result}; +use crate::{entities::itemsiter::ItemsIter, format_err}; +use futures::Stream; use hyper_old_types::header::{parsing, Link, RelationType}; -use reqwest::{header::LINK, Response}; +use reqwest::{header::LINK, Response, Url}; use serde::Deserialize; -use url::Url; - -use crate::http_send::HttpSend; +// use url::Url; macro_rules! pages { ($($direction:ident: $fun:ident),*) => { @@ -13,21 +12,19 @@ macro_rules! pages { $( doc_comment!(concat!( "Method to retrieve the ", stringify!($direction), " page of results"), - pub fn $fun(&mut self) -> Result>> { + pub async fn $fun(&mut self) -> Result>> { let url = match self.$direction.take() { Some(s) => s, None => return Ok(None), }; - let response = self.mastodon.send( - self.mastodon.client.get(url) - )?; + let response = self.mastodon.client.get(url).send().await?; let (prev, next) = get_links(&response)?; self.next = next; self.prev = prev; - deserialise(response) + Ok(Some(response.json().await?)) }); )* } @@ -41,71 +38,47 @@ macro_rules! pages { /// ```no_run /// use elefren::{ /// prelude::*, -/// page::OwnedPage, -/// http_send::HttpSender, +/// page::Page, /// entities::status::Status /// }; /// use std::cell::RefCell; /// -/// let data = Data::default(); -/// struct HomeTimeline { -/// client: Mastodon, -/// page: RefCell>>, -/// } -/// let client = Mastodon::from(data); -/// let home = client.get_home_timeline().unwrap().to_owned(); -/// let tl = HomeTimeline { -/// client, -/// page: RefCell::new(Some(home)), -/// }; +/// tokio_test::block_on(async { +/// let data = Data::default(); +/// struct HomeTimeline { +/// client: Mastodon, +/// page: RefCell>>, +/// } +/// let client = Mastodon::from(data); +/// let home = client.get_home_timeline().await.unwrap(); +/// let tl = HomeTimeline { +/// client, +/// page: RefCell::new(Some(home)), +/// }; +/// }); /// ``` -#[derive(Debug, Clone)] -pub struct OwnedPage Deserialize<'de>, H: HttpSend> { - mastodon: Mastodon, - next: Option, - prev: Option, - /// Initial set of items - pub initial_items: Vec, -} - -impl Deserialize<'de>, H: HttpSend> OwnedPage { - pages! { - next: next_page, - prev: prev_page - } -} - -impl<'a, T: for<'de> Deserialize<'de>, H: HttpSend> From> for OwnedPage { - fn from(page: Page<'a, T, H>) -> OwnedPage { - OwnedPage { - mastodon: page.mastodon.clone(), - next: page.next, - prev: page.prev, - initial_items: page.initial_items, - } - } -} /// Represents a single page of API results #[derive(Debug, Clone)] -pub struct Page<'a, T: for<'de> Deserialize<'de>, H: 'a + HttpSend> { - mastodon: &'a Mastodon, +pub struct Page Deserialize<'de>> { + mastodon: Mastodon, next: Option, prev: Option, /// Initial set of items pub initial_items: Vec, } -impl<'a, T: for<'de> Deserialize<'de>, H: HttpSend> Page<'a, T, H> { +impl<'a, T: for<'de> Deserialize<'de>> Page { pages! { next: next_page, prev: prev_page } - pub(crate) fn new(mastodon: &'a Mastodon, response: Response) -> Result { + /// Create a new Page. + pub(crate) async fn new(mastodon: Mastodon, response: Response) -> Result { let (prev, next) = get_links(&response)?; Ok(Page { - initial_items: deserialise(response)?, + initial_items: response.json().await?, next, prev, mastodon, @@ -113,31 +86,7 @@ impl<'a, T: for<'de> Deserialize<'de>, H: HttpSend> Page<'a, T, H> { } } -impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> Page<'a, T, H> { - /// Returns an owned version of this struct that doesn't borrow the client - /// that created it - /// - /// // Example - /// - /// ```no_run - /// use elefren::{Mastodon, page::OwnedPage, http_send::HttpSender, entities::status::Status, prelude::*}; - /// use std::cell::RefCell; - /// let data = Data::default(); - /// struct HomeTimeline { - /// client: Mastodon, - /// page: RefCell>>, - /// } - /// let client = Mastodon::from(data); - /// let home = client.get_home_timeline().unwrap().to_owned(); - /// let tl = HomeTimeline { - /// client, - /// page: RefCell::new(Some(home)), - /// }; - /// ``` - pub fn to_owned(self) -> OwnedPage { - OwnedPage::from(self) - } - +impl Deserialize<'de>> Page { /// Returns an iterator that provides a stream of `T`s /// /// This abstracts away the process of iterating over each item in a page, @@ -152,19 +101,21 @@ impl<'a, T: Clone + for<'de> Deserialize<'de>, H: HttpSend> Page<'a, T, H> { /// /// ```no_run /// use elefren::prelude::*; + /// use futures_util::StreamExt; + /// /// let data = Data::default(); /// let mastodon = Mastodon::from(data); /// let req = StatusesRequest::new(); - /// let resp = mastodon.statuses("some-id", req).unwrap(); - /// for status in resp.items_iter() { - /// // do something with status - /// } + /// + /// tokio_test::block_on(async { + /// let resp = mastodon.statuses("some-id", req).await.unwrap(); + /// resp.items_iter().for_each(|status| async move { + /// // do something with status + /// }).await; + /// }); /// ``` - pub fn items_iter(self) -> impl Iterator + 'a - where - T: 'a, - { - ItemsIter::new(self) + pub fn items_iter(self) -> impl Stream { + ItemsIter::new(self).stream() } } @@ -179,11 +130,22 @@ fn get_links(response: &Response) -> Result<(Option, Option)> { for value in link_header.values() { if let Some(relations) = value.rel() { if relations.contains(&RelationType::Next) { - next = Some(Url::parse(value.link())?); + // next = Some(Url::parse(value.link())?); + next = if let Ok(url) = Url::parse(value.link()) { + Some(url) + } else { + // HACK: url::ParseError::into isn't working for some reason. + return Err(format_err!("error parsing url {:?}", value.link())); + }; } if relations.contains(&RelationType::Prev) { - prev = Some(Url::parse(value.link())?); + prev = if let Ok(url) = Url::parse(value.link()) { + Some(url) + } else { + // HACK: url::ParseError::into isn't working for some reason. + return Err(format_err!("error parsing url {:?}", value.link())); + }; } } } diff --git a/src/registration.rs b/src/registration.rs index dda76c8..8f823e6 100644 --- a/src/registration.rs +++ b/src/registration.rs @@ -1,16 +1,14 @@ use std::borrow::Cow; -use reqwest::{Client, RequestBuilder, Response}; +use reqwest::Client; use url::percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; use crate::{ apps::{App, AppBuilder}, - http_send::{HttpSend, HttpSender}, scopes::Scopes, Data, Error, Mastodon, - MastodonBuilder, Result, }; @@ -19,12 +17,11 @@ const DEFAULT_REDIRECT_URI: &'static str = "urn:ietf:wg:oauth:2.0:oob"; /// Handles registering your mastodon app to your instance. It is recommended /// you cache your data struct to avoid registering on every run. #[derive(Debug, Clone)] -pub struct Registration<'a, H: HttpSend = HttpSender> { +pub struct Registration<'a> { base: String, client: Client, app_builder: AppBuilder<'a>, force_login: bool, - http_sender: H, } #[derive(Deserialize)] @@ -44,7 +41,7 @@ struct AccessToken { access_token: String, } -impl<'a> Registration<'a, HttpSender> { +impl<'a> Registration<'a> { /// Construct a new registration process to the instance of the `base` url. /// ``` /// use elefren::prelude::*; @@ -57,20 +54,18 @@ impl<'a> Registration<'a, HttpSender> { client: Client::new(), app_builder: AppBuilder::new(), force_login: false, - http_sender: HttpSender, } } } -impl<'a, H: HttpSend> Registration<'a, H> { +impl<'a> Registration<'a> { #[allow(dead_code)] - pub(crate) fn with_sender>(base: I, http_sender: H) -> Self { + pub(crate) fn with_sender>(base: I) -> Self { Registration { base: base.into(), client: Client::new(), app_builder: AppBuilder::new(), force_login: false, - http_sender, } } @@ -110,33 +105,34 @@ impl<'a, H: HttpSend> Registration<'a, H> { self } - fn send(&self, req: RequestBuilder) -> Result { - Ok(self.http_sender.send(&self.client, req)?) - } - /// Register the given application /// /// ```no_run /// use elefren::{apps::App, prelude::*}; /// - /// let mut app = App::builder(); - /// app.client_name("elefren_test"); + /// tokio_test::block_on(async { + /// let mut app = App::builder(); + /// app.client_name("elefren_test"); /// - /// let registration = Registration::new("https://botsin.space").register(app).unwrap(); - /// let url = registration.authorize_url().unwrap(); - /// // Here you now need to open the url in the browser - /// // And handle a the redirect url coming back with the code. - /// let code = String::from("RETURNED_FROM_BROWSER"); - /// let mastodon = registration.complete(&code).unwrap(); + /// let registration = Registration::new("https://botsin.space") + /// .register(app) + /// .await + /// .unwrap(); + /// let url = registration.authorize_url().unwrap(); + /// // Here you now need to open the url in the browser + /// // And handle a the redirect url coming back with the code. + /// let code = String::from("RETURNED_FROM_BROWSER"); + /// let mastodon = registration.complete(&code).await.unwrap(); /// - /// println!("{:?}", mastodon.get_home_timeline().unwrap().initial_items); + /// println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items); + /// }); /// ``` - pub fn register>(&mut self, app: I) -> Result> + pub async fn register>(&mut self, app: I) -> Result where Error: From<>::Error>, { let app = app.try_into()?; - let oauth = self.send_app(&app)?; + let oauth = self.send_app(&app).await?; Ok(Registered { base: self.base.clone(), @@ -146,7 +142,6 @@ impl<'a, H: HttpSend> Registration<'a, H> { redirect: oauth.redirect_uri, scopes: app.scopes().clone(), force_login: self.force_login, - http_sender: self.http_sender.clone(), }) } @@ -155,20 +150,24 @@ impl<'a, H: HttpSend> Registration<'a, H> { /// ```no_run /// use elefren::prelude::*; /// - /// let registration = Registration::new("https://botsin.space") - /// .client_name("elefren_test") - /// .build().unwrap(); - /// let url = registration.authorize_url().unwrap(); - /// // Here you now need to open the url in the browser - /// // And handle a the redirect url coming back with the code. - /// let code = String::from("RETURNED_FROM_BROWSER"); - /// let mastodon = registration.complete(&code).unwrap(); + /// tokio_test::block_on(async { + /// let registration = Registration::new("https://botsin.space") + /// .client_name("elefren_test") + /// .build() + /// .await + /// .unwrap(); + /// let url = registration.authorize_url().unwrap(); + /// // Here you now need to open the url in the browser + /// // And handle a the redirect url coming back with the code. + /// let code = String::from("RETURNED_FROM_BROWSER"); + /// let mastodon = registration.complete(&code).await.unwrap(); /// - /// println!("{:?}", mastodon.get_home_timeline().unwrap().initial_items); + /// println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items); + /// }); /// ``` - pub fn build(&mut self) -> Result> { + pub async fn build(&mut self) -> Result { let app: App = self.app_builder.clone().build()?; - let oauth = self.send_app(&app)?; + let oauth = self.send_app(&app).await?; Ok(Registered { base: self.base.clone(), @@ -178,17 +177,17 @@ impl<'a, H: HttpSend> Registration<'a, H> { redirect: oauth.redirect_uri, scopes: app.scopes().clone(), force_login: self.force_login, - http_sender: self.http_sender.clone(), }) } - fn send_app(&self, app: &App) -> Result { + async fn send_app(&self, app: &App) -> Result { let url = format!("{}/api/v1/apps", self.base); - Ok(self.send(self.client.post(&url).json(&app))?.json()?) + let response = self.client.post(&url).json(&app).send().await?; + Ok(response.json().await?) } } -impl Registered { +impl Registered { /// Skip having to retrieve the client id and secret from the server by /// creating a `Registered` struct directly /// @@ -197,21 +196,23 @@ impl Registered { /// ```no_run /// use elefren::{prelude::*, registration::Registered}; /// - /// let registration = Registered::from_parts( - /// "https://example.com", - /// "the-client-id", - /// "the-client-secret", - /// "https://example.com/redirect", - /// Scopes::read_all(), - /// false, - /// ); - /// let url = registration.authorize_url().unwrap(); - /// // Here you now need to open the url in the browser - /// // And handle a the redirect url coming back with the code. - /// let code = String::from("RETURNED_FROM_BROWSER"); - /// let mastodon = registration.complete(&code).unwrap(); + /// tokio_test::block_on(async { + /// let registration = Registered::from_parts( + /// "https://example.com", + /// "the-client-id", + /// "the-client-secret", + /// "https://example.com/redirect", + /// Scopes::read_all(), + /// false, + /// ); + /// let url = registration.authorize_url().unwrap(); + /// // Here you now need to open the url in the browser + /// // And handle a the redirect url coming back with the code. + /// let code = String::from("RETURNED_FROM_BROWSER"); + /// let mastodon = registration.complete(&code).await.unwrap(); /// - /// println!("{:?}", mastodon.get_home_timeline().unwrap().initial_items); + /// println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items); + /// }); /// ``` pub fn from_parts( base: &str, @@ -220,7 +221,7 @@ impl Registered { redirect: &str, scopes: Scopes, force_login: bool, - ) -> Registered { + ) -> Registered { Registered { base: base.to_string(), client: Client::new(), @@ -229,16 +230,11 @@ impl Registered { redirect: redirect.to_string(), scopes, force_login, - http_sender: HttpSender, } } } -impl Registered { - fn send(&self, req: RequestBuilder) -> Result { - Ok(self.http_sender.send(&self.client, req)?) - } - +impl Registered { /// Returns the parts of the `Registered` struct that can be used to /// recreate another `Registered` struct /// @@ -304,35 +300,45 @@ impl Registered { Ok(url) } + /// Construct authentication data once token is known + fn registered(&self, token: String) -> Data { + Data { + base: self.base.clone().into(), + client_id: self.client_id.clone().into(), + client_secret: self.client_secret.clone().into(), + redirect: self.redirect.clone().into(), + token: token.into(), + } + } + /// Create an access token from the client id, client secret, and code /// provided by the authorization url. - pub fn complete(&self, code: &str) -> Result> { + pub async fn complete(&self, code: &str) -> Result { let url = format!( "{}/oauth/token?client_id={}&client_secret={}&code={}&grant_type=authorization_code&\ redirect_uri={}", self.base, self.client_id, self.client_secret, code, self.redirect ); - let token: AccessToken = self.send(self.client.post(&url))?.json()?; + let token: AccessToken = self + .client + .post(&url) + .send() + .await? + .error_for_status()? + .json() + .await?; - let data = Data { - base: self.base.clone().into(), - client_id: self.client_id.clone().into(), - client_secret: self.client_secret.clone().into(), - redirect: self.redirect.clone().into(), - token: token.access_token.into(), - }; + let data = self.registered(token.access_token); - let mut builder = MastodonBuilder::new(self.http_sender.clone()); - builder.client(self.client.clone()).data(data); - Ok(builder.build()?) + Ok(Mastodon::new(self.client.clone(), data)) } } /// Represents the state of the auth flow when the app has been registered but /// the user is not authenticated #[derive(Debug, Clone)] -pub struct Registered { +pub struct Registered { base: String, client: Client, client_id: String, @@ -340,7 +346,6 @@ pub struct Registered { redirect: String, scopes: Scopes, force_login: bool, - http_sender: H, } #[cfg(test)] @@ -352,15 +357,13 @@ mod tests { let r = Registration::new("https://example.com"); assert_eq!(r.base, "https://example.com".to_string()); assert_eq!(r.app_builder, AppBuilder::new()); - assert_eq!(r.http_sender, HttpSender); } #[test] fn test_registration_with_sender() { - let r = Registration::with_sender("https://example.com", HttpSender); + let r = Registration::with_sender("https://example.com"); assert_eq!(r.base, "https://example.com".to_string()); assert_eq!(r.app_builder, AppBuilder::new()); - assert_eq!(r.http_sender, HttpSender); } #[test] diff --git a/src/requests/push.rs b/src/requests/push.rs index 1c5c516..cae3656 100644 --- a/src/requests/push.rs +++ b/src/requests/push.rs @@ -41,17 +41,19 @@ impl Keys { /// // Example /// /// ```no_run -/// use elefren::{MastodonClient, Mastodon, Data}; +/// use elefren::{Mastodon, Data}; /// use elefren::requests::{AddPushRequest, Keys}; /// -/// let data = Data::default(); -/// let client = Mastodon::from(data); +/// tokio_test::block_on(async { +/// let data = Data::default(); +/// let client = Mastodon::from(data); /// -/// let keys = Keys::new("stahesuahoei293ise===", "tasecoa,nmeozka=="); -/// let mut request = AddPushRequest::new("http://example.com/push/endpoint", &keys); -/// request.follow().reblog(); +/// let keys = Keys::new("stahesuahoei293ise===", "tasecoa,nmeozka=="); +/// let mut request = AddPushRequest::new("http://example.com/push/endpoint", &keys); +/// request.follow().reblog(); /// -/// client.add_push_subscription(&request).unwrap(); +/// client.add_push_subscription(&request).await.unwrap(); +/// }); /// ``` #[derive(Debug, Default, Clone, PartialEq)] pub struct AddPushRequest { @@ -200,16 +202,18 @@ impl AddPushRequest { /// // Example /// /// ```no_run -/// use elefren::{MastodonClient, Mastodon, Data, requests::UpdatePushRequest}; -/// let data = Data::default(); +/// use elefren::{Mastodon, Data, requests::UpdatePushRequest}; /// +/// let data = Data::default(); /// let client = Mastodon::from(data); /// /// let mut request = UpdatePushRequest::new("foobar"); /// request.follow(true) /// .reblog(true); /// -/// client.update_push_data(&request).unwrap(); +/// tokio_test::block_on(async { +/// client.update_push_data(&request).await.unwrap(); +/// }); /// ``` #[derive(Debug, Default, Clone, PartialEq, Serialize)] pub struct UpdatePushRequest { diff --git a/src/requests/update_credentials.rs b/src/requests/update_credentials.rs index e3348cb..9dbe922 100644 --- a/src/requests/update_credentials.rs +++ b/src/requests/update_credentials.rs @@ -22,7 +22,9 @@ use crate::{ /// /// builder.privacy(Visibility::Unlisted); /// -/// let result = client.update_credentials(&mut builder).unwrap(); +/// tokio_test::block_on(async { +/// let result = client.update_credentials(&mut builder).await.unwrap(); +/// }); /// ``` #[derive(Debug, Default, Clone, PartialEq)] pub struct UpdateCredsRequest { diff --git a/src/status_builder.rs b/src/status_builder.rs index 6bcd9a1..e0a1db2 100644 --- a/src/status_builder.rs +++ b/src/status_builder.rs @@ -42,7 +42,10 @@ impl StatusBuilder { /// .visibility(Visibility::Public) /// .build() /// .unwrap(); - /// client.new_status(status).unwrap(); + /// + /// tokio_test::block_on(async { + /// client.new_status(status).await.unwrap(); + /// }); /// ``` pub fn new() -> StatusBuilder { StatusBuilder::default()