spring_batch_rs/tasklet/s3/mod.rs
1//! # S3 Tasklet
2//!
3//! This module provides tasklets for Amazon S3 file transfer operations (put and get).
4//! It is designed to be similar to Spring Batch's S3 capabilities for batch file transfers.
5//!
6//! ## Features
7//!
8//! - S3 PUT operations (upload local files to S3)
9//! - S3 GET operations (download S3 objects to local files, streaming)
10//! - S3 PUT FOLDER operations (upload entire local folder to an S3 prefix)
11//! - S3 GET FOLDER operations (download all objects under an S3 prefix to a local folder)
12//! - Explicit credential configuration (access key / secret key)
13//! - AWS default credential chain (environment variables, `~/.aws/credentials`, IAM role)
14//! - Custom endpoint URL for S3-compatible services (MinIO, LocalStack)
15//! - Configurable multipart upload chunk size
16//!
17//! ## Examples
18//!
19//! ### S3 PUT Operation
20//!
21//! ```rust,no_run
22//! use spring_batch_rs::tasklet::s3::put::S3PutTaskletBuilder;
23//!
24//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
25//! let tasklet = S3PutTaskletBuilder::new()
26//! .bucket("my-bucket")
27//! .key("exports/file.csv")
28//! .local_file("./output/file.csv")
29//! .region("eu-west-1")
30//! .build()?;
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! ### S3 GET Operation
36//!
37//! ```rust,no_run
38//! use spring_batch_rs::tasklet::s3::get::S3GetTaskletBuilder;
39//!
40//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
41//! let tasklet = S3GetTaskletBuilder::new()
42//! .bucket("my-bucket")
43//! .key("imports/file.csv")
44//! .local_file("./input/file.csv")
45//! .region("eu-west-1")
46//! .build()?;
47//! # Ok(())
48//! # }
49//! ```
50//!
51//! ### S3 with MinIO (custom endpoint)
52//!
53//! ```rust,no_run
54//! use spring_batch_rs::tasklet::s3::put::S3PutTaskletBuilder;
55//!
56//! # fn example() -> Result<(), spring_batch_rs::BatchError> {
57//! let tasklet = S3PutTaskletBuilder::new()
58//! .bucket("my-bucket")
59//! .key("file.csv")
60//! .local_file("./output/file.csv")
61//! .endpoint_url("http://localhost:9000")
62//! .access_key_id("minioadmin")
63//! .secret_access_key("minioadmin")
64//! .build()?;
65//! # Ok(())
66//! # }
67//! ```
68
69pub mod get;
70pub mod put;
71
72use crate::BatchError;
73use aws_config::BehaviorVersion;
74use aws_config::meta::region::RegionProviderChain;
75use aws_sdk_s3::config::Builder as S3ConfigBuilder;
76use aws_sdk_s3::config::Credentials;
77
78/// Configuration for connecting to an S3-compatible service.
79///
80/// All fields are optional. When `access_key_id` and `secret_access_key` are both
81/// `None`, the AWS default credential chain is used (environment variables,
82/// `~/.aws/credentials`, IAM instance role).
83///
84/// Set `endpoint_url` to connect to S3-compatible services such as MinIO or LocalStack.
85///
86/// # Examples
87///
88/// ```
89/// use spring_batch_rs::tasklet::s3::S3ClientConfig;
90///
91/// let config = S3ClientConfig {
92/// region: Some("eu-west-1".to_string()),
93/// endpoint_url: None,
94/// access_key_id: None,
95/// secret_access_key: None,
96/// };
97/// assert_eq!(config.region.as_deref(), Some("eu-west-1"));
98/// ```
99#[derive(Debug, Clone, Default)]
100pub struct S3ClientConfig {
101 /// AWS region (e.g. `"eu-west-1"`). Falls back to `AWS_DEFAULT_REGION` env var when `None`.
102 pub region: Option<String>,
103 /// Custom endpoint URL for S3-compatible services (e.g. `"http://localhost:9000"` for MinIO).
104 pub endpoint_url: Option<String>,
105 /// AWS access key ID. Uses default credential chain when `None`.
106 pub access_key_id: Option<String>,
107 /// AWS secret access key. Uses default credential chain when `None`.
108 pub secret_access_key: Option<String>,
109}
110
111/// Builds an [`aws_sdk_s3::Client`] from the given [`S3ClientConfig`].
112///
113/// When both `access_key_id` and `secret_access_key` are set, explicit static
114/// credentials are used. Otherwise the AWS default credential chain applies.
115///
116/// # Errors
117///
118/// Returns [`BatchError::Configuration`] if the AWS SDK configuration cannot be loaded.
119pub(crate) async fn build_s3_client(
120 config: &S3ClientConfig,
121) -> Result<aws_sdk_s3::Client, BatchError> {
122 let region_provider =
123 RegionProviderChain::first_try(config.region.clone().map(aws_sdk_s3::config::Region::new))
124 .or_default_provider();
125
126 let sdk_config = aws_config::defaults(BehaviorVersion::latest())
127 .region(region_provider)
128 .load()
129 .await;
130
131 let mut builder = S3ConfigBuilder::from(&sdk_config);
132
133 if let Some(url) = &config.endpoint_url {
134 builder = builder.endpoint_url(url).force_path_style(true);
135 }
136
137 if let (Some(key_id), Some(secret)) = (&config.access_key_id, &config.secret_access_key) {
138 let creds = Credentials::new(key_id, secret, None, None, "static");
139 builder = builder.credentials_provider(creds);
140 }
141
142 Ok(aws_sdk_s3::Client::from_conf(builder.build()))
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn should_default_to_none_fields() {
151 let config = S3ClientConfig::default();
152 assert!(config.region.is_none(), "region should default to None");
153 assert!(
154 config.endpoint_url.is_none(),
155 "endpoint_url should default to None"
156 );
157 assert!(
158 config.access_key_id.is_none(),
159 "access_key_id should default to None"
160 );
161 assert!(
162 config.secret_access_key.is_none(),
163 "secret_access_key should default to None"
164 );
165 }
166
167 #[test]
168 fn should_store_region() {
169 let config = S3ClientConfig {
170 region: Some("us-east-1".to_string()),
171 ..Default::default()
172 };
173 assert_eq!(config.region.as_deref(), Some("us-east-1"));
174 }
175
176 #[test]
177 fn should_store_endpoint_url() {
178 let config = S3ClientConfig {
179 endpoint_url: Some("http://localhost:9000".to_string()),
180 ..Default::default()
181 };
182 assert_eq!(
183 config.endpoint_url.as_deref(),
184 Some("http://localhost:9000")
185 );
186 }
187
188 #[test]
189 fn should_store_explicit_credentials() {
190 let config = S3ClientConfig {
191 access_key_id: Some("AKID".to_string()),
192 secret_access_key: Some("SECRET".to_string()),
193 ..Default::default()
194 };
195 assert_eq!(config.access_key_id.as_deref(), Some("AKID"));
196 assert_eq!(config.secret_access_key.as_deref(), Some("SECRET"));
197 }
198}