use std::{
error::Error,
fmt::{Display, Formatter},
str::FromStr,
};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Region {
Aws(String),
Custom { name: String, endpoint: String },
}
impl Region {
pub fn aws(name: impl Into<String>) -> Self {
Self::Aws(name.into())
}
pub fn custom(name: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self::Custom {
name: name.into(),
endpoint: endpoint.into(),
}
}
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Aws(name) | Self::Custom { name, .. } => name,
}
}
#[must_use]
pub fn endpoint(&self) -> String {
match self {
Self::Aws(region) => format!("s3.{region}.amazonaws.com"),
Self::Custom { endpoint, .. } => endpoint.clone(),
}
}
#[must_use]
pub fn host(&self) -> String {
match self {
Self::Aws(region) => format!("s3.{region}.amazonaws.com"),
Self::Custom { endpoint, .. } => {
endpoint
.strip_prefix("http://")
.or_else(|| endpoint.strip_prefix("https://"))
.unwrap_or(endpoint)
.to_string()
}
}
}
#[must_use]
pub fn is_aws_region(&self) -> bool {
match self {
Self::Aws(region) => {
let parts: Vec<&str> = region.split('-').collect();
if parts.len() < 3 {
return false;
}
parts
.last()
.is_some_and(|&last| last.chars().all(|c| c.is_ascii_digit()))
}
Self::Custom { .. } => false,
}
}
}
impl FromStr for Region {
type Err = ParseRegionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let input = s.trim();
if input.is_empty() || input.contains(' ') {
return Err(ParseRegionError::new(s));
}
if input.contains("://") || input.contains('.') {
let name = if let Some(host) = input.split("://").nth(1) {
host.split('.').next().unwrap_or(input).to_string()
} else {
input.split('.').next().unwrap_or(input).to_string()
};
return Ok(Self::custom(name, input));
}
let region = input.to_lowercase();
let aws_region = Self::aws(region);
if !aws_region.is_aws_region() {
return Err(ParseRegionError::new(s));
}
Ok(aws_region)
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct ParseRegionError {
message: String,
}
impl ParseRegionError {
#[must_use]
pub fn new(input: &str) -> Self {
Self {
message: format!("Not a valid AWS region: {input}"),
}
}
}
impl Error for ParseRegionError {}
impl Display for ParseRegionError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.message)
}
}
impl Default for Region {
fn default() -> Self {
std::env::var("AWS_DEFAULT_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.as_ref()
.map_or_else(
|_| Self::aws("us-east-1"),
|v| Self::from_str(v).unwrap_or_else(|_| Self::aws("us-east-1")),
)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::unnecessary_wraps
)]
mod tests {
use super::*;
#[test]
fn test_region_from_str() {
assert_eq!(
"us-east-1".parse::<Region>().unwrap(),
Region::aws("us-east-1")
);
assert_eq!(
"eu-west-1".parse::<Region>().unwrap(),
Region::aws("eu-west-1")
);
assert_eq!(
"ap-southeast-1".parse::<Region>().unwrap(),
Region::aws("ap-southeast-1")
);
}
#[test]
fn test_endpoints() {
let us_east = Region::aws("us-east-1");
assert_eq!(us_east.endpoint(), "s3.us-east-1.amazonaws.com");
let custom = Region::custom("custom", "localhost:9000");
assert_eq!(custom.endpoint(), "localhost:9000");
let custom = "foo.bar".parse::<Region>().unwrap();
assert_eq!(custom.name(), "foo");
assert_eq!(custom.endpoint(), "foo.bar");
}
#[test]
fn test_validation() {
assert!(Region::aws("us-east-1").is_aws_region());
assert!(Region::aws("eu-west-2").is_aws_region());
assert!(!Region::aws("invalid").is_aws_region());
assert!(!Region::custom("custom", "localhost:9000").is_aws_region());
let name = String::from("foo");
assert_eq!(name.parse::<Region>(), Err(ParseRegionError::new("foo")));
}
}