#[cfg(feature = "metrics")]
use std::collections::HashMap;
use std::fmt;
use actix_web::http::header::{CacheDirective, HeaderValue, from_comma_delimited};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser};
use crate::config::args::PreferredEncoding;
#[cfg(all(feature = "webui", not(docsrs)))]
use crate::config::args::WebUiMode;
#[cfg(feature = "metrics")]
use crate::config::file::UnrecognizedValues;
use crate::config::file::cors::CorsConfig;
use crate::config::file::{CollectUnrecognizedKeys, ConfigurationLivecycleHooks, UnrecognizedKeys};
pub const DEFAULT_KEEP_ALIVE: u64 = 75;
pub const DEFAULT_LISTEN_ADDRESSES: &str = "0.0.0.0:3000";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheControlHeader(HeaderValue);
impl CollectUnrecognizedKeys for CacheControlHeader {
fn collect_unrecognized(&self, _path: &str, _out: &mut UnrecognizedKeys) {}
}
impl CacheControlHeader {
#[must_use]
pub(crate) fn header_value(&self) -> HeaderValue {
self.0.clone()
}
}
impl fmt::Display for CacheControlHeader {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0.to_str().map_err(|_e| fmt::Error)?)
}
}
impl Serialize for CacheControlHeader {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.0.to_str().map_err(ser::Error::custom)?)
}
}
impl<'de> Deserialize<'de> for CacheControlHeader {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let value = HeaderValue::from_str(&raw).map_err(de::Error::custom)?;
let directives: Vec<CacheDirective> = from_comma_delimited(std::iter::once(&value))
.map_err(|error| {
de::Error::custom(format_args!(
"invalid Cache-Control header value '{raw}': {error}"
))
})?;
if directives.is_empty() {
return Err(de::Error::custom(format_args!(
"invalid Cache-Control header value '{raw}': no valid directives"
)));
}
Ok(Self(value))
}
}
#[serde_with::skip_serializing_none]
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Default,
CollectUnrecognizedKeys,
ConfigurationLivecycleHooks,
)]
#[cfg_attr(feature = "unstable-schemas", derive(schemars::JsonSchema))]
pub struct SrvConfig {
#[cfg_attr(feature = "unstable-schemas", schemars(example = &75u64))]
pub keep_alive: Option<u64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &"0.0.0.0:3000"))]
pub listen_addresses: Option<String>,
pub route_prefix: Option<String>,
pub base_path: Option<String>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &8usize))]
pub worker_processes: Option<usize>,
pub preferred_encoding: Option<PreferredEncoding>,
#[cfg_attr(feature = "unstable-schemas", schemars(with = "Option<String>"))]
pub cache_control: Option<CacheControlHeader>,
#[cfg(all(feature = "webui", not(docsrs)))]
pub web_ui: Option<WebUiMode>,
pub cors: Option<CorsConfig>,
#[cfg(feature = "metrics")]
pub observability: Option<ObservabilityConfig>,
#[cfg(feature = "_tiles")]
#[cfg_attr(feature = "unstable-schemas", schemars(example = &"version"))]
pub tilejson_url_version_param: Option<String>,
}
impl SrvConfig {
pub(crate) fn cache_control_header(&self) -> Option<HeaderValue> {
self.cache_control
.as_ref()
.map(CacheControlHeader::header_value)
}
#[must_use]
pub fn public_path_prefix(&self) -> Option<&str> {
self.base_path.as_deref().or(self.route_prefix.as_deref())
}
}
#[cfg(feature = "metrics")]
#[serde_with::skip_serializing_none]
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Default,
CollectUnrecognizedKeys,
ConfigurationLivecycleHooks,
)]
#[cfg_attr(feature = "unstable-schemas", derive(schemars::JsonSchema))]
pub struct ObservabilityConfig {
pub metrics: Option<MetricsConfig>,
#[serde(flatten, skip_serializing)]
#[cfg_attr(feature = "unstable-schemas", schemars(skip))]
pub unrecognized: UnrecognizedValues,
}
#[cfg(feature = "metrics")]
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Default,
CollectUnrecognizedKeys,
ConfigurationLivecycleHooks,
)]
#[cfg_attr(feature = "unstable-schemas", derive(schemars::JsonSchema))]
pub struct MetricsConfig {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub add_labels: HashMap<String, String>,
#[serde(flatten, skip_serializing)]
#[cfg_attr(feature = "unstable-schemas", schemars(skip))]
pub unrecognized: UnrecognizedValues,
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use super::*;
use crate::config::file::UnrecognizedValues;
use crate::config::file::cors::CorsProperties;
use crate::config::test_helpers::render_failure;
#[test]
fn parse_config() {
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
..Default::default()
}
);
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
preferred_encoding: br
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
preferred_encoding: Some(PreferredEncoding::Brotli),
..Default::default()
}
);
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
preferred_encoding: brotli
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
preferred_encoding: Some(PreferredEncoding::Brotli),
..Default::default()
}
);
}
#[test]
fn parse_cache_control() {
let config = serde_saphyr::from_str::<SrvConfig>(indoc! {"
cache_control: public, max-age=3600, stale-while-revalidate=60
"})
.unwrap();
assert_eq!(
config.cache_control.as_ref().map(ToString::to_string),
Some("public, max-age=3600, stale-while-revalidate=60".to_owned())
);
assert_eq!(
config.cache_control_header().unwrap(),
"public, max-age=3600, stale-while-revalidate=60"
);
}
#[test]
fn reject_invalid_cache_control_header() {
insta::assert_snapshot!(
render_failure(indoc::indoc! {"
cache_control: max-age=invalid
"}),
@r"
martin::config::yaml (https://maplibre.org/martin/config-file/)
× invalid Cache-Control header value 'max-age=invalid': no valid directives
help: Check the highlighted token in your YAML. The error usually indicates
a mismatched type or an unexpected shape.
");
}
#[test]
fn reject_empty_cache_control_header() {
insta::assert_snapshot!(
render_failure(indoc::indoc! {"
cache_control: ''
"}),
@r"
martin::config::yaml (https://maplibre.org/martin/config-file/)
× invalid Cache-Control header value '': no valid directives
help: Check the highlighted token in your YAML. The error usually indicates
a mismatched type or an unexpected shape.
");
}
#[test]
fn parse_config_cors() {
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
cors: false
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
cors: Some(CorsConfig::SimpleFlag(false)),
..Default::default()
}
);
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
cors: true
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
cors: Some(CorsConfig::SimpleFlag(true)),
..Default::default()
}
);
assert_eq!(
serde_saphyr::from_str::<SrvConfig>(indoc! {"
keep_alive: 75
listen_addresses: '0.0.0.0:3000'
worker_processes: 8
cors:
origin:
- https://martin.maplibre.org
- https://example.org
"})
.unwrap(),
SrvConfig {
keep_alive: Some(75),
listen_addresses: Some("0.0.0.0:3000".to_owned()),
worker_processes: Some(8),
cors: Some(CorsConfig::Properties(CorsProperties {
origin: vec![
"https://martin.maplibre.org".to_owned(),
"https://example.org".to_owned()
],
max_age: None,
unrecognized: UnrecognizedValues::default()
})),
..Default::default()
}
);
}
}