├── .env.sample ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Cargo.toml ├── LICENCE-APACHE ├── LICENCE-MIT ├── README.md ├── examples ├── follow_profile.rs ├── print_your_profile.rs ├── register.rs ├── search.rs └── upload_photo.rs └── src ├── apps.rs ├── entities ├── account.rs ├── attachment.rs ├── card.rs ├── context.rs ├── instance.rs ├── itemsiter.rs ├── list.rs ├── mention.rs ├── mod.rs ├── notification.rs ├── relationship.rs ├── report.rs ├── search_result.rs └── status.rs ├── lib.rs ├── media_builder.rs ├── page.rs ├── registration.rs └── status_builder.rs /.env.sample: -------------------------------------------------------------------------------- 1 | export TOKEN='snakeoil' 2 | export CLIENT_ID='' 3 | export CLIENT_SECRET='' 4 | export REDIRECT='' 5 | export BASE='https://mastodon.social' 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | .env 4 | mastodon-data.toml 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - beta 5 | - nightly 6 | matrix: 7 | allow_failures: 8 | - rust: nightly 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.13 2 | - Added `media` endpoint and `MediaBuilder` to enable media uploads. By @klausi 3 | - Changed `StatusBuilder`'s ID type to be `String`. 4 | 5 | # 0.12 6 | - Added the `StatusesRequest` struct. 7 | - `Mastodon::statuses` now takes an id and a `StatusesRequest` 8 | - Documentation should be properly formatted. 9 | - Added the Page iterator. 10 | - Updated reqwest to 0.9 11 | - Fixed various codegen bugs. 12 | 13 | # 0.11 14 | - Added more examples to `examples` directory. 15 | - Fixed `follow` and `unfollow` routes. 16 | - Updated `moved` field to be `Box`. 17 | 18 | # 0.10 19 | 20 | - Added the ability to handle paged entities like favourites and such.(Only favourites in prerelease) 21 | - Added optional `source` and `moved` fields to `Account`. 22 | - Added `Source` struct to match with the `Account.source` field. 23 | - Added `CredientialsBuilder` struct for updating profile using 24 | `verify_credientials`. 25 | - Attachment now handles being sent an empty object, which is converted 26 | to `None`. 27 | - Added ombed data fields to `Card`. 28 | - Added `version` and `urls` fields to `Instance`. 29 | - Added `id`, `muting_notifications`, and `domain_blocking` to `Relationship`. 30 | - Added `emojis`, `language`, and `pinned` fields to `Status` 31 | - Added `Emoji` struct. 32 | - Added `List` and `Mention` structs(matching routes not added yet). 33 | - Added example that prints your profile. 34 | - Updated dependencies 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mammut" 3 | version = "0.13.0" 4 | edition = "2018" 5 | 6 | description = "A wrapper around the Mastodon API." 7 | authors = ["Aaron Power "] 8 | license = "MIT/Apache-2.0" 9 | readme = "README.md" 10 | repository = "https://github.com/Aaronepower/mammut.git" 11 | keywords = ["api", "web", "social", "mastodon", "wrapper"] 12 | categories = ["web-programming", "http-client"] 13 | 14 | [dependencies] 15 | doc-comment = "0.3" 16 | reqwest = "0.9" 17 | hyperx = "0.15" 18 | serde = "1" 19 | serde_derive = "1" 20 | serde_json = "1" 21 | url = "1" 22 | log = "0.4.6" 23 | 24 | [dependencies.chrono] 25 | version = "0.4" 26 | features = ["serde"] 27 | 28 | [dev-dependencies] 29 | toml = "0.5" 30 | -------------------------------------------------------------------------------- /LICENCE-APACHE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Aaron Power 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /LICENCE-MIT: -------------------------------------------------------------------------------- 1 | MIT License (MIT) 2 | 3 | Copyright (c) 2016 Aaron Power 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mammut. A API Wrapper for the Mastodon API. 2 | 3 | This project is no longer maintained. For a more updated version see [elefren](https://github.com/pwoolcoc/elefren). 4 | 5 | [![crates.io](https://img.shields.io/crates/v/mammut.svg)](https://crates.io/crates/mammut) 6 | [![Docs](https://docs.rs/mammut/badge.svg)](https://docs.rs/mammut) 7 | [![MIT/APACHE-2.0](https://img.shields.io/crates/l/mammut.svg)](https://crates.io/crates/mammut) 8 | 70 | -------------------------------------------------------------------------------- /examples/follow_profile.rs: -------------------------------------------------------------------------------- 1 | mod register; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mastodon = register::get_mastodon_data()?; 5 | let input = register::read_line("Enter the account id you'd like to follow: ")?; 6 | let new_follow = mastodon.follow(input.trim())?; 7 | 8 | println!("{:#?}", new_follow); 9 | 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /examples/print_your_profile.rs: -------------------------------------------------------------------------------- 1 | mod register; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mastodon = register::get_mastodon_data()?; 5 | let you = mastodon.verify_credentials()?; 6 | 7 | println!("{:#?}", you); 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /examples/register.rs: -------------------------------------------------------------------------------- 1 | extern crate mammut; 2 | extern crate toml; 3 | 4 | use std::{error::Error, fs, io}; 5 | 6 | use self::mammut::{ 7 | apps::{AppBuilder, Scopes}, 8 | Mastodon, Registration, 9 | }; 10 | 11 | #[allow(dead_code)] 12 | fn main() -> Result<(), Box> { 13 | register()?; 14 | Ok(()) 15 | } 16 | 17 | #[allow(dead_code)] 18 | pub fn get_mastodon_data() -> Result> { 19 | if let Ok(config) = fs::read_to_string("mastodon-data.toml") { 20 | Ok(Mastodon::from_data(toml::from_str(&config)?)) 21 | } else { 22 | register() 23 | } 24 | } 25 | 26 | pub fn register() -> Result> { 27 | let app = AppBuilder { 28 | client_name: "mammut-examples", 29 | redirect_uris: "urn:ietf:wg:oauth:2.0:oob", 30 | scopes: Scopes::All, 31 | website: Some("https://github.com/Aaronepower/mammut"), 32 | }; 33 | 34 | let website = read_line("Please enter your mastodon instance url:")?; 35 | let mut registration = Registration::new(website.trim()); 36 | registration.register(app)?; 37 | let url = registration.authorise()?; 38 | 39 | println!("Click this link to authorize on Mastodon: {}", url); 40 | let input = read_line("Paste the returned authorization code: ")?; 41 | 42 | let code = input.trim(); 43 | let mastodon = registration.create_access_token(code.to_string())?; 44 | 45 | // Save app data for using on the next run. 46 | let toml = toml::to_string(&*mastodon)?; 47 | fs::write("mastodon-data.toml", toml.as_bytes())?; 48 | 49 | Ok(mastodon) 50 | } 51 | 52 | pub fn read_line(message: &str) -> Result> { 53 | println!("{}", message); 54 | 55 | let mut input = String::new(); 56 | io::stdin().read_line(&mut input)?; 57 | 58 | Ok(input) 59 | } 60 | -------------------------------------------------------------------------------- /examples/search.rs: -------------------------------------------------------------------------------- 1 | mod register; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mastodon = register::get_mastodon_data()?; 5 | let input = register::read_line("Enter the term you'd like to search: ")?; 6 | let result = mastodon.search_accounts(&input, None, true)?; 7 | 8 | println!("{:#?}", result.initial_items); 9 | 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /examples/upload_photo.rs: -------------------------------------------------------------------------------- 1 | mod register; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mastodon = register::get_mastodon_data()?; 5 | let input = register::read_line("Enter the path to the photo you'd like to post: ")?; 6 | 7 | mastodon.media(input.into())?; 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /src/apps.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | /// Builder struct for defining your application. 4 | /// ``` 5 | /// use mammut::apps::{AppBuilder, Scopes}; 6 | /// 7 | /// let app = AppBuilder { 8 | /// client_name: "mammut_test", 9 | /// redirect_uris: "urn:ietf:wg:oauth:2.0:oob", 10 | /// scopes: Scopes::Read, 11 | /// website: None, 12 | /// }; 13 | /// ``` 14 | #[derive(Debug, Default, Serialize)] 15 | pub struct AppBuilder<'a> { 16 | /// Name of the application. Will be displayed when the user is deciding to 17 | /// grant permission. 18 | pub client_name: &'a str, 19 | /// Where the user should be redirected after authorization 20 | /// (for no redirect, use `urn:ietf:wg:oauth:2.0:oob`) 21 | pub redirect_uris: &'a str, 22 | /// Permission scope of the application. 23 | pub scopes: Scopes, 24 | /// URL to the homepage of your application. 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | pub website: Option<&'a str>, 27 | } 28 | 29 | /// Permission scope of the application. 30 | /// [Details on what each permission provides](//github.com/tootsuite/documentation/blob/master/Using-the-API/OAuth-details.md) 31 | #[derive(Debug, Clone, Copy, Serialize)] 32 | pub enum Scopes { 33 | /// All Permissions, equivalent to `read write follow` 34 | #[serde(rename = "read write follow")] 35 | All, 36 | /// Only permission to add and remove followers. 37 | #[serde(rename = "follow")] 38 | Follow, 39 | /// Read only permissions. 40 | #[serde(rename = "read")] 41 | Read, 42 | /// Read & Follow permissions. 43 | #[serde(rename = "read follow")] 44 | ReadFollow, 45 | /// Read & Write permissions. 46 | #[serde(rename = "read write")] 47 | ReadWrite, 48 | /// Write only permissions. 49 | #[serde(rename = "write")] 50 | Write, 51 | /// Write & Follow permissions. 52 | #[serde(rename = "write follow")] 53 | WriteFollow, 54 | } 55 | 56 | impl fmt::Display for Scopes { 57 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 58 | use self::Scopes::*; 59 | write!( 60 | f, 61 | "{}", 62 | match *self { 63 | All => "read%20write%20follow", 64 | Follow => "follow", 65 | Read => "read", 66 | ReadFollow => "read%20follow", 67 | ReadWrite => "read%20write", 68 | Write => "write", 69 | WriteFollow => "write%20follow", 70 | } 71 | ) 72 | } 73 | } 74 | 75 | impl Default for Scopes { 76 | fn default() -> Self { 77 | Scopes::Read 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/entities/account.rs: -------------------------------------------------------------------------------- 1 | //! A module containing everything relating to a account returned from the api. 2 | 3 | use std::path::Path; 4 | 5 | use chrono::prelude::*; 6 | use reqwest::multipart::Form; 7 | 8 | use crate::Result; 9 | 10 | /// A struct representing an Account. 11 | #[derive(Debug, Clone, Deserialize)] 12 | pub struct Account { 13 | /// Equals `username` for local users, includes `@domain` for remote ones. 14 | pub acct: String, 15 | /// URL to the avatar image 16 | pub avatar: String, 17 | /// URL to the avatar static image (gif) 18 | pub avatar_static: String, 19 | /// The time the account was created. 20 | pub created_at: DateTime, 21 | /// The account's display name. 22 | pub display_name: String, 23 | /// The number of followers for the account. 24 | pub followers_count: u64, 25 | /// The number of accounts the given account is following. 26 | pub following_count: u64, 27 | /// URL to the header image. 28 | pub header: String, 29 | /// URL to the header static image (gif). 30 | pub header_static: String, 31 | /// The ID of the account. 32 | pub id: String, 33 | /// Boolean for when the account cannot be followed without waiting for 34 | /// approval first. 35 | pub locked: bool, 36 | /// Biography of user. 37 | pub note: String, 38 | /// The number of statuses the account has made. 39 | pub statuses_count: u64, 40 | /// URL of the user's profile page (can be remote). 41 | pub url: String, 42 | /// The username of the account. 43 | pub username: String, 44 | /// An extra attribute given from `verify_credentials` giving defaults about 45 | /// a user 46 | pub source: Option, 47 | /// If the owner decided to switch accounts, new account is in 48 | /// this attribute 49 | pub moved: Option>, 50 | } 51 | 52 | /// An extra object given from `verify_credentials` giving defaults about a user 53 | #[derive(Debug, Clone, Deserialize)] 54 | pub struct Source { 55 | privacy: crate::status_builder::Visibility, 56 | sensitive: bool, 57 | note: String, 58 | } 59 | 60 | pub struct CredientialsBuilder<'a> { 61 | display_name: Option<&'a str>, 62 | note: Option<&'a str>, 63 | avatar: Option<&'a Path>, 64 | header: Option<&'a Path>, 65 | } 66 | 67 | impl<'a> CredientialsBuilder<'a> { 68 | pub fn into_form(self) -> Result
{ 69 | let mut form = Form::new(); 70 | macro_rules! add_to_form { 71 | ($key:ident : Text; $($rest:tt)*) => {{ 72 | if let Some(val) = self.$key { 73 | form = form.text(stringify!($key), val.to_owned()); 74 | } 75 | 76 | add_to_form!{$($rest)*} 77 | }}; 78 | 79 | ($key:ident : File; $($rest:tt)*) => {{ 80 | if let Some(val) = self.$key { 81 | form = form.file(stringify!($key), val)?; 82 | } 83 | 84 | add_to_form!{$($rest)*} 85 | }}; 86 | 87 | () => {} 88 | } 89 | 90 | add_to_form! { 91 | display_name: Text; 92 | note: Text; 93 | avatar: File; 94 | header: File; 95 | } 96 | 97 | Ok(form) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/entities/attachment.rs: -------------------------------------------------------------------------------- 1 | //! Module containing everything related to media attachements. 2 | use super::Empty; 3 | use serde::{Deserialize, Deserializer}; 4 | 5 | /// A struct representing a media attachment. 6 | #[derive(Debug, Clone, Deserialize)] 7 | pub struct Attachment { 8 | /// ID of the attachment. 9 | pub id: String, 10 | /// The media type of an attachment. 11 | #[serde(rename = "type")] 12 | pub media_type: MediaType, 13 | /// URL of the locally hosted version of the image. 14 | pub url: String, 15 | /// For remote images, the remote URL of the original image. 16 | pub remote_url: Option, 17 | /// URL of the preview image. 18 | pub preview_url: String, 19 | /// Shorter URL for the image, for insertion into text 20 | /// (only present on local images) 21 | pub text_url: Option, 22 | /// Meta information about the attachment. 23 | #[serde(deserialize_with = "empty_as_none")] 24 | pub meta: Option, 25 | /// Noop will be removed. 26 | pub description: Option, 27 | } 28 | 29 | fn empty_as_none<'de, D: Deserializer<'de>>(val: D) -> Result, D::Error> { 30 | #[derive(Deserialize)] 31 | #[serde(untagged)] 32 | enum EmptyOrMeta { 33 | Empty(Empty), 34 | Meta(Meta), 35 | } 36 | 37 | Ok(match EmptyOrMeta::deserialize(val)? { 38 | EmptyOrMeta::Empty(_) => None, 39 | EmptyOrMeta::Meta(m) => Some(m), 40 | }) 41 | } 42 | 43 | /// Information about the attachment itself. 44 | #[derive(Debug, Deserialize, Clone)] 45 | pub struct Meta { 46 | /// Original version. 47 | original: ImageDetails, 48 | /// Smaller version. 49 | small: ImageDetails, 50 | } 51 | 52 | /// Dimensions of an attachement. 53 | #[derive(Debug, Deserialize, Clone)] 54 | pub struct ImageDetails { 55 | /// width of attachment. 56 | width: u64, 57 | /// height of attachment. 58 | height: u64, 59 | /// A string of `widthxheight`. 60 | size: String, 61 | /// The aspect ratio of the attachment. 62 | aspect: f64, 63 | } 64 | 65 | /// The type of media attachment. 66 | #[derive(Debug, Deserialize, Clone, Copy)] 67 | pub enum MediaType { 68 | /// An image. 69 | #[serde(rename = "image")] 70 | Image, 71 | /// A video file. 72 | #[serde(rename = "video")] 73 | Video, 74 | /// A gifv format file. 75 | #[serde(rename = "gifv")] 76 | Gifv, 77 | /// Unknown format. 78 | #[serde(rename = "unknown")] 79 | Unknown, 80 | } 81 | -------------------------------------------------------------------------------- /src/entities/card.rs: -------------------------------------------------------------------------------- 1 | //! Module representing cards of statuses. 2 | 3 | /// A card of a status. 4 | #[derive(Debug, Clone, Deserialize)] 5 | pub struct Card { 6 | /// The url associated with the card. 7 | pub url: String, 8 | /// The title of the card. 9 | pub title: String, 10 | /// The card description. 11 | pub description: String, 12 | /// The image associated with the card, if any. 13 | pub image: Option, 14 | /// OEmbed data 15 | author_name: Option, 16 | /// OEmbed data 17 | author_url: Option, 18 | /// OEmbed data 19 | provider_name: Option, 20 | /// OEmbed data 21 | provider_url: Option, 22 | /// OEmbed data 23 | html: Option, 24 | /// OEmbed data 25 | width: Option, 26 | /// OEmbed data 27 | height: Option, 28 | } 29 | -------------------------------------------------------------------------------- /src/entities/context.rs: -------------------------------------------------------------------------------- 1 | //! A module about contexts of statuses. 2 | 3 | use super::status::Status; 4 | 5 | /// A context of a status returning a list of statuses it replied to and 6 | /// statuses replied to it. 7 | #[derive(Debug, Clone, Deserialize)] 8 | pub struct Context { 9 | /// Statuses that were replied to. 10 | pub ancestors: Vec, 11 | /// Statuses that replied to this status. 12 | pub descendants: Vec, 13 | } 14 | -------------------------------------------------------------------------------- /src/entities/instance.rs: -------------------------------------------------------------------------------- 1 | //! Module containing everything related to an instance. 2 | use super::account::Account; 3 | 4 | /// A struct containing info of an instance. 5 | #[derive(Debug, Clone, Deserialize)] 6 | pub struct Instance { 7 | /// URI of the current instance 8 | pub uri: String, 9 | /// The instance's title. 10 | pub title: String, 11 | /// A description for the instance. 12 | pub description: String, 13 | /// An email address which can be used to contact the 14 | /// instance administrator. 15 | pub email: String, 16 | /// The Mastodon version used by instance. 17 | pub version: String, 18 | /// Urls to the streaming api. 19 | pub urls: Option, 20 | /// Stats about the instance. 21 | pub stats: Option, 22 | /// Thumbnail of the server image. 23 | pub thumbnail: Option, 24 | /// List of languages used on the server. 25 | pub languages: Option>, 26 | /// Contact account for the server. 27 | pub contact_account: Option, 28 | } 29 | 30 | /// Object containing url for streaming api. 31 | #[derive(Debug, Clone, Deserialize)] 32 | pub struct StreamingApi { 33 | /// Url for streaming API, typically a `wss://` url. 34 | pub streaming_api: String, 35 | } 36 | 37 | /// Statistics about the Mastodon instance. 38 | #[derive(Debug, Clone, Deserialize)] 39 | pub struct Stats { 40 | user_count: u64, 41 | status_count: u64, 42 | domain_count: u64, 43 | } 44 | -------------------------------------------------------------------------------- /src/entities/itemsiter.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::page::Page; 4 | 5 | /// Abstracts away the `next_page` logic into a single stream of items 6 | /// 7 | /// ```no_run 8 | /// # extern crate mammut; 9 | /// # use mammut::{Data, Mastodon}; 10 | /// # use std::error::Error; 11 | /// # fn main() -> Result<(), Box> { 12 | /// # let data = Data { 13 | /// # base: "".into(), 14 | /// # client_id: "".into(), 15 | /// # client_secret: "".into(), 16 | /// # redirect: "".into(), 17 | /// # token: "".into(), 18 | /// # }; 19 | /// let client = Mastodon::from_data(data); 20 | /// let statuses = client.statuses("user-id", None)?; 21 | /// for status in statuses.items_iter() { 22 | /// // do something with `status` 23 | /// } 24 | /// # Ok(()) 25 | /// # } 26 | /// ``` 27 | pub(crate) struct ItemsIter<'a, T: Clone + for<'de> Deserialize<'de>> { 28 | page: Page<'a, T>, 29 | buffer: Vec, 30 | cur_idx: usize, 31 | use_initial: bool, 32 | } 33 | 34 | impl<'a, T: Clone + for<'de> Deserialize<'de>> ItemsIter<'a, T> { 35 | pub(crate) fn new(page: Page<'a, T>) -> ItemsIter<'a, T> { 36 | ItemsIter { 37 | page: page, 38 | buffer: vec![], 39 | cur_idx: 0, 40 | use_initial: true, 41 | } 42 | } 43 | 44 | fn need_next_page(&self) -> bool { 45 | self.buffer.is_empty() || self.cur_idx == self.buffer.len() 46 | } 47 | 48 | fn fill_next_page(&mut self) -> Option<()> { 49 | let items = if let Ok(items) = self.page.next_page() { 50 | items 51 | } else { 52 | return None; 53 | }; 54 | if let Some(items) = items { 55 | self.buffer = items; 56 | self.cur_idx = 0; 57 | Some(()) 58 | } else { 59 | None 60 | } 61 | } 62 | } 63 | 64 | impl<'a, T: Clone + for<'de> Deserialize<'de>> Iterator for ItemsIter<'a, T> { 65 | type Item = T; 66 | 67 | fn next(&mut self) -> Option { 68 | if self.use_initial { 69 | if self.page.initial_items.is_empty() { 70 | return None; 71 | } 72 | let idx = self.cur_idx; 73 | if self.cur_idx == self.page.initial_items.len() - 1 { 74 | self.cur_idx = 0; 75 | self.use_initial = false; 76 | } else { 77 | self.cur_idx += 1; 78 | } 79 | Some(self.page.initial_items[idx].clone()) 80 | } else { 81 | if self.need_next_page() { 82 | if self.fill_next_page().is_none() { 83 | return None; 84 | } 85 | } 86 | let idx = self.cur_idx; 87 | self.cur_idx += 1; 88 | Some(self.buffer[idx].clone()) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/entities/list.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug, Deserialize)] 2 | pub struct List { 3 | id: String, 4 | title: String, 5 | } 6 | -------------------------------------------------------------------------------- /src/entities/mention.rs: -------------------------------------------------------------------------------- 1 | pub struct Mention { 2 | /// URL of user's profile (can be remote) 3 | pub url: String, 4 | /// The username of the account 5 | pub username: String, 6 | /// Equals username for local users, includes `@domain` for remote ones 7 | pub acct: String, 8 | /// Account ID 9 | pub id: String, 10 | } 11 | -------------------------------------------------------------------------------- /src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod attachment; 3 | pub mod card; 4 | pub mod context; 5 | pub mod instance; 6 | pub(crate) mod itemsiter; 7 | pub mod list; 8 | pub mod mention; 9 | pub mod notification; 10 | pub mod relationship; 11 | pub mod report; 12 | pub mod search_result; 13 | pub mod status; 14 | 15 | /// An empty JSON object. 16 | #[derive(Deserialize)] 17 | pub struct Empty {} 18 | 19 | pub mod prelude { 20 | //! The purpose of this module is to alleviate imports of many common structs 21 | //! by adding a glob import to the top of mastodon heavy modules: 22 | pub use super::account::{Account, CredientialsBuilder, Source}; 23 | pub use super::attachment::{Attachment, MediaType}; 24 | pub use super::card::Card; 25 | pub use super::context::Context; 26 | pub use super::instance::*; 27 | pub use super::list::List; 28 | pub use super::mention::Mention; 29 | pub use super::notification::Notification; 30 | pub use super::relationship::Relationship; 31 | pub use super::report::Report; 32 | pub use super::search_result::SearchResult; 33 | pub use super::status::{Application, Emoji, Status}; 34 | pub use super::Empty; 35 | } 36 | -------------------------------------------------------------------------------- /src/entities/notification.rs: -------------------------------------------------------------------------------- 1 | //! Module containing all info about notifications. 2 | 3 | use super::account::Account; 4 | use super::status::Status; 5 | use chrono::prelude::*; 6 | 7 | /// A struct containing info about a notification. 8 | #[derive(Debug, Clone, Deserialize)] 9 | pub struct Notification { 10 | /// The notification ID. 11 | pub id: String, 12 | /// The type of notification. 13 | #[serde(rename = "type")] 14 | pub notification_type: NotificationType, 15 | /// The time the notification was created. 16 | pub created_at: DateTime, 17 | /// The Account sending the notification to the user. 18 | pub account: Account, 19 | /// The Status associated with the notification, if applicable. 20 | pub status: Option, 21 | } 22 | 23 | /// The type of notification. 24 | #[derive(Debug, Clone, Deserialize)] 25 | pub enum NotificationType { 26 | /// Someone mentioned the application client in another status. 27 | #[serde(rename = "mention")] 28 | Mention, 29 | /// Someone reblogged one of the application client's statuses. 30 | #[serde(rename = "reblog")] 31 | Reblog, 32 | /// Someone favourited one of the application client's statuses. 33 | #[serde(rename = "favourite")] 34 | Favourite, 35 | /// Someone followed the application client. 36 | #[serde(rename = "follow")] 37 | Follow, 38 | } 39 | -------------------------------------------------------------------------------- /src/entities/relationship.rs: -------------------------------------------------------------------------------- 1 | //! module containing everything relating to a relationship with 2 | //! another account. 3 | 4 | /// A struct containing information about a relationship with another account. 5 | #[derive(Debug, Clone, Deserialize)] 6 | pub struct Relationship { 7 | /// Target account id 8 | pub id: String, 9 | /// Whether the application client follows the account. 10 | pub following: bool, 11 | /// Whether the account follows the application client. 12 | pub followed_by: bool, 13 | /// Whether the application client blocks the account. 14 | pub blocking: bool, 15 | /// Whether the application client blocks the account. 16 | pub muting: bool, 17 | /// Whether the application client has requested to follow the account. 18 | pub requested: bool, 19 | /// Whether the user is also muting notifications 20 | pub muting_notifications: bool, 21 | /// Whether the user is currently blocking the accounts's domain 22 | pub domain_blocking: bool, 23 | } 24 | -------------------------------------------------------------------------------- /src/entities/report.rs: -------------------------------------------------------------------------------- 1 | //! module containing information about a finished report of a user. 2 | 3 | /// A struct containing info about a report. 4 | #[derive(Debug, Clone, Deserialize)] 5 | pub struct Report { 6 | /// The ID of the report. 7 | pub id: String, 8 | /// The action taken in response to the report. 9 | pub action_taken: String, 10 | } 11 | -------------------------------------------------------------------------------- /src/entities/search_result.rs: -------------------------------------------------------------------------------- 1 | //! A module containing info relating to a search result. 2 | 3 | use super::prelude::{Account, Status}; 4 | 5 | /// A struct containing results of a search. 6 | #[derive(Debug, Clone, Deserialize)] 7 | pub struct SearchResult { 8 | /// An array of matched Accounts. 9 | pub accounts: Vec, 10 | /// An array of matched Statuses. 11 | pub statuses: Vec, 12 | /// An array of matched hashtags, as strings. 13 | pub hashtags: Vec, 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/status.rs: -------------------------------------------------------------------------------- 1 | //! Module containing all info relating to a status. 2 | 3 | use super::prelude::*; 4 | use crate::status_builder::Visibility; 5 | use chrono::prelude::*; 6 | 7 | /// A status from the instance. 8 | #[derive(Debug, Clone, Deserialize)] 9 | pub struct Status { 10 | /// The ID of the status. 11 | pub id: String, 12 | /// A Fediverse-unique resource ID. 13 | pub uri: String, 14 | /// URL to the status page (can be remote) 15 | pub url: Option, 16 | /// The Account which posted the status. 17 | pub account: Account, 18 | /// The ID of the status this status is replying to, if the status is 19 | /// a reply. 20 | pub in_reply_to_id: Option, 21 | /// The ID of the account this status is replying to, if the status is 22 | /// a reply. 23 | pub in_reply_to_account_id: Option, 24 | /// If this status is a reblogged Status of another User. 25 | pub reblog: Option>, 26 | /// Body of the status; this will contain HTML 27 | /// (remote HTML already sanitized) 28 | pub content: String, 29 | /// The time the status was created. 30 | pub created_at: DateTime, 31 | /// An array of Emoji 32 | pub emojis: Vec, 33 | /// The number of reblogs for the status. 34 | pub reblogs_count: u64, 35 | /// The number of favourites for the status. 36 | pub favourites_count: u64, 37 | /// Whether the application client has reblogged the status. 38 | pub reblogged: Option, 39 | /// Whether the application client has favourited the status. 40 | pub favourited: Option, 41 | /// Whether media attachments should be hidden by default. 42 | pub sensitive: bool, 43 | /// If not empty, warning text that should be displayed before the actual 44 | /// content. 45 | pub spoiler_text: String, 46 | /// The visibilty of the status. 47 | pub visibility: Visibility, 48 | /// An array of attachments. 49 | pub media_attachments: Vec, 50 | /// An array of mentions. 51 | pub mentions: Vec, 52 | /// An array of tags. 53 | pub tags: Vec, 54 | /// Name of application used to post status. 55 | pub application: Option, 56 | /// The detected language for the status, if detected. 57 | pub language: Option, 58 | /// Whether this is the pinned status for the account that posted it. 59 | pub pinned: Option, 60 | } 61 | 62 | /// A mention of another user. 63 | #[derive(Debug, Clone, Deserialize)] 64 | pub struct Mention { 65 | /// URL of user's profile (can be remote). 66 | pub url: String, 67 | /// The username of the account. 68 | pub username: String, 69 | /// Equals `username` for local users, includes `@domain` for remote ones. 70 | pub acct: String, 71 | /// Account ID. 72 | pub id: String, 73 | } 74 | 75 | /// Struct representing an emoji within text. 76 | #[derive(Clone, Debug, Deserialize)] 77 | pub struct Emoji { 78 | /// The shortcode of the emoji 79 | pub shortcode: String, 80 | /// URL to the emoji static image 81 | pub static_url: String, 82 | /// URL to the emoji image 83 | pub url: String, 84 | } 85 | 86 | /// Hashtags in the status. 87 | #[derive(Debug, Clone, Deserialize)] 88 | pub struct Tag { 89 | /// The hashtag, not including the preceding `#`. 90 | pub name: String, 91 | /// The URL of the hashtag. 92 | pub url: String, 93 | } 94 | 95 | /// Application details. 96 | #[derive(Debug, Clone, Deserialize)] 97 | pub struct Application { 98 | /// Name of the application. 99 | pub name: String, 100 | /// Homepage URL of the application. 101 | pub website: Option, 102 | } 103 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Mammut: API Wrapper around the Mastodon API. 2 | //! 3 | //! Most of the api is documented on [Mastodon's 4 | //! github](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) 5 | //! 6 | //! ```no_run 7 | //! # extern crate mammut; 8 | //! # fn main() -> Result<(), Box> { 9 | //! use mammut::Registration; 10 | //! use mammut::apps::{AppBuilder, Scopes}; 11 | //! 12 | //! let app = AppBuilder { 13 | //! client_name: "mammut_test", 14 | //! redirect_uris: "urn:ietf:wg:oauth:2.0:oob", 15 | //! scopes: Scopes::Read, 16 | //! website: None, 17 | //! }; 18 | //! 19 | //! let mut registration = Registration::new("https://mastodon.social"); 20 | //! registration.register(app)?; 21 | //! let url = registration.authorise()?; 22 | //! // Here you now need to open the url in the browser 23 | //! // And handle a the redirect url coming back with the code. 24 | //! let code = String::from("RETURNED_FROM_BROWSER"); 25 | //! let mastodon = registration.create_access_token(code)?; 26 | //! 27 | //! println!("{:?}", mastodon.get_home_timeline()?.initial_items); 28 | //! # Ok(()) 29 | //! # } 30 | //! ``` 31 | 32 | #![cfg_attr(test, deny(warnings))] 33 | #![cfg_attr(test, deny(missing_docs))] 34 | 35 | #[macro_use] 36 | extern crate serde_derive; 37 | #[macro_use] 38 | extern crate doc_comment; 39 | #[macro_use] 40 | extern crate serde_json as json; 41 | 42 | /// Registering your App 43 | pub mod apps; 44 | /// Entities returned from the API 45 | pub mod entities; 46 | /// Constructing media attachments for a status. 47 | pub mod media_builder; 48 | /// Handling multiple pages of entities. 49 | pub mod page; 50 | /// Registering your app. 51 | pub mod registration; 52 | /// Constructing a status 53 | pub mod status_builder; 54 | 55 | use std::borrow::Cow; 56 | use std::error::Error as StdError; 57 | use std::fmt; 58 | use std::io::Error as IoError; 59 | use std::ops; 60 | 61 | use hyperx::Error as HyperxError; 62 | use json::Error as SerdeError; 63 | use log::debug; 64 | use reqwest::header::ToStrError as HeaderToStrError; 65 | use reqwest::header::{self, HeaderMap, HeaderValue}; 66 | use reqwest::Error as HttpError; 67 | use reqwest::{Client, Response, StatusCode}; 68 | use url::ParseError as UrlError; 69 | 70 | use entities::prelude::*; 71 | pub use media_builder::MediaBuilder; 72 | use page::Page; 73 | pub use status_builder::StatusBuilder; 74 | 75 | pub use registration::Registration; 76 | /// Convience type over `std::result::Result` with `Error` as the error type. 77 | pub type Result = std::result::Result; 78 | 79 | macro_rules! methods { 80 | ($($method:ident,)+) => { 81 | $( 82 | fn $method serde::Deserialize<'de>>(&self, url: String) 83 | -> Result 84 | { 85 | let request = self.client.$method(&url) 86 | .headers(self.headers.clone()); 87 | debug!("REQUEST: {:?}", request); 88 | 89 | let response = request.send()?; 90 | debug!("RESPONSE: {:?}", response); 91 | 92 | deserialise(response) 93 | } 94 | )+ 95 | }; 96 | } 97 | 98 | macro_rules! paged_routes { 99 | 100 | (($method:ident) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { 101 | doc_comment! { 102 | concat!( 103 | "Equivalent to `/api/v1/", 104 | $url, 105 | "`\n# Errors\nIf `access_token` is not set."), 106 | pub fn $name(&self) -> Result> { 107 | let url = self.route(concat!("/api/v1/", $url)); 108 | let response = self.client.$method(&url) 109 | .headers(self.headers.clone()) 110 | .send()?; 111 | 112 | Page::new(self, response) 113 | } 114 | 115 | } 116 | 117 | paged_routes!{$($rest)*} 118 | }; 119 | 120 | () => {} 121 | } 122 | 123 | macro_rules! route { 124 | 125 | (($method:ident ($($param:ident: $typ:ty,)*)) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { 126 | doc_comment! { 127 | concat!( 128 | "Equivalent to `/api/v1/", 129 | $url, 130 | "`\n# Errors\nIf `access_token` is not set."), 131 | 132 | pub fn $name(&self, $($param: $typ,)*) -> Result<$ret> { 133 | 134 | let form_data = json!({ 135 | $( 136 | stringify!($param): $param, 137 | )* 138 | }); 139 | 140 | let response = self.client.$method(&self.route(concat!("/api/v1/", $url))) 141 | .headers(self.headers.clone()) 142 | .json(&form_data) 143 | .send()?; 144 | 145 | let status = response.status().clone(); 146 | 147 | if status.is_client_error() { 148 | return Err(Error::Client(status)); 149 | } else if status.is_server_error() { 150 | return Err(Error::Server(status)); 151 | } 152 | 153 | deserialise(response) 154 | } 155 | } 156 | 157 | route!{$($rest)*} 158 | }; 159 | 160 | (($method:ident) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { 161 | doc_comment! { 162 | concat!( 163 | "Equivalent to `/api/v1/", 164 | $url, 165 | "`\n# Errors\nIf `access_token` is not set."), 166 | pub fn $name(&self) -> Result<$ret> { 167 | self.$method(self.route(concat!("/api/v1/", $url))) 168 | } 169 | } 170 | 171 | route!{$($rest)*} 172 | }; 173 | 174 | () => {} 175 | } 176 | 177 | macro_rules! route_id { 178 | 179 | ($(($method:ident) $name:ident: $url:expr => $ret:ty,)*) => { 180 | $( 181 | doc_comment! { 182 | concat!( 183 | "Equivalent to `/api/v1/", 184 | $url, 185 | "`\n# Errors\nIf `access_token` is not set."), 186 | pub fn $name(&self, id: &str) -> Result<$ret> { 187 | self.$method(self.route(&format!(concat!("/api/v1/", $url), id))) 188 | } 189 | } 190 | )* 191 | } 192 | 193 | } 194 | macro_rules! paged_routes_with_id { 195 | 196 | (($method:ident) $name:ident: $url:expr => $ret:ty, $($rest:tt)*) => { 197 | doc_comment! { 198 | concat!( 199 | "Equivalent to `/api/v1/", 200 | $url, 201 | "`\n# Errors\nIf `access_token` is not set."), 202 | pub fn $name(&self, id: &str) -> Result> { 203 | let url = self.route(&format!(concat!("/api/v1/", $url), id)); 204 | let response = self.client.$method(&url) 205 | .headers(self.headers.clone()) 206 | .send()?; 207 | 208 | Page::new(self, response) 209 | } 210 | } 211 | 212 | paged_routes_with_id!{$($rest)*} 213 | }; 214 | 215 | () => {} 216 | } 217 | 218 | /// Your mastodon application client, handles all requests to and from Mastodon. 219 | #[derive(Clone, Debug)] 220 | pub struct Mastodon { 221 | client: Client, 222 | headers: HeaderMap, 223 | /// Raw data about your mastodon instance. 224 | pub data: Data, 225 | } 226 | 227 | /// Raw data about mastodon app. Save `Data` using `serde` to prevent needing 228 | /// to authenticate on every run. 229 | #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] 230 | pub struct Data { 231 | /// Base url of instance eg. `https://mastodon.social`. 232 | pub base: Cow<'static, str>, 233 | /// The client's id given by the instance. 234 | pub client_id: Cow<'static, str>, 235 | /// The client's secret given by the instance. 236 | pub client_secret: Cow<'static, str>, 237 | /// Url to redirect back to your application from the instance signup. 238 | pub redirect: Cow<'static, str>, 239 | /// The client's access token. 240 | pub token: Cow<'static, str>, 241 | } 242 | 243 | /// enum of possible errors encountered using the mastodon API. 244 | #[derive(Debug, Deserialize)] 245 | #[serde(untagged)] 246 | pub enum Error { 247 | /// Error from the Mastodon API. This typically means something went 248 | /// wrong with your authentication or data. 249 | Api(ApiError), 250 | /// Error deserialising to json. Typically represents a breaking change in 251 | /// the Mastodon API 252 | #[serde(skip_deserializing)] 253 | Serde(SerdeError), 254 | /// Error encountered in the HTTP backend while requesting a route. 255 | #[serde(skip_deserializing)] 256 | Http(HttpError), 257 | /// Wrapper around the `std::io::Error` struct. 258 | #[serde(skip_deserializing)] 259 | Io(IoError), 260 | /// Wrapper around the `url::ParseError` struct. 261 | #[serde(skip_deserializing)] 262 | Url(UrlError), 263 | /// Missing Client Id. 264 | #[serde(skip_deserializing)] 265 | ClientIdRequired, 266 | /// Missing Client Secret. 267 | #[serde(skip_deserializing)] 268 | ClientSecretRequired, 269 | /// Missing Access Token. 270 | #[serde(skip_deserializing)] 271 | AccessTokenRequired, 272 | /// Generic client error. 273 | #[serde(skip_deserializing)] 274 | Client(StatusCode), 275 | /// Generic server error. 276 | #[serde(skip_deserializing)] 277 | Server(StatusCode), 278 | /// A possible error when converting a HeaderValue to a string representation. 279 | #[serde(skip_deserializing)] 280 | Header(HeaderToStrError), 281 | /// Errors while parsing headers and associated types. 282 | #[serde(skip_deserializing)] 283 | Hyperx(HyperxError), 284 | } 285 | 286 | impl fmt::Display for Error { 287 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 288 | write!(f, "{:?}", self) 289 | } 290 | } 291 | 292 | impl StdError for Error { 293 | fn description(&self) -> &str { 294 | match *self { 295 | Error::Api(ref e) => e 296 | .error_description 297 | .as_ref() 298 | .map(|i| &**i) 299 | .or(e.error.as_ref().map(|i| &**i)) 300 | .unwrap_or("Unknown API Error"), 301 | Error::Serde(ref e) => e.description(), 302 | Error::Http(ref e) => e.description(), 303 | Error::Io(ref e) => e.description(), 304 | Error::Url(ref e) => e.description(), 305 | Error::Client(ref status) | Error::Server(ref status) => { 306 | status.canonical_reason().unwrap_or("Unknown Status code") 307 | } 308 | Error::Hyperx(ref e) => e.description(), 309 | Error::Header(ref e) => e.description(), 310 | Error::ClientIdRequired => "ClientIdRequired", 311 | Error::ClientSecretRequired => "ClientSecretRequired", 312 | Error::AccessTokenRequired => "AccessTokenRequired", 313 | } 314 | } 315 | } 316 | 317 | impl From for Error { 318 | fn from(error: HyperxError) -> Self { 319 | Error::Hyperx(error) 320 | } 321 | } 322 | 323 | impl From for Error { 324 | fn from(error: HeaderToStrError) -> Self { 325 | Error::Header(error) 326 | } 327 | } 328 | 329 | /// Error returned from the Mastodon API. 330 | #[derive(Clone, Debug, Deserialize)] 331 | pub struct ApiError { 332 | /// The type of error. 333 | pub error: Option, 334 | /// The description of the error. 335 | pub error_description: Option, 336 | } 337 | 338 | /// # Example 339 | /// 340 | /// ``` 341 | /// # extern crate mammut; 342 | /// # use mammut::StatusesRequest; 343 | /// let request = StatusesRequest::new() 344 | /// .only_media() 345 | /// .pinned() 346 | /// .since_id("foo"); 347 | /// # assert_eq!(&request.to_querystring()[..], "?only_media=1&pinned=1&since_id=foo"); 348 | /// ``` 349 | #[derive(Clone, Debug, Default)] 350 | pub struct StatusesRequest<'a> { 351 | only_media: bool, 352 | exclude_replies: bool, 353 | pinned: bool, 354 | max_id: Option>, 355 | since_id: Option>, 356 | min_id: Option>, 357 | limit: Option, 358 | exclude_reblogs: bool, 359 | } 360 | 361 | impl<'a> StatusesRequest<'a> { 362 | pub fn new() -> Self { 363 | Self::default() 364 | } 365 | 366 | pub fn only_media(mut self) -> Self { 367 | self.only_media = true; 368 | self 369 | } 370 | 371 | pub fn exclude_replies(mut self) -> Self { 372 | self.exclude_replies = true; 373 | self 374 | } 375 | 376 | pub fn pinned(mut self) -> Self { 377 | self.pinned = true; 378 | self 379 | } 380 | 381 | pub fn max_id>>(mut self, max_id: S) -> Self { 382 | self.max_id = Some(max_id.into()); 383 | self 384 | } 385 | 386 | pub fn since_id>>(mut self, since_id: S) -> Self { 387 | self.since_id = Some(since_id.into()); 388 | self 389 | } 390 | 391 | pub fn min_id>>(mut self, min_id: S) -> Self { 392 | self.min_id = Some(min_id.into()); 393 | self 394 | } 395 | 396 | pub fn limit(mut self, limit: usize) -> Self { 397 | self.limit = Some(limit); 398 | self 399 | } 400 | 401 | pub fn exclude_reblogs(mut self) -> Self { 402 | self.exclude_reblogs = true; 403 | self 404 | } 405 | 406 | pub fn to_querystring(&self) -> String { 407 | let mut opts = vec![]; 408 | 409 | if self.only_media { 410 | opts.push("only_media=1".into()); 411 | } 412 | 413 | if self.exclude_replies { 414 | opts.push("exclude_replies=1".into()); 415 | } 416 | 417 | if self.pinned { 418 | opts.push("pinned=1".into()); 419 | } 420 | 421 | if let Some(ref max_id) = self.max_id { 422 | opts.push(format!("max_id={}", max_id)); 423 | } 424 | 425 | if let Some(ref since_id) = self.since_id { 426 | opts.push(format!("since_id={}", since_id)); 427 | } 428 | 429 | if let Some(ref min_id) = self.min_id { 430 | opts.push(format!("min_id={}", min_id)); 431 | } 432 | 433 | if let Some(limit) = self.limit { 434 | opts.push(format!("limit={}", limit)); 435 | } 436 | 437 | if self.exclude_reblogs { 438 | opts.push("exclude_reblogs=1".into()); 439 | } 440 | 441 | if opts.is_empty() { 442 | String::new() 443 | } else { 444 | format!("?{}", opts.join("&")) 445 | } 446 | } 447 | } 448 | 449 | impl Mastodon { 450 | fn from_registration( 451 | base: I, 452 | client_id: I, 453 | client_secret: I, 454 | redirect: I, 455 | token: I, 456 | client: Client, 457 | ) -> Self 458 | where 459 | I: Into>, 460 | { 461 | let data = Data { 462 | base: base.into(), 463 | client_id: client_id.into(), 464 | client_secret: client_secret.into(), 465 | redirect: redirect.into(), 466 | token: token.into(), 467 | }; 468 | 469 | let mut headers = HeaderMap::new(); 470 | let auth = HeaderValue::from_str(&format!("Bearer {}", data.token)); 471 | headers.insert(header::AUTHORIZATION, auth.unwrap()); 472 | 473 | Mastodon { 474 | client: client, 475 | headers: headers, 476 | data: data, 477 | } 478 | } 479 | 480 | /// Creates a mastodon instance from the data struct. 481 | pub fn from_data(data: Data) -> Self { 482 | let mut headers = HeaderMap::new(); 483 | let auth = HeaderValue::from_str(&format!("Bearer {}", data.token)); 484 | headers.insert(header::AUTHORIZATION, auth.unwrap()); 485 | 486 | Mastodon { 487 | client: Client::new(), 488 | headers: headers, 489 | data: data, 490 | } 491 | } 492 | 493 | paged_routes! { 494 | (get) favourites: "favourites" => Status, 495 | (get) blocks: "blocks" => Account, 496 | (get) domain_blocks: "domain_blocks" => String, 497 | (get) follow_requests: "follow_requests" => Account, 498 | (get) get_home_timeline: "timelines/home" => Status, 499 | (get) get_emojis: "custom_emojis" => Emoji, 500 | (get) mutes: "mutes" => Account, 501 | (get) notifications: "notifications" => Notification, 502 | (get) reports: "reports" => Report, 503 | } 504 | 505 | paged_routes_with_id! { 506 | (get) followers: "accounts/{}/followers" => Account, 507 | (get) following: "accounts/{}/following" => Account, 508 | (get) reblogged_by: "statuses/{}/reblogged_by" => Account, 509 | (get) favourited_by: "statuses/{}/favourited_by" => Account, 510 | } 511 | 512 | route! { 513 | (delete (domain: String,)) unblock_domain: "domain_blocks" => Empty, 514 | (get) instance: "instance" => Instance, 515 | (get) verify_credentials: "accounts/verify_credentials" => Account, 516 | (post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report, 517 | (post (domain: String,)) block_domain: "domain_blocks" => Empty, 518 | (post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty, 519 | (post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty, 520 | (post (q: String, resolve: bool,)) search: "search" => SearchResult, 521 | (post (uri: Cow<'static, str>,)) follows: "follows" => Account, 522 | (post) clear_notifications: "notifications/clear" => Empty, 523 | } 524 | 525 | route_id! { 526 | (get) get_account: "accounts/{}" => Account, 527 | (post) follow: "accounts/{}/follow" => Account, 528 | (post) unfollow: "accounts/{}/unfollow" => Account, 529 | (get) block: "accounts/{}/block" => Account, 530 | (get) unblock: "accounts/{}/unblock" => Account, 531 | (get) mute: "accounts/{}/mute" => Account, 532 | (get) unmute: "accounts/{}/unmute" => Account, 533 | (get) get_notification: "notifications/{}" => Notification, 534 | (get) get_status: "statuses/{}" => Status, 535 | (get) get_context: "statuses/{}/context" => Context, 536 | (get) get_card: "statuses/{}/card" => Card, 537 | (post) reblog: "statuses/{}/reblog" => Status, 538 | (post) unreblog: "statuses/{}/unreblog" => Status, 539 | (post) favourite: "statuses/{}/favourite" => Status, 540 | (post) unfavourite: "statuses/{}/unfavourite" => Status, 541 | (delete) delete_status: "statuses/{}" => Empty, 542 | } 543 | 544 | pub fn update_credentials(&self, changes: CredientialsBuilder) -> Result { 545 | let url = self.route("/api/v1/accounts/update_credentials"); 546 | let response = self 547 | .client 548 | .patch(&url) 549 | .headers(self.headers.clone()) 550 | .multipart(changes.into_form()?) 551 | .send()?; 552 | 553 | let status = response.status().clone(); 554 | 555 | if status.is_client_error() { 556 | return Err(Error::Client(status)); 557 | } else if status.is_server_error() { 558 | return Err(Error::Server(status)); 559 | } 560 | 561 | deserialise(response) 562 | } 563 | 564 | /// Post a new status to the account. 565 | pub fn new_status(&self, status: StatusBuilder) -> Result { 566 | let response = self 567 | .client 568 | .post(&self.route("/api/v1/statuses")) 569 | .headers(self.headers.clone()) 570 | .json(&status) 571 | .send()?; 572 | 573 | deserialise(response) 574 | } 575 | 576 | /// Get the federated timeline for the instance. 577 | pub fn get_public_timeline(&self, local: bool) -> Result> { 578 | let mut url = self.route("/api/v1/timelines/public"); 579 | 580 | if local { 581 | url += "?local=1"; 582 | } 583 | 584 | self.get(url) 585 | } 586 | 587 | /// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or 588 | /// federated. 589 | pub fn get_tagged_timeline(&self, hashtag: String, local: bool) -> Result> { 590 | let mut url = self.route("/api/v1/timelines/tag/"); 591 | url += &hashtag; 592 | 593 | if local { 594 | url += "?local=1"; 595 | } 596 | 597 | self.get(url) 598 | } 599 | 600 | /// Get statuses of a single account by id. Optionally only with pictures 601 | /// and or excluding replies. 602 | /// 603 | /// # Example 604 | /// 605 | /// ```no_run 606 | /// # extern crate mammut; 607 | /// # use mammut::{Data, Mastodon}; 608 | /// # use std::error::Error; 609 | /// # fn main() -> Result<(), Box> { 610 | /// # let data = Data { 611 | /// # base: "".into(), 612 | /// # client_id: "".into(), 613 | /// # client_secret: "".into(), 614 | /// # redirect: "".into(), 615 | /// # token: "".into(), 616 | /// # }; 617 | /// let client = Mastodon::from_data(data); 618 | /// let statuses = client.statuses("user-id", None)?; 619 | /// # Ok(()) 620 | /// # } 621 | /// ``` 622 | /// 623 | /// ```no_run 624 | /// # extern crate mammut; 625 | /// # use mammut::{Data, Mastodon, StatusesRequest}; 626 | /// # use std::error::Error; 627 | /// # fn main() -> Result<(), Box> { 628 | /// # let data = Data { 629 | /// # base: "".into(), 630 | /// # client_id: "".into(), 631 | /// # client_secret: "".into(), 632 | /// # redirect: "".into(), 633 | /// # token: "".into(), 634 | /// # }; 635 | /// let client = Mastodon::from_data(data); 636 | /// let request = StatusesRequest::default() 637 | /// .only_media(); 638 | /// let statuses = client.statuses("user-id", request)?; 639 | /// # Ok(()) 640 | /// # } 641 | /// ``` 642 | pub fn statuses<'a, S>(&self, id: &str, request: S) -> Result> 643 | where 644 | S: Into>>, 645 | { 646 | let mut url = format!("{}/api/v1/accounts/{}/statuses", self.base, id); 647 | 648 | if let Some(request) = request.into() { 649 | url = format!("{}{}", url, request.to_querystring()); 650 | } 651 | 652 | let response = self.client.get(&url).headers(self.headers.clone()).send()?; 653 | 654 | Page::new(self, response) 655 | } 656 | 657 | /// Returns the client account's relationship to a list of other accounts. 658 | /// Such as whether they follow them or vice versa. 659 | pub fn relationships(&self, ids: &[&str]) -> Result> { 660 | let mut url = self.route("/api/v1/accounts/relationships?"); 661 | 662 | if ids.len() == 1 { 663 | url += "id="; 664 | url += &ids[0]; 665 | } else { 666 | for id in ids { 667 | url += "id[]="; 668 | url += &id; 669 | url += "&"; 670 | } 671 | url.pop(); 672 | } 673 | 674 | let response = self.client.get(&url).headers(self.headers.clone()).send()?; 675 | 676 | Page::new(self, response) 677 | } 678 | 679 | /// Search for accounts by their name. 680 | /// Will lookup an account remotely if the search term is in the 681 | /// `username@domain` format and not yet in the database. 682 | pub fn search_accounts( 683 | &self, 684 | query: &str, 685 | limit: Option, 686 | following: bool, 687 | ) -> Result> { 688 | let url = format!( 689 | "{}/api/v1/accounts/search?q={}&limit={}&following={}", 690 | self.base, 691 | query, 692 | limit.unwrap_or(40), 693 | following 694 | ); 695 | 696 | let response = self.client.get(&url).headers(self.headers.clone()).send()?; 697 | 698 | Page::new(self, response) 699 | } 700 | 701 | methods![get, post, delete,]; 702 | 703 | fn route(&self, url: &str) -> String { 704 | let mut s = (*self.base).to_owned(); 705 | s += url; 706 | s 707 | } 708 | 709 | /// Equivalent to /api/v1/media 710 | pub fn media(&self, media_builder: MediaBuilder) -> Result { 711 | use reqwest::multipart::Form; 712 | 713 | let mut form_data = Form::new().file("file", media_builder.file.as_ref())?; 714 | 715 | if let Some(description) = media_builder.description { 716 | form_data = form_data.text("description", description); 717 | } 718 | 719 | if let Some(focus) = media_builder.focus { 720 | let string = format!("{},{}", focus.0, focus.1); 721 | form_data = form_data.text("focus", string); 722 | } 723 | 724 | let response = self 725 | .client 726 | .post(&self.route("/api/v1/media")) 727 | .headers(self.headers.clone()) 728 | .multipart(form_data) 729 | .send()?; 730 | 731 | let status = response.status().clone(); 732 | 733 | if status.is_client_error() { 734 | return Err(Error::Client(status)); 735 | } else if status.is_server_error() { 736 | return Err(Error::Server(status)); 737 | } 738 | 739 | deserialise(response) 740 | } 741 | } 742 | 743 | impl ops::Deref for Mastodon { 744 | type Target = Data; 745 | 746 | fn deref(&self) -> &Self::Target { 747 | &self.data 748 | } 749 | } 750 | 751 | macro_rules! from { 752 | ($($typ:ident, $variant:ident,)*) => { 753 | $( 754 | impl From<$typ> for Error { 755 | fn from(from: $typ) -> Self { 756 | use Error::*; 757 | $variant(from) 758 | } 759 | } 760 | )* 761 | } 762 | } 763 | 764 | from! { 765 | HttpError, Http, 766 | IoError, Io, 767 | SerdeError, Serde, 768 | UrlError, Url, 769 | } 770 | 771 | // Convert the HTTP response body from JSON. Pass up deserialization errors 772 | // transparently. 773 | fn deserialise serde::Deserialize<'de>>(mut response: Response) -> Result { 774 | use std::io::Read; 775 | 776 | let mut vec = Vec::new(); 777 | response.read_to_end(&mut vec)?; 778 | 779 | match json::from_slice(&vec) { 780 | Ok(t) => Ok(t), 781 | // If deserializing into the desired type fails try again to 782 | // see if this is an error response. 783 | Err(e) => { 784 | if let Ok(error) = json::from_slice(&vec) { 785 | return Err(Error::Api(error)); 786 | } 787 | Err(e.into()) 788 | } 789 | } 790 | } 791 | -------------------------------------------------------------------------------- /src/media_builder.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | /// A builder pattern struct for constructing a media attachment. 4 | #[derive(Debug, Default, Clone, Serialize)] 5 | pub struct MediaBuilder { 6 | /// The file name of the attachment to be uploaded. 7 | pub file: Cow<'static, str>, 8 | /// The alt text of the attachment. 9 | pub description: Option>, 10 | /// The focus point for images. 11 | pub focus: Option<(f32, f32)>, 12 | } 13 | 14 | impl MediaBuilder { 15 | /// Create a new attachment from a file name. 16 | pub fn new(file: Cow<'static, str>) -> Self { 17 | MediaBuilder { 18 | file, 19 | description: None, 20 | focus: None, 21 | } 22 | } 23 | /// Set an alt text description for the attachment. 24 | pub fn description(mut self, description: Cow<'static, str>) -> Self { 25 | self.description = Some(description); 26 | self 27 | } 28 | 29 | /// Set a focus point for an image attachment. 30 | pub fn focus(mut self, f1: f32, f2: f32) -> Self { 31 | self.focus = Some((f1, f2)); 32 | self 33 | } 34 | } 35 | 36 | // Convenience helper so that the mastodon.media() method can be called with a 37 | // file name only (owned string). 38 | impl From for MediaBuilder { 39 | fn from(file: String) -> MediaBuilder { 40 | MediaBuilder { 41 | file: file.into(), 42 | description: None, 43 | focus: None, 44 | } 45 | } 46 | } 47 | 48 | // Convenience helper so that the mastodon.media() method can be called with a 49 | // file name only (borrowed string). 50 | impl From<&'static str> for MediaBuilder { 51 | fn from(file: &'static str) -> MediaBuilder { 52 | MediaBuilder { 53 | file: file.into(), 54 | description: None, 55 | focus: None, 56 | } 57 | } 58 | } 59 | 60 | // Convenience helper so that the mastodon.media() method can be called with a 61 | // file name only (Cow string). 62 | impl From> for MediaBuilder { 63 | fn from(file: Cow<'static, str>) -> MediaBuilder { 64 | MediaBuilder { 65 | file, 66 | description: None, 67 | focus: None, 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/page.rs: -------------------------------------------------------------------------------- 1 | use hyperx::header::{Header, Link, RelationType}; 2 | use reqwest::header::LINK; 3 | use reqwest::Response; 4 | use serde::Deserialize; 5 | use url::Url; 6 | 7 | use super::{deserialise, Mastodon, Result}; 8 | use crate::entities::itemsiter::ItemsIter; 9 | 10 | pub struct Page<'a, T: for<'de> Deserialize<'de>> { 11 | mastodon: &'a Mastodon, 12 | next: Option, 13 | prev: Option, 14 | /// Initial set of items 15 | pub initial_items: Vec, 16 | } 17 | 18 | macro_rules! pages { 19 | ($($direction:ident: $fun:ident),*) => { 20 | 21 | $( 22 | pub fn $fun(&mut self) -> Result>> { 23 | let url = match self.$direction.take() { 24 | Some(s) => s, 25 | None => return Ok(None), 26 | }; 27 | 28 | let response = self.mastodon.client.get(url) 29 | .headers(self.mastodon.headers.clone()) 30 | .send()?; 31 | 32 | let (prev, next) = get_links(&response)?; 33 | self.next = next; 34 | self.prev = prev; 35 | 36 | deserialise(response) 37 | } 38 | )* 39 | } 40 | } 41 | 42 | impl<'a, T: for<'de> Deserialize<'de>> Page<'a, T> { 43 | pub fn new(mastodon: &'a Mastodon, response: Response) -> Result { 44 | let (prev, next) = get_links(&response)?; 45 | Ok(Page { 46 | initial_items: deserialise(response)?, 47 | next, 48 | prev, 49 | mastodon, 50 | }) 51 | } 52 | 53 | pages! { 54 | next: next_page, 55 | prev: prev_page 56 | } 57 | } 58 | 59 | impl<'a, T: Clone + for<'de> Deserialize<'de>> Page<'a, T> { 60 | /// Returns an iterator that provides a stream of `T`s 61 | /// 62 | /// This abstracts away the process of iterating over each item in a page, then making an http 63 | /// call, then iterating over each item in the new page, etc. The iterator provides a stream of 64 | /// `T`s, calling `self.next_page()` when necessary to get more of them, until there are no more 65 | /// items. 66 | /// 67 | /// # Example 68 | /// 69 | /// ```no_run 70 | /// # extern crate mammut; 71 | /// # use std::error::Error; 72 | /// use mammut::{Mastodon, Data, StatusesRequest}; 73 | /// # fn main() -> Result<(), Box> { 74 | /// # let data = Data { 75 | /// # base: "".into(), 76 | /// # client_id: "".into(), 77 | /// # client_secret: "".into(), 78 | /// # redirect: "".into(), 79 | /// # token: "".into(), 80 | /// # }; 81 | /// let mastodon = Mastodon::from_data(data); 82 | /// let req = StatusesRequest::new(); 83 | /// let resp = mastodon.statuses("some-id", req)?; 84 | /// for status in resp.items_iter() { 85 | /// // do something with status 86 | /// } 87 | /// # Ok(()) 88 | /// # } 89 | /// ``` 90 | pub fn items_iter(self) -> impl Iterator + 'a 91 | where 92 | T: 'a, 93 | { 94 | ItemsIter::new(self) 95 | } 96 | } 97 | 98 | fn get_links(response: &Response) -> Result<(Option, Option)> { 99 | let mut prev = None; 100 | let mut next = None; 101 | 102 | let link_header = response.headers().get_all(LINK); 103 | for value in &link_header { 104 | let parsed: Link = Header::parse_header(&value)?; 105 | for value in parsed.values() { 106 | if let Some(relations) = value.rel() { 107 | if relations.contains(&RelationType::Next) { 108 | next = Some(Url::parse(value.link())?); 109 | } 110 | 111 | if relations.contains(&RelationType::Prev) { 112 | prev = Some(Url::parse(value.link())?); 113 | } 114 | } 115 | } 116 | } 117 | 118 | Ok((prev, next)) 119 | } 120 | -------------------------------------------------------------------------------- /src/registration.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | 3 | use super::{Error, Mastodon, Result}; 4 | use crate::apps::{AppBuilder, Scopes}; 5 | 6 | /// Handles registering your mastodon app to your instance. It is recommended 7 | /// you cache your data struct to avoid registering on every run. 8 | pub struct Registration { 9 | base: String, 10 | client: Client, 11 | client_id: Option, 12 | client_secret: Option, 13 | redirect: Option, 14 | scopes: Scopes, 15 | } 16 | 17 | #[derive(Deserialize)] 18 | struct OAuth { 19 | client_id: String, 20 | client_secret: String, 21 | redirect_uri: String, 22 | } 23 | 24 | #[derive(Deserialize)] 25 | struct AccessToken { 26 | access_token: String, 27 | } 28 | 29 | impl Registration { 30 | /// Construct a new registration process to the instance of the `base` url. 31 | /// ``` 32 | /// use mammut::registration::Registration; 33 | /// 34 | /// let registration = Registration::new("https://mastodon.social"); 35 | /// ``` 36 | pub fn new>(base: I) -> Self { 37 | Registration { 38 | base: base.into(), 39 | client: Client::new(), 40 | client_id: None, 41 | client_secret: None, 42 | redirect: None, 43 | scopes: Scopes::Read, 44 | } 45 | } 46 | 47 | /// Register the application with the server from the `base` url. 48 | /// 49 | /// ```no_run 50 | /// # extern crate mammut; 51 | /// # fn main() -> Result<(), Box> { 52 | /// use mammut::Registration; 53 | /// use mammut::apps::{AppBuilder, Scopes}; 54 | /// 55 | /// let app = AppBuilder { 56 | /// client_name: "mammut_test", 57 | /// redirect_uris: "urn:ietf:wg:oauth:2.0:oob", 58 | /// scopes: Scopes::Read, 59 | /// website: None, 60 | /// }; 61 | /// 62 | /// let mut registration = Registration::new("https://mastodon.social"); 63 | /// registration.register(app)?; 64 | /// let url = registration.authorise()?; 65 | /// // Here you now need to open the url in the browser 66 | /// // And handle a the redirect url coming back with the code. 67 | /// let code = String::from("RETURNED_FROM_BROWSER"); 68 | /// let mastodon = registration.create_access_token(code)?; 69 | /// 70 | /// println!("{:?}", mastodon.get_home_timeline()?.initial_items); 71 | /// # Ok(()) 72 | /// # } 73 | /// ``` 74 | pub fn register(&mut self, app_builder: AppBuilder) -> Result<()> { 75 | let url = format!("{}/api/v1/apps", self.base); 76 | self.scopes = app_builder.scopes; 77 | let app: OAuth = self.client.post(&url).form(&app_builder).send()?.json()?; 78 | 79 | self.client_id = Some(app.client_id); 80 | self.client_secret = Some(app.client_secret); 81 | self.redirect = Some(app.redirect_uri); 82 | 83 | Ok(()) 84 | } 85 | 86 | /// Returns the full url needed for authorisation. This needs to be opened 87 | /// in a browser. 88 | pub fn authorise(&mut self) -> Result { 89 | self.is_registered()?; 90 | 91 | let url = format!( 92 | "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&response_type=code", 93 | self.base, 94 | self.client_id.clone().unwrap(), 95 | self.redirect.clone().unwrap(), 96 | self.scopes, 97 | ); 98 | 99 | Ok(url) 100 | } 101 | 102 | fn is_registered(&self) -> Result<()> { 103 | if self.client_id.is_none() { 104 | Err(Error::ClientIdRequired) 105 | } else if self.client_secret.is_none() { 106 | Err(Error::ClientSecretRequired) 107 | } else { 108 | Ok(()) 109 | } 110 | } 111 | 112 | /// Create an access token from the client id, client secret, and code 113 | /// provided by the authorisation url. 114 | pub fn create_access_token(self, code: String) -> Result { 115 | self.is_registered()?; 116 | let url = format!( 117 | "{}/oauth/token?client_id={}&client_secret={}&code={}&grant_type=authorization_code&redirect_uri={}", 118 | self.base, 119 | self.client_id.clone().unwrap(), 120 | self.client_secret.clone().unwrap(), 121 | code, 122 | self.redirect.clone().unwrap() 123 | ); 124 | 125 | let token: AccessToken = self.client.post(&url).send()?.json()?; 126 | 127 | Ok(Mastodon::from_registration( 128 | self.base, 129 | self.client_id.unwrap(), 130 | self.client_secret.unwrap(), 131 | self.redirect.unwrap(), 132 | token.access_token, 133 | self.client, 134 | )) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/status_builder.rs: -------------------------------------------------------------------------------- 1 | /// A builder pattern struct for constructing a status. 2 | #[derive(Debug, Default, Clone, Serialize)] 3 | pub struct StatusBuilder { 4 | /// The text of the status. 5 | pub status: String, 6 | /// The ID of the status this status is replying to, if the status is 7 | /// a reply. 8 | #[serde(skip_serializing_if = "Option::is_none")] 9 | pub in_reply_to_id: Option, 10 | /// Ids of media attachments being attached to the status. 11 | #[serde(skip_serializing_if = "Option::is_none")] 12 | pub media_ids: Option>, 13 | /// Whether current status is sensitive. 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub sensitive: Option, 16 | /// Text to precede the normal status text. 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub spoiler_text: Option, 19 | /// Visibility of the status, defaults to `Public`. 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub visibility: Option, 22 | } 23 | 24 | /// The visibility of a status. 25 | #[derive(Clone, Copy, Debug, Deserialize, Serialize)] 26 | pub enum Visibility { 27 | /// A Direct message to a user 28 | #[serde(rename = "direct")] 29 | Direct, 30 | /// Only available to followers 31 | #[serde(rename = "private")] 32 | Private, 33 | /// Not shown in public timelines 34 | #[serde(rename = "unlisted")] 35 | Unlisted, 36 | /// Posted to public timelines 37 | #[serde(rename = "public")] 38 | Public, 39 | } 40 | 41 | impl StatusBuilder { 42 | /// Create a new status with text. 43 | /// ``` 44 | /// use mammut::status_builder::StatusBuilder; 45 | /// 46 | /// let status = StatusBuilder::new("Hello World!".into()); 47 | /// ``` 48 | pub fn new(status: String) -> Self { 49 | StatusBuilder { 50 | status: status, 51 | ..Self::default() 52 | } 53 | } 54 | } 55 | 56 | impl Default for Visibility { 57 | fn default() -> Self { 58 | Visibility::Public 59 | } 60 | } 61 | --------------------------------------------------------------------------------