#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, Watching};
pub use aws_config::SdkConfig;
pub use aws_sdk_s3::Client;
pub struct S3 {
client: Client,
bucket: String,
key: String,
format: Option<Format>,
endpoint: Option<String>,
}
impl S3 {
pub async fn new(bucket: impl Into<String>, key: impl Into<String>) -> Result<Self, Error> {
let config = aws_config::load_from_env().await;
Ok(Self::with_config(&config, bucket, key))
}
#[must_use]
pub fn with_config(
config: &SdkConfig,
bucket: impl Into<String>,
key: impl Into<String>,
) -> Self {
let s3 = aws_sdk_s3::config::Builder::from(config)
.force_path_style(true)
.build();
let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
source.endpoint = config.endpoint_url().map(str::to_owned);
source
}
#[must_use]
pub fn from_client(client: Client, bucket: impl Into<String>, key: impl Into<String>) -> Self {
let key = key.into();
let format = Format::from_key(&key);
Self {
client,
bucket: bucket.into(),
key,
format,
endpoint: None,
}
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self
}
pub async fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error> + Send,
{
self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})?;
let mut seen: Option<String> = None;
while watching.keep_going() {
match self.etag().await {
Ok(tag) if seen.is_none() => seen = Some(tag),
Ok(tag) if seen.as_ref() != Some(&tag) => {
if let Ok((document, current)) = self.read().await {
seen = current.or(Some(tag));
on_change(document)?;
}
}
_ => {}
}
sleep_while(interval, watching).await;
}
Ok(())
}
async fn etag(&self) -> Result<String, Error> {
let head = self
.client
.head_object()
.bucket(&self.bucket)
.key(&self.key)
.send()
.await
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
head.e_tag()
.map(str::to_owned)
.ok_or_else(|| Error::remote(format!("{}: the object has no ETag", self.describe())))
}
async fn read(&self) -> Result<(Fetched, Option<String>), Error> {
let format = self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})?;
let object = self
.client
.get_object()
.bucket(&self.bucket)
.key(&self.key)
.send()
.await
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
let tag = object.e_tag().map(str::to_owned);
let bytes = object
.body
.collect()
.await
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?
.into_bytes();
let text = String::from_utf8(bytes.to_vec()).map_err(|error| {
Error::remote(format!(
"{}: the object is not UTF-8: {error}",
self.describe()
))
})?;
Ok((Fetched::new(text, format), tag))
}
}
impl AsyncRemoteSource for S3 {
fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
Box::pin(async move { self.read().await.map(|(document, _tag)| document) })
}
fn describe(&self) -> String {
match &self.endpoint {
Some(endpoint) => format!("s3 {endpoint} {}/{}", self.bucket, self.key),
None => format!("s3 {}/{}", self.bucket, self.key),
}
}
}
impl std::fmt::Debug for S3 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3")
.field("bucket", &self.bucket)
.field("key", &self.key)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
async fn sleep_while(total: Duration, watching: &Watching) {
const SLICE: Duration = Duration::from_millis(250);
let mut slept = Duration::ZERO;
while slept < total && watching.keep_going() {
tokio::time::sleep(SLICE).await;
slept += SLICE;
}
}