Skip to main content

oxigdal_server/
config.rs

1//! Server configuration management
2//!
3//! This module handles configuration from multiple sources:
4//! - TOML configuration files
5//! - Environment variables
6//! - Command-line arguments
7//!
8//! # Example Configuration
9//!
10//! ```toml
11//! [server]
12//! host = "0.0.0.0"
13//! port = 8080
14//! workers = 4
15//! metrics_enabled = true
16//! metrics_port = 8081
17//!
18//! [cache]
19//! memory_size_mb = 256
20//! disk_cache = "/tmp/oxigdal-cache"
21//! ttl_seconds = 3600
22//!
23//! [[layers]]
24//! name = "landsat"
25//! path = "/data/landsat.tif"
26//! formats = ["png", "jpeg", "webp"]
27//! tile_size = 256
28//! ```
29//!
30//! Unrecognized keys/tables (typos, stale field names) are rejected with a `TomlParse` error
31//! rather than silently ignored - every struct in this module derives
32//! `#[serde(deny_unknown_fields)]`.
33
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::net::IpAddr;
37use std::path::{Path, PathBuf};
38use std::str::FromStr;
39use thiserror::Error;
40
41/// Configuration errors
42#[derive(Debug, Error)]
43pub enum ConfigError {
44    /// Invalid configuration value
45    #[error("Invalid configuration: {0}")]
46    Invalid(String),
47
48    /// Configuration file I/O error
49    #[error("Failed to read config file: {0}")]
50    Io(#[from] std::io::Error),
51
52    /// TOML parsing error
53    #[error("Failed to parse TOML: {0}")]
54    TomlParse(#[from] toml::de::Error),
55
56    /// Missing required field
57    #[error("Missing required field: {0}")]
58    MissingField(String),
59
60    /// Layer not found
61    #[error("Layer not found: {0}")]
62    LayerNotFound(String),
63}
64
65/// Result type for configuration operations
66pub type ConfigResult<T> = Result<T, ConfigError>;
67
68/// Server configuration
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ServerConfig {
72    /// Host address to bind to
73    #[serde(default = "default_host")]
74    pub host: IpAddr,
75
76    /// Port to bind to
77    #[serde(default = "default_port")]
78    pub port: u16,
79
80    /// Number of worker threads (0 = number of CPUs)
81    #[serde(default = "default_workers")]
82    pub workers: usize,
83
84    /// Maximum request size in bytes
85    #[serde(default = "default_max_request_size")]
86    pub max_request_size: usize,
87
88    /// Request timeout in seconds
89    #[serde(default = "default_timeout")]
90    pub timeout_seconds: u64,
91
92    /// Enable CORS
93    #[serde(default = "default_cors")]
94    pub enable_cors: bool,
95
96    /// Allowed CORS origins (empty = all)
97    #[serde(default)]
98    pub cors_origins: Vec<String>,
99
100    /// Enable the Prometheus `/metrics` endpoint on its own listener
101    #[serde(default = "default_metrics_enabled")]
102    pub metrics_enabled: bool,
103
104    /// Port the Prometheus `/metrics` endpoint listens on (separate from `port`), matching
105    /// the `metrics` container/service port declared in `k8s/deployment.yaml` and the scrape
106    /// target in `monitoring/prometheus.yml`
107    #[serde(default = "default_metrics_port")]
108    pub metrics_port: u16,
109}
110
111/// Cache configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct CacheConfig {
115    /// In-memory cache size in megabytes
116    #[serde(default = "default_memory_cache_mb")]
117    pub memory_size_mb: usize,
118
119    /// Optional disk cache directory
120    #[serde(default)]
121    pub disk_cache: Option<PathBuf>,
122
123    /// Time-to-live for cached tiles in seconds
124    #[serde(default = "default_ttl_seconds")]
125    pub ttl_seconds: u64,
126
127    /// Enable cache statistics
128    #[serde(default = "default_enable_stats")]
129    pub enable_stats: bool,
130
131    /// Cache compression (gzip tiles in memory)
132    #[serde(default = "default_compression")]
133    pub compression: bool,
134}
135
136/// Layer configuration
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct LayerConfig {
140    /// Layer name (used in URLs)
141    pub name: String,
142
143    /// Display title
144    #[serde(default)]
145    pub title: Option<String>,
146
147    /// Layer description
148    #[serde(default)]
149    pub abstract_: Option<String>,
150
151    /// Path to dataset file
152    pub path: PathBuf,
153
154    /// Supported output formats
155    #[serde(default = "default_formats")]
156    pub formats: Vec<ImageFormat>,
157
158    /// Tile size in pixels
159    #[serde(default = "default_tile_size")]
160    pub tile_size: u32,
161
162    /// Minimum zoom level
163    #[serde(default)]
164    pub min_zoom: u8,
165
166    /// Maximum zoom level
167    #[serde(default = "default_max_zoom")]
168    pub max_zoom: u8,
169
170    /// Supported tile matrix sets
171    #[serde(default = "default_tile_matrix_sets")]
172    pub tile_matrix_sets: Vec<String>,
173
174    /// Optional style configuration
175    #[serde(default)]
176    pub style: Option<StyleConfig>,
177
178    /// Layer-specific metadata
179    #[serde(default)]
180    pub metadata: HashMap<String, String>,
181
182    /// Enable this layer
183    #[serde(default = "default_enabled")]
184    pub enabled: bool,
185}
186
187/// Style configuration for rendering
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct StyleConfig {
191    /// Style name
192    pub name: String,
193
194    /// Colormap name (e.g., "viridis", "terrain")
195    #[serde(default)]
196    pub colormap: Option<String>,
197
198    /// Value range for colormap
199    #[serde(default)]
200    pub value_range: Option<(f64, f64)>,
201
202    /// Alpha/transparency value (0.0-1.0)
203    #[serde(default = "default_alpha")]
204    pub alpha: f32,
205
206    /// Gamma correction
207    #[serde(default = "default_gamma")]
208    pub gamma: f32,
209
210    /// Brightness adjustment (-1.0 to 1.0)
211    #[serde(default)]
212    pub brightness: f32,
213
214    /// Contrast adjustment (0.0 to 2.0)
215    #[serde(default = "default_contrast")]
216    pub contrast: f32,
217}
218
219/// Image format enumeration
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum ImageFormat {
223    /// PNG format
224    Png,
225    /// JPEG format
226    Jpeg,
227    /// WebP format
228    Webp,
229    /// GeoTIFF format
230    Geotiff,
231}
232
233impl ImageFormat {
234    /// Get MIME type for this format
235    pub fn mime_type(&self) -> &'static str {
236        match self {
237            ImageFormat::Png => "image/png",
238            ImageFormat::Jpeg => "image/jpeg",
239            ImageFormat::Webp => "image/webp",
240            ImageFormat::Geotiff => "image/tiff",
241        }
242    }
243
244    /// Get file extension for this format
245    pub fn extension(&self) -> &'static str {
246        match self {
247            ImageFormat::Png => "png",
248            ImageFormat::Jpeg => "jpg",
249            ImageFormat::Webp => "webp",
250            ImageFormat::Geotiff => "tif",
251        }
252    }
253}
254
255/// Error type for parsing image format
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ParseImageFormatError(String);
258
259impl std::fmt::Display for ParseImageFormatError {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(f, "unknown image format: {}", self.0)
262    }
263}
264
265impl std::error::Error for ParseImageFormatError {}
266
267impl FromStr for ImageFormat {
268    type Err = ParseImageFormatError;
269
270    fn from_str(s: &str) -> Result<Self, Self::Err> {
271        match s.to_lowercase().as_str() {
272            "png" => Ok(ImageFormat::Png),
273            "jpeg" | "jpg" => Ok(ImageFormat::Jpeg),
274            "webp" => Ok(ImageFormat::Webp),
275            "geotiff" | "tif" | "tiff" => Ok(ImageFormat::Geotiff),
276            _ => Err(ParseImageFormatError(s.to_string())),
277        }
278    }
279}
280
281/// Complete server configuration
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct Config {
285    /// Server settings
286    #[serde(default)]
287    pub server: ServerConfig,
288
289    /// Cache settings
290    #[serde(default)]
291    pub cache: CacheConfig,
292
293    /// Layer definitions
294    #[serde(default)]
295    pub layers: Vec<LayerConfig>,
296
297    /// Global metadata
298    #[serde(default)]
299    pub metadata: MetadataConfig,
300}
301
302/// Service metadata configuration
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct MetadataConfig {
306    /// Service title
307    #[serde(default = "default_service_title")]
308    pub title: String,
309
310    /// Service abstract/description
311    #[serde(default = "default_service_abstract")]
312    pub abstract_: String,
313
314    /// Contact information
315    #[serde(default)]
316    pub contact: Option<ContactInfo>,
317
318    /// Keywords
319    #[serde(default)]
320    pub keywords: Vec<String>,
321
322    /// Online resource URL
323    #[serde(default)]
324    pub online_resource: Option<String>,
325}
326
327/// Contact information
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct ContactInfo {
331    /// Organization name
332    pub organization: String,
333
334    /// Contact person
335    #[serde(default)]
336    pub person: Option<String>,
337
338    /// Email address
339    #[serde(default)]
340    pub email: Option<String>,
341
342    /// Phone number
343    #[serde(default)]
344    pub phone: Option<String>,
345}
346
347// Default value functions
348fn default_host() -> IpAddr {
349    IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0))
350}
351
352fn default_port() -> u16 {
353    8080
354}
355
356fn default_workers() -> usize {
357    std::thread::available_parallelism()
358        .map(|n| n.get())
359        .ok()
360        .unwrap_or(4)
361}
362
363fn default_max_request_size() -> usize {
364    10 * 1024 * 1024 // 10 MB
365}
366
367fn default_timeout() -> u64 {
368    30
369}
370
371fn default_cors() -> bool {
372    true
373}
374
375fn default_metrics_enabled() -> bool {
376    true
377}
378
379fn default_metrics_port() -> u16 {
380    8081
381}
382
383fn default_memory_cache_mb() -> usize {
384    256
385}
386
387fn default_ttl_seconds() -> u64 {
388    3600
389}
390
391fn default_enable_stats() -> bool {
392    true
393}
394
395fn default_compression() -> bool {
396    false
397}
398
399fn default_formats() -> Vec<ImageFormat> {
400    vec![ImageFormat::Png, ImageFormat::Jpeg]
401}
402
403fn default_tile_size() -> u32 {
404    256
405}
406
407fn default_max_zoom() -> u8 {
408    18
409}
410
411fn default_tile_matrix_sets() -> Vec<String> {
412    vec!["WebMercatorQuad".to_string(), "WorldCRS84Quad".to_string()]
413}
414
415fn default_enabled() -> bool {
416    true
417}
418
419fn default_alpha() -> f32 {
420    1.0
421}
422
423fn default_gamma() -> f32 {
424    1.0
425}
426
427fn default_contrast() -> f32 {
428    1.0
429}
430
431fn default_service_title() -> String {
432    "OxiGDAL Tile Server".to_string()
433}
434
435fn default_service_abstract() -> String {
436    "WMS/WMTS tile server powered by OxiGDAL".to_string()
437}
438
439impl Default for ServerConfig {
440    fn default() -> Self {
441        Self {
442            host: default_host(),
443            port: default_port(),
444            workers: default_workers(),
445            max_request_size: default_max_request_size(),
446            timeout_seconds: default_timeout(),
447            enable_cors: default_cors(),
448            cors_origins: Vec::new(),
449            metrics_enabled: default_metrics_enabled(),
450            metrics_port: default_metrics_port(),
451        }
452    }
453}
454
455impl Default for CacheConfig {
456    fn default() -> Self {
457        Self {
458            memory_size_mb: default_memory_cache_mb(),
459            disk_cache: None,
460            ttl_seconds: default_ttl_seconds(),
461            enable_stats: default_enable_stats(),
462            compression: default_compression(),
463        }
464    }
465}
466
467impl Default for MetadataConfig {
468    fn default() -> Self {
469        Self {
470            title: default_service_title(),
471            abstract_: default_service_abstract(),
472            contact: None,
473            keywords: Vec::new(),
474            online_resource: None,
475        }
476    }
477}
478
479impl Config {
480    /// Load configuration from TOML file
481    pub fn from_file<P: AsRef<Path>>(path: P) -> ConfigResult<Self> {
482        let contents = std::fs::read_to_string(path)?;
483        Self::from_toml(&contents)
484    }
485
486    /// Parse configuration from TOML string
487    pub fn from_toml(toml: &str) -> ConfigResult<Self> {
488        let config: Config = toml::from_str(toml)?;
489        config.validate()?;
490        Ok(config)
491    }
492
493    /// Create a default configuration
494    pub fn default_config() -> Self {
495        Self {
496            server: ServerConfig::default(),
497            cache: CacheConfig::default(),
498            layers: Vec::new(),
499            metadata: MetadataConfig::default(),
500        }
501    }
502
503    /// Validate the configuration
504    pub fn validate(&self) -> ConfigResult<()> {
505        // Check for duplicate layer names
506        let mut names = std::collections::HashSet::new();
507        for layer in &self.layers {
508            if !names.insert(&layer.name) {
509                return Err(ConfigError::Invalid(format!(
510                    "Duplicate layer name: {}",
511                    layer.name
512                )));
513            }
514
515            // Validate layer path exists
516            if !layer.path.exists() {
517                return Err(ConfigError::Invalid(format!(
518                    "Layer path does not exist: {}",
519                    layer.path.display()
520                )));
521            }
522
523            // Validate tile size is power of 2
524            if !layer.tile_size.is_power_of_two() {
525                return Err(ConfigError::Invalid(format!(
526                    "Tile size must be power of 2, got {}",
527                    layer.tile_size
528                )));
529            }
530
531            // Validate zoom levels
532            if layer.min_zoom > layer.max_zoom {
533                return Err(ConfigError::Invalid(format!(
534                    "min_zoom ({}) cannot be greater than max_zoom ({})",
535                    layer.min_zoom, layer.max_zoom
536                )));
537            }
538        }
539
540        // Validate cache settings
541        if self.cache.memory_size_mb == 0 {
542            return Err(ConfigError::Invalid(
543                "Cache memory size must be greater than 0".to_string(),
544            ));
545        }
546
547        // The metrics endpoint binds its own listener on the same host; it must not collide
548        // with the main application port.
549        if self.server.metrics_enabled && self.server.metrics_port == self.server.port {
550            return Err(ConfigError::Invalid(format!(
551                "server.metrics_port ({}) must differ from server.port when metrics_enabled is true",
552                self.server.metrics_port
553            )));
554        }
555
556        Ok(())
557    }
558
559    /// Get a layer by name
560    pub fn get_layer(&self, name: &str) -> ConfigResult<&LayerConfig> {
561        self.layers
562            .iter()
563            .find(|l| l.name == name && l.enabled)
564            .ok_or_else(|| ConfigError::LayerNotFound(name.to_string()))
565    }
566
567    /// Get all enabled layers
568    pub fn enabled_layers(&self) -> impl Iterator<Item = &LayerConfig> {
569        self.layers.iter().filter(|l| l.enabled)
570    }
571
572    /// Get server bind address
573    pub fn bind_address(&self) -> String {
574        format!("{}:{}", self.server.host, self.server.port)
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_default_config() {
584        let config = Config::default_config();
585        assert_eq!(config.server.port, 8080);
586        assert_eq!(config.cache.memory_size_mb, 256);
587        assert!(config.layers.is_empty());
588        assert!(config.server.metrics_enabled);
589        assert_eq!(config.server.metrics_port, 8081);
590    }
591
592    #[test]
593    fn test_unknown_server_field_is_rejected() {
594        // Regression test: `Config` derives `deny_unknown_fields` everywhere so a mistyped or
595        // stale field name in a shipped TOML (e.g. `k8s/configmap.yaml`) fails loudly instead
596        // of being silently dropped.
597        let toml = r#"
598            [server]
599            host = "0.0.0.0"
600            port = 8080
601            size_mb = 1024
602        "#;
603
604        let err = Config::from_toml(toml).expect_err("unknown field must be rejected");
605        assert!(matches!(err, ConfigError::TomlParse(_)));
606    }
607
608    #[test]
609    fn test_unknown_top_level_table_is_rejected() {
610        let toml = r#"
611            [server]
612            host = "0.0.0.0"
613
614            [tile]
615            max_age = 3600
616        "#;
617
618        let err = Config::from_toml(toml).expect_err("unknown top-level table must be rejected");
619        assert!(matches!(err, ConfigError::TomlParse(_)));
620    }
621
622    #[test]
623    fn test_metrics_port_must_differ_from_server_port() {
624        let mut config = Config::default_config();
625        config.server.metrics_port = config.server.port;
626        assert!(config.validate().is_err());
627
628        config.server.metrics_enabled = false;
629        assert!(config.validate().is_ok());
630    }
631
632    #[test]
633    fn test_configmap_server_toml_matches_real_schema() {
634        // Regression test for the k8s/configmap.yaml drift bug: the exact server.toml body
635        // shipped in that ConfigMap must parse cleanly against the real `Config` schema.
636        let toml = r#"
637            [server]
638            host = "0.0.0.0"
639            port = 8080
640            workers = 4
641            enable_cors = true
642            cors_origins = ["*"]
643            metrics_enabled = true
644            metrics_port = 8081
645
646            [cache]
647            memory_size_mb = 1024
648            ttl_seconds = 3600
649            enable_stats = true
650            compression = false
651
652            [metadata]
653            title = "OxiGDAL Tile Server"
654        "#;
655
656        let config = Config::from_toml(toml).expect("shipped ConfigMap TOML must be valid");
657        assert_eq!(config.cache.memory_size_mb, 1024);
658        assert_eq!(config.server.metrics_port, 8081);
659        assert!(config.server.enable_cors);
660        assert_eq!(config.server.cors_origins, vec!["*".to_string()]);
661    }
662
663    #[test]
664    fn test_image_format_mime_types() {
665        assert_eq!(ImageFormat::Png.mime_type(), "image/png");
666        assert_eq!(ImageFormat::Jpeg.mime_type(), "image/jpeg");
667        assert_eq!(ImageFormat::Webp.mime_type(), "image/webp");
668        assert_eq!(ImageFormat::Geotiff.mime_type(), "image/tiff");
669    }
670
671    #[test]
672    fn test_image_format_from_str() {
673        assert_eq!("png".parse::<ImageFormat>().ok(), Some(ImageFormat::Png));
674        assert_eq!("PNG".parse::<ImageFormat>().ok(), Some(ImageFormat::Png));
675        assert_eq!("jpeg".parse::<ImageFormat>().ok(), Some(ImageFormat::Jpeg));
676        assert_eq!("jpg".parse::<ImageFormat>().ok(), Some(ImageFormat::Jpeg));
677        assert_eq!("webp".parse::<ImageFormat>().ok(), Some(ImageFormat::Webp));
678        assert_eq!(
679            "geotiff".parse::<ImageFormat>().ok(),
680            Some(ImageFormat::Geotiff)
681        );
682        assert!("invalid".parse::<ImageFormat>().is_err());
683    }
684
685    #[test]
686    fn test_config_from_toml() {
687        let toml = r#"
688            [server]
689            host = "127.0.0.1"
690            port = 9000
691            workers = 8
692
693            [cache]
694            memory_size_mb = 512
695            ttl_seconds = 7200
696
697            [metadata]
698            title = "Test Server"
699        "#;
700
701        let config = Config::from_toml(toml).expect("valid config");
702        assert_eq!(config.server.host.to_string(), "127.0.0.1");
703        assert_eq!(config.server.port, 9000);
704        assert_eq!(config.server.workers, 8);
705        assert_eq!(config.cache.memory_size_mb, 512);
706        assert_eq!(config.metadata.title, "Test Server");
707    }
708
709    #[test]
710    fn test_bind_address() {
711        let config = Config::default_config();
712        assert_eq!(config.bind_address(), "0.0.0.0:8080");
713    }
714}