gproxy-file 4.1.13

Optional local filesystem and S3/R2 storage for GPROXY, powered by OpenDAL
Documentation
#![cfg(all(feature = "s3", not(target_arch = "wasm32")))]

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use gproxy_file::{
    Buffer, ErrorKind, S3,
    opendal::{HttpBody, HttpTransport, HttpTransporter},
    s3_with_transport,
};
use http::{Request, Response};

#[derive(Clone, Debug, Default)]
struct S3Transport {
    objects: Arc<Mutex<HashMap<String, Vec<u8>>>>,
    requests: Arc<Mutex<Vec<(String, String, String)>>>,
}

impl HttpTransport for S3Transport {
    async fn fetch(&self, request: Request<Buffer>) -> gproxy_file::Result<Response<HttpBody>> {
        let path = request.uri().path().to_owned();
        let method = request.method().as_str().to_owned();
        let authorization = request
            .headers()
            .get("authorization")
            .unwrap()
            .to_str()
            .unwrap()
            .to_owned();
        self.requests
            .lock()
            .unwrap()
            .push((method.clone(), path.clone(), authorization));
        let mut objects = self.objects.lock().unwrap();
        let (status, body, size) = match method.as_str() {
            "PUT" => {
                objects.insert(path, request.body().to_vec());
                (200, Vec::new(), 0)
            }
            "GET" => match objects.get(&path) {
                Some(data) => (200, data.clone(), data.len()),
                None => {
                    let body = b"<Error><Code>NoSuchKey</Code></Error>".to_vec();
                    let size = body.len();
                    (404, body, size)
                }
            },
            "HEAD" => match objects.get(&path) {
                Some(data) => (200, Vec::new(), data.len()),
                None => (404, Vec::new(), 0),
            },
            "DELETE" => {
                objects.remove(&path);
                (204, Vec::new(), 0)
            }
            _ => panic!("unexpected S3 method {method}"),
        };
        let body = HttpBody::new(futures_util::stream::iter([Ok(Buffer::from(body))]), None);
        Ok(Response::builder()
            .status(status)
            .header("content-length", size)
            .header("etag", "\"test-etag\"")
            .body(body)
            .unwrap())
    }
}

#[tokio::test]
async fn signed_s3_requests_roundtrip_through_the_injected_transport() {
    let transport = S3Transport::default();
    let files = s3_with_transport(
        S3::default()
            .bucket("test-bucket")
            .region("auto")
            .endpoint("https://test-account.r2.cloudflarestorage.com")
            .access_key_id("test-access-key")
            .secret_access_key("test-secret-key")
            .disable_config_load()
            .disable_ec2_metadata(),
        HttpTransporter::new(transport.clone()),
    )
    .unwrap();
    files
        .write("folder/a b.bin", vec![0, 128, 255])
        .await
        .unwrap();
    assert_eq!(
        files.read("folder/a b.bin").await.unwrap().to_vec(),
        [0, 128, 255]
    );
    assert_eq!(
        files.stat("folder/a b.bin").await.unwrap().content_length(),
        3
    );
    files.write("folder/a b.bin", Buffer::new()).await.unwrap();
    assert!(files.read("folder/a b.bin").await.unwrap().is_empty());
    files.delete("folder/a b.bin").await.unwrap();
    assert_eq!(
        files.stat("folder/a b.bin").await.unwrap_err().kind(),
        ErrorKind::NotFound
    );
    assert_eq!(
        files.read("folder/a b.bin").await.unwrap_err().kind(),
        ErrorKind::NotFound
    );
    let requests = transport.requests.lock().unwrap();
    assert!(
        requests
            .iter()
            .any(|(method, path, _)| method == "PUT" && path == "/test-bucket/folder/a%20b.bin")
    );
    assert!(requests.iter().all(|(_, _, authorization)| {
        authorization.starts_with("AWS4-HMAC-SHA256 ")
            && authorization.contains("/auto/s3/aws4_request")
    }));
}