wsi_streamer/slide/
s3_source.rs

1//! S3-backed slide source implementation.
2//!
3//! This module provides an implementation of `SlideSource` that creates
4//! `S3RangeReader` instances for slides stored in S3 or S3-compatible storage.
5
6use async_trait::async_trait;
7use aws_sdk_s3::Client;
8
9use crate::error::IoError;
10use crate::io::S3RangeReader;
11
12use super::{SlideListResult, SlideSource};
13
14// =============================================================================
15// Slide Extension Filtering
16// =============================================================================
17
18/// Supported slide file extensions (case-insensitive).
19const SLIDE_EXTENSIONS: &[&str] = &[".svs", ".tif", ".tiff"];
20
21/// Check if a file path has a supported slide extension.
22fn is_slide_file(path: &str) -> bool {
23    let path_lower = path.to_lowercase();
24    SLIDE_EXTENSIONS.iter().any(|ext| path_lower.ends_with(ext))
25}
26
27/// S3-backed implementation of `SlideSource`.
28///
29/// Creates `S3RangeReader` instances for slides stored in an S3 bucket.
30/// The slide ID is used as the object key within the bucket.
31///
32/// # Example
33///
34/// ```ignore
35/// use wsi_streamer::slide::S3SlideSource;
36/// use wsi_streamer::io::create_s3_client;
37///
38/// let client = create_s3_client(None).await;
39/// let source = S3SlideSource::new(client, "my-bucket".to_string());
40///
41/// // The slide ID "slides/example.svs" becomes the S3 key
42/// let reader = source.create_reader("slides/example.svs").await?;
43/// ```
44#[derive(Clone)]
45pub struct S3SlideSource {
46    client: Client,
47    bucket: String,
48}
49
50impl S3SlideSource {
51    /// Create a new S3SlideSource for the given bucket.
52    ///
53    /// # Arguments
54    /// * `client` - AWS S3 client to use for requests
55    /// * `bucket` - S3 bucket name containing the slides
56    pub fn new(client: Client, bucket: String) -> Self {
57        Self { client, bucket }
58    }
59
60    /// Get the bucket name.
61    pub fn bucket(&self) -> &str {
62        &self.bucket
63    }
64}
65
66#[async_trait]
67impl SlideSource for S3SlideSource {
68    type Reader = S3RangeReader;
69
70    async fn create_reader(&self, slide_id: &str) -> Result<Self::Reader, IoError> {
71        S3RangeReader::new(
72            self.client.clone(),
73            self.bucket.clone(),
74            slide_id.to_string(),
75        )
76        .await
77    }
78
79    async fn list_slides(
80        &self,
81        limit: u32,
82        cursor: Option<&str>,
83        prefix: Option<&str>,
84    ) -> Result<SlideListResult, IoError> {
85        let mut request = self
86            .client
87            .list_objects_v2()
88            .bucket(&self.bucket)
89            .max_keys(limit as i32);
90
91        if let Some(token) = cursor {
92            request = request.continuation_token(token);
93        }
94
95        if let Some(prefix) = prefix {
96            request = request.prefix(prefix);
97        }
98
99        let response = request
100            .send()
101            .await
102            .map_err(|e| IoError::S3(e.to_string()))?;
103
104        let slides: Vec<String> = response
105            .contents()
106            .iter()
107            .filter_map(|obj| obj.key())
108            .filter(|key| is_slide_file(key))
109            .map(|s| s.to_string())
110            .collect();
111
112        Ok(SlideListResult {
113            slides,
114            next_cursor: response.next_continuation_token().map(|s| s.to_string()),
115        })
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder;
123    use hyper_rustls::HttpsConnectorBuilder;
124
125    #[test]
126    fn test_s3_slide_source_bucket() {
127        // We can't test actual S3 operations without credentials,
128        // but we can test the basic structure
129        let https_connector = HttpsConnectorBuilder::new()
130            .with_webpki_roots()
131            .https_only()
132            .enable_http1()
133            .enable_http2()
134            .build();
135        let http_client = HyperClientBuilder::new().build(https_connector);
136        let config = aws_sdk_s3::Config::builder()
137            .behavior_version_latest()
138            .http_client(http_client)
139            .build();
140        let client = aws_sdk_s3::Client::from_conf(config);
141        let source = S3SlideSource::new(client, "test-bucket".to_string());
142        assert_eq!(source.bucket(), "test-bucket");
143    }
144
145    #[test]
146    fn test_is_slide_file_svs() {
147        assert!(is_slide_file("slide.svs"));
148        assert!(is_slide_file("path/to/slide.svs"));
149        assert!(is_slide_file("SLIDE.SVS"));
150        assert!(is_slide_file("path/to/SLIDE.Svs"));
151    }
152
153    #[test]
154    fn test_is_slide_file_tif() {
155        assert!(is_slide_file("slide.tif"));
156        assert!(is_slide_file("path/to/slide.tif"));
157        assert!(is_slide_file("SLIDE.TIF"));
158    }
159
160    #[test]
161    fn test_is_slide_file_tiff() {
162        assert!(is_slide_file("slide.tiff"));
163        assert!(is_slide_file("path/to/slide.tiff"));
164        assert!(is_slide_file("SLIDE.TIFF"));
165    }
166
167    #[test]
168    fn test_is_slide_file_non_slide() {
169        assert!(!is_slide_file("image.jpg"));
170        assert!(!is_slide_file("document.pdf"));
171        assert!(!is_slide_file("slide.svs.backup"));
172        assert!(!is_slide_file("slide_svs"));
173        assert!(!is_slide_file(""));
174        assert!(!is_slide_file("no_extension"));
175    }
176}