notedthat-write 0.12.1

Shared write path (commit, patch, replace) for NotedThat HTTP API and WebDAV surfaces
Documentation
use async_trait::async_trait;
use bytes::Bytes;
use notedthat_core::{
    ByteRange, ConditionalHeaders, KbManifest, KbSlug, ListResponse, ObjectMeta, ObjectPath,
    ObjectRead, PutOutcome, Storage, StorageError,
};
use std::{collections::HashMap, sync::Mutex};

mod runner;

pub(in crate::replace::tests) use runner::{
    ReplaceArgs, conditionals, expect_replace_err, kb, path, run_replace, run_replace_with,
};

#[derive(Clone)]
struct StoredObject {
    body: Bytes,
    etag: String,
    content_type: Option<String>,
}

#[derive(Default)]
pub(super) struct TestStorage {
    objects: Mutex<HashMap<String, StoredObject>>,
    calls: Mutex<Calls>,
    script: Mutex<Script>,
}

#[derive(Default)]
pub(super) struct Calls {
    pub(super) head: u32,
    pub(super) get: u32,
    pub(super) put: u32,
}

#[derive(Default)]
pub(super) struct Script {
    pub(super) get_failures_remaining: u32,
    pub(super) put_failures_remaining: u32,
}

#[async_trait]
impl Storage for TestStorage {
    async fn probe(&self, _kb: &KbSlug) -> Result<(), StorageError> {
        Ok(())
    }

    async fn ensure_bucket(&self, _kb: &KbSlug) -> Result<(), StorageError> {
        unimplemented!()
    }

    async fn read_manifest(&self, _kb: &KbSlug) -> Result<KbManifest, StorageError> {
        unimplemented!()
    }

    async fn write_manifest(
        &self,
        _kb: &KbSlug,
        _manifest: &KbManifest,
    ) -> Result<(), StorageError> {
        unimplemented!()
    }

    async fn head_object(
        &self,
        kb: &KbSlug,
        path: &ObjectPath,
        conditionals: ConditionalHeaders,
    ) -> Result<ObjectMeta, StorageError> {
        self.calls.lock().expect("mutex not poisoned").head += 1;
        let object = self.object(kb, path)?;
        check_if_match(&conditionals, &object.etag)?;
        Ok(meta(path, &object))
    }

    async fn get_object(
        &self,
        kb: &KbSlug,
        path: &ObjectPath,
        _range: Option<ByteRange>,
        conditionals: ConditionalHeaders,
    ) -> Result<ObjectRead, StorageError> {
        self.calls.lock().expect("mutex not poisoned").get += 1;
        let object = self.object(kb, path)?;
        check_if_match(&conditionals, &object.etag)?;
        let mut script = self.script.lock().expect("mutex not poisoned");
        if script.get_failures_remaining > 0 {
            script.get_failures_remaining -= 1;
            return Err(StorageError::PreconditionFailed);
        }
        drop(script);
        Ok(ObjectRead {
            bytes: object.body.clone(),
            meta: meta(path, &object),
            content_range: None,
        })
    }

    async fn get_object_stream(
        &self,
        _kb: &KbSlug,
        _path: &ObjectPath,
        _range: Option<ByteRange>,
        _conditionals: ConditionalHeaders,
    ) -> Result<notedthat_core::ObjectStream, StorageError> {
        unimplemented!()
    }

    async fn put_object(
        &self,
        kb: &KbSlug,
        path: &ObjectPath,
        bytes: Bytes,
        content_type: Option<&str>,
        conditionals: ConditionalHeaders,
    ) -> Result<PutOutcome, StorageError> {
        self.calls.lock().expect("mutex not poisoned").put += 1;
        let key = key(kb, path);
        let mut objects = self.objects.lock().expect("mutex not poisoned");
        let current = objects.get(&key).map(|object| object.etag.as_str());
        if conditionals
            .if_match
            .as_deref()
            .is_some_and(|etag| current != Some(etag))
        {
            return Err(StorageError::PreconditionFailed);
        }
        let mut script = self.script.lock().expect("mutex not poisoned");
        if script.put_failures_remaining > 0 {
            script.put_failures_remaining -= 1;
            return Err(StorageError::PreconditionFailed);
        }
        drop(script);
        let etag = "etag2".to_string();
        objects.insert(
            key,
            StoredObject {
                body: bytes,
                etag: etag.clone(),
                content_type: content_type.map(str::to_string),
            },
        );
        Ok(PutOutcome { etag: Some(etag) })
    }

    async fn put_staged_object(
        &self,
        _kb: &KbSlug,
        _path: &ObjectPath,
        _body: notedthat_core::StagedBody,
        _content_type: Option<&str>,
        _conditionals: ConditionalHeaders,
    ) -> Result<PutOutcome, StorageError> {
        unimplemented!()
    }

    async fn copy_object(
        &self,
        _kb: &KbSlug,
        _source: &ObjectPath,
        _destination: &ObjectPath,
        _options: notedthat_core::CopyObjectOptions,
    ) -> Result<PutOutcome, StorageError> {
        unimplemented!()
    }

    async fn delete_object(
        &self,
        _kb: &KbSlug,
        _path: &ObjectPath,
        _conditionals: ConditionalHeaders,
    ) -> Result<(), StorageError> {
        unimplemented!()
    }

    async fn list_objects(
        &self,
        _kb: &KbSlug,
        _prefix: Option<&str>,
        _limit: u32,
        _cursor: Option<&str>,
    ) -> Result<ListResponse, StorageError> {
        unimplemented!()
    }
}

impl TestStorage {
    pub(super) fn with_body(body: &'static [u8]) -> Self {
        Self::with_bytes(Bytes::from_static(body), Some("text/plain"))
    }

    pub(super) fn with_body_and_content_type(body: &'static [u8], content_type: &str) -> Self {
        Self::with_bytes(Bytes::from_static(body), Some(content_type))
    }

    pub(super) fn with_bytes(body: Bytes, content_type: Option<&str>) -> Self {
        let storage = Self::default();
        storage.objects.lock().expect("mutex not poisoned").insert(
            key(&make_kb(), &make_path()),
            StoredObject {
                body,
                etag: "etag1".to_string(),
                content_type: content_type.map(str::to_string),
            },
        );
        storage
    }

    pub(super) fn with_script(body: &'static [u8], script: Script) -> Self {
        let storage = Self {
            objects: Mutex::new(HashMap::new()),
            calls: Mutex::new(Calls::default()),
            script: Mutex::new(script),
        };
        storage.objects.lock().expect("mutex not poisoned").insert(
            key(&make_kb(), &make_path()),
            StoredObject {
                body: Bytes::from_static(body),
                etag: "etag1".to_string(),
                content_type: Some("text/plain".to_string()),
            },
        );
        storage
    }

    pub(super) async fn read(&self) -> ObjectRead {
        self.get_object(
            &make_kb(),
            &make_path(),
            None,
            ConditionalHeaders::default(),
        )
        .await
        .expect("object read succeeds")
    }

    pub(super) fn body(&self) -> Bytes {
        self.objects
            .lock()
            .expect("mutex not poisoned")
            .get(&key(&make_kb(), &make_path()))
            .expect("object exists")
            .body
            .clone()
    }

    pub(super) fn calls(&self) -> Calls {
        let calls = self.calls.lock().expect("mutex not poisoned");
        Calls {
            head: calls.head,
            get: calls.get,
            put: calls.put,
        }
    }

    fn object(&self, kb: &KbSlug, path: &ObjectPath) -> Result<StoredObject, StorageError> {
        self.objects
            .lock()
            .expect("mutex not poisoned")
            .get(&key(kb, path))
            .cloned()
            .ok_or_else(|| StorageError::NotFound { key: key(kb, path) })
    }
}

fn check_if_match(
    conditionals: &ConditionalHeaders,
    current_etag: &str,
) -> Result<(), StorageError> {
    if conditionals
        .if_match
        .as_deref()
        .is_some_and(|etag| etag != current_etag)
    {
        return Err(StorageError::PreconditionFailed);
    }
    Ok(())
}

fn meta(path: &ObjectPath, object: &StoredObject) -> ObjectMeta {
    ObjectMeta {
        key: path.as_str().to_string(),
        size: object.body.len() as u64,
        last_modified: None,
        content_type: object.content_type.clone(),
        etag: Some(object.etag.clone()),
    }
}

fn make_conditionals(etag: Option<&str>) -> ConditionalHeaders {
    ConditionalHeaders {
        if_match: etag.map(str::to_string),
        ..ConditionalHeaders::default()
    }
}

fn make_kb() -> KbSlug {
    KbSlug::try_new("test-kb").expect("valid kb slug")
}

fn make_path() -> ObjectPath {
    ObjectPath::try_from_str("test.md").expect("valid path")
}

fn key(kb: &KbSlug, path: &ObjectPath) -> String {
    format!("{}/{}", kb.as_str(), path.as_str())
}