s3/
put_object_request.rs

1//! Builder pattern for S3 PUT operations with customizable options
2//!
3//! This module provides a builder pattern for constructing PUT requests with
4//! various options including custom headers, content type, and other metadata.
5
6use crate::error::S3Error;
7use crate::request::{Request as _, ResponseData};
8use crate::{Bucket, command::Command};
9use http::{HeaderMap, HeaderName, HeaderValue};
10
11#[cfg(feature = "with-tokio")]
12use tokio::io::AsyncRead;
13
14#[cfg(feature = "with-async-std")]
15use async_std::io::Read as AsyncRead;
16
17#[cfg(feature = "with-async-std")]
18use crate::request::async_std_backend::SurfRequest as RequestImpl;
19#[cfg(feature = "sync")]
20use crate::request::blocking::AttoRequest as RequestImpl;
21#[cfg(feature = "with-tokio")]
22use crate::request::tokio_backend::ReqwestRequest as RequestImpl;
23
24/// Builder for constructing S3 PUT object requests with custom options
25///
26/// # Example
27/// ```no_run
28/// use s3::bucket::Bucket;
29/// use s3::creds::Credentials;
30/// use anyhow::Result;
31///
32/// # #[tokio::main]
33/// # async fn main() -> Result<()> {
34/// let bucket = Bucket::new("my-bucket", "us-east-1".parse()?, Credentials::default()?)?;
35///
36/// // Upload with custom headers using builder pattern
37/// let response = bucket.put_object_builder("/my-file.txt", b"Hello, World!")
38///     .with_content_type("text/plain")
39///     .with_cache_control("public, max-age=3600")?
40///     .with_content_encoding("gzip")?
41///     .execute()
42///     .await?;
43/// # Ok(())
44/// # }
45/// ```
46#[derive(Debug, Clone)]
47pub struct PutObjectRequest<'a> {
48    bucket: &'a Bucket,
49    path: String,
50    content: Vec<u8>,
51    content_type: String,
52    custom_headers: HeaderMap,
53}
54
55impl<'a> PutObjectRequest<'a> {
56    /// Create a new PUT object request builder
57    pub(crate) fn new<S: AsRef<str>>(bucket: &'a Bucket, path: S, content: &[u8]) -> Self {
58        Self {
59            bucket,
60            path: path.as_ref().to_string(),
61            content: content.to_vec(),
62            content_type: "application/octet-stream".to_string(),
63            custom_headers: HeaderMap::new(),
64        }
65    }
66
67    /// Set the Content-Type header
68    pub fn with_content_type<S: AsRef<str>>(mut self, content_type: S) -> Self {
69        self.content_type = content_type.as_ref().to_string();
70        self
71    }
72
73    /// Set the Cache-Control header
74    pub fn with_cache_control<S: AsRef<str>>(mut self, cache_control: S) -> Result<Self, S3Error> {
75        let value = cache_control
76            .as_ref()
77            .parse::<HeaderValue>()
78            .map_err(S3Error::InvalidHeaderValue)?;
79        self.custom_headers
80            .insert(http::header::CACHE_CONTROL, value);
81        Ok(self)
82    }
83
84    /// Set the Content-Encoding header
85    pub fn with_content_encoding<S: AsRef<str>>(mut self, encoding: S) -> Result<Self, S3Error> {
86        let value = encoding
87            .as_ref()
88            .parse::<HeaderValue>()
89            .map_err(S3Error::InvalidHeaderValue)?;
90        self.custom_headers
91            .insert(http::header::CONTENT_ENCODING, value);
92        Ok(self)
93    }
94
95    /// Set the Content-Disposition header
96    pub fn with_content_disposition<S: AsRef<str>>(
97        mut self,
98        disposition: S,
99    ) -> Result<Self, S3Error> {
100        let value = disposition
101            .as_ref()
102            .parse::<HeaderValue>()
103            .map_err(S3Error::InvalidHeaderValue)?;
104        self.custom_headers
105            .insert(http::header::CONTENT_DISPOSITION, value);
106        Ok(self)
107    }
108
109    /// Set the Expires header
110    pub fn with_expires<S: AsRef<str>>(mut self, expires: S) -> Result<Self, S3Error> {
111        let value = expires
112            .as_ref()
113            .parse::<HeaderValue>()
114            .map_err(S3Error::InvalidHeaderValue)?;
115        self.custom_headers.insert(http::header::EXPIRES, value);
116        Ok(self)
117    }
118
119    /// Add a custom header
120    pub fn with_header<V>(mut self, key: &str, value: V) -> Result<Self, S3Error>
121    where
122        V: AsRef<str>,
123    {
124        let header_name = HeaderName::from_bytes(key.as_bytes())?;
125        let header_value = value
126            .as_ref()
127            .parse::<HeaderValue>()
128            .map_err(S3Error::InvalidHeaderValue)?;
129        self.custom_headers.insert(header_name, header_value);
130        Ok(self)
131    }
132
133    /// Add multiple custom headers (already validated HeaderMap)
134    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
135        self.custom_headers.extend(headers);
136        self
137    }
138
139    /// Add S3 metadata header (x-amz-meta-*)
140    pub fn with_metadata<K: AsRef<str>, V: AsRef<str>>(
141        mut self,
142        key: K,
143        value: V,
144    ) -> Result<Self, S3Error> {
145        let header_name = format!("x-amz-meta-{}", key.as_ref());
146        let name = header_name.parse::<http::HeaderName>()?;
147        let value = value
148            .as_ref()
149            .parse::<HeaderValue>()
150            .map_err(S3Error::InvalidHeaderValue)?;
151        self.custom_headers.insert(name, value);
152        Ok(self)
153    }
154
155    /// Add x-amz-storage-class header
156    pub fn with_storage_class<S: AsRef<str>>(mut self, storage_class: S) -> Result<Self, S3Error> {
157        let header_value = storage_class
158            .as_ref()
159            .parse::<HeaderValue>()
160            .map_err(S3Error::InvalidHeaderValue)?;
161        self.custom_headers.insert(
162            http::HeaderName::from_static("x-amz-storage-class"),
163            header_value,
164        );
165        Ok(self)
166    }
167
168    /// Add x-amz-server-side-encryption header
169    pub fn with_server_side_encryption<S: AsRef<str>>(
170        mut self,
171        encryption: S,
172    ) -> Result<Self, S3Error> {
173        let header_value = encryption
174            .as_ref()
175            .parse::<HeaderValue>()
176            .map_err(S3Error::InvalidHeaderValue)?;
177        self.custom_headers.insert(
178            http::HeaderName::from_static("x-amz-server-side-encryption"),
179            header_value,
180        );
181        Ok(self)
182    }
183
184    /// Execute the PUT request
185    #[maybe_async::maybe_async]
186    pub async fn execute(self) -> Result<ResponseData, S3Error> {
187        let command = Command::PutObject {
188            content: &self.content,
189            content_type: &self.content_type,
190            custom_headers: if self.custom_headers.is_empty() {
191                None
192            } else {
193                Some(self.custom_headers)
194            },
195            multipart: None,
196        };
197
198        let request = RequestImpl::new(self.bucket, &self.path, command).await?;
199        request.response_data(true).await
200    }
201}
202
203/// Builder for streaming PUT operations
204#[cfg(any(feature = "with-tokio", feature = "with-async-std"))]
205#[derive(Debug, Clone)]
206pub struct PutObjectStreamRequest<'a> {
207    bucket: &'a Bucket,
208    path: String,
209    content_type: String,
210    custom_headers: HeaderMap,
211}
212
213#[cfg(any(feature = "with-tokio", feature = "with-async-std"))]
214impl<'a> PutObjectStreamRequest<'a> {
215    /// Create a new streaming PUT request builder
216    pub(crate) fn new<S: AsRef<str>>(bucket: &'a Bucket, path: S) -> Self {
217        Self {
218            bucket,
219            path: path.as_ref().to_string(),
220            content_type: "application/octet-stream".to_string(),
221            custom_headers: HeaderMap::new(),
222        }
223    }
224
225    /// Set the Content-Type header
226    pub fn with_content_type<S: AsRef<str>>(mut self, content_type: S) -> Self {
227        self.content_type = content_type.as_ref().to_string();
228        self
229    }
230
231    /// Set the Cache-Control header
232    pub fn with_cache_control<S: AsRef<str>>(mut self, cache_control: S) -> Result<Self, S3Error> {
233        let value = cache_control
234            .as_ref()
235            .parse::<HeaderValue>()
236            .map_err(S3Error::InvalidHeaderValue)?;
237        self.custom_headers
238            .insert(http::header::CACHE_CONTROL, value);
239        Ok(self)
240    }
241
242    /// Set the Content-Encoding header
243    pub fn with_content_encoding<S: AsRef<str>>(mut self, encoding: S) -> Result<Self, S3Error> {
244        let value = encoding
245            .as_ref()
246            .parse::<HeaderValue>()
247            .map_err(S3Error::InvalidHeaderValue)?;
248        self.custom_headers
249            .insert(http::header::CONTENT_ENCODING, value);
250        Ok(self)
251    }
252
253    /// Add a custom header
254    pub fn with_header<K, V>(mut self, key: K, value: V) -> Result<Self, S3Error>
255    where
256        K: Into<http::HeaderName>,
257        V: AsRef<str>,
258    {
259        let header_value = value
260            .as_ref()
261            .parse::<HeaderValue>()
262            .map_err(S3Error::InvalidHeaderValue)?;
263        self.custom_headers.insert(key.into(), header_value);
264        Ok(self)
265    }
266
267    /// Add multiple custom headers (already validated HeaderMap)
268    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
269        self.custom_headers.extend(headers);
270        self
271    }
272
273    /// Add S3 metadata header (x-amz-meta-*)
274    pub fn with_metadata<K: AsRef<str>, V: AsRef<str>>(
275        mut self,
276        key: K,
277        value: V,
278    ) -> Result<Self, S3Error> {
279        let header_name = format!("x-amz-meta-{}", key.as_ref());
280        let name = header_name.parse::<http::HeaderName>()?;
281        let value = value
282            .as_ref()
283            .parse::<HeaderValue>()
284            .map_err(S3Error::InvalidHeaderValue)?;
285        self.custom_headers.insert(name, value);
286        Ok(self)
287    }
288
289    /// Execute the streaming PUT request
290    #[cfg(feature = "with-tokio")]
291    pub async fn execute_stream<R: AsyncRead + Unpin + ?Sized>(
292        self,
293        reader: &mut R,
294    ) -> Result<crate::utils::PutStreamResponse, S3Error> {
295        // AsyncReadExt trait is not used here
296
297        self.bucket
298            ._put_object_stream_with_content_type_and_headers(
299                reader,
300                &self.path,
301                &self.content_type,
302                if self.custom_headers.is_empty() {
303                    None
304                } else {
305                    Some(self.custom_headers)
306                },
307            )
308            .await
309    }
310
311    #[cfg(feature = "with-async-std")]
312    pub async fn execute_stream<R: AsyncRead + Unpin + ?Sized>(
313        self,
314        reader: &mut R,
315    ) -> Result<crate::utils::PutStreamResponse, S3Error> {
316        self.bucket
317            ._put_object_stream_with_content_type_and_headers(
318                reader,
319                &self.path,
320                &self.content_type,
321                if self.custom_headers.is_empty() {
322                    None
323                } else {
324                    Some(self.custom_headers)
325                },
326            )
327            .await
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::Region;
335    use crate::creds::Credentials;
336
337    #[test]
338    fn test_builder_chain() {
339        let bucket =
340            Bucket::new("test", Region::UsEast1, Credentials::anonymous().unwrap()).unwrap();
341
342        let content = b"test content";
343        let request = PutObjectRequest::new(&bucket, "/test.txt", content)
344            .with_content_type("text/plain")
345            .with_cache_control("max-age=3600")
346            .unwrap()
347            .with_content_encoding("gzip")
348            .unwrap()
349            .with_metadata("author", "test-user")
350            .unwrap()
351            .with_header("x-custom", "value")
352            .unwrap()
353            .with_storage_class("STANDARD_IA")
354            .unwrap();
355
356        assert_eq!(request.content_type, "text/plain");
357        assert!(
358            request
359                .custom_headers
360                .contains_key(http::header::CACHE_CONTROL)
361        );
362        assert!(
363            request
364                .custom_headers
365                .contains_key(http::header::CONTENT_ENCODING)
366        );
367        assert!(request.custom_headers.contains_key("x-amz-meta-author"));
368        assert!(request.custom_headers.contains_key("x-custom"));
369        assert!(request.custom_headers.contains_key("x-amz-storage-class"));
370    }
371
372    #[test]
373    fn test_metadata_headers() {
374        let bucket =
375            Bucket::new("test", Region::UsEast1, Credentials::anonymous().unwrap()).unwrap();
376
377        let request = PutObjectRequest::new(&bucket, "/test.txt", b"test")
378            .with_metadata("key1", "value1")
379            .unwrap()
380            .with_metadata("key2", "value2")
381            .unwrap();
382
383        assert!(request.custom_headers.contains_key("x-amz-meta-key1"));
384        assert!(request.custom_headers.contains_key("x-amz-meta-key2"));
385    }
386}