1use 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#[derive(Debug, Error)]
43pub enum ConfigError {
44 #[error("Invalid configuration: {0}")]
46 Invalid(String),
47
48 #[error("Failed to read config file: {0}")]
50 Io(#[from] std::io::Error),
51
52 #[error("Failed to parse TOML: {0}")]
54 TomlParse(#[from] toml::de::Error),
55
56 #[error("Missing required field: {0}")]
58 MissingField(String),
59
60 #[error("Layer not found: {0}")]
62 LayerNotFound(String),
63}
64
65pub type ConfigResult<T> = Result<T, ConfigError>;
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ServerConfig {
72 #[serde(default = "default_host")]
74 pub host: IpAddr,
75
76 #[serde(default = "default_port")]
78 pub port: u16,
79
80 #[serde(default = "default_workers")]
82 pub workers: usize,
83
84 #[serde(default = "default_max_request_size")]
86 pub max_request_size: usize,
87
88 #[serde(default = "default_timeout")]
90 pub timeout_seconds: u64,
91
92 #[serde(default = "default_cors")]
94 pub enable_cors: bool,
95
96 #[serde(default)]
98 pub cors_origins: Vec<String>,
99
100 #[serde(default = "default_metrics_enabled")]
102 pub metrics_enabled: bool,
103
104 #[serde(default = "default_metrics_port")]
108 pub metrics_port: u16,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct CacheConfig {
115 #[serde(default = "default_memory_cache_mb")]
117 pub memory_size_mb: usize,
118
119 #[serde(default)]
121 pub disk_cache: Option<PathBuf>,
122
123 #[serde(default = "default_ttl_seconds")]
125 pub ttl_seconds: u64,
126
127 #[serde(default = "default_enable_stats")]
129 pub enable_stats: bool,
130
131 #[serde(default = "default_compression")]
133 pub compression: bool,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct LayerConfig {
140 pub name: String,
142
143 #[serde(default)]
145 pub title: Option<String>,
146
147 #[serde(default)]
149 pub abstract_: Option<String>,
150
151 pub path: PathBuf,
153
154 #[serde(default = "default_formats")]
156 pub formats: Vec<ImageFormat>,
157
158 #[serde(default = "default_tile_size")]
160 pub tile_size: u32,
161
162 #[serde(default)]
164 pub min_zoom: u8,
165
166 #[serde(default = "default_max_zoom")]
168 pub max_zoom: u8,
169
170 #[serde(default = "default_tile_matrix_sets")]
172 pub tile_matrix_sets: Vec<String>,
173
174 #[serde(default)]
176 pub style: Option<StyleConfig>,
177
178 #[serde(default)]
180 pub metadata: HashMap<String, String>,
181
182 #[serde(default = "default_enabled")]
184 pub enabled: bool,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct StyleConfig {
191 pub name: String,
193
194 #[serde(default)]
196 pub colormap: Option<String>,
197
198 #[serde(default)]
200 pub value_range: Option<(f64, f64)>,
201
202 #[serde(default = "default_alpha")]
204 pub alpha: f32,
205
206 #[serde(default = "default_gamma")]
208 pub gamma: f32,
209
210 #[serde(default)]
212 pub brightness: f32,
213
214 #[serde(default = "default_contrast")]
216 pub contrast: f32,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum ImageFormat {
223 Png,
225 Jpeg,
227 Webp,
229 Geotiff,
231}
232
233impl ImageFormat {
234 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct Config {
285 #[serde(default)]
287 pub server: ServerConfig,
288
289 #[serde(default)]
291 pub cache: CacheConfig,
292
293 #[serde(default)]
295 pub layers: Vec<LayerConfig>,
296
297 #[serde(default)]
299 pub metadata: MetadataConfig,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct MetadataConfig {
306 #[serde(default = "default_service_title")]
308 pub title: String,
309
310 #[serde(default = "default_service_abstract")]
312 pub abstract_: String,
313
314 #[serde(default)]
316 pub contact: Option<ContactInfo>,
317
318 #[serde(default)]
320 pub keywords: Vec<String>,
321
322 #[serde(default)]
324 pub online_resource: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct ContactInfo {
331 pub organization: String,
333
334 #[serde(default)]
336 pub person: Option<String>,
337
338 #[serde(default)]
340 pub email: Option<String>,
341
342 #[serde(default)]
344 pub phone: Option<String>,
345}
346
347fn 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 }
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 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 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 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 pub fn validate(&self) -> ConfigResult<()> {
505 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 if !layer.path.exists() {
517 return Err(ConfigError::Invalid(format!(
518 "Layer path does not exist: {}",
519 layer.path.display()
520 )));
521 }
522
523 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 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 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 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 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 pub fn enabled_layers(&self) -> impl Iterator<Item = &LayerConfig> {
569 self.layers.iter().filter(|l| l.enabled)
570 }
571
572 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 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 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}