modo-rs 0.8.0

Rust web framework for small monolithic apps
Documentation
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;

use bytes::Bytes;

use super::options::{Acl, PutOptions};
use crate::error::Result;

#[allow(dead_code)]
struct StoredObject {
    data: Bytes,
    content_type: String,
    acl: Option<Acl>,
}

pub(crate) struct MemoryBackend {
    objects: RwLock<HashMap<String, StoredObject>>,
    fake_url_base: String,
}

impl MemoryBackend {
    #[cfg_attr(not(any(test, feature = "test-helpers")), allow(dead_code))]
    pub fn new() -> Self {
        Self {
            objects: RwLock::new(HashMap::new()),
            fake_url_base: "https://memory.test".to_string(),
        }
    }

    pub async fn put(
        &self,
        key: &str,
        data: Bytes,
        content_type: &str,
        opts: &PutOptions,
    ) -> Result<()> {
        let mut map = self.objects.write().expect("lock poisoned");
        map.insert(
            key.to_string(),
            StoredObject {
                data,
                content_type: content_type.to_string(),
                acl: opts.acl,
            },
        );
        Ok(())
    }

    pub async fn delete(&self, key: &str) -> Result<()> {
        let mut map = self.objects.write().expect("lock poisoned");
        map.remove(key);
        Ok(())
    }

    pub async fn exists(&self, key: &str) -> Result<bool> {
        let map = self.objects.read().expect("lock poisoned");
        Ok(map.contains_key(key))
    }

    pub async fn list(&self, prefix: &str) -> Result<Vec<String>> {
        let map = self.objects.read().expect("lock poisoned");
        let keys = map
            .keys()
            .filter(|k| k.starts_with(prefix))
            .cloned()
            .collect();
        Ok(keys)
    }

    pub async fn presigned_url(&self, key: &str, expires_in: Duration) -> Result<String> {
        Ok(format!(
            "{}/{}?expires={}",
            self.fake_url_base,
            key,
            expires_in.as_secs()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn put_and_exists() {
        let backend = MemoryBackend::new();
        backend
            .put(
                "test/file.txt",
                Bytes::from("hello"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();
        assert!(backend.exists("test/file.txt").await.unwrap());
    }

    #[tokio::test]
    async fn exists_false_for_missing() {
        let backend = MemoryBackend::new();
        assert!(!backend.exists("missing.txt").await.unwrap());
    }

    #[tokio::test]
    async fn delete_removes_key() {
        let backend = MemoryBackend::new();
        backend
            .put(
                "key.txt",
                Bytes::from("data"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();
        backend.delete("key.txt").await.unwrap();
        assert!(!backend.exists("key.txt").await.unwrap());
    }

    #[tokio::test]
    async fn delete_nonexistent_is_noop() {
        let backend = MemoryBackend::new();
        backend.delete("missing.txt").await.unwrap();
    }

    #[tokio::test]
    async fn list_by_prefix() {
        let backend = MemoryBackend::new();
        backend
            .put(
                "prefix/a.txt",
                Bytes::from("a"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();
        backend
            .put(
                "prefix/b.txt",
                Bytes::from("b"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();
        backend
            .put(
                "other/c.txt",
                Bytes::from("c"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();

        let mut keys = backend.list("prefix/").await.unwrap();
        keys.sort();
        assert_eq!(keys, vec!["prefix/a.txt", "prefix/b.txt"]);
    }

    #[tokio::test]
    async fn presigned_url_returns_fake() {
        let backend = MemoryBackend::new();
        let url = backend
            .presigned_url("test/file.txt", Duration::from_secs(3600))
            .await
            .unwrap();
        assert_eq!(url, "https://memory.test/test/file.txt?expires=3600");
    }

    #[tokio::test]
    async fn put_stores_acl() {
        let backend = MemoryBackend::new();
        let opts = PutOptions {
            acl: Some(super::super::options::Acl::PublicRead),
            ..Default::default()
        };
        backend
            .put("test/file.txt", Bytes::from("hello"), "text/plain", &opts)
            .await
            .unwrap();

        let map = backend.objects.read().expect("lock poisoned");
        let obj = map.get("test/file.txt").unwrap();
        assert_eq!(obj.acl, Some(super::super::options::Acl::PublicRead));
    }

    #[tokio::test]
    async fn put_stores_none_acl_by_default() {
        let backend = MemoryBackend::new();
        backend
            .put(
                "test/file.txt",
                Bytes::from("hello"),
                "text/plain",
                &PutOptions::default(),
            )
            .await
            .unwrap();

        let map = backend.objects.read().expect("lock poisoned");
        let obj = map.get("test/file.txt").unwrap();
        assert_eq!(obj.acl, None);
    }
}