use std::hash::{Hash as _, Hasher as _};
use martin_tile_utils::{Format, TileCoord};
use xxhash_rust::xxh3::Xxh3;
use crate::tiles::passthrough::PassthroughError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UrlTemplate(String);
impl UrlTemplate {
pub fn new(url: String) -> Result<Self, PassthroughError> {
if is_template(&url) {
Ok(Self(url))
} else {
Err(PassthroughError::InvalidUrlTemplate(url))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
pub(crate) fn is_template(url: &str) -> bool {
url.contains("{z}") && url.contains("{x}") && url.contains("{y}")
}
#[must_use]
pub(crate) fn substitute(template: &str, xyz: TileCoord) -> String {
template
.replace("{z}", &xyz.z.to_string())
.replace("{x}", &xyz.x.to_string())
.replace("{y}", &xyz.y.to_string())
}
#[must_use]
pub(crate) fn select_url(urls: &[String], xyz: TileCoord) -> &str {
if let [single] = urls {
return single;
}
let mut hasher = Xxh3::new();
xyz.z.hash(&mut hasher);
xyz.x.hash(&mut hasher);
xyz.y.hash(&mut hasher);
let idx = usize::try_from(hasher.finish() % urls.len() as u64).unwrap_or(0);
urls.get(idx).map_or(&urls[0], |u| u)
}
pub(crate) fn derive_format(
id: &str,
cfg_format: Option<Format>,
url_for_ext: &str,
tilejson_format: Option<&str>,
) -> Result<Format, PassthroughError> {
cfg_format
.or_else(|| extension_format(url_for_ext))
.or_else(|| tilejson_format.and_then(Format::parse))
.ok_or_else(|| PassthroughError::FormatUndeterminable(id.to_string()))
}
fn extension_format(url: &str) -> Option<Format> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let last_segment = path.rsplit('/').next().unwrap_or(path);
let (_, ext) = last_segment.rsplit_once('.')?;
Format::parse(ext)
}
#[cfg(test)]
mod tests {
use martin_tile_utils::{Format, TileCoord};
use rstest::rstest;
use super::*;
fn coord(z: u8, x: u32, y: u32) -> TileCoord {
TileCoord::new_unchecked(z, x, y)
}
#[rstest]
#[case::full("https://e.com/{z}/{x}/{y}.pbf", true)]
#[case::missing_y("https://e.com/{z}/{x}.pbf", false)]
#[case::tilejson("https://e.com/tiles.json", false)]
fn url_template_validates_placeholders(#[case] url: &str, #[case] ok: bool) {
let parsed = UrlTemplate::new(url.to_string());
assert_eq!(parsed.is_ok(), ok);
if ok {
assert_eq!(parsed.unwrap().as_str(), url);
} else {
assert!(matches!(
parsed,
Err(PassthroughError::InvalidUrlTemplate(_))
));
}
}
#[test]
fn substitutes_placeholders_only() {
assert_eq!(
substitute("https://e.com/{z}/{x}/{y}.pbf", coord(3, 1, 2)),
"https://e.com/3/1/2.pbf"
);
assert_eq!(
substitute("https://e.com/{s}/{z}/{x}/{y}", coord(10, 511, 340)),
"https://e.com/{s}/10/511/340"
);
}
#[test]
fn select_url_is_deterministic_and_in_range() {
let urls = vec![
"https://a/{z}/{x}/{y}".to_string(),
"https://b/{z}/{x}/{y}".to_string(),
"https://c/{z}/{x}/{y}".to_string(),
];
let first = select_url(&urls, coord(5, 10, 20));
assert_eq!(first, select_url(&urls, coord(5, 10, 20)));
for z in 0..8u8 {
for x in 0..16u32 {
let picked = select_url(&urls, coord(z, x, x));
assert!(urls.iter().any(|u| u == picked));
}
}
}
#[test]
fn single_url_always_selected() {
let urls = vec!["https://only/{z}/{x}/{y}".to_string()];
assert_eq!(select_url(&urls, coord(7, 3, 9)), urls[0]);
}
#[rstest]
#[case::config_overrides_extension(
Some(Format::Png),
"https://e/{z}/{x}/{y}.pbf",
Some("webp"),
Format::Png
)]
#[case::extension_fallback(None, "https://e/{z}/{x}/{y}.pbf", None, Format::Mvt)]
#[case::tilejson_fallback(None, "https://e/{z}/{x}/{y}", Some("png"), Format::Png)]
#[case::extension_ignores_query(None, "https://e/{z}/{x}/{y}.webp?key=abc", None, Format::Webp)]
fn derive_format_layers_precedence(
#[case] cfg_format: Option<Format>,
#[case] url: &str,
#[case] tilejson_format: Option<&str>,
#[case] expected: Format,
) {
assert_eq!(
derive_format("s", cfg_format, url, tilejson_format).unwrap(),
expected
);
}
#[test]
fn derive_format_errors_when_undeterminable() {
assert!(matches!(
derive_format("my-src", None, "https://e/{z}/{x}/{y}", None),
Err(PassthroughError::FormatUndeterminable(id)) if id == "my-src"
));
}
}