commit 36ae371beb82e1e76f0aacbd03b2434f3b1ddf4b Author: Aaron Power Date: Mon Apr 10 17:48:41 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9d37c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0032201 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mammut" +version = "0.1.0" +description = "A wrapper around the Mastodon API." +authors = ["Aaron Power "] +license = "MIT/Apache-2.0" +readme = "README.md" +repository = "https://github.com/Aaronepower/mastodon.rs.git" +keywords = ["api", "web", "social", "mastodon", "wrapper"] +categories = ["web-programming", "http-client"] + +[dependencies] +reqwest = "0.5" +serde = "0.9" +serde_json = "0.9" +serde_derive = "0.9" + +[dependencies.chrono] +version = "0.3" +features = ["serde"] diff --git a/LICENCE-APACHE b/LICENCE-APACHE new file mode 100644 index 0000000..97fdf99 --- /dev/null +++ b/LICENCE-APACHE @@ -0,0 +1,13 @@ +Copyright 2016 Aaron Power + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/LICENCE-MIT b/LICENCE-MIT new file mode 100644 index 0000000..0e95451 --- /dev/null +++ b/LICENCE-MIT @@ -0,0 +1,21 @@ +MIT License (MIT) + +Copyright (c) 2016 Aaron Power + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a05eb28 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Mammut. A API Wrapper for the Mastodon API. + +### [Documentation](https://docs.rs/mastodon) + +A wrapper around the [API](https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/API.md#tag) for [Mastodon](https://mastodon.social/) diff --git a/src/apps.rs b/src/apps.rs new file mode 100644 index 0000000..a6e251b --- /dev/null +++ b/src/apps.rs @@ -0,0 +1,32 @@ +#[derive(Debug, Default, Serialize)] +pub struct AppBuilder<'a> { + pub client_name: &'a str, + pub redirect_uris: &'a str, + pub scopes: Scope, + #[serde(skip_serializing_if="Option::is_none")] + pub website: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub enum Scope { + #[serde(rename = "read write follow")] + All, + #[serde(rename = "follow")] + Follow, + #[serde(rename = "read")] + Read, + #[serde(rename = "read follow")] + ReadFollow, + #[serde(rename = "read write")] + ReadWrite, + #[serde(rename = "write")] + Write, + #[serde(rename = "write follow")] + WriteFollow, +} + +impl Default for Scope { + fn default() -> Self { + Scope::Read + } +} diff --git a/src/entities/account.rs b/src/entities/account.rs new file mode 100644 index 0000000..82af4f7 --- /dev/null +++ b/src/entities/account.rs @@ -0,0 +1,17 @@ +use chrono::prelude::*; +#[derive(Deserialize)] +pub struct Account { + pub id: u64, + pub username: String, + pub acct: String, + pub display_name: String, + pub note: String, + pub url: String, + pub avatar: String, + pub header: String, + pub locked: bool, + pub created_at: DateTime, + pub followers_count: u64, + pub following_count: u64, + pub statuses_count: u64, +} diff --git a/src/entities/attachment.rs b/src/entities/attachment.rs new file mode 100644 index 0000000..1fd960b --- /dev/null +++ b/src/entities/attachment.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Clone, Deserialize)] +pub struct Attachment { + pub id: u64, + #[serde(rename="type")] + pub media_type: MediaType, + pub url: String, + pub remote_url: String, + pub preview_url: String, + pub text_url: String, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +pub enum MediaType { + #[serde(rename = "image")] + Image, + #[serde(rename = "video")] + Video, + #[serde(rename = "gifv")] + Gifv, +} diff --git a/src/entities/card.rs b/src/entities/card.rs new file mode 100644 index 0000000..ea34f70 --- /dev/null +++ b/src/entities/card.rs @@ -0,0 +1,7 @@ +#[derive(Deserialize)] +pub struct Card { + pub url: String, + pub title: String, + pub description: String, + pub image: String, +} diff --git a/src/entities/context.rs b/src/entities/context.rs new file mode 100644 index 0000000..dc8c3ce --- /dev/null +++ b/src/entities/context.rs @@ -0,0 +1,7 @@ +use super::status::Status; + +#[derive(Deserialize)] +pub struct Context { + pub ancestors: Vec, + pub descendants: Vec, +} diff --git a/src/entities/instance.rs b/src/entities/instance.rs new file mode 100644 index 0000000..5979c8d --- /dev/null +++ b/src/entities/instance.rs @@ -0,0 +1,7 @@ +#[derive(Deserialize)] +pub struct Instance { + pub uri: String, + pub title: String, + pub description: String, + pub email: String, +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs new file mode 100644 index 0000000..d914d05 --- /dev/null +++ b/src/entities/mod.rs @@ -0,0 +1,28 @@ +pub mod account; +pub mod attachment; +pub mod card; +pub mod context; +pub mod instance; +pub mod notification; +pub mod relationship; +pub mod report; +pub mod search_result; +pub mod status; + +/// An empty JSON object. +#[derive(Deserialize)] +pub struct Empty {} + +pub mod prelude { + pub use super::Empty; + pub use super::account::Account; + pub use super::attachment::{Attachment, MediaType}; + pub use super::card::Card; + pub use super::context::Context; + pub use super::instance::Instance; + 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}; +} diff --git a/src/entities/notification.rs b/src/entities/notification.rs new file mode 100644 index 0000000..bdb3ce2 --- /dev/null +++ b/src/entities/notification.rs @@ -0,0 +1,20 @@ +use chrono::prelude::*; +use super::account::Account; +use super::status::Status; + +#[derive(Deserialize)] +pub struct Notification { + pub id: u64, + pub notification_type: NotificationType, + pub created_at: DateTime, + pub account: Account, + pub status: Option, +} + +#[derive(Deserialize)] +pub enum NotificationType { + Mention, + Reblog, + Favourite, + Follow, +} diff --git a/src/entities/relationship.rs b/src/entities/relationship.rs new file mode 100644 index 0000000..24879b1 --- /dev/null +++ b/src/entities/relationship.rs @@ -0,0 +1,8 @@ +#[derive(Deserialize)] +pub struct Relationship { + pub following: bool, + pub followed_by: bool, + pub blocking: bool, + pub muting: bool, + pub requested: bool, +} diff --git a/src/entities/report.rs b/src/entities/report.rs new file mode 100644 index 0000000..a2fbdbc --- /dev/null +++ b/src/entities/report.rs @@ -0,0 +1,5 @@ +#[derive(Deserialize)] +pub struct Report { + pub id: u64, + pub action_taken: String, +} diff --git a/src/entities/search_result.rs b/src/entities/search_result.rs new file mode 100644 index 0000000..a92bd08 --- /dev/null +++ b/src/entities/search_result.rs @@ -0,0 +1,8 @@ +use super::prelude::{Account, Status}; + +#[derive(Deserialize)] +pub struct SearchResult { + pub accounts: Vec, + pub statuses: Vec, + pub hashtags: Vec, +} diff --git a/src/entities/status.rs b/src/entities/status.rs new file mode 100644 index 0000000..5d472c2 --- /dev/null +++ b/src/entities/status.rs @@ -0,0 +1,49 @@ +use chrono::prelude::*; +use super::prelude::*; +use status_builder::Visibility; + +#[derive(Deserialize)] +pub struct Status { + pub id: i64, + pub uri: String, + pub url: String, + pub account: Account, + pub in_reply_to_id: Option, + pub in_reply_to_account_id: Option, + pub reblog: Option>, + pub content: String, + pub created_at: DateTime, + pub reblogs_count: u64, + pub favourites_count: u64, + pub reblogged: bool, + pub favourited: bool, + pub sensitive: bool, + pub spoiler_text: String, + pub visibility: Visibility, + pub media_attachments: Vec, + pub mentions: Vec, + pub tags: Vec, + pub application: Application, +} + +#[derive(Deserialize)] +pub struct Mention { + pub url: String, + pub username: String, + pub acct: String, + pub id: u64, +} + +#[derive(Deserialize)] +pub struct Tag { + pub name: String, + pub url: String, +} + +#[derive(Deserialize)] +pub struct Application { + pub name: String, + pub website: String, +} + + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c0f1824 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,374 @@ +//! # Mammut: API Wrapper around the Mastodon API. +//! +//! Most of the api is documented on [Mastodon's +//! github](https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/API.md#tag) +#![deny(unused_must_use)] + +#[cfg_attr(test, deny(warnings))] + +#[macro_use] extern crate serde_derive; +#[macro_use] extern crate serde_json as json; +extern crate chrono; +extern crate reqwest; +extern crate serde; + +pub mod apps; +pub mod status_builder; +pub mod entities; + +use json::Error as SerdeError; +use reqwest::Error as HttpError; +use reqwest::Client; +use reqwest::header::{Authorization, Bearer, Headers}; + +use entities::prelude::*; +use status_builder::StatusBuilder; + +pub type Result = std::result::Result; + +macro_rules! methods { + ($($method:ident,)+) => { + $( + fn $method(&self, url: String) + -> Result + { + Ok(self.client.$method(&url) + .headers(self.access_token.clone().unwrap()) + .send()? + .json()?) + } + )+ + }; +} + +macro_rules! route { + + ((post ($($param:ident: $typ:ty,)*)) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { + /// Requires `access_token` or will return error. + pub fn $name(&self, $($param: $typ,)*) -> Result<$ret> { + self.has_access_token()?; + + let form_data = json!({ + $( + stringify!($param): $param, + )* + }); + + Ok(self.client.post(&self.route(concat!("/api/v1/", $url))) + .headers(self.access_token.clone().unwrap()) + .form(&form_data) + .send()? + .json()?) + } + route!{$($rest)*} + }; + + (($method:ident) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { + /// Requires `access_token` or will return error. + pub fn $name(&self) -> Result<$ret> { + self.has_access_token()?; + + self.$method(self.route(concat!("/api/v1/", $url))) + } + + route!{$($rest)*} + }; + + () => {} +} + +macro_rules! route_id { + + ($(($method:ident) $name:ident: $url:expr => $ret:ty,)*) => { + $( + /// Requires `access_token` or will return error. + pub fn $name(&self, id: u64) -> Result<$ret> { + self.has_access_token()?; + + + self.$method(self.route(&format!(concat!("/api/v1/", $url), id))) + } + )* + } + +} + +#[derive(Clone, Debug)] +pub struct Mastodon { + base_url: String, + client: Client, + client_id: Option, + client_secret: Option, + redirect_uri: Option, + access_token: Option, + id: Option, +} + +#[derive(Deserialize)] +struct OAuth { + client_id: String, + client_secret: String, + id: u64, + redirect_uri: String, +} + +#[derive(Debug)] +pub enum Error { + Serde(SerdeError), + Http(HttpError), + ClientIdRequired, + ClientSecretRequired, + AccessTokenRequired, +} + +impl Mastodon { + /// Inits new Mastodon object. `base_url` is expected in the following + /// format `https://mastodon.social` with no leading forward slash. + /// + /// ``` + /// use mammut::Mastodon; + /// + /// let mastodon = Mastodon::new("https://mastodon.social").unwrap(); + /// ``` + pub fn new>(base_url: I) -> Result { + Ok(Mastodon { + base_url: base_url.into(), + client: Client::new()?, + client_id: None, + client_secret: None, + redirect_uri: None, + access_token: None, + id: None, + }) + } + + /// Register the application with the server from `base_url`. + /// + /// ``` + /// # extern crate mammut; + /// # fn main() { + /// # try().unwrap(); + /// # } + /// + /// # fn try() -> mammut::Result<()> { + /// use mammut::Mastodon; + /// use mammut::apps::{AppBuilder, Scope}; + /// + /// let app = AppBuilder { + /// client_name: "mammut_test", + /// redirect_uris: "urn:ietf:wg:oauth:2.0:oob", + /// scopes: Scope::Read, + /// website: None, + /// }; + /// + /// let mut mastodon = Mastodon::new("https://mastodon.social")?; + /// mastodon.register(app)?; + /// # Ok(()) + /// # } + /// ``` + pub fn register(&mut self, app_builder: apps::AppBuilder) -> Result<()> { + let url = self.route("/api/v1/apps"); + + let app: OAuth = self.client.post(&url).form(&app_builder).send()?.json()?; + + self.id = Some(app.id); + self.client_id = Some(app.client_id); + self.client_secret = Some(app.client_secret); + self.redirect_uri = Some(app.redirect_uri); + + Ok(()) + } + + /// Returns the full url needed for authorisation. This needs to be opened + /// in a browser. + pub fn authorise(&mut self) -> Result { + self.is_registered()?; + + let url = format!( + "{}/oauth/authorize?client_id={}&redirect_uri={}&response_type=code", + self.base_url, + self.client_id.clone().unwrap(), + self.redirect_uri.clone().unwrap(), + ); + + Ok(url) + } + + /// Set `access_token` required to use any method about the user. + pub fn set_access_token(&mut self, access_token: String) { + let mut headers = Headers::new(); + + headers.set(Authorization(Bearer { token: access_token })); + + self.access_token = Some(headers); + } + + route! { + (get) verify: "accounts/verify_credentials" => Account, + (get) blocks: "blocks" => Vec, + (get) follow_requests: "follow_requests" => 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, + (post (id: u64,)) reject_follow_request: "accounts/follow_requests/reject" => Empty, + (post (uri: String,)) follows: "follows" => Account, + (post) clear_notifications: "notifications/clear" => Empty, + (post (file: Vec,)) media: "media" => Attachment, + (post (account_id: u64, status_ids: Vec, comment: String,)) report: + "reports" => Report, + (post (q: String, resolve: bool,)) search: "search" => SearchResult, + (post (status: StatusBuilder,)) new_status: "statuses" => Status, + } + + route_id! { + (get) get_account: "accounts/{}" => Account, + (get) followers: "accounts/{}/followers" => Vec, + (get) following: "accounts/{}/following" => Vec, + (get) follow: "accounts/{}/follow" => Account, + (get) unfollow: "accounts/{}/unfollow" => Account, + (get) block: "accounts/{}/block" => Account, + (get) unblock: "accounts/{}/unblock" => Account, + (get) mute: "accounts/{}/mute" => Account, + (get) unmute: "accounts/{}/unmute" => Account, + (get) get_notification: "notifications/{}" => Notification, + (get) get_status: "statuses/{}" => Status, + (get) get_context: "statuses/{}/context" => Context, + (get) get_card: "statuses/{}/card" => Card, + (get) reblogged_by: "statuses/{}/reblogged_by" => Vec, + (get) favourited_by: "statuses/{}/favourited_by" => Vec, + (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, + } + + pub fn get_public_timeline(&self, local: bool) -> Result> { + self.has_access_token()?; + + let mut url = self.route("/api/v1/timelines/public"); + + if local { + url += "?local=1"; + } + + self.get(url) + } + + pub fn get_tagged_timeline(&self, hashtag: String, local: bool) -> Result> { + self.has_access_token()?; + + let mut url = self.route("/api/v1/timelines/tag/"); + url += &hashtag; + + if local { + url += "?local=1"; + } + + self.get(url) + } + + pub fn statuses(&self, id: u64, only_media: bool, exclude_replies: bool) + -> Result> + { + self.has_access_token()?; + let mut url = format!("{}/api/v1/accounts/{}/statuses", self.base_url, id); + + if only_media { + url += "?only_media=1"; + } + + if exclude_replies { + url += if only_media { + "&" + } else { + "?" + }; + + url += "exclude_replies=1"; + } + + self.get(url) + } + + + pub fn relationships(&self, ids: &[u64]) -> Result> { + self.has_access_token()?; + + let mut url = self.route("/api/v1/accounts/relationships?"); + + if ids.len() == 1 { + url += "id="; + url += &ids[0].to_string(); + } else { + for id in ids { + url += "id[]="; + url += &id.to_string(); + url += "&"; + } + url.pop(); + } + + self.get(url) + } + + // TODO: Add a limit fn + pub fn search_accounts(&self, query: &str) -> Result> { + self.has_access_token()?; + self.get(format!("{}/api/v1/accounts/search?q={}", self.base_url, query)) + } + + pub fn instance(&self) -> Result { + self.is_registered()?; + + self.get(self.route("/api/v1/instance")) + } + + + fn has_access_token(&self) -> Result<()> { + if self.access_token.is_none() { + Err(Error::AccessTokenRequired) + } else { + Ok(()) + } + } + + fn is_registered(&self) -> Result<()> { + if self.client_id.is_none() { + Err(Error::ClientIdRequired) + } else if self.client_secret.is_none() { + Err(Error::ClientSecretRequired) + } else { + Ok(()) + } + } + + methods![get, post, delete,]; + + fn route(&self, url: &str) -> String { + let mut s = self.base_url.clone(); + s += url; + s + } +} + + +macro_rules! from { + ($($typ:ident, $variant:ident,)*) => { + $( + impl From<$typ> for Error { + fn from(from: $typ) -> Self { + use Error::*; + $variant(from) + } + } + )* + } +} + +from! { + SerdeError, Serde, + HttpError, Http, +} diff --git a/src/status_builder.rs b/src/status_builder.rs new file mode 100644 index 0000000..054c12f --- /dev/null +++ b/src/status_builder.rs @@ -0,0 +1,42 @@ +#[derive(Debug, Default, Clone, Serialize)] +pub struct StatusBuilder { + status: String, + #[serde(skip_serializing_if="Option::is_none")] + in_reply_to_id: Option, + #[serde(skip_serializing_if="Option::is_none")] + media_ids: Option>, + #[serde(skip_serializing_if="Option::is_none")] + sensitive: Option, + #[serde(skip_serializing_if="Option::is_none")] + spoiler_text: Option, + #[serde(skip_serializing_if="Option::is_none")] + visibility: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum Visibility { + #[serde(rename = "direct")] + Direct, + #[serde(rename = "private")] + Private, + #[serde(rename = "unlisted")] + Unlisted, + #[serde(rename = "public")] + Public, +} + +impl StatusBuilder { + + pub fn new(status: String) -> Self { + StatusBuilder { + status: status, + ..Self::default() + } + } +} + +impl Default for Visibility { + fn default() -> Self { + Visibility::Public + } +}