use std::sync::Arc;
use rainier_support::{Error, ErrorKind};
#[derive(Clone)]
pub struct AwsConnector {
config: Arc<aws_config::SdkConfig>,
endpoint: Option<String>,
force_path_style: bool,
}
impl AwsConnector {
pub async fn from_env() -> Self {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
Self { config: Arc::new(config), endpoint: None, force_path_style: false }
}
pub async fn in_region(region: impl Into<String>) -> Self {
let region = aws_config::Region::new(region.into());
let config =
aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region).load().await;
Self { config: Arc::new(config), endpoint: None, force_path_style: false }
}
pub async fn with_credentials(
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
region: impl Into<String>,
) -> Self {
let credentials = aws_credential_types::Credentials::new(
access_key_id.into(),
secret_access_key.into(),
None,
None,
"rainier-explicit",
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new(region.into()))
.credentials_provider(credentials)
.load()
.await;
Self { config: Arc::new(config), endpoint: None, force_path_style: false }
}
pub fn from_sdk_config(config: aws_config::SdkConfig) -> Self {
Self { config: Arc::new(config), endpoint: None, force_path_style: false }
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into().trim_end_matches('/').to_string());
self
}
pub fn path_style(mut self, path_style: bool) -> Self {
self.force_path_style = path_style;
self
}
pub fn sdk_config(&self) -> &aws_config::SdkConfig {
&self.config
}
pub fn region(&self) -> Option<&str> {
self.config.region().map(|region| region.as_ref())
}
pub fn endpoint_url(&self) -> Option<&str> {
self.endpoint.as_deref()
}
pub fn is_path_style(&self) -> bool {
self.force_path_style || self.endpoint.is_some()
}
#[cfg(feature = "aws-s3")]
pub fn s3(&self) -> aws_sdk_s3::Client {
let mut builder = aws_sdk_s3::config::Builder::from(self.config.as_ref());
if let Some(endpoint) = &self.endpoint {
builder = builder.endpoint_url(endpoint);
}
if self.is_path_style() {
builder = builder.force_path_style(true);
}
aws_sdk_s3::Client::from_conf(builder.build())
}
#[cfg(feature = "aws-sqs")]
pub fn sqs(&self) -> aws_sdk_sqs::Client {
let mut builder = aws_sdk_sqs::config::Builder::from(self.config.as_ref());
if let Some(endpoint) = &self.endpoint {
builder = builder.endpoint_url(endpoint);
}
aws_sdk_sqs::Client::from_conf(builder.build())
}
#[cfg(feature = "aws-dynamodb")]
pub fn dynamodb(&self) -> aws_sdk_dynamodb::Client {
let mut builder = aws_sdk_dynamodb::config::Builder::from(self.config.as_ref());
if let Some(endpoint) = &self.endpoint {
builder = builder.endpoint_url(endpoint);
}
aws_sdk_dynamodb::Client::from_conf(builder.build())
}
}
impl std::fmt::Debug for AwsConnector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwsConnector")
.field("region", &self.region())
.field("endpoint", &self.endpoint)
.field("path_style", &self.is_path_style())
.finish()
}
}
pub fn sdk_error<E, R>(action: &str, error: aws_sdk_s3::error::SdkError<E, R>) -> Error
where
E: std::error::Error + aws_sdk_s3::error::ProvideErrorMetadata,
{
use aws_sdk_s3::error::SdkError;
let (kind, detail) = match &error {
SdkError::ConstructionFailure(_) => {
(ErrorKind::Internal, "the request could not be built".to_string())
}
SdkError::TimeoutError(_) => (ErrorKind::ServiceUnavailable, "timed out".to_string()),
SdkError::DispatchFailure(_) => {
(ErrorKind::ServiceUnavailable, "could not be sent".to_string())
}
SdkError::ResponseError(_) => {
(ErrorKind::ServiceUnavailable, "returned an unreadable response".to_string())
}
SdkError::ServiceError(service) => {
let code = service.err().code().unwrap_or("an unrecognised error").to_string();
let kind = match code.as_str() {
"AccessDenied"
| "AccessDeniedException"
| "InvalidAccessKeyId"
| "SignatureDoesNotMatch" => ErrorKind::Unauthorized,
"NoSuchKey"
| "NotFound"
| "ResourceNotFoundException"
| "AWS.SimpleQueueService.NonExistentQueue" => ErrorKind::NotFound,
"SlowDown"
| "ThrottlingException"
| "RequestThrottled"
| "ProvisionedThroughputExceededException"
| "RequestLimitExceeded" => ErrorKind::ServiceUnavailable,
_ => ErrorKind::Internal,
};
(kind, code)
}
_ => (ErrorKind::Internal, "failed".to_string()),
};
Error::new(kind, format!("AWS {action}: {detail}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn explicit_credentials_produce_a_usable_configuration() {
let connector = AwsConnector::with_credentials("id", "secret", "eu-west-2").await;
assert_eq!(connector.region(), Some("eu-west-2"));
assert!(!connector.is_path_style());
assert_eq!(connector.endpoint_url(), None);
}
#[tokio::test]
async fn an_endpoint_override_turns_on_path_style() {
let connector = AwsConnector::with_credentials("id", "secret", "auto")
.await
.endpoint("https://account.r2.cloudflarestorage.com/");
assert_eq!(
connector.endpoint_url(),
Some("https://account.r2.cloudflarestorage.com"),
"a trailing slash is trimmed"
);
assert!(connector.is_path_style());
}
#[tokio::test]
async fn path_style_can_be_forced_without_an_endpoint() {
let connector =
AwsConnector::with_credentials("id", "secret", "us-east-1").await.path_style(true);
assert!(connector.is_path_style());
}
#[tokio::test]
async fn a_region_can_be_pinned() {
assert_eq!(
AwsConnector::in_region("ap-southeast-2").await.region(),
Some("ap-southeast-2")
);
}
#[tokio::test]
async fn debug_holds_no_credentials_to_disclose() {
let connector =
AwsConnector::with_credentials("AKIA-visible", "super-secret", "us-east-1").await;
let rendered = format!("{connector:?}");
assert!(!rendered.contains("super-secret"), "{rendered}");
assert!(!rendered.contains("AKIA-visible"), "{rendered}");
assert!(rendered.contains("us-east-1"));
}
#[cfg(feature = "aws-s3")]
#[tokio::test]
async fn every_service_client_builds_from_one_configuration() {
let connector = AwsConnector::with_credentials("id", "secret", "us-east-1").await;
let _ = connector.s3();
#[cfg(feature = "aws-sqs")]
let _ = connector.sqs();
#[cfg(feature = "aws-dynamodb")]
let _ = connector.dynamodb();
}
}