use crate::s3::key_gen::KeyGenerationStrategy;
#[derive(Debug, thiserror::Error)]
pub enum S3ConfigError {
#[error("bucket is required")]
MissingBucket,
#[error("bucket cannot be empty")]
EmptyBucket,
#[error("invalid bucket name: {name} ({reason})")]
InvalidBucket {
name: String,
reason: &'static str,
},
#[error("region is required")]
MissingRegion,
#[error("region cannot be empty")]
EmptyRegion,
#[error("invalid prefix: {prefix} ({reason})")]
InvalidPrefix {
prefix: String,
reason: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerializationFormat {
Json,
#[cfg(feature = "parquet")]
Parquet,
#[cfg(feature = "csv")]
Csv,
#[cfg(feature = "avro")]
Avro,
}
impl SerializationFormat {
#[must_use]
pub const fn extension(&self) -> &'static str {
match self {
Self::Json => "jsonl",
#[cfg(feature = "parquet")]
Self::Parquet => "parquet",
#[cfg(feature = "csv")]
Self::Csv => "csv",
#[cfg(feature = "avro")]
Self::Avro => "avro",
}
}
#[must_use]
pub const fn content_type(&self) -> &'static str {
match self {
Self::Json => "application/x-ndjson",
#[cfg(feature = "parquet")]
Self::Parquet => "application/octet-stream",
#[cfg(feature = "csv")]
Self::Csv => "text/csv",
#[cfg(feature = "avro")]
Self::Avro => "application/avro",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Compression {
#[default]
None,
#[cfg(feature = "gzip")]
Gzip,
#[cfg(feature = "zstandard")]
Zstd,
}
impl Compression {
#[must_use]
pub const fn extension(&self) -> &'static str {
match self {
Self::None => "",
#[cfg(feature = "gzip")]
Self::Gzip => ".gz",
#[cfg(feature = "zstandard")]
Self::Zstd => ".zst",
}
}
#[must_use]
pub const fn encoding(&self) -> Option<&'static str> {
match self {
Self::None => None,
#[cfg(feature = "gzip")]
Self::Gzip => Some("gzip"),
#[cfg(feature = "zstandard")]
Self::Zstd => Some("zstd"),
}
}
}
#[derive(Debug, Clone)]
pub struct AwsCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
}
impl AwsCredentials {
#[must_use]
pub fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
Self {
access_key_id: access_key_id.into(),
secret_access_key: secret_access_key.into(),
session_token: None,
}
}
#[must_use]
pub fn with_session_token(
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
session_token: impl Into<String>,
) -> Self {
Self {
access_key_id: access_key_id.into(),
secret_access_key: secret_access_key.into(),
session_token: Some(session_token.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct S3Config {
pub bucket: String,
pub region: String,
pub prefix: Option<String>,
pub format: SerializationFormat,
pub compression: Compression,
pub key_strategy: KeyGenerationStrategy,
pub max_retries: u32,
pub endpoint_url: Option<String>,
pub force_path_style: bool,
pub credentials: Option<AwsCredentials>,
}
impl Default for S3Config {
fn default() -> Self {
Self {
bucket: String::new(),
region: String::from("us-east-1"),
prefix: None,
format: SerializationFormat::Json,
compression: Compression::None,
key_strategy: KeyGenerationStrategy::DateHourPartitioned,
max_retries: 3,
endpoint_url: None,
force_path_style: false,
credentials: None,
}
}
}
#[derive(Debug, Default)]
pub struct S3ConfigBuilder {
bucket: Option<String>,
region: Option<String>,
prefix: Option<String>,
format: Option<SerializationFormat>,
compression: Option<Compression>,
key_strategy: Option<KeyGenerationStrategy>,
max_retries: Option<u32>,
endpoint_url: Option<String>,
force_path_style: Option<bool>,
credentials: Option<AwsCredentials>,
}
impl S3ConfigBuilder {
#[must_use]
pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
self.bucket = Some(bucket.into());
self
}
#[must_use]
pub fn region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
#[must_use]
pub fn format(mut self, format: SerializationFormat) -> Self {
self.format = Some(format);
self
}
#[must_use]
pub fn compression(mut self, compression: Compression) -> Self {
self.compression = Some(compression);
self
}
#[must_use]
pub fn key_strategy(mut self, strategy: KeyGenerationStrategy) -> Self {
self.key_strategy = Some(strategy);
self
}
#[must_use]
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = Some(retries);
self
}
#[must_use]
pub fn endpoint_url(mut self, url: impl Into<String>) -> Self {
self.endpoint_url = Some(url.into());
self
}
#[must_use]
pub fn force_path_style(mut self, force: bool) -> Self {
self.force_path_style = Some(force);
self
}
#[must_use]
pub fn credentials(mut self, credentials: AwsCredentials) -> Self {
self.credentials = Some(credentials);
self
}
pub fn build(self) -> Result<S3Config, S3ConfigError> {
let bucket = self.bucket.ok_or(S3ConfigError::MissingBucket)?;
if bucket.is_empty() {
return Err(S3ConfigError::EmptyBucket);
}
if bucket.len() < 3 || bucket.len() > 63 {
return Err(S3ConfigError::InvalidBucket {
name: bucket,
reason: "must be 3-63 characters",
});
}
if !bucket
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
{
return Err(S3ConfigError::InvalidBucket {
name: bucket,
reason: "must contain only lowercase letters, numbers, hyphens, and periods",
});
}
let region = self.region.ok_or(S3ConfigError::MissingRegion)?;
if region.is_empty() {
return Err(S3ConfigError::EmptyRegion);
}
if let Some(ref prefix) = self.prefix {
if prefix.contains("..") {
return Err(S3ConfigError::InvalidPrefix {
prefix: prefix.clone(),
reason: "prefix cannot contain '..' (path traversal)",
});
}
if prefix.starts_with('/') {
return Err(S3ConfigError::InvalidPrefix {
prefix: prefix.clone(),
reason: "prefix cannot start with '/'",
});
}
}
Ok(S3Config {
bucket,
region,
prefix: self.prefix,
format: self.format.unwrap_or(SerializationFormat::Json),
compression: self.compression.unwrap_or_default(),
key_strategy: self
.key_strategy
.unwrap_or(KeyGenerationStrategy::DateHourPartitioned),
max_retries: self.max_retries.unwrap_or(3),
endpoint_url: self.endpoint_url,
force_path_style: self.force_path_style.unwrap_or(false),
credentials: self.credentials,
})
}
}
impl S3Config {
#[must_use]
pub fn builder() -> S3ConfigBuilder {
S3ConfigBuilder::default()
}
}