use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Invalid configuration: {0}")]
Invalid(String),
#[error("Failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse TOML: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Layer not found: {0}")]
LayerNotFound(String),
}
pub type ConfigResult<T> = Result<T, ConfigError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: IpAddr,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_workers")]
pub workers: usize,
#[serde(default = "default_max_request_size")]
pub max_request_size: usize,
#[serde(default = "default_timeout")]
pub timeout_seconds: u64,
#[serde(default = "default_cors")]
pub enable_cors: bool,
#[serde(default)]
pub cors_origins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_memory_cache_mb")]
pub memory_size_mb: usize,
#[serde(default)]
pub disk_cache: Option<PathBuf>,
#[serde(default = "default_ttl_seconds")]
pub ttl_seconds: u64,
#[serde(default = "default_enable_stats")]
pub enable_stats: bool,
#[serde(default = "default_compression")]
pub compression: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerConfig {
pub name: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub abstract_: Option<String>,
pub path: PathBuf,
#[serde(default = "default_formats")]
pub formats: Vec<ImageFormat>,
#[serde(default = "default_tile_size")]
pub tile_size: u32,
#[serde(default)]
pub min_zoom: u8,
#[serde(default = "default_max_zoom")]
pub max_zoom: u8,
#[serde(default = "default_tile_matrix_sets")]
pub tile_matrix_sets: Vec<String>,
#[serde(default)]
pub style: Option<StyleConfig>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleConfig {
pub name: String,
#[serde(default)]
pub colormap: Option<String>,
#[serde(default)]
pub value_range: Option<(f64, f64)>,
#[serde(default = "default_alpha")]
pub alpha: f32,
#[serde(default = "default_gamma")]
pub gamma: f32,
#[serde(default)]
pub brightness: f32,
#[serde(default = "default_contrast")]
pub contrast: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Png,
Jpeg,
Webp,
Geotiff,
}
impl ImageFormat {
pub fn mime_type(&self) -> &'static str {
match self {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Webp => "image/webp",
ImageFormat::Geotiff => "image/tiff",
}
}
pub fn extension(&self) -> &'static str {
match self {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
ImageFormat::Webp => "webp",
ImageFormat::Geotiff => "tif",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseImageFormatError(String);
impl std::fmt::Display for ParseImageFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown image format: {}", self.0)
}
}
impl std::error::Error for ParseImageFormatError {}
impl FromStr for ImageFormat {
type Err = ParseImageFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"png" => Ok(ImageFormat::Png),
"jpeg" | "jpg" => Ok(ImageFormat::Jpeg),
"webp" => Ok(ImageFormat::Webp),
"geotiff" | "tif" | "tiff" => Ok(ImageFormat::Geotiff),
_ => Err(ParseImageFormatError(s.to_string())),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub layers: Vec<LayerConfig>,
#[serde(default)]
pub metadata: MetadataConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataConfig {
#[serde(default = "default_service_title")]
pub title: String,
#[serde(default = "default_service_abstract")]
pub abstract_: String,
#[serde(default)]
pub contact: Option<ContactInfo>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub online_resource: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactInfo {
pub organization: String,
#[serde(default)]
pub person: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub phone: Option<String>,
}
fn default_host() -> IpAddr {
IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0))
}
fn default_port() -> u16 {
8080
}
fn default_workers() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.ok()
.unwrap_or(4)
}
fn default_max_request_size() -> usize {
10 * 1024 * 1024 }
fn default_timeout() -> u64 {
30
}
fn default_cors() -> bool {
true
}
fn default_memory_cache_mb() -> usize {
256
}
fn default_ttl_seconds() -> u64 {
3600
}
fn default_enable_stats() -> bool {
true
}
fn default_compression() -> bool {
false
}
fn default_formats() -> Vec<ImageFormat> {
vec![ImageFormat::Png, ImageFormat::Jpeg]
}
fn default_tile_size() -> u32 {
256
}
fn default_max_zoom() -> u8 {
18
}
fn default_tile_matrix_sets() -> Vec<String> {
vec!["WebMercatorQuad".to_string(), "WorldCRS84Quad".to_string()]
}
fn default_enabled() -> bool {
true
}
fn default_alpha() -> f32 {
1.0
}
fn default_gamma() -> f32 {
1.0
}
fn default_contrast() -> f32 {
1.0
}
fn default_service_title() -> String {
"OxiGDAL Tile Server".to_string()
}
fn default_service_abstract() -> String {
"WMS/WMTS tile server powered by OxiGDAL".to_string()
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
workers: default_workers(),
max_request_size: default_max_request_size(),
timeout_seconds: default_timeout(),
enable_cors: default_cors(),
cors_origins: Vec::new(),
}
}
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
memory_size_mb: default_memory_cache_mb(),
disk_cache: None,
ttl_seconds: default_ttl_seconds(),
enable_stats: default_enable_stats(),
compression: default_compression(),
}
}
}
impl Default for MetadataConfig {
fn default() -> Self {
Self {
title: default_service_title(),
abstract_: default_service_abstract(),
contact: None,
keywords: Vec::new(),
online_resource: None,
}
}
}
impl Config {
pub fn from_file<P: AsRef<Path>>(path: P) -> ConfigResult<Self> {
let contents = std::fs::read_to_string(path)?;
Self::from_toml(&contents)
}
pub fn from_toml(toml: &str) -> ConfigResult<Self> {
let config: Config = toml::from_str(toml)?;
config.validate()?;
Ok(config)
}
pub fn default_config() -> Self {
Self {
server: ServerConfig::default(),
cache: CacheConfig::default(),
layers: Vec::new(),
metadata: MetadataConfig::default(),
}
}
pub fn validate(&self) -> ConfigResult<()> {
let mut names = std::collections::HashSet::new();
for layer in &self.layers {
if !names.insert(&layer.name) {
return Err(ConfigError::Invalid(format!(
"Duplicate layer name: {}",
layer.name
)));
}
if !layer.path.exists() {
return Err(ConfigError::Invalid(format!(
"Layer path does not exist: {}",
layer.path.display()
)));
}
if !layer.tile_size.is_power_of_two() {
return Err(ConfigError::Invalid(format!(
"Tile size must be power of 2, got {}",
layer.tile_size
)));
}
if layer.min_zoom > layer.max_zoom {
return Err(ConfigError::Invalid(format!(
"min_zoom ({}) cannot be greater than max_zoom ({})",
layer.min_zoom, layer.max_zoom
)));
}
}
if self.cache.memory_size_mb == 0 {
return Err(ConfigError::Invalid(
"Cache memory size must be greater than 0".to_string(),
));
}
Ok(())
}
pub fn get_layer(&self, name: &str) -> ConfigResult<&LayerConfig> {
self.layers
.iter()
.find(|l| l.name == name && l.enabled)
.ok_or_else(|| ConfigError::LayerNotFound(name.to_string()))
}
pub fn enabled_layers(&self) -> impl Iterator<Item = &LayerConfig> {
self.layers.iter().filter(|l| l.enabled)
}
pub fn bind_address(&self) -> String {
format!("{}:{}", self.server.host, self.server.port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default_config();
assert_eq!(config.server.port, 8080);
assert_eq!(config.cache.memory_size_mb, 256);
assert!(config.layers.is_empty());
}
#[test]
fn test_image_format_mime_types() {
assert_eq!(ImageFormat::Png.mime_type(), "image/png");
assert_eq!(ImageFormat::Jpeg.mime_type(), "image/jpeg");
assert_eq!(ImageFormat::Webp.mime_type(), "image/webp");
assert_eq!(ImageFormat::Geotiff.mime_type(), "image/tiff");
}
#[test]
fn test_image_format_from_str() {
assert_eq!("png".parse::<ImageFormat>().ok(), Some(ImageFormat::Png));
assert_eq!("PNG".parse::<ImageFormat>().ok(), Some(ImageFormat::Png));
assert_eq!("jpeg".parse::<ImageFormat>().ok(), Some(ImageFormat::Jpeg));
assert_eq!("jpg".parse::<ImageFormat>().ok(), Some(ImageFormat::Jpeg));
assert_eq!("webp".parse::<ImageFormat>().ok(), Some(ImageFormat::Webp));
assert_eq!(
"geotiff".parse::<ImageFormat>().ok(),
Some(ImageFormat::Geotiff)
);
assert!("invalid".parse::<ImageFormat>().is_err());
}
#[test]
fn test_config_from_toml() {
let toml = r#"
[server]
host = "127.0.0.1"
port = 9000
workers = 8
[cache]
memory_size_mb = 512
ttl_seconds = 7200
[metadata]
title = "Test Server"
"#;
let config = Config::from_toml(toml).expect("valid config");
assert_eq!(config.server.host.to_string(), "127.0.0.1");
assert_eq!(config.server.port, 9000);
assert_eq!(config.server.workers, 8);
assert_eq!(config.cache.memory_size_mb, 512);
assert_eq!(config.metadata.title, "Test Server");
}
#[test]
fn test_bind_address() {
let config = Config::default_config();
assert_eq!(config.bind_address(), "0.0.0.0:8080");
}
}