diff --git a/Cargo.lock b/Cargo.lock index 933cf3509..9abcce6ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "martin" -version = "0.16.0" +version = "0.17.0" dependencies = [ "actix-cors", "actix-http", @@ -2824,6 +2824,7 @@ dependencies = [ "tokio-postgres-rustls", "url", "walkdir", + "xxhash-rust", ] [[package]] diff --git a/martin/Cargo.toml b/martin/Cargo.toml index 30118d320..487bf4142 100644 --- a/martin/Cargo.toml +++ b/martin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "martin" -version = "0.16.0" +version = "0.17.0" authors = ["Stepan Kuzmin ", "Yuri Astrakhan ", "MapLibre contributors"] description = "Blazing fast and lightweight tile server with PostGIS, MBTiles, and PMTiles support" keywords = ["maps", "tiles", "mbtiles", "pmtiles", "postgis"] @@ -111,6 +111,7 @@ tokio = { workspace = true, features = ["io-std"] } tokio-postgres-rustls = { workspace = true, optional = true } url.workspace = true walkdir = { workspace = true, optional = true } +xxhash-rust.workspace = true [build-dependencies] walkdir = { workspace = true, optional = true } diff --git a/martin/benches/bench.rs b/martin/benches/bench.rs index bc26a171d..09b963866 100644 --- a/martin/benches/bench.rs +++ b/martin/benches/bench.rs @@ -56,7 +56,7 @@ impl Source for NullSource { } async fn process_tile(sources: &TileSources) { - let src = DynTileSource::new(sources, "null", Some(0), "", None, None, None).unwrap(); + let src = DynTileSource::new(sources, "null", Some(0), "", None, None, None, None).unwrap(); src.get_http_response(TileCoord { z: 0, x: 0, y: 0 }) .await .unwrap(); diff --git a/martin/src/bin/martin-cp.rs b/martin/src/bin/martin-cp.rs index e0cd807cd..252783bb2 100644 --- a/martin/src/bin/martin-cp.rs +++ b/martin/src/bin/martin-cp.rs @@ -285,6 +285,7 @@ async fn run_tile_copy(args: CopyArgs, state: ServerState) -> MartinCpResult<()> Some(parse_encoding(args.encoding.as_str())?), None, None, + None, )?; // parallel async below uses move, so we must only use copyable types let src = &src; diff --git a/martin/src/srv/tiles.rs b/martin/src/srv/tiles.rs index 50e0ef534..b86f94e28 100644 --- a/martin/src/srv/tiles.rs +++ b/martin/src/srv/tiles.rs @@ -2,7 +2,8 @@ use actix_http::ContentEncoding; use actix_http::header::Quality; use actix_web::error::{ErrorBadRequest, ErrorNotAcceptable, ErrorNotFound}; use actix_web::http::header::{ - AcceptEncoding, CONTENT_ENCODING, Encoding as HeaderEnc, Preference, + AcceptEncoding, CONTENT_ENCODING, ETAG, Encoding as HeaderEnc, EntityTag, IfNoneMatch, + Preference, }; use actix_web::web::{Data, Path, Query}; use actix_web::{HttpMessage, HttpRequest, HttpResponse, Result as ActixResult, route}; @@ -49,6 +50,7 @@ async fn get_tile( Some(path.z), req.query_string(), req.get_header::(), + req.get_header::(), srv_config.preferred_encoding, cache.as_ref().as_ref(), )?; @@ -67,17 +69,20 @@ pub struct DynTileSource<'a> { pub query_str: Option<&'a str>, pub query_obj: Option, pub accept_enc: Option, + pub if_none_match: Option, pub preferred_enc: Option, pub cache: Option<&'a MainCache>, } impl<'a> DynTileSource<'a> { + #[expect(clippy::too_many_arguments)] pub fn new( sources: &'a TileSources, source_ids: &str, zoom: Option, query: &'a str, accept_enc: Option, + if_none_match: Option, preferred_enc: Option, cache: Option<&'a MainCache>, ) -> ActixResult { @@ -100,6 +105,7 @@ impl<'a> DynTileSource<'a> { query_str, query_obj, accept_enc, + if_none_match, preferred_enc, cache, }) @@ -107,17 +113,26 @@ impl<'a> DynTileSource<'a> { pub async fn get_http_response(&self, xyz: TileCoord) -> ActixResult { let tile = self.get_tile_content(xyz).await?; - - Ok(if tile.data.is_empty() { - HttpResponse::NoContent().finish() - } else { - let mut response = HttpResponse::Ok(); - response.content_type(tile.info.format.content_type()); - if let Some(val) = tile.info.encoding.content_encoding() { - response.insert_header((CONTENT_ENCODING, val)); + if tile.data.is_empty() { + return Ok(HttpResponse::NoContent().finish()); + } + let hash = xxhash_rust::xxh3::xxh3_128(&tile.data); + let etag = EntityTag::new_strong(hash.to_string()); + if let Some(IfNoneMatch::Items(expected_etags)) = &self.if_none_match { + for expected_etag in expected_etags { + if etag.strong_eq(expected_etag) { + return Ok(HttpResponse::NotModified().finish()); + } } - response.body(tile.data) - }) + } + + let mut response = HttpResponse::Ok(); + response.content_type(tile.info.format.content_type()); + response.insert_header((ETAG, etag)); + if let Some(val) = tile.info.encoding.content_encoding() { + response.insert_header((CONTENT_ENCODING, val)); + } + Ok(response.body(tile.data)) } pub async fn get_tile_content(&self, xyz: TileCoord) -> ActixResult { @@ -294,6 +309,7 @@ pub fn to_encoding(val: ContentEncoding) -> Option { #[cfg(test)] mod tests { + use actix_http::header::TryIntoHeaderValue; use rstest::rstest; use tilejson::tilejson; @@ -335,6 +351,7 @@ mod tests { None, "", accept_enc, + None, preferred_enc, None, ) @@ -345,6 +362,47 @@ mod tests { assert_eq!(tile.info.encoding, expected_enc); } + #[rstest] + #[case(200, None, Some(EntityTag::new_strong("229249875805521414007261281044017345339".to_string())))] + #[case(304, Some(IfNoneMatch::Items(vec![EntityTag::new_strong("229249875805521414007261281044017345339".to_string())])), None)] + #[case(200, Some(IfNoneMatch::Items(vec![EntityTag::new_strong("incorrect_etag".to_string())])), Some(EntityTag::new_strong("229249875805521414007261281044017345339".to_string())))] + #[actix_rt::test] + async fn test_etag( + #[case] expected_status: u16, + #[case] if_none_match: Option, + #[case] expected_etag: Option, + ) { + let source_id = "source1"; + let source1 = TestSource { + id: source_id, + tj: tilejson! { tiles: vec![] }, + data: vec![1_u8, 2, 3], + }; + let sources = TileSources::new(vec![vec![Box::new(source1)]]); + + let src = DynTileSource::new( + &sources, + source_id, + None, + "", + None, + if_none_match, + None, + None, + ) + .unwrap(); + let resp = &src + .get_http_response(TileCoord { z: 0, x: 0, y: 0 }) + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), expected_status); + let etag = resp.headers().get(ETAG); + assert_eq!( + etag, + expected_etag.map(|e| e.try_into_value().unwrap()).as_ref() + ); + } + #[actix_rt::test] async fn test_tile_content() { let non_empty_source = TestSource { @@ -372,7 +430,8 @@ mod tests { ("empty,non-empty", vec![1_u8, 2, 3]), ("empty,non-empty,empty", vec![1_u8, 2, 3]), ] { - let src = DynTileSource::new(&sources, source_id, None, "", None, None, None).unwrap(); + let src = + DynTileSource::new(&sources, source_id, None, "", None, None, None, None).unwrap(); let xyz = TileCoord { z: 0, x: 0, y: 0 }; assert_eq!(expected, &src.get_tile_content(xyz).await.unwrap().data); }