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
This commit is contained in:
D. Scott Boggs 2022-12-05 08:52:48 -05:00
parent f054c7d805
commit e69d92f71e
20 changed files with 879 additions and 1077 deletions

View File

@ -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 <theaaronepower@gmail.com>", "Paul Woolcock <paul@woolcock.us>"]
authors = ["Aaron Power <theaaronepower@gmail.com>", "Paul Woolcock <paul@woolcock.us>", "D. Scott Boggs <scott@tams.tech>"]
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"]

View File

@ -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")]

View File

@ -4,7 +4,7 @@
extern crate pretty_env_logger;
mod register;
use register::MastodonClient;
use register::Mastodon;
use std::error;
#[cfg(feature = "toml")]

View File

@ -4,7 +4,7 @@
extern crate pretty_env_logger;
mod register;
use register::MastodonClient;
use register::Mastodon;
use std::error;
#[cfg(feature = "toml")]

View File

@ -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")]

View File

@ -4,7 +4,7 @@
extern crate pretty_env_logger;
mod register;
use register::MastodonClient;
use register::Mastodon;
use std::error;
#[cfg(feature = "toml")]

View File

@ -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")]

View File

@ -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<T: Clone + for<'de> Deserialize<'de>> {
page: Page<T>,
buffer: Vec<T>,
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<T> {
pub(crate) fn new(page: Page<T>) -> ItemsIter<T> {
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<Self::Item> {
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<Item = T> {
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())
}
})
}
}

View File

@ -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(..));
}

91
src/event_stream.rs Normal file
View File

@ -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<str>,
) -> Result<impl TryStream<Ok = Event, Error = Error, Item = Result<Event>>> {
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<Event> {
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<String>,
}
let message = serde_json::from_str::<Message>(&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::<Notification>(&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::<Status>(&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))),
})
}

View File

@ -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<H: HttpSend>(registration: Registered<H>) -> Result<Mastodon<H>> {
pub async fn authenticate(registration: Registered) -> Result<Mastodon> {
let url = registration.authorize_url()?;
let stdout = io::stdout();
@ -20,5 +20,5 @@ pub fn authenticate<H: HttpSend>(registration: Registered<H>) -> Result<Mastodon
let mut input = String::new();
stdin.read_line(&mut input)?;
let code = input.trim();
Ok(registration.complete(code)?)
registration.complete(code).await
}

View File

@ -1,26 +0,0 @@
use crate::Result;
use reqwest::{Client, Request, RequestBuilder, Response};
use std::fmt::Debug;
/// Abstracts away the process of turning an HTTP request into an HTTP response
pub trait HttpSend: Clone + Debug {
/// Converts an HTTP request into an HTTP response
fn execute(&self, client: &Client, request: Request) -> Result<Response>;
/// Convenience method so that .build() doesn't have to be called at every
/// call site
fn send(&self, client: &Client, builder: RequestBuilder) -> Result<Response> {
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<Response> {
Ok(client.execute(request)?)
}
}

View File

@ -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::<Vec<_>>()
//! );
//! println!(
//! "{:?}",
//! mastodon
//! .get_home_timeline()
//! .await
//! .unwrap()
//! .items_iter()
//! .take(100)
//! .collect::<Vec<_>>()
//! .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<H: HttpSend = HttpSender> {
client: Client,
http_sender: H,
/// Raw data about your mastodon instance.
pub data: Data,
}
impl<H: HttpSend> Mastodon<H> {
methods![get, post, delete,];
fn route(&self, url: &str) -> String {
format!("{}{}", self.base, url)
}
pub(crate) fn send(&self, req: RequestBuilder) -> Result<Response> {
Ok(self
.http_sender
.send(&self.client, req.bearer_auth(&self.token))?)
}
}
impl From<Data> for Mastodon<HttpSender> {
/// Creates a mastodon instance from the data struct.
fn from(data: Data) -> Mastodon<HttpSender> {
let mut builder = MastodonBuilder::new(HttpSender);
builder.data(data);
builder
.build()
.expect("We know `data` is present, so this should be fine")
}
}
impl<H: HttpSend> MastodonClient<H> for Mastodon<H> {
type Stream = EventReader<WebSocket>;
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<u64>, 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<Status>,
(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<Filter>,
(get) get_follow_suggestions: "suggestions" => Vec<Account>,
}
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<Filter> {
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<Filter> {
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<Account> {
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<Status> {
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<Vec<Status>> {
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<Page<Status, H>>
where
S: Into<Option<StatusesRequest<'a>>>,
{
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<Page<Relationship, H>> {
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<Subscription> {
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<Subscription> {
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<Page<Account, H>> {
let me = self.verify_credentials()?;
Ok(self.followers(&me.id)?)
}
/// Get all accounts that the authenticated user follows
fn followed_by_me(&self) -> Result<Page<Account, H>> {
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<Self::Stream> {
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<Self::Stream> {
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<Self::Stream> {
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<Self::Stream> {
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<Self::Stream> {
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<Self::Stream> {
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<Self::Stream> {
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<AutoStream>);
/// A type that streaming events can be read from
pub trait EventStream {
/// Read a message from this stream
fn read_message(&mut self) -> Result<String>;
}
impl<R: BufRead> EventStream for R {
fn read_message(&mut self) -> Result<String> {
let mut buf = String::new();
self.read_line(&mut buf)?;
Ok(buf)
}
}
impl EventStream for WebSocket {
fn read_message(&mut self) -> Result<String> {
Ok(self.0.read_message()?.into_text()?)
}
}
#[derive(Debug)]
/// Iterator that produces events from a mastodon streaming API event stream
pub struct EventReader<R: EventStream>(R);
impl<R: EventStream> Iterator for EventReader<R> {
type Item = Event;
fn next(&mut self) -> Option<Self::Item> {
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<R: EventStream> EventReader<R> {
fn make_event(&self, lines: &[String]) -> Result<Event> {
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<String>,
}
let message = serde_json::from_str::<Message>(&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::<Notification>(&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::<Status>(&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<H: HttpSend> ops::Deref for Mastodon<H> {
type Target = Data;
fn deref(&self) -> &Self::Target {
&self.data
}
}
struct MastodonBuilder<H: HttpSend> {
client: Option<Client>,
http_sender: H,
data: Option<Data>,
}
impl<H: HttpSend> MastodonBuilder<H> {
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<Mastodon<H>> {
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<H: HttpSend = HttpSender> {
client: Client,
http_sender: H,
base: url::Url,
}
impl MastodonUnauth<HttpSender> {
/// Create a new unauthenticated client
pub fn new(base: &str) -> Result<MastodonUnauth<HttpSender>> {
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<H: HttpSend> MastodonUnauth<H> {
fn route(&self, url: &str) -> Result<url::Url> {
Ok(self.base.join(url)?)
}
fn send(&self, req: RequestBuilder) -> Result<Response> {
Ok(self.http_sender.send(&self.client, req)?)
}
}
impl<H: HttpSend> MastodonUnauthenticated<H> for MastodonUnauth<H> {
/// GET /api/v1/statuses/:id
fn get_status(&self, id: &str) -> Result<Status> {
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<Context> {
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<Card> {
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<T: for<'de> serde::Deserialize<'de>>(response: Response) -> Result<T> {
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;

View File

@ -1,14 +1,18 @@
macro_rules! methods {
($($method:ident,)+) => {
$(
fn $method<T: for<'de> serde::Deserialize<'de>>(&self, url: String)
-> Result<T>
async fn $method<T: for<'de> serde::Deserialize<'de>>(&self, url: impl AsRef<str>) -> Result<T>
{
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<Page<$ret, H>> {
pub async fn $name(&self) -> Result<Page<$ret>> {
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<Page<$ret, H>> {
pub async fn $name<'a>(&self, $($param: $typ,)*) -> Result<Page<$ret>> {
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<Page<$ret, H>> {
pub async fn $name(&self, id: &str) -> Result<Page<$ret>> {
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
}
}

410
src/mastodon.rs Normal file
View File

@ -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<MastodonClient>);
/// 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<Data> 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<u64>, 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<Status>,
(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<Filter>,
(get) get_follow_suggestions: "suggestions" => Vec<Account>,
}
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<Filter> {
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<Filter> {
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<Account> {
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<Status> {
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<Vec<Status>> {
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<Page<Status>>
where
S: Into<Option<StatusesRequest<'a>>>,
{
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<Page<Relationship>> {
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<Subscription> {
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<Subscription> {
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<Page<Account>> {
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<Page<Account>> {
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<impl TryStream<Ok = Event, Error = Error>> {
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<str>) -> Result<MastodonUnauthenticated> {
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<Url> {
Ok(self.base.join(url)?)
}
/// GET /api/v1/statuses/:id
pub async fn get_status(&self, id: &str) -> Result<Status> {
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<Context> {
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<Card> {
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<MastodonClient>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<MastodonClient> for Mastodon {
fn from(value: MastodonClient) -> Self {
Mastodon(Arc::new(value))
}
}

View File

@ -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<Option<Vec<T>>> {
pub async fn $fun(&mut self) -> Result<Option<Vec<T>>> {
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<Option<OwnedPage<Status, HttpSender>>>,
/// }
/// 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<Option<Page<Status>>>,
/// }
/// 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<T: for<'de> Deserialize<'de>, H: HttpSend> {
mastodon: Mastodon<H>,
next: Option<Url>,
prev: Option<Url>,
/// Initial set of items
pub initial_items: Vec<T>,
}
impl<T: for<'de> Deserialize<'de>, H: HttpSend> OwnedPage<T, H> {
pages! {
next: next_page,
prev: prev_page
}
}
impl<'a, T: for<'de> Deserialize<'de>, H: HttpSend> From<Page<'a, T, H>> for OwnedPage<T, H> {
fn from(page: Page<'a, T, H>) -> OwnedPage<T, H> {
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<H>,
pub struct Page<T: for<'de> Deserialize<'de>> {
mastodon: Mastodon,
next: Option<Url>,
prev: Option<Url>,
/// Initial set of items
pub initial_items: Vec<T>,
}
impl<'a, T: for<'de> Deserialize<'de>, H: HttpSend> Page<'a, T, H> {
impl<'a, T: for<'de> Deserialize<'de>> Page<T> {
pages! {
next: next_page,
prev: prev_page
}
pub(crate) fn new(mastodon: &'a Mastodon<H>, response: Response) -> Result<Self> {
/// Create a new Page.
pub(crate) async fn new(mastodon: Mastodon, response: Response) -> Result<Self> {
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<Option<OwnedPage<Status, HttpSender>>>,
/// }
/// 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<T, H> {
OwnedPage::from(self)
}
impl<T: Clone + for<'de> Deserialize<'de>> Page<T> {
/// 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<Item = T> + 'a
where
T: 'a,
{
ItemsIter::new(self)
pub fn items_iter(self) -> impl Stream<Item = T> {
ItemsIter::new(self).stream()
}
}
@ -179,11 +130,22 @@ fn get_links(response: &Response) -> Result<(Option<Url>, Option<Url>)> {
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()));
};
}
}
}

View File

@ -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<I: Into<String>>(base: I, http_sender: H) -> Self {
pub(crate) fn with_sender<I: Into<String>>(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<Response> {
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<I: TryInto<App>>(&mut self, app: I) -> Result<Registered<H>>
pub async fn register<I: TryInto<App>>(&mut self, app: I) -> Result<Registered>
where
Error: From<<I as TryInto<App>>::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<Registered<H>> {
pub async fn build(&mut self) -> Result<Registered> {
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<OAuth> {
async fn send_app(&self, app: &App) -> Result<OAuth> {
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<HttpSender> {
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<HttpSender> {
/// ```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<HttpSender> {
redirect: &str,
scopes: Scopes,
force_login: bool,
) -> Registered<HttpSender> {
) -> Registered {
Registered {
base: base.to_string(),
client: Client::new(),
@ -229,16 +230,11 @@ impl Registered<HttpSender> {
redirect: redirect.to_string(),
scopes,
force_login,
http_sender: HttpSender,
}
}
}
impl<H: HttpSend> Registered<H> {
fn send(&self, req: RequestBuilder) -> Result<Response> {
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<H: HttpSend> Registered<H> {
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<Mastodon<H>> {
pub async fn complete(&self, code: &str) -> Result<Mastodon> {
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<H: HttpSend> {
pub struct Registered {
base: String,
client: Client,
client_id: String,
@ -340,7 +346,6 @@ pub struct Registered<H: HttpSend> {
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]

View File

@ -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 {

View File

@ -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 {

View File

@ -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()