wsi_streamer/
config.rs

1//! Configuration management for WSI Streamer.
2//!
3//! This module provides a flexible configuration system that supports:
4//! - Command-line arguments via clap with subcommands
5//! - Environment variables with `WSI_` prefix
6//! - Sensible defaults for all optional settings
7//!
8//! # Subcommands
9//!
10//! - `serve` (default): Start the tile server
11//! - `sign`: Generate signed URLs for authentication
12//! - `check`: Validate configuration and test S3 connectivity
13//!
14//! # Example
15//!
16//! ```ignore
17//! use wsi_streamer::config::Cli;
18//! use clap::Parser;
19//!
20//! match Cli::parse() {
21//!     Cli::Serve(config) => { /* start server */ }
22//!     Cli::Sign(config) => { /* generate signed URL */ }
23//!     Cli::Check(config) => { /* validate config */ }
24//! }
25//! ```
26//!
27//! # Environment Variables
28//!
29//! All configuration options can be set via environment variables with the `WSI_` prefix:
30//!
31//! - `WSI_HOST` - Server bind address (default: 0.0.0.0)
32//! - `WSI_PORT` - Server port (default: 3000)
33//! - `WSI_S3_BUCKET` - S3 bucket name
34//! - `WSI_S3_ENDPOINT` - Custom S3 endpoint for S3-compatible services
35//! - `WSI_S3_REGION` - AWS region (default: us-east-1)
36//! - `WSI_AUTH_SECRET` - HMAC secret for signed URLs
37//! - `WSI_AUTH_ENABLED` - Enable authentication (default: false)
38//! - `WSI_CACHE_SLIDES` - Max slides to cache (default: 100)
39//! - `WSI_CACHE_BLOCKS` - Max blocks per slide (default: 100)
40//! - `WSI_CACHE_TILES` - Tile cache size in bytes (default: 100MB)
41//! - `WSI_JPEG_QUALITY` - Default JPEG quality (default: 80)
42//! - `WSI_CACHE_MAX_AGE` - HTTP cache max-age seconds (default: 3600)
43
44use clap::{Args, Parser, Subcommand, ValueEnum};
45use std::fmt;
46
47use crate::io::DEFAULT_BLOCK_SIZE;
48use crate::tile::{DEFAULT_JPEG_QUALITY, DEFAULT_TILE_CACHE_CAPACITY};
49
50// =============================================================================
51// Default Values
52// =============================================================================
53
54/// Default server host.
55pub const DEFAULT_HOST: &str = "0.0.0.0";
56
57/// Default server port.
58pub const DEFAULT_PORT: u16 = 3000;
59
60/// Default AWS region.
61pub const DEFAULT_REGION: &str = "us-east-1";
62
63/// Default number of slides to cache.
64pub const DEFAULT_SLIDE_CACHE_CAPACITY: usize = 100;
65
66/// Default number of blocks to cache per slide.
67pub const DEFAULT_BLOCK_CACHE_CAPACITY: usize = 100;
68
69/// Default HTTP cache max-age in seconds (1 hour).
70pub const DEFAULT_CACHE_MAX_AGE: u32 = 3600;
71
72/// Default TTL for signed URLs in seconds (1 hour).
73pub const DEFAULT_SIGN_TTL: u64 = 3600;
74
75// =============================================================================
76// CLI Structure
77// =============================================================================
78
79/// WSI Streamer - A tile server for Whole Slide Images.
80///
81/// Serves tiles from Whole Slide Images stored in S3 or S3-compatible storage
82/// using HTTP range requests. No local file downloads required.
83#[derive(Parser, Debug)]
84#[command(name = "wsi-streamer")]
85#[command(author, version, about, long_about = None)]
86#[command(propagate_version = true)]
87#[command(after_help = "\
88EXAMPLES:
89    # Start serving slides from an S3 bucket
90    wsi-streamer s3://my-slides-bucket
91
92    # Use a custom port
93    wsi-streamer s3://my-slides --port 8080
94
95    # With MinIO (local S3-compatible storage)
96    wsi-streamer s3://slides --s3-endpoint http://localhost:9000
97
98    # Enable authentication for production
99    wsi-streamer s3://my-slides --auth-enabled --auth-secret $SECRET
100
101    # Check S3 connectivity and list slides
102    wsi-streamer check s3://my-slides --list-slides
103
104    # Generate a signed URL for a tile
105    wsi-streamer sign --path /tiles/slide.svs/0/0/0.jpg --secret $SECRET
106")]
107pub struct Cli {
108    #[command(subcommand)]
109    pub command: Option<Command>,
110
111    #[command(flatten)]
112    pub serve: ServeConfig,
113}
114
115impl Cli {
116    /// Returns the command to execute, defaulting to Serve if none specified.
117    pub fn into_command(self) -> Command {
118        self.command.unwrap_or(Command::Serve(self.serve))
119    }
120}
121
122#[derive(Subcommand, Debug, Clone)]
123pub enum Command {
124    /// Start the tile server (default command)
125    Serve(ServeConfig),
126
127    /// Generate a signed URL for authenticated access
128    Sign(SignConfig),
129
130    /// Validate configuration and test S3 connectivity
131    Check(CheckConfig),
132}
133
134// =============================================================================
135// Serve Configuration
136// =============================================================================
137
138/// Configuration for the `serve` command (tile server).
139#[derive(Args, Debug, Clone)]
140pub struct ServeConfig {
141    /// S3 bucket URI (e.g., s3://my-bucket) or just the bucket name.
142    /// Alternative to --s3-bucket flag.
143    #[arg(value_name = "S3_URI")]
144    pub s3_uri: Option<String>,
145
146    // =========================================================================
147    // Server Configuration
148    // =========================================================================
149    /// Host address to bind the server to.
150    #[arg(long, default_value = DEFAULT_HOST, env = "WSI_HOST")]
151    pub host: String,
152
153    /// Port to listen on.
154    #[arg(short, long, default_value_t = DEFAULT_PORT, env = "WSI_PORT")]
155    pub port: u16,
156
157    // =========================================================================
158    // S3 Configuration
159    // =========================================================================
160    /// S3 bucket name containing the slide files.
161    /// Can also be provided as a positional argument (s3://bucket).
162    #[arg(long, env = "WSI_S3_BUCKET")]
163    pub s3_bucket: Option<String>,
164
165    /// Custom S3 endpoint URL for S3-compatible services (MinIO, etc.).
166    ///
167    /// If not specified, uses the default AWS S3 endpoint.
168    #[arg(long, env = "WSI_S3_ENDPOINT")]
169    pub s3_endpoint: Option<String>,
170
171    /// AWS region for S3.
172    #[arg(long, default_value = DEFAULT_REGION, env = "WSI_S3_REGION")]
173    pub s3_region: String,
174
175    // =========================================================================
176    // Authentication Configuration
177    // =========================================================================
178    /// Secret key for HMAC-SHA256 signed URL authentication.
179    ///
180    /// Required when authentication is enabled.
181    #[arg(long, env = "WSI_AUTH_SECRET")]
182    pub auth_secret: Option<String>,
183
184    /// Enable signed URL authentication.
185    ///
186    /// When disabled (default), all tile requests are allowed without authentication.
187    /// Enable for production deployments.
188    #[arg(long, default_value_t = false, env = "WSI_AUTH_ENABLED")]
189    pub auth_enabled: bool,
190
191    // =========================================================================
192    // Cache Configuration
193    // =========================================================================
194    /// Maximum number of slides to keep in cache.
195    #[arg(long, default_value_t = DEFAULT_SLIDE_CACHE_CAPACITY, env = "WSI_CACHE_SLIDES")]
196    pub cache_slides: usize,
197
198    /// Maximum number of blocks to cache per slide (256KB each).
199    #[arg(long, default_value_t = DEFAULT_BLOCK_CACHE_CAPACITY, env = "WSI_CACHE_BLOCKS")]
200    pub cache_blocks: usize,
201
202    /// Maximum tile cache size in bytes (default: 100MB).
203    #[arg(long, default_value_t = DEFAULT_TILE_CACHE_CAPACITY, env = "WSI_CACHE_TILES")]
204    pub cache_tiles: usize,
205
206    /// Block size in bytes for the block cache.
207    #[arg(long, default_value_t = DEFAULT_BLOCK_SIZE, env = "WSI_BLOCK_SIZE")]
208    pub block_size: usize,
209
210    // =========================================================================
211    // Tile Configuration
212    // =========================================================================
213    /// Default JPEG quality for tile encoding (1-100).
214    #[arg(long, default_value_t = DEFAULT_JPEG_QUALITY, env = "WSI_JPEG_QUALITY")]
215    pub jpeg_quality: u8,
216
217    /// HTTP Cache-Control max-age in seconds.
218    #[arg(long, default_value_t = DEFAULT_CACHE_MAX_AGE, env = "WSI_CACHE_MAX_AGE")]
219    pub cache_max_age: u32,
220
221    // =========================================================================
222    // CORS Configuration
223    // =========================================================================
224    /// Allowed CORS origins (comma-separated).
225    ///
226    /// If not specified, allows any origin.
227    #[arg(long, env = "WSI_CORS_ORIGINS", value_delimiter = ',')]
228    pub cors_origins: Option<Vec<String>>,
229
230    // =========================================================================
231    // Logging Configuration
232    // =========================================================================
233    /// Enable verbose logging (debug level).
234    #[arg(short, long, default_value_t = false)]
235    pub verbose: bool,
236
237    /// Disable request tracing.
238    #[arg(long, default_value_t = false)]
239    pub no_tracing: bool,
240}
241
242impl ServeConfig {
243    /// Resolve the S3 bucket name from either the positional URI or --s3-bucket flag.
244    pub fn resolve_bucket(&self) -> Result<String, String> {
245        // First try the positional S3 URI
246        if let Some(ref uri) = self.s3_uri {
247            return parse_s3_uri(uri);
248        }
249
250        // Fall back to --s3-bucket flag
251        if let Some(ref bucket) = self.s3_bucket {
252            if bucket.is_empty() {
253                return Err("S3 bucket name cannot be empty".to_string());
254            }
255            return Ok(bucket.clone());
256        }
257
258        Err(
259            "S3 bucket is required. Use: wsi-streamer s3://bucket-name or --s3-bucket=name"
260                .to_string(),
261        )
262    }
263
264    /// Validate the configuration and return an error message if invalid.
265    pub fn validate(&self) -> Result<(), String> {
266        // Resolve and validate bucket
267        self.resolve_bucket()?;
268
269        // Check auth secret is provided when auth is enabled
270        if self.auth_enabled && self.auth_secret.is_none() {
271            return Err("Authentication is enabled but no secret provided. \
272                Set --auth-secret or WSI_AUTH_SECRET, or disable auth with --auth-enabled=false"
273                .to_string());
274        }
275
276        // Validate cache sizes
277        if self.cache_slides == 0 {
278            return Err("cache_slides must be greater than 0".to_string());
279        }
280        if self.cache_blocks == 0 {
281            return Err("cache_blocks must be greater than 0".to_string());
282        }
283        if self.cache_tiles == 0 {
284            return Err("cache_tiles must be greater than 0".to_string());
285        }
286
287        // Validate JPEG quality
288        if self.jpeg_quality == 0 || self.jpeg_quality > 100 {
289            return Err("jpeg_quality must be between 1 and 100".to_string());
290        }
291
292        // Validate block size (must be reasonable)
293        if self.block_size < 1024 || self.block_size > 16 * 1024 * 1024 {
294            return Err("block_size must be between 1KB and 16MB".to_string());
295        }
296
297        Ok(())
298    }
299
300    /// Get the server bind address as "host:port".
301    pub fn bind_address(&self) -> String {
302        format!("{}:{}", self.host, self.port)
303    }
304
305    /// Get the auth secret, returning empty string if not set.
306    pub fn auth_secret_or_empty(&self) -> &str {
307        self.auth_secret.as_deref().unwrap_or("")
308    }
309
310    /// Get the resolved bucket name, panicking if not set (call validate() first).
311    pub fn bucket(&self) -> String {
312        self.resolve_bucket()
313            .expect("bucket should be validated before calling this method")
314    }
315}
316
317// =============================================================================
318// Sign Configuration
319// =============================================================================
320
321/// Output format for the sign command.
322#[derive(Debug, Clone, Copy, Default, ValueEnum)]
323pub enum SignOutputFormat {
324    /// Output the complete signed URL (default)
325    #[default]
326    Url,
327    /// Output as JSON with signature, expiry, and URL
328    Json,
329    /// Output only the signature (hex-encoded)
330    Signature,
331}
332
333impl fmt::Display for SignOutputFormat {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        match self {
336            SignOutputFormat::Url => write!(f, "url"),
337            SignOutputFormat::Json => write!(f, "json"),
338            SignOutputFormat::Signature => write!(f, "signature"),
339        }
340    }
341}
342
343/// Configuration for the `sign` command.
344#[derive(Args, Debug, Clone)]
345pub struct SignConfig {
346    /// Path to sign (e.g., /tiles/slide.svs/0/0/0.jpg)
347    #[arg(short, long)]
348    pub path: String,
349
350    /// Secret key for HMAC-SHA256 signing.
351    /// Can also be set via WSI_AUTH_SECRET environment variable.
352    #[arg(short, long, env = "WSI_AUTH_SECRET")]
353    pub secret: String,
354
355    /// Time-to-live in seconds (default: 3600 = 1 hour)
356    #[arg(short, long, default_value_t = DEFAULT_SIGN_TTL)]
357    pub ttl: u64,
358
359    /// Base URL for complete signed URL output (e.g., http://localhost:3000)
360    #[arg(short, long)]
361    pub base_url: Option<String>,
362
363    /// Additional query parameters (format: key=value, comma-separated)
364    #[arg(short = 'P', long, value_delimiter = ',')]
365    pub params: Option<Vec<String>>,
366
367    /// Output format: url (default), json, or signature
368    #[arg(short, long, default_value = "url")]
369    pub format: SignOutputFormat,
370}
371
372impl SignConfig {
373    /// Parse the additional parameters into key-value pairs.
374    pub fn parse_params(&self) -> Result<Vec<(String, String)>, String> {
375        let Some(ref params) = self.params else {
376            return Ok(Vec::new());
377        };
378
379        params
380            .iter()
381            .map(|p| {
382                let parts: Vec<&str> = p.splitn(2, '=').collect();
383                if parts.len() != 2 {
384                    Err(format!(
385                        "Invalid parameter format '{}'. Expected key=value",
386                        p
387                    ))
388                } else {
389                    Ok((parts[0].to_string(), parts[1].to_string()))
390                }
391            })
392            .collect()
393    }
394
395    /// Validate the sign configuration.
396    pub fn validate(&self) -> Result<(), String> {
397        if self.path.is_empty() {
398            return Err("Path cannot be empty".to_string());
399        }
400
401        if self.secret.is_empty() {
402            return Err("Secret cannot be empty. Set --secret or WSI_AUTH_SECRET".to_string());
403        }
404
405        if self.ttl == 0 {
406            return Err("TTL must be greater than 0".to_string());
407        }
408
409        // Validate params format
410        self.parse_params()?;
411
412        Ok(())
413    }
414}
415
416// =============================================================================
417// Check Configuration
418// =============================================================================
419
420/// Configuration for the `check` command.
421#[derive(Args, Debug, Clone)]
422pub struct CheckConfig {
423    /// S3 bucket URI (e.g., s3://my-bucket) or just the bucket name.
424    #[arg(value_name = "S3_URI")]
425    pub s3_uri: Option<String>,
426
427    /// S3 bucket name (alternative to positional argument).
428    #[arg(long, env = "WSI_S3_BUCKET")]
429    pub s3_bucket: Option<String>,
430
431    /// Custom S3 endpoint URL for S3-compatible services.
432    #[arg(long, env = "WSI_S3_ENDPOINT")]
433    pub s3_endpoint: Option<String>,
434
435    /// AWS region for S3.
436    #[arg(long, default_value = DEFAULT_REGION, env = "WSI_S3_REGION")]
437    pub s3_region: String,
438
439    /// Test loading a specific slide by name.
440    #[arg(long)]
441    pub test_slide: Option<String>,
442
443    /// List all slides found in the bucket.
444    #[arg(long, default_value_t = false)]
445    pub list_slides: bool,
446
447    /// Enable verbose output.
448    #[arg(short, long, default_value_t = false)]
449    pub verbose: bool,
450}
451
452impl CheckConfig {
453    /// Resolve the S3 bucket name from either the positional URI or --s3-bucket flag.
454    pub fn resolve_bucket(&self) -> Result<String, String> {
455        if let Some(ref uri) = self.s3_uri {
456            return parse_s3_uri(uri);
457        }
458
459        if let Some(ref bucket) = self.s3_bucket {
460            if bucket.is_empty() {
461                return Err("S3 bucket name cannot be empty".to_string());
462            }
463            return Ok(bucket.clone());
464        }
465
466        Err(
467            "S3 bucket is required. Use: wsi-streamer check s3://bucket-name or --s3-bucket=name"
468                .to_string(),
469        )
470    }
471}
472
473// =============================================================================
474// Helper Functions
475// =============================================================================
476
477/// Parse an S3 URI (s3://bucket-name or s3://bucket-name/prefix) and return the bucket name.
478fn parse_s3_uri(uri: &str) -> Result<String, String> {
479    // Handle both s3:// prefix and plain bucket names
480    let uri = uri.trim();
481
482    if let Some(path) = uri.strip_prefix("s3://") {
483        let bucket = path.split('/').next().unwrap_or("");
484        if bucket.is_empty() {
485            return Err(format!(
486                "Invalid S3 URI '{}'. Expected format: s3://bucket-name",
487                uri
488            ));
489        }
490        Ok(bucket.to_string())
491    } else if uri.contains("://") {
492        Err(format!(
493            "Invalid URI scheme in '{}'. Expected s3:// or plain bucket name",
494            uri
495        ))
496    } else {
497        // Plain bucket name
498        if uri.is_empty() {
499            return Err("Bucket name cannot be empty".to_string());
500        }
501        Ok(uri.to_string())
502    }
503}
504
505// =============================================================================
506// Legacy Compatibility
507// =============================================================================
508
509/// Legacy Config type alias for backward compatibility.
510/// New code should use `Cli` and `ServeConfig` instead.
511pub type Config = ServeConfig;
512
513// =============================================================================
514// Tests
515// =============================================================================
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    fn test_serve_config() -> ServeConfig {
522        ServeConfig {
523            s3_uri: None,
524            host: "127.0.0.1".to_string(),
525            port: 8080,
526            s3_bucket: Some("test-bucket".to_string()),
527            s3_endpoint: None,
528            s3_region: "us-west-2".to_string(),
529            auth_secret: Some("test-secret".to_string()),
530            auth_enabled: true,
531            cache_slides: 50,
532            cache_blocks: 100,
533            cache_tiles: 500,
534            block_size: DEFAULT_BLOCK_SIZE,
535            jpeg_quality: 85,
536            cache_max_age: 7200,
537            cors_origins: None,
538            verbose: false,
539            no_tracing: false,
540        }
541    }
542
543    #[test]
544    fn test_valid_config() {
545        let config = test_serve_config();
546        assert!(config.validate().is_ok());
547    }
548
549    #[test]
550    fn test_missing_auth_secret() {
551        let mut config = test_serve_config();
552        config.auth_secret = None;
553        config.auth_enabled = true;
554
555        let result = config.validate();
556        assert!(result.is_err());
557        assert!(result.unwrap_err().contains("secret"));
558    }
559
560    #[test]
561    fn test_auth_disabled_no_secret_ok() {
562        let mut config = test_serve_config();
563        config.auth_secret = None;
564        config.auth_enabled = false;
565
566        assert!(config.validate().is_ok());
567    }
568
569    #[test]
570    fn test_empty_bucket() {
571        let mut config = test_serve_config();
572        config.s3_bucket = Some(String::new());
573        config.s3_uri = None;
574
575        let result = config.validate();
576        assert!(result.is_err());
577    }
578
579    #[test]
580    fn test_missing_bucket() {
581        let mut config = test_serve_config();
582        config.s3_bucket = None;
583        config.s3_uri = None;
584
585        let result = config.validate();
586        assert!(result.is_err());
587        assert!(result.unwrap_err().contains("bucket"));
588    }
589
590    #[test]
591    fn test_s3_uri_parsing() {
592        // Valid S3 URIs
593        assert_eq!(parse_s3_uri("s3://my-bucket").unwrap(), "my-bucket");
594        assert_eq!(
595            parse_s3_uri("s3://my-bucket/prefix/path").unwrap(),
596            "my-bucket"
597        );
598        assert_eq!(parse_s3_uri("my-bucket").unwrap(), "my-bucket");
599
600        // Invalid URIs
601        assert!(parse_s3_uri("s3://").is_err());
602        assert!(parse_s3_uri("http://bucket").is_err());
603        assert!(parse_s3_uri("").is_err());
604    }
605
606    #[test]
607    fn test_s3_uri_takes_precedence() {
608        let mut config = test_serve_config();
609        config.s3_uri = Some("s3://uri-bucket".to_string());
610        config.s3_bucket = Some("flag-bucket".to_string());
611
612        assert_eq!(config.resolve_bucket().unwrap(), "uri-bucket");
613    }
614
615    #[test]
616    fn test_invalid_cache_sizes() {
617        let mut config = test_serve_config();
618        config.cache_slides = 0;
619        assert!(config.validate().is_err());
620
621        let mut config = test_serve_config();
622        config.cache_blocks = 0;
623        assert!(config.validate().is_err());
624
625        let mut config = test_serve_config();
626        config.cache_tiles = 0;
627        assert!(config.validate().is_err());
628    }
629
630    #[test]
631    fn test_invalid_jpeg_quality() {
632        let mut config = test_serve_config();
633        config.jpeg_quality = 0;
634        assert!(config.validate().is_err());
635
636        let mut config = test_serve_config();
637        config.jpeg_quality = 101;
638        assert!(config.validate().is_err());
639    }
640
641    #[test]
642    fn test_bind_address() {
643        let config = test_serve_config();
644        assert_eq!(config.bind_address(), "127.0.0.1:8080");
645    }
646
647    #[test]
648    fn test_auth_secret_or_empty() {
649        let config = test_serve_config();
650        assert_eq!(config.auth_secret_or_empty(), "test-secret");
651
652        let mut config = test_serve_config();
653        config.auth_secret = None;
654        assert_eq!(config.auth_secret_or_empty(), "");
655    }
656
657    #[test]
658    fn test_cors_origins() {
659        let mut config = test_serve_config();
660        config.cors_origins = Some(vec![
661            "https://example.com".to_string(),
662            "https://other.com".to_string(),
663        ]);
664        assert!(config.validate().is_ok());
665        assert_eq!(config.cors_origins.as_ref().unwrap().len(), 2);
666    }
667
668    #[test]
669    fn test_sign_config_parse_params() {
670        let config = SignConfig {
671            path: "/tiles/test.svs/0/0/0.jpg".to_string(),
672            secret: "secret".to_string(),
673            ttl: 3600,
674            base_url: None,
675            params: Some(vec!["quality=90".to_string(), "format=jpg".to_string()]),
676            format: SignOutputFormat::Url,
677        };
678
679        let params = config.parse_params().unwrap();
680        assert_eq!(params.len(), 2);
681        assert_eq!(params[0], ("quality".to_string(), "90".to_string()));
682        assert_eq!(params[1], ("format".to_string(), "jpg".to_string()));
683    }
684
685    #[test]
686    fn test_sign_config_invalid_params() {
687        let config = SignConfig {
688            path: "/tiles/test.svs/0/0/0.jpg".to_string(),
689            secret: "secret".to_string(),
690            ttl: 3600,
691            base_url: None,
692            params: Some(vec!["invalid_param".to_string()]),
693            format: SignOutputFormat::Url,
694        };
695
696        assert!(config.parse_params().is_err());
697    }
698
699    #[test]
700    fn test_check_config_resolve_bucket() {
701        let config = CheckConfig {
702            s3_uri: Some("s3://check-bucket".to_string()),
703            s3_bucket: None,
704            s3_endpoint: None,
705            s3_region: DEFAULT_REGION.to_string(),
706            test_slide: None,
707            list_slides: false,
708            verbose: false,
709        };
710
711        assert_eq!(config.resolve_bucket().unwrap(), "check-bucket");
712    }
713}