diff --git a/src/lib.rs b/src/lib.rs index 165c775..9886352 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,10 +70,11 @@ unused_qualifications )] #![cfg_attr(feature = "nightly", allow(broken_intra_doc_links))] +#![allow(broken_intra_doc_links)] use std::{borrow::Cow, io::BufRead, ops}; -use reqwest::blocking::{Client, RequestBuilder, Response}; +use reqwest::blocking::{multipart, Client, RequestBuilder, Response}; use tap_reader::Tap; use tungstenite::client::AutoStream; @@ -619,35 +620,81 @@ impl MastodonClient for Mastodon { Ok(EventReader(WebSocket(client))) } - /// Equivalent to /api/v1/media - fn media(&self, media_builder: MediaBuilder) -> Result { - use reqwest::blocking::multipart::Form; + /// Upload some media to the server for possible attaching to a new status + /// + /// Upon successful upload of a media attachment, the server will assign it an id. To actually + /// use the attachment in a new status, you can use the `media_ids` field of + /// [`StatusBuilder`] + /// + /// There are two ways of providing the data to be attached: by reading a file, or by using a + /// reader. + /// + /// ## Files + /// If the `MediaBuilder` was supplied with a file path, the filename and mimetype will be + /// automatically populated from that file; their values set in the `MediaBuilder` will be + /// ignored. For example: + /// + /// ```no_run + /// let client = Mastodon::from(data); + /// let builder = crate::MediaBuilder::from_file("/tmp/my_image.png".into()); + /// + /// let attachment = client.media(builder); + /// ``` + /// + /// ## Readers + /// The `MediaBuilder` can also be supplied with a reader. This is useful for uploading data + /// already in memory, for example from a `Vec` containing some image data. For example: + /// + /// ```no_run + /// use std::io::Cursor; + /// let client = Mastodon::from(data); + /// + /// let mut image_data: Vec = Vec::new(); + /// populate_image_data(&mut image_data); + /// + /// let builder = crate::MediaBuilder::from_reader(Cursor::new(image_data)); + /// let attachment = client.media(builder); + /// + /// ``` + /// + /// ## Errors + /// This function may return an `Error::Http` before sending anything over the network if the + /// `MediaBuilder` was supplied with a reader and a `mimetype` string which cannot be pasrsed. + fn media(&self, media: MediaBuilder) -> Result { + use media_builder::MediaBuilderData; + + let mut form = multipart::Form::new(); + form = match media.data { + MediaBuilderData::Reader(reader) => { + let mut part = multipart::Part::reader(reader); + + if let Some(filename) = media.filename { + part = part.file_name(filename); + } + + if let Some(mimetype) = media.mimetype { + part = part.mime_str(&mimetype)?; + } - let mut form_data = Form::new().file("file", media_builder.file.as_ref())?; + form.part("file", part) + } + MediaBuilderData::File(file) => form.file("file", &file)?, + }; - if let Some(description) = media_builder.description { - form_data = form_data.text("description", description); + if let Some(description) = media.description { + form = form.text("description", description); } - if let Some(focus) = media_builder.focus { - let string = format!("{},{}", focus.0, focus.1); - form_data = form_data.text("focus", string); + if let Some((x, y)) = media.focus { + form = form.text("focus", format!("{},{}", x, y)); } let response = self.send( self.client .post(&self.route("/api/v1/media")) - .multipart(form_data), + .multipart(form), )?; - let status = response.status(); - - if status.is_client_error() { - return Err(Error::Client(status)); - } else if status.is_server_error() { - return Err(Error::Server(status)); - } - deserialise(response) } } diff --git a/src/media_builder.rs b/src/media_builder.rs index b4c7e92..1e99ae1 100644 --- a/src/media_builder.rs +++ b/src/media_builder.rs @@ -1,72 +1,148 @@ -use serde::Serialize; -use std::borrow::Cow; +use std::fmt; +use std::io::Read; +use std::path::{Path, PathBuf}; -/// A builder pattern struct for constructing a media attachment. -#[derive(Debug, Default, Clone, Serialize)] +#[derive(Debug)] +/// A builder pattern struct for preparing a single attachment for upload. +/// +/// For more details, see [`new_media()`](struct.Mastodon.html#method.new_media). pub struct MediaBuilder { - /// The file name of the attachment to be uploaded. - pub file: Cow<'static, str>, - /// The alt text of the attachment. - pub description: Option>, - /// The focus point for images. - pub focus: Option<(f32, f32)>, + /// The media attachment itself + pub data: MediaBuilderData, + + /// The filename to send to the server + pub filename: Option, + + /// Mimetype to send to the server, identifying what is in the attachment. + /// + /// The string should be a valid mimetype. + pub mimetype: Option, + + /// Plain text description of the attached piece of media, for accessibility + pub description: Option, + + /// (x, y) focus point, used by clients to determine how to crop an image + pub focus: Option<(f64, f64)>, +} + +/// Enum representing possible sources of attachments to upload +pub enum MediaBuilderData { + /// An arbitrary reader. It is useful for reading from media already in memory. + Reader(Box), + + /// Variant represening a file path of the file to attach. + File(PathBuf), +} + +impl fmt::Debug for MediaBuilderData { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + match self { + MediaBuilderData::File(f) => fmt.debug_tuple("File").field(&f).finish(), + MediaBuilderData::Reader(_) => fmt + .debug_tuple("Reader") + .field(&format_args!("...")) + .finish(), + } + } } impl MediaBuilder { - /// Create a new attachment from a file name. - pub fn new(file: Cow<'static, str>) -> Self { + /// Create a new MediaBuilder from a reader `data` + pub fn from_reader(data: R) -> MediaBuilder { + MediaBuilder { + data: MediaBuilderData::Reader(Box::from(data)), + filename: None, + mimetype: None, + description: None, + focus: None, + } + } + + /// Create a new MediaBuilder from a file under `path` + /// + /// This function will not check whether the file exists or if it can be read. If the path is + /// not valid, [`add_media()`](trait.MastodonClient.html#method.add_media) will return an error when called with the `MediaBuilder`. + pub fn from_file(path: impl AsRef) -> MediaBuilder { + let pb = path.as_ref().to_owned(); + let filename = pb + .file_name() + .expect("file name") + .to_string_lossy() + .to_string(); + let mimetype = match pb.extension().map(|s| s.to_str()).flatten() { + Some("jpg") | Some("jpeg") => Some("image/jpeg".to_string()), + Some("png") => Some("image/png".to_string()), + Some("gif") => Some("image/gif".to_string()), + Some("txt") => Some("text/plain".to_string()), + // ... + _ => None, + }; + MediaBuilder { - file, + data: MediaBuilderData::File(pb), + filename: Some(filename), + mimetype, description: None, focus: None, } } + /// Set filename + pub fn filename(&mut self, filename: impl ToString) { + self.filename = Some(filename.to_string()); + } + + /// Set custom mime type + pub fn mimetype(&mut self, mimetype: impl ToString) { + self.mimetype = Some(mimetype.to_string()); + } + /// Set an alt text description for the attachment. - pub fn description(mut self, description: Cow<'static, str>) -> Self { - self.description = Some(description); - self + pub fn description(&mut self, description: impl ToString) { + self.description = Some(description.to_string()); } /// Set a focus point for an image attachment. - pub fn focus(mut self, f1: f32, f2: f32) -> Self { + pub fn focus(&mut self, f1: f64, f2: f64) { self.focus = Some((f1, f2)); - self } } -// Convenience helper so that the mastodon.media() method can be called with a -// file name only (owned string). -impl From for MediaBuilder { - fn from(file: String) -> MediaBuilder { - MediaBuilder { - file: file.into(), - description: None, - focus: None, - } - } -} +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; -// Convenience helper so that the mastodon.media() method can be called with a -// file name only (borrowed string). -impl From<&'static str> for MediaBuilder { - fn from(file: &'static str) -> MediaBuilder { - MediaBuilder { - file: file.into(), - description: None, - focus: None, + #[test] + fn test_from_reader() { + let source = vec![0u8, 1, 2]; + let builder = MediaBuilder::from_reader(Cursor::new(source.clone())); + + assert_eq!(builder.filename, None); + assert_eq!(builder.mimetype, None); + assert_eq!(builder.description, None); + assert_eq!(builder.focus, None); + + if let MediaBuilderData::Reader(r) = builder.data { + assert_eq!(r.bytes().map(|b| b.unwrap()).collect::>(), source); + } else { + panic!("Unable to destructure MediaBuilder.data into a reader"); } } -} -// Convenience helper so that the mastodon.media() method can be called with a -// file name only (Cow string). -impl From> for MediaBuilder { - fn from(file: Cow<'static, str>) -> MediaBuilder { - MediaBuilder { - file, - description: None, - focus: None, + #[test] + fn test_from_file() { + let builder = MediaBuilder::from_file("/fake/file/path.png".into()); + + assert_eq!(builder.filename, None); + assert_eq!(builder.mimetype, None); + assert_eq!(builder.description, None); + assert_eq!(builder.focus, None); + + if let MediaBuilderData::File(f) = builder.data { + assert_eq!(f, "/fake/file/path.png"); + } else { + panic!("Unable to destructure MediaBuilder.data into a filepath"); } } }