From 4984dfaacfc5b8e89809d00e863c4b9bd1d3892f Mon Sep 17 00:00:00 2001 From: Aaron Power Date: Mon, 12 Feb 2018 00:07:59 +0000 Subject: [PATCH] Version 0.10.0-rc1 - Added the ability to handle paged entities like favourites and such. (Only favourites in prerelease) - Added optional `source` and `moved` fields to `Account`. - Added `Source` struct to match with the `Account.source` field. - Added `CredientialsBuilder` struct for updating profile using `verify_credientials`. - Attachment now handles being sent an empty object, which is converted to `None`. - Added ombed data fields to `Card`. - Added `version` and `urls` fields to `Instance`. - Added `id`, `muting_notifications`, and `domain_blocking` to `Relationship`. - Added `emojis`, `language`, and `pinned` fields to `Status` - Added `Emoji` struct. - Added `List` and `Mention` structs(matching routes not added yet). - Added example that prints your profile. --- .gitignore | 1 + CHANGELOG.md | 16 ++++++ Cargo.toml | 4 +- examples/print_profile.rs | 52 +++++++++++++++++++ src/entities/account.rs | 58 +++++++++++++++++++++ src/entities/attachment.rs | 19 +++++++ src/entities/card.rs | 16 +++++- src/entities/instance.rs | 4 ++ src/entities/list.rs | 5 ++ src/entities/mention.rs | 10 ++++ src/entities/mod.rs | 8 ++- src/entities/relationship.rs | 6 +++ src/entities/status.rs | 17 +++++++ src/lib.rs | 98 ++++++++++++++++++++++++++++-------- src/page.rs | 76 ++++++++++++++++++++++++++++ 15 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 examples/print_profile.rs create mode 100644 src/entities/list.rs create mode 100644 src/entities/mention.rs create mode 100644 src/page.rs diff --git a/.gitignore b/.gitignore index e08f5fc..03265b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target Cargo.lock .env +mastodon-data.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e8bcc74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# 0.10 (prerelease) + +- Added the ability to handle paged entities like favourites and such.(Only favourites in prerelease) +- Added optional `source` and `moved` fields to `Account`. +- Added `Source` struct to match with the `Account.source` field. +- Added `CredientialsBuilder` struct for updating profile using + `verify_credientials`. +- Attachment now handles being sent an empty object, which is converted + to `None`. +- Added ombed data fields to `Card`. +- Added `version` and `urls` fields to `Instance`. +- Added `id`, `muting_notifications`, and `domain_blocking` to `Relationship`. +- Added `emojis`, `language`, and `pinned` fields to `Status` +- Added `Emoji` struct. +- Added `List` and `Mention` structs(matching routes not added yet). +- Added example that prints your profile. diff --git a/Cargo.toml b/Cargo.toml index 1a3269e..599a033 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mammut" -version = "0.9.1" +version = "0.10.0-rc1" description = "A wrapper around the Mastodon API." authors = ["Aaron Power "] @@ -15,6 +15,7 @@ reqwest = "0.8" serde = "1" serde_json = "1" serde_derive = "1" +url = "1" [dependencies.chrono] version = "0.4" @@ -22,3 +23,4 @@ features = ["serde"] [dev-dependencies] dotenv = "0.10" +toml = "0.4" diff --git a/examples/print_profile.rs b/examples/print_profile.rs new file mode 100644 index 0000000..315a11e --- /dev/null +++ b/examples/print_profile.rs @@ -0,0 +1,52 @@ +extern crate mammut; +extern crate toml; +use mammut::{Data, Mastodon, Registration}; +use mammut::apps::{AppBuilder, Scopes}; +use std::io; +use std::fs::File; +use std::io::prelude::*; + +fn main() { + let mastodon = match File::open("mastodon-data.toml") { + Ok(mut file) => { + let mut config = String::new(); + file.read_to_string(&mut config).unwrap(); + let data: Data = toml::from_str(&config).unwrap(); + Mastodon::from_data(data) + }, + Err(_) => register(), + }; + + let you = mastodon.verify_credentials().unwrap(); + + println!("{:#?}", you); +} + +fn register() -> Mastodon { + let app = AppBuilder { + client_name: "mammut-examples", + redirect_uris: "urn:ietf:wg:oauth:2.0:oob", + scopes: Scopes::Read, + website: Some("https://github.com/Aaronepower/mammut"), + }; + + let mut registration = Registration::new("https://mastodon.social"); + registration.register(app).unwrap();; + let url = registration.authorise().unwrap(); + + println!("Click this link to authorize on Mastodon: {}", url); + println!("Paste the returned authorization code: "); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + + let code = input.trim(); + let mastodon = registration.create_access_token(code.to_string()).unwrap(); + + // Save app data for using on the next run. + let toml = toml::to_string(&*mastodon).unwrap(); + let mut file = File::create("mastodon-data.toml").unwrap(); + file.write_all(toml.as_bytes()).unwrap(); + + mastodon +} diff --git a/src/entities/account.rs b/src/entities/account.rs index fd2117f..cef7d83 100644 --- a/src/entities/account.rs +++ b/src/entities/account.rs @@ -1,6 +1,9 @@ //! A module containing everything relating to a account returned from the api. use chrono::prelude::*; +use reqwest::multipart::Form; +use ::Result; +use std::path::Path; /// A struct representing an Account. #[derive(Debug, Clone, Deserialize)] @@ -36,4 +39,59 @@ pub struct Account { pub url: String, /// The username of the account. pub username: String, + /// An extra attribute given from `verify_credentials` giving defaults about + /// a user + pub source: Option, + /// If the owner decided to switch accounts, new account is in + /// this attribute + pub moved: Option, +} + +/// An extra object given from `verify_credentials` giving defaults about a user +#[derive(Debug, Clone, Deserialize)] +pub struct Source { + privacy: ::status_builder::Visibility, + sensitive: bool, + note: String, +} + +pub struct CredientialsBuilder<'a> { + display_name: Option<&'a str>, + note: Option<&'a str>, + avatar: Option<&'a Path>, + header: Option<&'a Path>, +} + +impl<'a> CredientialsBuilder<'a> { + pub fn into_form(self) -> Result
{ + let mut form = Form::new(); + macro_rules! add_to_form { + ($key:ident : Text; $($rest:tt)*) => {{ + if let Some(val) = self.$key { + form = form.text(stringify!($key), val.to_owned()); + } + + add_to_form!{$($rest)*} + }}; + + ($key:ident : File; $($rest:tt)*) => {{ + if let Some(val) = self.$key { + form = form.file(stringify!($key), val)?; + } + + add_to_form!{$($rest)*} + }}; + + () => {} + } + + add_to_form! { + display_name: Text; + note: Text; + avatar: File; + header: File; + } + + Ok(form) + } } diff --git a/src/entities/attachment.rs b/src/entities/attachment.rs index 788348a..2784987 100644 --- a/src/entities/attachment.rs +++ b/src/entities/attachment.rs @@ -1,4 +1,6 @@ //! Module containing everything related to media attachements. +use serde::{Deserialize, Deserializer}; +use super::Empty; /// A struct representing a media attachment. #[derive(Debug, Clone, Deserialize)] @@ -18,11 +20,28 @@ pub struct Attachment { /// (only present on local images) pub text_url: Option, /// Meta information about the attachment. + #[serde(deserialize_with="empty_as_none")] pub meta: Option, /// Noop will be removed. pub description: Option, } +fn empty_as_none<'de, D: Deserializer<'de>>(val: D) + -> Result, D::Error> +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum EmptyOrMeta { + Empty(Empty), + Meta(Meta), + } + + Ok(match EmptyOrMeta::deserialize(val)? { + EmptyOrMeta::Empty(_) => None, + EmptyOrMeta::Meta(m) => Some(m), + }) +} + /// Information about the attachment itself. #[derive(Debug, Deserialize, Clone)] pub struct Meta { diff --git a/src/entities/card.rs b/src/entities/card.rs index babd43d..475d0e5 100644 --- a/src/entities/card.rs +++ b/src/entities/card.rs @@ -10,5 +10,19 @@ pub struct Card { /// The card description. pub description: String, /// The image associated with the card, if any. - pub image: String, + pub image: Option, + /// OEmbed data + author_name: Option, + /// OEmbed data + author_url: Option, + /// OEmbed data + provider_name: Option, + /// OEmbed data + provider_url: Option, + /// OEmbed data + html: Option, + /// OEmbed data + width: Option, + /// OEmbed data + height: Option, } diff --git a/src/entities/instance.rs b/src/entities/instance.rs index 6100138..b4c63e5 100644 --- a/src/entities/instance.rs +++ b/src/entities/instance.rs @@ -12,4 +12,8 @@ pub struct Instance { /// An email address which can be used to contact the /// instance administrator. pub email: String, + /// The Mastodon version used by instance. + pub version: String, + /// `streaming_api` + pub urls: Vec, } diff --git a/src/entities/list.rs b/src/entities/list.rs new file mode 100644 index 0000000..77ee53a --- /dev/null +++ b/src/entities/list.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Debug, Deserialize)] +pub struct List { + id: String, + title: String, +} diff --git a/src/entities/mention.rs b/src/entities/mention.rs new file mode 100644 index 0000000..479a691 --- /dev/null +++ b/src/entities/mention.rs @@ -0,0 +1,10 @@ +pub struct Mention { + /// URL of user's profile (can be remote) + pub url: String, + /// The username of the account + pub username: String, + /// Equals username for local users, includes `@domain` for remote ones + pub acct: String, + /// Account ID + pub id: String, +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 4cd65db..a13e7ff 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -3,6 +3,8 @@ pub mod attachment; pub mod card; pub mod context; pub mod instance; +pub mod list; +pub mod mention; pub mod notification; pub mod relationship; pub mod report; @@ -17,14 +19,16 @@ pub mod prelude { //! The purpose of this module is to alleviate imports of many common structs //! by adding a glob import to the top of mastodon heavy modules: pub use super::Empty; - pub use super::account::Account; + pub use super::account::{Account, CredientialsBuilder, Source}; pub use super::attachment::{Attachment, MediaType}; pub use super::card::Card; pub use super::context::Context; pub use super::instance::Instance; + pub use super::list::List; + pub use super::mention::Mention; pub use super::notification::Notification; pub use super::relationship::Relationship; pub use super::report::Report; pub use super::search_result::SearchResult; - pub use super::status::{Status, Application}; + pub use super::status::{Application, Emoji, Status}; } diff --git a/src/entities/relationship.rs b/src/entities/relationship.rs index 120ab5f..ee48b72 100644 --- a/src/entities/relationship.rs +++ b/src/entities/relationship.rs @@ -4,6 +4,8 @@ /// A struct containing information about a relationship with another account. #[derive(Debug, Clone, Deserialize)] pub struct Relationship { + /// Target account id + pub id: String, /// Whether the application client follows the account. pub following: bool, /// Whether the account follows the application client. @@ -14,4 +16,8 @@ pub struct Relationship { pub muting: bool, /// Whether the application client has requested to follow the account. pub requested: bool, + /// Whether the user is also muting notifications + pub muting_notifications: bool, + /// Whether the user is currently blocking the accounts's domain + pub domain_blocking: bool, } diff --git a/src/entities/status.rs b/src/entities/status.rs index fdd9d87..158ba0a 100644 --- a/src/entities/status.rs +++ b/src/entities/status.rs @@ -28,6 +28,8 @@ pub struct Status { pub content: String, /// The time the status was created. pub created_at: DateTime, + /// An array of Emoji + pub emojis: Vec, /// The number of reblogs for the status. pub reblogs_count: u64, /// The number of favourites for the status. @@ -51,6 +53,10 @@ pub struct Status { pub tags: Vec, /// Name of application used to post status. pub application: Option, + /// The detected language for the status, if detected. + pub language: Option, + /// Whether this is the pinned status for the account that posted it. + pub pinned: Option, } /// A mention of another user. @@ -66,6 +72,17 @@ pub struct Mention { pub id: String, } +/// Struct representing an emoji within text. +#[derive(Clone, Debug, Deserialize)] +pub struct Emoji { + /// The shortcode of the emoji + pub shortcode: String, + /// URL to the emoji static image + pub static_url: String, + /// URL to the emoji image + pub url: String, +} + /// Hashtags in the status. #[derive(Debug, Clone, Deserialize)] pub struct Tag { diff --git a/src/lib.rs b/src/lib.rs index cb0133b..763a50e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,7 @@ extern crate chrono; extern crate reqwest; extern crate serde; +extern crate url; /// Registering your App pub mod apps; @@ -49,6 +50,8 @@ pub mod status_builder; pub mod entities; /// Registering your app. pub mod registration; +/// Handling multiple pages of entities. +pub mod page; use std::borrow::Cow; use std::error::Error as StdError; @@ -60,9 +63,11 @@ use json::Error as SerdeError; use reqwest::Error as HttpError; use reqwest::{Client, Response, StatusCode}; use reqwest::header::{Authorization, Bearer, Headers}; +use url::ParseError as UrlError; use entities::prelude::*; pub use status_builder::StatusBuilder; +use page::Page; pub use registration::Registration; /// Convience type over `std::result::Result` with `Error` as the error type. @@ -78,12 +83,34 @@ macro_rules! methods { .headers(self.headers.clone()) .send()?; - json_convert_response(response) + deserialise(response) } )+ }; } +macro_rules! paged_routes { + + (($method:ident) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { + /// Equivalent to `/api/v1/ + #[doc = $url] + /// ` + /// + #[doc = "# Errors"] + /// If `access_token` is not set. + pub fn $name(&self) -> Result> { + let url = self.route(concat!("/api/v1/", $url)); + let response = self.client.$method(&url) + .headers(self.headers.clone()) + .send()?; + + Page::new(self, response) + } + + route!{$($rest)*} + }; +} + macro_rules! route { ((post multipart ($($param:ident: $typ:ty,)*)) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { @@ -114,13 +141,13 @@ macro_rules! route { return Err(Error::Server(status)); } - json_convert_response(response) + deserialise(response) } route!{$($rest)*} }; - ((post ($($param:ident: $typ:ty,)*)) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { + (($method:ident ($($param:ident: $typ:ty,)*)) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { /// Equivalent to `/api/v1/ #[doc = $url] /// ` @@ -135,7 +162,7 @@ macro_rules! route { )* }); - let response = self.client.post(&self.route(concat!("/api/v1/", $url))) + let response = self.client.$method(&self.route(concat!("/api/v1/", $url))) .headers(self.headers.clone()) .json(&form_data) .send()?; @@ -148,7 +175,7 @@ macro_rules! route { return Err(Error::Server(status)); } - json_convert_response(response) + deserialise(response) } route!{$($rest)*} @@ -231,6 +258,9 @@ pub enum Error { /// Wrapper around the `std::io::Error` struct. #[serde(skip_deserializing)] Io(IoError), + /// Wrapper around the `url::ParseError` struct. + #[serde(skip_deserializing)] + Url(UrlError), /// Missing Client Id. #[serde(skip_deserializing)] ClientIdRequired, @@ -263,6 +293,7 @@ impl StdError for Error { Error::Serde(ref e) => e.description(), Error::Http(ref e) => e.description(), Error::Io(ref e) => e.description(), + Error::Url(ref e) => e.description(), Error::Client(ref status) | Error::Server(ref status) => { status.canonical_reason().unwrap_or("Unknown Status code") }, @@ -323,22 +354,30 @@ impl Mastodon { } } + paged_routes! { + (get) favourites: "favourites" => Status, + } + route! { - (get) verify: "accounts/verify_credentials" => Account, + (delete (domain: String,)) unblock_domain: "domain_blocks" => Empty, (get) blocks: "blocks" => Vec, + (get) domain_blocks: "domain_blocks" => Vec, (get) follow_requests: "follow_requests" => Vec, + (get) get_home_timeline: "timelines/home" => Vec, + (get) instance: "instance" => Instance, + (get) get_emojis: "custom_emojis" => Vec, (get) mutes: "mutes" => Vec, (get) notifications: "notifications" => Vec, (get) reports: "reports" => Vec, - (get) get_home_timeline: "timelines/home" => Vec, - (post (id: u64,)) allow_follow_request: "accounts/follow_requests/authorize" => Empty, + (get) verify_credentials: "accounts/verify_credentials" => Account, + (post (account_id: u64, status_ids: Vec, comment: String,)) report: "reports" => Report, + (post (domain: String,)) block_domain: "domain_blocks" => Empty, + (post (id: u64,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty, (post (id: u64,)) reject_follow_request: "accounts/follow_requests/reject" => Empty, + (post (q: String, resolve: bool,)) search: "search" => SearchResult, (post (uri: Cow<'static, str>,)) follows: "follows" => Account, - (post) clear_notifications: "notifications/clear" => Empty, (post multipart (file: Cow<'static, str>,)) media: "media" => Attachment, - (post (account_id: u64, status_ids: Vec, comment: String,)) report: - "reports" => Report, - (post (q: String, resolve: bool,)) search: "search" => SearchResult, + (post) clear_notifications: "notifications/clear" => Empty, } route_id! { @@ -364,6 +403,27 @@ impl Mastodon { (delete) delete_status: "statuses/{}" => Empty, } + pub fn update_credentials(&self, changes: CredientialsBuilder) + -> Result + { + + let url = self.route("/api/v1/accounts/update_credentials"); + let response = self.client.patch(&url) + .headers(self.headers.clone()) + .multipart(changes.into_form()?) + .send()?; + + let status = response.status().clone(); + + if status.is_client_error() { + return Err(Error::Client(status)); + } else if status.is_server_error() { + return Err(Error::Server(status)); + } + + deserialise(response) + } + /// Post a new status to the account. pub fn new_status(&self, status: StatusBuilder) -> Result { @@ -372,7 +432,7 @@ impl Mastodon { .json(&status) .send()?; - json_convert_response(response) + deserialise(response) } /// Get the federated timeline for the instance. @@ -452,11 +512,6 @@ impl Mastodon { self.get(format!("{}/api/v1/accounts/search?q={}", self.base, query)) } - /// Returns the current Instance. - pub fn instance(&self) -> Result { - self.get(self.route("/api/v1/instance")) - } - methods![get, post, delete,]; fn route(&self, url: &str) -> String { @@ -488,14 +543,17 @@ macro_rules! from { } from! { - SerdeError, Serde, HttpError, Http, IoError, Io, + SerdeError, Serde, + UrlError, Url, } // Convert the HTTP response body from JSON. Pass up deserialization errors // transparently. -fn json_convert_response serde::Deserialize<'de>>(mut response: Response) -> Result { +fn deserialise serde::Deserialize<'de>>(mut response: Response) + -> Result +{ use std::io::Read; let mut vec = Vec::new(); diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..1a9cf98 --- /dev/null +++ b/src/page.rs @@ -0,0 +1,76 @@ +use super::{Mastodon, Result, deserialise}; +use reqwest::Response; +use reqwest::header::{Link, RelationType}; +use serde::Deserialize; +use url::Url; + +pub struct Page<'a, T: for<'de> Deserialize<'de>> { + mastodon: &'a Mastodon, + next: Option, + prev: Option, + /// Initial set of items + pub initial_items: Vec, +} + +macro_rules! pages { + ($($direction:ident: $fun:ident),*) => { + + $( + pub fn $fun(&mut self) -> Result>> { + let url = match self.$direction.take() { + Some(s) => s, + None => return Ok(None), + }; + + let response = self.mastodon.client.get(url) + .headers(self.mastodon.headers.clone()) + .send()?; + + let (prev, next) = get_links(&response)?; + self.next = next; + self.prev = prev; + + deserialise(response) + } + )* + } +} + +impl<'a, T: for<'de> Deserialize<'de>> Page<'a, T> { + pub fn new(mastodon: &'a Mastodon, response: Response) -> Result { + let (prev, next) = get_links(&response)?; + Ok(Page { + initial_items: deserialise(response)?, + next, + prev, + mastodon + }) + } + + pages! { + next: next_page, + prev: prev_page + } +} + + +fn get_links(response: &Response) -> Result<(Option, Option)> { + let mut prev = None; + let mut next = None; + + if let Some(link_header) = response.headers().get::() { + 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)) +}