Skip to content

Commit f5e6c77

Browse files
authored
Change CreateAttachment to stream data from disk when possible (#3322)
1 parent c056feb commit f5e6c77

26 files changed

+238
-147
lines changed

examples/e08_create_message_builder/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ impl EventHandler for Handler {
3636
let builder = CreateMessage::new()
3737
.content("Hello, World!")
3838
.embed(embed)
39-
.add_file(CreateAttachment::path("./ferris_eyes.png").await.unwrap());
39+
.add_file(CreateAttachment::path("./ferris_eyes.png".as_ref()).unwrap());
4040
let msg = new_message.channel_id.send_message(&ctx.http, builder).await;
4141

4242
if let Err(why) = msg {

src/builder/create_attachment.rs

Lines changed: 110 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ use serde::ser::{Serialize, SerializeSeq, Serializer};
66
use tokio::fs::File;
77
use tokio::io::AsyncReadExt;
88

9-
#[cfg(doc)]
10-
use crate::error::Error;
11-
use crate::error::Result;
9+
use crate::error::{Error, Result, UrlError};
1210
#[cfg(feature = "http")]
1311
use crate::http::Http;
1412
use crate::model::channel::Message;
1513
use crate::model::id::AttachmentId;
1614

15+
#[derive(Clone, Debug)]
16+
pub enum AttachmentData<'a> {
17+
Bytes(Bytes),
18+
File(&'a File),
19+
Path(&'a Path),
20+
}
21+
1722
/// A builder for creating a new attachment from a file path, file data, or URL.
1823
///
1924
/// [Discord docs](https://discord.com/developers/docs/resources/channel#attachment-object-attachment-structure).
@@ -23,50 +28,44 @@ use crate::model::id::AttachmentId;
2328
pub struct CreateAttachment<'a> {
2429
pub filename: Cow<'static, str>,
2530
pub description: Option<Cow<'a, str>>,
26-
pub data: Bytes,
31+
pub data: AttachmentData<'a>,
2732
}
2833

2934
impl<'a> CreateAttachment<'a> {
3035
/// Builds an [`CreateAttachment`] from the raw attachment data.
3136
pub fn bytes(data: impl Into<Bytes>, filename: impl Into<Cow<'static, str>>) -> Self {
3237
CreateAttachment {
33-
data: data.into(),
3438
filename: filename.into(),
3539
description: None,
40+
data: AttachmentData::Bytes(data.into()),
3641
}
3742
}
3843

3944
/// Builds an [`CreateAttachment`] by reading a local file.
4045
///
4146
/// # Errors
4247
///
43-
/// [`Error::Io`] if reading the file fails.
44-
pub async fn path(path: impl AsRef<Path>) -> Result<Self> {
45-
async fn inner(path: &Path) -> Result<CreateAttachment<'static>> {
46-
let mut file = File::open(path).await?;
47-
let mut data = Vec::new();
48-
file.read_to_end(&mut data).await?;
49-
50-
let filename = path
51-
.file_name()
52-
.ok_or_else(|| std::io::Error::other("attachment path must not be a directory"))?;
53-
54-
Ok(CreateAttachment::bytes(data, filename.to_string_lossy().into_owned()))
55-
}
56-
57-
inner(path.as_ref()).await
48+
/// Returns [`Error::Io`] if the path is not a valid file path.
49+
pub fn path(path: &'a Path) -> Result<Self> {
50+
let filename = path
51+
.file_name()
52+
.ok_or_else(|| std::io::Error::other("attachment path must not be a directory"))?
53+
.to_string_lossy()
54+
.into_owned();
55+
Ok(CreateAttachment {
56+
filename: filename.into(),
57+
description: None,
58+
data: AttachmentData::Path(path),
59+
})
5860
}
5961

6062
/// Builds an [`CreateAttachment`] by reading from a file handler.
61-
///
62-
/// # Errors
63-
///
64-
/// [`Error::Io`] error if reading the file fails.
65-
pub async fn file(file: &File, filename: impl Into<Cow<'static, str>>) -> Result<Self> {
66-
let mut data = Vec::new();
67-
file.try_clone().await?.read_to_end(&mut data).await?;
68-
69-
Ok(CreateAttachment::bytes(data, filename))
63+
pub fn file(file: &'a File, filename: impl Into<Cow<'static, str>>) -> Self {
64+
CreateAttachment {
65+
filename: filename.into(),
66+
description: None,
67+
data: AttachmentData::File(file),
68+
}
7069
}
7170

7271
/// Builds an [`CreateAttachment`] by downloading attachment data from a URL.
@@ -86,25 +85,51 @@ impl<'a> CreateAttachment<'a> {
8685
Ok(CreateAttachment::bytes(data, filename))
8786
}
8887

89-
/// Converts the stored data to the base64 representation.
88+
/// Returns the underlying data for the attachment.
9089
///
91-
/// This is used in the library internally because Discord expects image data as base64 in many
92-
/// places.
93-
#[must_use]
94-
pub fn to_base64(&self) -> String {
90+
/// # Errors
91+
///
92+
/// Returns an error if fetching the data failed in some way. If the attachment is a
93+
/// [`CreateAttachment::path`], then the file at the specified path was unable to be read. If
94+
/// instead it's [`CreateAttachment::file`], then cloning the handle to the file failed, likely
95+
/// due to hitting the system's limit on number of open file handles.
96+
pub async fn get_data(&self) -> Result<Bytes> {
97+
match &self.data {
98+
AttachmentData::Bytes(bytes) => Ok(bytes.clone()),
99+
AttachmentData::Path(path) => {
100+
let mut file = File::open(path).await?;
101+
let mut data = Vec::new();
102+
file.read_to_end(&mut data).await?;
103+
Ok(data.into())
104+
},
105+
AttachmentData::File(file) => {
106+
let mut data = Vec::new();
107+
file.try_clone().await?.read_to_end(&mut data).await?;
108+
Ok(data.into())
109+
},
110+
}
111+
}
112+
113+
/// Converts the attachment data to a base64-encoded data URI.
114+
///
115+
/// # Errors
116+
///
117+
/// See [`CreateAttachment::get_data`] for details.
118+
pub async fn encode(&self) -> Result<ImageData> {
95119
use base64::engine::{Config, Engine};
96120

97121
const PREFIX: &str = "data:image/png;base64,";
122+
let data = self.get_data().await?;
98123

99124
let engine = base64::prelude::BASE64_STANDARD;
100-
let encoded_size = base64::encoded_len(self.data.len(), engine.config().encode_padding())
125+
let encoded_size = base64::encoded_len(data.len(), engine.config().encode_padding())
101126
.and_then(|len| len.checked_add(PREFIX.len()))
102127
.expect("buffer capacity overflow");
103128

104129
let mut encoded = String::with_capacity(encoded_size);
105130
encoded.push_str(PREFIX);
106-
engine.encode_string(&self.data, &mut encoded);
107-
encoded
131+
engine.encode_string(&data, &mut encoded);
132+
Ok(ImageData(encoded.into()))
108133
}
109134

110135
/// Sets a description for the file (max 1024 characters).
@@ -114,6 +139,40 @@ impl<'a> CreateAttachment<'a> {
114139
}
115140
}
116141

142+
/// A wrapper around some base64-encoded image data. Used when an endpoint expects the image
143+
/// payload directly as part of the JSON body, instead of as a multipart upload.
144+
#[derive(Clone, Debug, Serialize)]
145+
#[serde(transparent)]
146+
pub struct ImageData<'a>(Cow<'a, str>);
147+
148+
impl<'a> ImageData<'a> {
149+
/// Constructs image data from a base64-encoded blob of data. The string must be a valid data
150+
/// URI, for example:
151+
///
152+
/// ```
153+
/// use serenity::builder::ImageData;
154+
///
155+
/// let s = "data:image/png;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=";
156+
/// assert!(ImageData::from_base64(s).is_ok());
157+
/// ```
158+
///
159+
/// # Errors
160+
///
161+
/// Returns a [`Error::Url`] if the string is not a valid data URI. See the [Discord
162+
/// docs](https://discord.com/developers/docs/reference#image-data).
163+
pub fn from_base64(s: impl Into<Cow<'a, str>>) -> Result<Self> {
164+
let s = s.into();
165+
if let Some(("data", tail)) = s.split_once(':') {
166+
if let Some((mimetype, encoding)) = tail.split_once(';') {
167+
if mimetype.split_once('/').is_some() && encoding.starts_with("base64,") {
168+
return Ok(Self(s));
169+
}
170+
}
171+
}
172+
Err(Error::Url(UrlError::InvalidDataURI))
173+
}
174+
}
175+
117176
#[derive(Clone, Debug, Serialize)]
118177
struct ExistingAttachment {
119178
id: AttachmentId,
@@ -235,10 +294,9 @@ impl<'a> EditAttachments<'a> {
235294
///
236295
/// Opposite of [`Self::keep`].
237296
pub fn remove(mut self, id: AttachmentId) -> Self {
238-
#[expect(clippy::match_like_matches_macro)] // `matches!` is less clear here
239297
self.new_and_existing_attachments.retain(|a| match a {
240-
NewOrExisting::Existing(a) if a.id == id => false,
241-
_ => true,
298+
NewOrExisting::Existing(a) => a.id != id,
299+
NewOrExisting::New(_) => true,
242300
});
243301
self
244302
}
@@ -251,22 +309,19 @@ impl<'a> EditAttachments<'a> {
251309
}
252310

253311
/// Clones all new attachments into a new Vec, keeping only data and filename, because those
254-
/// are needed for the multipart form data. The data is taken out of `self` in the process, so
255-
/// this method can only be called once.
312+
/// are needed for the multipart form data.
256313
#[cfg(feature = "http")]
257-
pub(crate) fn take_files(&mut self) -> Vec<CreateAttachment<'a>> {
258-
let mut files = Vec::new();
259-
for attachment in &mut self.new_and_existing_attachments {
260-
if let NewOrExisting::New(attachment) = attachment {
261-
let cloned_attachment = CreateAttachment::bytes(
262-
std::mem::take(&mut attachment.data),
263-
attachment.filename.clone(),
264-
);
265-
266-
files.push(cloned_attachment);
267-
}
268-
}
269-
files
314+
pub(crate) fn new_attachments(&self) -> Vec<CreateAttachment<'a>> {
315+
self.new_and_existing_attachments
316+
.iter()
317+
.filter_map(|attachment| {
318+
if let NewOrExisting::New(attachment) = &attachment {
319+
Some(attachment.clone())
320+
} else {
321+
None
322+
}
323+
})
324+
.collect()
270325
}
271326
}
272327

src/builder/create_forum_post.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ impl<'a> CreateForumPost<'a> {
9898
///
9999
/// Returns [`Error::Http`] if the current user lacks permission, or if invalid data is given.
100100
#[cfg(feature = "http")]
101-
pub async fn execute(mut self, http: &Http, channel_id: ChannelId) -> Result<GuildChannel> {
102-
let files = self.message.attachments.take_files();
101+
pub async fn execute(self, http: &Http, channel_id: ChannelId) -> Result<GuildChannel> {
102+
let files = self.message.attachments.new_attachments();
103103
http.create_forum_post(channel_id, &self, files, self.audit_log_reason).await
104104
}
105105
}

src/builder/create_interaction_response.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ impl CreateInteractionResponse<'_> {
131131
let files = match &mut self {
132132
CreateInteractionResponse::Message(msg)
133133
| CreateInteractionResponse::Defer(msg)
134-
| CreateInteractionResponse::UpdateMessage(msg) => msg.attachments.take_files(),
134+
| CreateInteractionResponse::UpdateMessage(msg) => msg.attachments.new_attachments(),
135135
_ => Vec::new(),
136136
};
137137

src/builder/create_interaction_response_followup.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ impl<'a> CreateInteractionResponseFollowup<'a> {
179179
) -> Result<Message> {
180180
self.check_length()?;
181181

182-
let files = self.attachments.take_files();
182+
let files = self.attachments.new_attachments();
183183

184184
if self.allowed_mentions.is_none() {
185185
self.allowed_mentions.clone_from(&http.default_allowed_mentions);

src/builder/create_message.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ impl<'a> CreateMessage<'a> {
295295
) -> Result<Message> {
296296
self.check_length()?;
297297

298-
let files = self.attachments.take_files();
298+
let files = self.attachments.new_attachments();
299299
if self.allowed_mentions.is_none() {
300300
self.allowed_mentions.clone_from(&http.default_allowed_mentions);
301301
}

src/builder/create_scheduled_event.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::borrow::Cow;
22

3-
use super::CreateAttachment;
3+
use super::ImageData;
44
#[cfg(feature = "http")]
55
use crate::http::Http;
66
#[cfg(feature = "http")]
@@ -24,7 +24,7 @@ pub struct CreateScheduledEvent<'a> {
2424
description: Option<Cow<'a, str>>,
2525
entity_type: ScheduledEventType,
2626
#[serde(skip_serializing_if = "Option::is_none")]
27-
image: Option<String>,
27+
image: Option<ImageData<'a>>,
2828

2929
#[serde(skip)]
3030
audit_log_reason: Option<&'a str>,
@@ -110,8 +110,8 @@ impl<'a> CreateScheduledEvent<'a> {
110110
}
111111

112112
/// Sets the cover image for the scheduled event.
113-
pub fn image(mut self, image: &CreateAttachment<'_>) -> Self {
114-
self.image = Some(image.to_base64());
113+
pub fn image(mut self, image: ImageData<'a>) -> Self {
114+
self.image = Some(image);
115115
self
116116
}
117117

src/builder/create_webhook.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::borrow::Cow;
22

3-
use super::CreateAttachment;
3+
use super::ImageData;
44
#[cfg(feature = "http")]
55
use crate::http::Http;
66
#[cfg(feature = "http")]
@@ -14,7 +14,7 @@ use crate::model::prelude::*;
1414
pub struct CreateWebhook<'a> {
1515
name: Cow<'a, str>,
1616
#[serde(skip_serializing_if = "Option::is_none")]
17-
avatar: Option<String>,
17+
avatar: Option<ImageData<'a>>,
1818

1919
#[serde(skip)]
2020
audit_log_reason: Option<&'a str>,
@@ -39,8 +39,8 @@ impl<'a> CreateWebhook<'a> {
3939
}
4040

4141
/// Set the webhook's default avatar.
42-
pub fn avatar(mut self, avatar: &CreateAttachment<'_>) -> Self {
43-
self.avatar = Some(avatar.to_base64());
42+
pub fn avatar(mut self, avatar: ImageData<'a>) -> Self {
43+
self.avatar = Some(avatar);
4444
self
4545
}
4646

0 commit comments

Comments
 (0)