flyer 3.0.7

HTTP framework for rust
Documentation
use std::path::Path;

use anyhow::{Context, Result};
use aws_credential_types::Credentials;
use aws_sdk_s3::config::Region;
use aws_sdk_s3::{Config};
use aws_sdk_s3::{primitives::ByteStream, Client};
use uuid::Uuid;

use crate::request::form::File;
use crate::storage::Storage;

#[derive(Debug, Clone)]
pub struct S3Config {
    pub access_key: String,
    pub secret_key: String,
    pub region: String,
    pub bucket: String,
    pub endpoint_url: Option<String>,
    pub session_token: Option<String>,
}

impl S3Config {
    pub fn new(
        access_key: impl Into<String>,
        secret_key: impl Into<String>,
        region: impl Into<String>,
        bucket: impl Into<String>,
    ) -> Self {
        Self {
            access_key: access_key.into(),
            secret_key: secret_key.into(),
            region: region.into(),
            bucket: bucket.into(),
            endpoint_url: None,
            session_token: None,
        }
    }

    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
        self.endpoint_url = Some(endpoint.into());
        self
    }

    pub fn with_session_token(mut self, token: impl Into<String>) -> Self {
        self.session_token = Some(token.into());
        self
    }
}

pub struct S3Storage {
    client: Client,
    bucket: String,
}

impl S3Storage {
    pub fn new(config: S3Config) -> Self {
        let credentials = Credentials::new(
            config.access_key,
            config.secret_key,
            config.session_token,
            None,
            "manual",
        );

        let mut builder = Config::builder()
            .behavior_version_latest()
            .region(Region::new(config.region))
            .credentials_provider(credentials);

        if let Some(endpoint) = config.endpoint_url {
            builder = builder.endpoint_url(endpoint);
        }

        let client = Client::from_conf(builder.build());

        Self {
            client,
            bucket: config.bucket,
        }
    }

    fn make_key(&self, folder: &str, name: &str) -> String {
        let folder = folder.trim_matches('/');
        let name = name.trim_start_matches('/');

        if folder.is_empty() {
            return name.to_string();
        }
        
        format!("{folder}/{name}")
    }

    pub fn generate_random_filename(&self, original_name: &str) -> String {
        let extension = Path::new(original_name)
            .extension()
            .and_then(|e| e.to_str())
            .map(|e| format!(".{e}"))
            .unwrap_or_default();

        format!("{}{}", Uuid::new_v4(), extension)
    }
}

impl Storage for S3Storage {
    async fn save_as(&self, folder: impl Into<String>, name: impl Into<String>, file: File) -> Result<String> {
        let key = self.make_key(&folder.into(), &name.into());

        self.client
            .put_object()
            .bucket(&self.bucket)
            .key(&key)
            .body(ByteStream::from(file.content))
            .content_type(file.mime)
            .send()
            .await
            .with_context(|| format!("Failed to upload object to S3 at key: '{key}'"))?;

        Ok(key)
    }

    async fn save(&self, folder: impl Into<String>, file: File) -> Result<String> {
        let random_name = self.generate_random_filename(&file.name);
        self.save_as(folder, random_name, file).await
    }

    async fn delete(&self, filename: impl Into<String>) -> Result<()> {
        let key = filename.into();

        self.client
            .delete_object()
            .bucket(&self.bucket)
            .key(&key)
            .send()
            .await
            .with_context(|| format!("Failed to delete object from S3 at key: '{key}'"))?;

        Ok(())
    }

    async fn exists(&self, filename: impl Into<String>) -> Result<bool> {
        let key = filename.into();

        match self
            .client
            .head_object()
            .bucket(&self.bucket)
            .key(&key)
            .send()
            .await
        {
            Ok(_) => Ok(true),
            Err(err) => {
                if let Some(service_err) = err.as_service_error() {
                    if service_err.is_not_found() {
                        return Ok(false);
                    }
                }
                Err(err).with_context(|| format!("Failed to check existence for S3 key: '{key}'"))
            }
        }
    }

    async fn get(&self, filename: impl Into<String>) -> Result<File> {
        let key = filename.into();

        let output = self
            .client
            .get_object()
            .bucket(&self.bucket)
            .key(&key)
            .send()
            .await
            .with_context(|| format!("Failed to retrieve S3 object at key: '{key}'"))?;

        let mime = output
            .content_type()
            .unwrap_or("application/octet-stream")
            .to_string();

        let file_name = Path::new(&key)
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_else(|| key.clone());

        let data = output
            .body
            .collect()
            .await
            .with_context(|| format!("Failed to read stream for S3 key: '{key}'"))?;

        Ok(File {
            name: file_name,
            mime,
            content: data.into_bytes(),
        })
    }
}