From 02ca0a89515413ac9fb3b655de2f21f6a711e0f2 Mon Sep 17 00:00:00 2001 From: Paul Woolcock Date: Wed, 7 Oct 2020 05:47:39 -0400 Subject: Add basic async client This adds a module, accessible by compiling with `--features async`, that provides an `elefren::async::Client`. The client is runtime-agnostic, and currently only provides unauthenticated access, see the docs for the full list of methods that can be performed* with this client. * note that some API calls are publicly available by default, but can be changed via instance settings to not be publicly accessible --- Cargo.toml | 8 +- src/async/auth.rs | 46 +++++++ src/async/client.rs | 52 ++++++++ src/async/mod.rs | 263 +++++++++++++++++++++++++++++++++++++ src/async/page.rs | 95 ++++++++++++++ src/entities/status.rs | 12 +- src/errors.rs | 16 +++ src/requests/directory.rs | 2 +- src/requests/filter.rs | 6 +- src/requests/statuses.rs | 175 +++++++----------------- src/requests/update_credentials.rs | 6 +- 11 files changed, 538 insertions(+), 143 deletions(-) create mode 100644 src/async/auth.rs create mode 100644 src/async/client.rs create mode 100644 src/async/mod.rs create mode 100644 src/async/page.rs diff --git a/Cargo.toml b/Cargo.toml index ad8b9c1..3736ba9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,11 @@ toml = { version = "0.5.0", optional = true } tungstenite = "0.11.0" async-trait = "0.1.40" tokio = "0.2.22" +async-h1 = { version = "2.1.2", optional = true } +async-native-tls = { version = "0.3.3", optional = true } +smol = { version = "1.2.2", optional = true } +http-types = { version = "2.5.0", optional = true } +async-mutex = { version = "1.4.0", optional = true } [dependencies.chrono] version = "0.4" @@ -36,9 +41,10 @@ features = ["serde"] default = ["reqwest/default-tls"] json = [] env = ["envy"] -all = ["toml", "json", "env"] +all = ["toml", "json", "env", "async"] rustls-tls = ["reqwest/rustls-tls"] nightly = [] +async = ["async-h1", "async-native-tls", "smol", "http-types", "async-mutex"] [dev-dependencies] tempfile = "3.0.3" diff --git a/src/async/auth.rs b/src/async/auth.rs new file mode 100644 index 0000000..6f593c4 --- /dev/null +++ b/src/async/auth.rs @@ -0,0 +1,46 @@ +//! Authentication mechanisms for async client +use async_mutex::Mutex; +use std::cell::RefCell; + +use crate::{ + entities::{account::Account, card::Card, context::Context, status::Status}, + errors::{Error, Result}, + requests::StatusesRequest, +}; +use http_types::{Method, Request, Response}; +use hyper_old_types::header::{parsing, Link, RelationType}; +use serde::Serialize; +use smol::{prelude::*, Async}; +use std::net::{TcpStream, ToSocketAddrs}; +use url::Url; + +/// strategies for authenticating mastodon requests need to implement this trait +#[async_trait::async_trait] +pub trait Authenticate { + async fn authenticate(&self, request: &mut Request) -> Result<()>; +} + +/// The null-strategy, will only allow the client to call public API endpoints +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Unauthenticated; +#[async_trait::async_trait] +impl Authenticate for Unauthenticated { + async fn authenticate(&self, _: &mut Request) -> Result<()> { + Ok(()) + } +} + +/// Authenticates to the server via oauth +#[derive(Debug, Clone, PartialEq)] +pub struct OAuth { + client_id: String, + client_secret: String, + redirect: String, + token: String, +} +#[async_trait::async_trait] +impl Authenticate for Mutex>> { + async fn authenticate(&self, _: &mut Request) -> Result<()> { + unimplemented!() + } +} diff --git a/src/async/client.rs b/src/async/client.rs new file mode 100644 index 0000000..5081c6f --- /dev/null +++ b/src/async/client.rs @@ -0,0 +1,52 @@ +use crate::{ + entities::{account::Account, card::Card, context::Context, status::Status}, + errors::{Error, Result}, +}; +use http_types::{Method, Request, Response}; +use hyper_old_types::header::{parsing, Link, RelationType}; +use smol::{prelude::*, Async}; +use std::net::{TcpStream, ToSocketAddrs}; +use url::Url; + +// taken pretty much verbatim from `smol`s example + +/// Sends a request and fetches the response. +pub(super) async fn fetch(req: Request) -> Result { + // Figure out the host and the port. + let host = req + .url() + .host() + .ok_or_else(|| String::from("No host found"))? + .to_string(); + let port = req + .url() + .port_or_known_default() + .ok_or_else(|| Error::Other(String::from("No port found")))?; + + // Connect to the host. + let socket_addr = { + let host = host.clone(); + smol::unblock(move || (host.as_str(), port).to_socket_addrs()) + .await? + .next() + .ok_or_else(|| Error::Other(String::from("No socket addr")))? + }; + let stream = Async::::connect(socket_addr).await?; + + // Send the request and wait for the response. + let resp = match req.url().scheme() { + "http" => async_h1::connect(stream, req).await?, + "https" => { + // In case of HTTPS, establish a secure TLS connection first. + let stream = async_native_tls::connect(&host, stream).await?; + async_h1::connect(stream, req).await? + }, + scheme => return Err(Error::Other(format!("unsupported scheme '{}'", scheme))), + }; + Ok(resp) +} + +pub(super) async fn get(url: Url) -> Result { + let req = Request::new(Method::Get, url); + Ok(fetch(req).await?) +} diff --git a/src/async/mod.rs b/src/async/mod.rs new file mode 100644 index 0000000..d75e131 --- /dev/null +++ b/src/async/mod.rs @@ -0,0 +1,263 @@ +//! Async Mastodon Client +//! +//! # Example +//! +//! ```rust,no_run +//! use elefren::r#async::Client; +//! use url::Url; +//! +//! # fn main() -> Result<(), Box> { +//! # smol::block_on(async { +//! let client = Client::new("https://mastodon.social")?; +//! +//! // iterate page-by-page +//! // this API isn't ideal, but one day we'll get better +//! // syntax support for iterating over streams and we can +//! // do better +//! let mut pages = client.public_timeline(None).await?; +//! while let Some(statuses) = pages.next_page().await? { +//! for status in statuses { +//! println!("{:?}", status); +//! } +//! } +//! # Ok(()) +//! # }) +//! } +//! ``` +#![allow(warnings)] +#![allow(missing_docs)] +use crate::{ + entities::{ + account::Account, + activity::Activity, + card::Card, + context::Context, + instance::Instance, + poll::Poll, + status::{Emoji, Status, Tag}, + }, + errors::{Error, Result}, + requests::{DirectoryRequest, StatusesRequest}, +}; +use http_types::{Method, Request, Response}; +use std::fmt::Debug; +use url::Url; + +pub use auth::Authenticate; +use auth::{OAuth, Unauthenticated}; +pub use page::Page; + +mod auth; +mod client; +mod page; + +/// Async unauthenticated client +#[derive(Debug)] +pub struct Client { + base_url: Url, + auth: A, +} +impl Client { + pub fn new>(base_url: S) -> Result> { + let base_url = Url::parse(base_url.as_ref())?; + Ok(Client { + base_url, + auth: Unauthenticated, + }) + } +} +impl Client { + async fn send(&self, mut req: Request) -> Result { + self.auth.authenticate(&mut req).await?; + Ok(client::fetch(req).await?) + } + + /// GET /api/v1/timelines/public + pub async fn public_timeline<'a, 'client: 'a, I: Into>>>( + &'client self, + opts: I, + ) -> Result> { + let mut url = self.base_url.join("api/v1/timelines/public")?; + if let Some(opts) = opts.into() { + let qs = opts.to_querystring()?; + url.set_query(Some(&qs[..])); + }; + Ok(Page::new(Request::new(Method::Get, url), &self.auth)) + } + + /// GET /api/v1/timelines/tag/:tag + pub async fn hashtag_timeline<'a, 'client: 'a, I: Into>>>( + &'client self, + tag: &str, + opts: I, + ) -> Result> { + let mut url = self + .base_url + .join(&format!("api/v1/timelines/tag/{}", tag))?; + if let Some(opts) = opts.into() { + let qs = opts.to_querystring()?; + url.set_query(Some(&qs[..])); + } + Ok(Page::new(Request::new(Method::Get, url), &self.auth)) + } + + /// GET /api/v1/statuses/:id + pub async fn status(&self, id: &str) -> Result { + let url = self.base_url.join(&format!("api/v1/statuses/{}", id))?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/statuses/:id/context + pub async fn context(&self, id: &str) -> Result { + let url = self + .base_url + .join(&format!("api/v1/statuses/{}/context", id))?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/statuses/:id/card + pub async fn card(&self, id: &str) -> Result { + let url = self + .base_url + .join(&format!("api/v1/statuses/{}/card", id))?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/statuses/:id/reblogged_by + pub async fn reblogged_by<'client>( + &'client self, + id: &str, + ) -> Result> { + let url = self + .base_url + .join(&format!("api/v1/statuses/{}/reblogged_by", id))?; + Ok(Page::new(Request::new(Method::Get, url), &self.auth)) + } + + /// GET /api/v1/statuses/:id/favourited_by + pub async fn favourited_by<'client>( + &'client self, + id: &str, + ) -> Result> { + let url = self + .base_url + .join(&format!("api/v1/statuses/{}/favourited_by", id))?; + Ok(Page::new(Request::new(Method::Get, url), &self.auth)) + } + + /// GET /api/v1/accounts/:id + pub async fn account(&self, id: &str) -> Result { + let url = self.base_url.join(&format!("api/v1/accounts/{}", id))?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/accounts/:id/statuses + pub async fn account_statuses<'a, 'client: 'a, I: Into>>>( + &'client self, + id: &str, + request: I, + ) -> Result> { + let mut url = self + .base_url + .join(&format!("api/v1/accounts/{}/statuses", id))?; + if let Some(request) = request.into() { + let qs = request.to_querystring()?; + url.set_query(Some(&qs[..])); + } + Ok(Page::new(Request::new(Method::Get, url), &self.auth)) + } + + /// GET /api/v1/polls/:id + pub async fn poll(&self, id: &str) -> Result { + let url = self.base_url.join(&format!("api/v1/polls/{}", id))?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/instance + pub async fn instance(&self) -> Result { + let url = self.base_url.join("api/v1/instance")?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/instance/peers + pub async fn peers(&self) -> Result> { + let url = self.base_url.join("api/v1/instance/peers")?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/instance/activity + pub async fn activity(&self) -> Result>> { + let url = self.base_url.join("api/v1/instance/activity")?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/custom_emojis + pub async fn custom_emojis(&self) -> Result> { + let url = self.base_url.join("api/v1/custom_emojis")?; + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/directory + pub async fn directory<'a, I: Into>>>( + &self, + opts: I, + ) -> Result> { + let mut url = self.base_url.join("api/v1/directory")?; + if let Some(opts) = opts.into() { + let qs = opts.to_querystring()?; + url.set_query(Some(&qs[..])); + } + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } + + /// GET /api/v1/trends + pub async fn trends>>(&self, limit: I) -> Result> { + let mut url = self.base_url.join("api/v1/trends")?; + if let Some(limit) = limit.into() { + url.set_query(Some(&format!("?limit={}", limit))); + } + let response = self.send(Request::new(Method::Get, url)).await?; + Ok(deserialize(response).await?) + } +} + +async fn deserialize(mut response: Response) -> Result { + let status = response.status(); + if status.is_client_error() { + // TODO + // return Err(Error::Client(status)); + return Err(Error::Other(String::from("4xx status code"))); + } else if status.is_server_error() { + // TODO + // return Err(Error::Server(status)) // TODO + return Err(Error::Other(String::from("5xx status code"))); + } else if status.is_redirection() || status.is_informational() { + return Err(Error::Other(String::from("3xx or 1xx status code"))); + } + let bytes = response.body_bytes().await?; + Ok(match serde_json::from_slice::(&bytes) { + Ok(t) => { + log::debug!("{}", String::from_utf8_lossy(&bytes)); + t + }, + Err(e) => { + log::error!("{}", String::from_utf8_lossy(&bytes)); + let err = if let Ok(error) = serde_json::from_slice(&bytes) { + Error::Api(error) + } else { + e.into() + }; + return Err(err); + }, + }) +} diff --git a/src/async/page.rs b/src/async/page.rs new file mode 100644 index 0000000..4ef4161 --- /dev/null +++ b/src/async/page.rs @@ -0,0 +1,95 @@ +use super::{client, deserialize, Authenticate}; +use crate::{ + entities::{account::Account, card::Card, context::Context, status::Status}, + errors::{Error, Result}, +}; +use http_types::{Method, Request, Response}; +use hyper_old_types::header::{parsing, Link, RelationType}; +use smol::{prelude::*, Async}; +use std::{ + fmt::Debug, + net::{TcpStream, ToSocketAddrs}, +}; +use url::Url; + +// link header name +const LINK: &str = "link"; + +#[derive(Debug)] +pub struct Page<'client, T, A: Authenticate + Debug + 'client> { + next: Option, + prev: Option, + auth: &'client A, + _marker: std::marker::PhantomData, +} +impl<'client, T: serde::de::DeserializeOwned, A: Authenticate + Debug + 'client> + Page<'client, T, A> +{ + pub fn new(next: Request, auth: &'client A) -> Page<'client, T, A> { + Page { + next: Some(next), + prev: None, + auth, + _marker: std::marker::PhantomData, + } + } + + pub async fn next_page(&mut self) -> Result>> { + let mut req = if let Some(next) = self.next.take() { + next + } else { + return Ok(None); + }; + Ok(self.send(req).await?) + } + + pub async fn prev_page(&mut self) -> Result>> { + let req = if let Some(prev) = self.prev.take() { + prev + } else { + return Ok(None); + }; + Ok(self.send(req).await?) + } + + async fn send(&mut self, mut req: Request) -> Result>> { + self.auth.authenticate(&mut req).await?; + log::trace!("Request: {:?}", req); + let response = client::fetch(req).await?; + log::trace!("Response: {:?}", response); + self.fill_links_from_resp(&response)?; + let items = deserialize(response).await?; + Ok(items) + } + + fn fill_links_from_resp(&mut self, response: &Response) -> Result<()> { + let (prev, next) = get_links(&response)?; + self.prev = prev.map(|url| Request::new(Method::Get, url)); + self.next = next.map(|url| Request::new(Method::Get, url)); + Ok(()) + } +} + +fn get_links(response: &Response) -> Result<(Option, Option)> { + let mut prev = None; + let mut next = None; + + if let Some(link_header) = response.header(LINK) { + let link_header = link_header.as_str(); + let link_header = link_header.as_bytes(); + let link_header: Link = parsing::from_raw_str(&link_header)?; + for value in link_header.values() { + if let Some(relations) = value.rel() { + if relations.contains(&RelationType::Next) { + next = Some(Url::parse(value.link())?); + } + + if relations.contains(&RelationType::Prev) { + prev = Some(Url::parse(value.link())?); + } + } + } + } + + Ok((prev, next)) +} diff --git a/src/entities/status.rs b/src/entities/status.rs index 7243449..c65cb9c 100644 --- a/src/entities/status.rs +++ b/src/entities/status.rs @@ -2,11 +2,8 @@ use super::prelude::*; use crate::{ - entities::{ - card::Card, - poll::Poll, - }, - status_builder::Visibility + entities::{card::Card, poll::Poll}, + status_builder::Visibility, }; use chrono::prelude::*; use serde::{Deserialize, Serialize}; @@ -64,7 +61,10 @@ pub struct Status { pub card: Option, /// The detected language for the status, if detected. pub language: Option, - /// Plain-text source of a status. Returned instead of content when status is deleted, so the user may redraft from the source text without the client having to reverse-engineer the original text from the HTML content. + /// Plain-text source of a status. Returned instead of content when status + /// is deleted, so the user may redraft from the source text without the + /// client having to reverse-engineer the original text from the HTML + /// content. pub text: Option, /// Whether the application client has favourited the status. pub favourited: Option, diff --git a/src/errors.rs b/src/errors.rs index 2813bfd..6b58d05 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,8 +5,12 @@ use std::{error, fmt, io::Error as IoError}; use ::toml::de::Error as TomlDeError; #[cfg(feature = "toml")] use ::toml::ser::Error as TomlSerError; +#[cfg(feature = "async")] +use async_native_tls::Error as TlsError; #[cfg(feature = "env")] use envy::Error as EnvyError; +#[cfg(feature = "async")] +use http_types::Error as HttpTypesError; use hyper_old_types::Error as HeaderParseError; use reqwest::{header::ToStrError as HeaderStrError, Error as HttpError, StatusCode}; use serde_json::Error as SerdeError; @@ -64,6 +68,12 @@ pub enum Error { SerdeQs(SerdeQsError), /// WebSocket error WebSocket(WebSocketError), + #[cfg(feature = "async")] + /// http-types error + HttpTypes(HttpTypesError), + #[cfg(feature = "async")] + /// TLS error + Tls(TlsError), /// Other errors Other(String), } @@ -99,6 +109,10 @@ impl error::Error for Error { Error::ClientSecretRequired => return None, Error::AccessTokenRequired => return None, Error::MissingField(_) => return None, + #[cfg(feature = "async")] + Error::HttpTypes(..) => return None, + #[cfg(feature = "async")] + Error::Tls(ref e) => e, Error::Other(..) => return None, }) } @@ -149,6 +163,8 @@ from! { #[cfg(feature = "env")] EnvyError, Envy, SerdeQsError, SerdeQs, WebSocketError, WebSocket, + #[cfg(feature = "async")] HttpTypesError, HttpTypes, + #[cfg(feature = "async")] TlsError, Tls, String, Other, } diff --git a/src/requests/directory.rs b/src/requests/directory.rs index 14ff2c4..f410e52 100644 --- a/src/requests/directory.rs +++ b/src/requests/directory.rs @@ -75,6 +75,6 @@ impl<'a> DirectoryRequest<'a> { /// ); /// ``` pub fn to_querystring(&self) -> Result { - Ok(format!("{}", serde_qs::to_string(&self)?)) + Ok(serde_qs::to_string(&self)?) } } diff --git a/src/requests/filter.rs b/src/requests/filter.rs index 7156243..448a18f 100644 --- a/src/requests/filter.rs +++ b/src/requests/filter.rs @@ -126,7 +126,8 @@ mod tests { #[test] fn test_expires_in() { - let request = AddFilterRequest::new("foo", FilterContext::Home).expires_in(Duration::from_secs(300)); + let request = + AddFilterRequest::new("foo", FilterContext::Home).expires_in(Duration::from_secs(300)); assert_eq!( request, AddFilterRequest { @@ -141,7 +142,8 @@ mod tests { #[test] fn test_serialize_request() { - let request = AddFilterRequest::new("foo", FilterContext::Home).expires_in(Duration::from_secs(300)); + let request = + AddFilterRequest::new("foo", FilterContext::Home).expires_in(Duration::from_secs(300)); let ser = serde_json::to_string(&request).expect("Couldn't serialize"); assert_eq!( ser, diff --git a/src/requests/statuses.rs b/src/requests/statuses.rs index 87baac6..79124a5 100644 --- a/src/requests/statuses.rs +++ b/src/requests/statuses.rs @@ -412,230 +412,143 @@ mod tests { #[test] fn test_to_querystring() { macro_rules! qs_test { - (|$r:ident| $b:block, $expected:expr) => { - { - let $r = StatusesRequest::new(); - let $r = $b; - let qs = $r.to_querystring().expect("Failed to serialize querystring"); - assert_eq!(&qs, $expected); - } - } + (| $r:ident | $b:block, $expected:expr) => {{ + let $r = StatusesRequest::new(); + let $r = $b; + let qs = $r + .to_querystring() + .expect("Failed to serialize querystring"); + assert_eq!(&qs, $expected); + }}; } + qs_test!(|request| { request.only_media() }, "?only_media=1"); qs_test!( - |request| { - request.only_media() - }, - "?only_media=1" - ); - qs_test!( - |request| { - request.exclude_replies() - }, + |request| { request.exclude_replies() }, "?exclude_replies=1" ); + qs_test!(|request| { request.pinned() }, "?pinned=1"); + qs_test!(|request| { request.max_id("foo") }, "?max_id=foo"); + qs_test!(|request| { request.since_id("foo") }, "?since_id=foo"); + qs_test!(|request| { request.limit(42) }, "?limit=42"); qs_test!( - |request| { - request.pinned() - }, - "?pinned=1" - ); - qs_test!( - |request| { - request.max_id("foo") - }, - "?max_id=foo" - ); - qs_test!( - |request| { - request.since_id("foo") - }, - "?since_id=foo" - ); - qs_test!( - |request| { - request.limit(42) - }, - "?limit=42" - ); - qs_test!( - |request| { - request.only_media().exclude_replies() - }, + |request| { request.only_media().exclude_replies() }, "?only_media=1&exclude_replies=1" ); qs_test!( - |request| { - request.only_media().pinned() - }, + |request| { request.only_media().pinned() }, "?only_media=1&pinned=1" ); qs_test!( - |request| { - request.only_media().max_id("foo") - }, + |request| { request.only_media().max_id("foo") }, "?only_media=1&max_id=foo" ); qs_test!( - |request| { - request.only_media().since_id("foo") - }, + |request| { request.only_media().since_id("foo") }, "?only_media=1&since_id=foo" ); qs_test!( - |request| { - request.only_media().limit(42) - }, + |request| { request.only_media().limit(42) }, "?only_media=1&limit=42" ); qs_test!( - |request| { - request.exclude_replies().only_media() - }, + |request| { request.exclude_replies().only_media() }, "?only_media=1&exclude_replies=1" ); qs_test!( - |request| { - request.exclude_replies().pinned() - }, + |request| { request.exclude_replies().pinned() }, "?exclude_replies=1&pinned=1" ); qs_test!( - |request| { - request.exclude_replies().max_id("foo") - }, + |request| { request.exclude_replies().max_id("foo") }, "?exclude_replies=1&max_id=foo" ); qs_test!( - |request| { - request.exclude_replies().since_id("foo") - }, + |request| { request.exclude_replies().since_id("foo") }, "?exclude_replies=1&since_id=foo" ); qs_test!( - |request| { - request.exclude_replies().limit(42) - }, + |request| { request.exclude_replies().limit(42) }, "?exclude_replies=1&limit=42" ); qs_test!( - |request| { - request.pinned().only_media() - }, + |request| { request.pinned().only_media() }, "?only_media=1&pinned=1" ); qs_test!( - |request| { - request.pinned().exclude_replies() - }, + |request| { request.pinned().exclude_replies() }, "?exclude_replies=1&pinned=1" ); qs_test!( - |request| { - request.pinned().max_id("foo") - }, + |request| { request.pinned().max_id("foo") }, "?pinned=1&max_id=foo" ); qs_test!( - |request| { - request.pinned().since_id("foo") - }, + |request| { request.pinned().since_id("foo") }, "?pinned=1&since_id=foo" ); qs_test!( - |request| { - request.pinned().limit(42) - }, + |request| { request.pinned().limit(42) }, "?pinned=1&limit=42" ); qs_test!( - |request| { - request.max_id("foo").only_media() - }, + |request| { request.max_id("foo").only_media() }, "?only_media=1&max_id=foo" ); qs_test!( - |request| { - request.max_id("foo").exclude_replies() - }, + |request| { request.max_id("foo").exclude_replies() }, "?exclude_replies=1&max_id=foo" ); qs_test!( - |request| { - request.max_id("foo").pinned() - }, + |request| { request.max_id("foo").pinned() }, "?pinned=1&max_id=foo" ); qs_test!( - |request| { - request.max_id("foo").since_id("foo") - }, + |request| { request.max_id("foo").since_id("foo") }, "?max_id=foo&since_id=foo" ); qs_test!( - |request| { - request.max_id("foo").limit(42) - }, + |request| { request.max_id("foo").limit(42) }, "?max_id=foo&limit=42" ); qs_test!( - |request| { - request.since_id("foo").only_media() - }, + |request| { request.since_id("foo").only_media() }, "?only_media=1&since_id=foo" ); qs_test!( - |request| { - request.since_id("foo").exclude_replies() - }, + |request| { request.since_id("foo").exclude_replies() }, "?exclude_replies=1&since_id=foo" ); qs_test!( - |request| { - request.since_id("foo").pinned() - }, + |request| { request.since_id("foo").pinned() }, "?pinned=1&since_id=foo" ); qs_test!( - |request| { - request.since_id("foo").max_id("foo") - }, + |request| { request.since_id("foo").max_id("foo") }, "?max_id=foo&since_id=foo" ); qs_test!( - |request| { - request.since_id("foo").limit(42) - }, + |request| { request.since_id("foo").limit(42) }, "?since_id=foo&limit=42" ); qs_test!( - |request| { - request.limit(42).only_media() - }, + |request| { request.limit(42).only_media() }, "?only_media=1&limit=42" ); qs_test!( - |request| { - request.limit(42).exclude_replies() - }, + |request| { request.limit(42).exclude_replies() }, "?exclude_replies=1&limit=42" ); qs_test!( - |request| { - request.limit(42).pinned() - }, + |request| { request.limit(42).pinned() }, "?pinned=1&limit=42" ); qs_test!( - |request| { - request.limit(42).max_id("foo") - }, + |request| { request.limit(42).max_id("foo") }, "?max_id=foo&limit=42" ); qs_test!( - |request| { - request.limit(42).since_id("foo") - }, + |request| { request.limit(42).since_id("foo") }, "?since_id=foo&limit=42" ); } diff --git a/src/requests/update_credentials.rs b/src/requests/update_credentials.rs index 18f279d..325eafd 100644 --- a/src/requests/update_credentials.rs +++ b/src/requests/update_credentials.rs @@ -195,7 +195,7 @@ impl UpdateCredsRequest { privacy: self.privacy, sensitive: self.sensitive, }), - fields_attributes: self.field_attributes.clone(), + fields_attributes: self.field_attributes, }) } } @@ -305,7 +305,9 @@ mod tests { #[test] fn test_update_creds_request_build() { - let builder = UpdateCredsRequest::new().display_name("test").note("a note"); + let builder = UpdateCredsRequest::new() + .display_name("test") + .note("a note"); let creds = builder.build().expect("Couldn't build Credentials"); assert_eq!( creds, -- cgit v1.2.3