use std::sync::Arc;
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
Html, Response,
},
routing::get,
Json, Router,
};
use ezu::core::TileId as CoreTileId;
use ezu::features::mvt;
use ezu::renderer::{OutputFormat, RenderOptions};
use futures::stream::{self, Stream};
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::broadcast;
use super::state::{validate_text, AppState, StyleSnapshot};
use crate::render::MissingTile;
use crate::source::{FeatureSource, SourceError};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/style", get(get_style).put(put_style))
.route("/style/params", get(get_params_schema))
.route("/style/attribution", get(get_attribution))
.route("/config", get(get_config))
.route("/style/validate", axum::routing::post(post_validate))
.route("/style/fetch", get(get_style_fetch))
.route("/style/events", get(get_style_events))
.route("/schemas/ezu-style.json", get(get_schema))
.route("/tiles/{z}/{x}/{y_ext}", get(get_tile))
.route("/mvt/{z}/{x}/{y}", get(get_mvt))
.route("/mvt-meta/{z}/{x}/{y}", get(get_mvt_meta))
}
async fn index() -> Html<&'static str> {
Html(include_str!("editor.html"))
}
const ABSOLUTE_MAXZOOM: u8 = 22;
async fn get_config(State(s): State<AppState>) -> Json<serde_json::Value> {
let data_maxzoom = s
.feature_sources
.iter()
.map(|src| src.max_zoom())
.collect::<Option<Vec<u8>>>()
.and_then(|zooms| zooms.into_iter().max());
let overzoom_levels = s.sources.overzoom_levels;
let raster_maxzoom = match data_maxzoom {
Some(z) => z.saturating_add(overzoom_levels).min(ABSOLUTE_MAXZOOM),
None => ABSOLUTE_MAXZOOM,
};
Json(serde_json::json!({
"overzoomLevels": overzoom_levels,
"dataMaxzoom": data_maxzoom,
"rasterMaxzoom": raster_maxzoom,
}))
}
async fn get_style(State(s): State<AppState>) -> Response {
let snap = s.style.read().await;
Response::builder()
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(snap.text.clone()))
.expect("response builder with valid headers + body never fails")
}
async fn put_style(
State(s): State<AppState>,
body: String,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let next_version = { s.style.read().await.version + 1 };
let snap = StyleSnapshot::build(body, next_version, &s.sources)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let version = snap.version;
*s.style.write().await = snap;
Ok(Json(json!({ "version": version })))
}
async fn get_params_schema(State(s): State<AppState>) -> Json<serde_json::Value> {
let snap = s.style.read().await;
Json(snap.doc.params_schema())
}
async fn get_attribution(State(s): State<AppState>) -> Json<serde_json::Value> {
let snap = s.style.read().await;
let mut list: Vec<String> = snap
.doc
.attributions()
.iter()
.map(|a| a.to_string())
.collect();
let mut push = |a: String| {
if !a.is_empty() && !list.contains(&a) {
list.push(a);
}
};
let pyramids = snap.tiles.pyramids();
for dem in [true, false] {
for src in pyramids.iter().filter(|p| p.is_dem == dem) {
let declares = snap
.doc
.sources
.get(&*src.name)
.is_some_and(|d| d.attribution().is_some());
if declares {
continue;
}
if let Some(a) = src.attribution().await {
push(a);
}
}
}
for src in s.feature_sources.iter() {
let declares = snap
.doc
.sources
.get(&*src.name)
.is_some_and(|d| d.attribution().is_some());
if !declares {
if let Some(a) = src.attribution() {
push(a.to_string());
}
}
}
Json(json!({ "attributions": list, "attribution": list.join(" | ") }))
}
async fn post_validate(body: String) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
validate_text(&body).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Json(json!({ "ok": true })))
}
async fn get_style_fetch(
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let url = q.get("url").ok_or((
StatusCode::BAD_REQUEST,
"missing url query parameter".into(),
))?;
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err((
StatusCode::BAD_REQUEST,
"only http(s) URLs are allowed".into(),
));
}
let text = crate::fetch_text(url)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(text))
.expect("response builder with valid headers + body never fails"))
}
async fn get_style_events(
State(s): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = s.events.subscribe();
let stream = stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(ev) => {
let event = Event::default()
.event("reload")
.json_data(&ev)
.unwrap_or_else(|_| Event::default());
return Some((Ok(event), rx));
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
async fn get_schema(State(s): State<AppState>) -> Response {
let body = serde_json::to_vec_pretty(&*s.schema).unwrap_or_default();
Response::builder()
.header(header::CONTENT_TYPE, "application/schema+json")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(body))
.expect("response builder with valid headers + body never fails")
}
#[derive(Clone, Copy)]
enum TileFormat {
Png,
Webp,
}
impl TileFormat {
fn content_type(self) -> &'static str {
match self {
TileFormat::Png => "image/png",
TileFormat::Webp => "image/webp",
}
}
fn encoding(self) -> OutputFormat {
match self {
TileFormat::Png => OutputFormat::Png,
TileFormat::Webp => OutputFormat::Webp,
}
}
}
fn status_of(e: &(dyn std::error::Error + 'static)) -> StatusCode {
use ezu::renderer::{Error, ErrorKind};
if e.downcast_ref::<MissingTile>().is_some() {
StatusCode::NOT_FOUND
} else if e.downcast_ref::<SourceError>().is_some()
|| e.downcast_ref::<Error>()
.is_some_and(|e| matches!(e.kind(), ErrorKind::DemDecode | ErrorKind::RasterDecode))
{
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
}
fn density(factor: &str) -> Result<u32, (StatusCode, String)> {
factor
.strip_suffix('x')
.and_then(|n| n.parse::<u32>().ok())
.filter(|n| (1..=4).contains(n))
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("`@{factor}`: expected @1x, @2x, @3x or @4x"),
)
})
}
async fn get_tile(
State(s): State<AppState>,
Path((z, x, y_ext)): Path<(u8, u32, String)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let (y_str, format) = if let Some(s) = y_ext.strip_suffix(".webp") {
(s, TileFormat::Webp)
} else if let Some(s) = y_ext.strip_suffix(".png") {
(s, TileFormat::Png)
} else {
(y_ext.as_str(), TileFormat::Png)
};
let (y_str, scale) = match y_str.split_once('@') {
Some((y, factor)) => (y, Some(density(factor)?)),
None => (y_str, None),
};
let y: u32 = y_str
.parse()
.map_err(|_| (StatusCode::BAD_REQUEST, "bad y".into()))?;
let tile = CoreTileId::new(z, x, y);
let png_compression = s.sources.png_compression;
let (tiles, params) = {
let snap = s.style.read().await;
let mut params = Vec::new();
for (name, raw) in &q {
if name == "v" {
continue;
}
ezu::graph::parse_param_value(&snap.doc.params, name, raw)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
params.push((name.clone(), raw.clone()));
}
(Arc::clone(&snap.tiles), params)
};
let pixel_ratio = scale.map(|n| n as f32);
let bytes = tiles
.render(tile, move |r, set, opts| {
let opts = RenderOptions {
format: format.encoding(),
params,
pixel_ratio: pixel_ratio.unwrap_or(opts.pixel_ratio),
png_compression,
..opts
};
r.render_tile_with(tile.z, tile.x, tile.y, set, opts)
})
.await
.map_err(|e| {
tracing::error!("tile {z}/{x}/{}: {e}", tile.y);
(status_of(e.as_ref()), e.to_string())
})?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, format.content_type())
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(bytes))
.expect("response builder with valid headers + body never fails"))
}
fn requested_source<'s>(
s: &'s AppState,
q: &HashMap<String, String>,
) -> Result<Option<&'s FeatureSource>, (StatusCode, String)> {
match q.get("source") {
Some(name) => s
.feature_sources
.get(Some(name))
.map(Some)
.ok_or((StatusCode::NOT_FOUND, format!("no feature source `{name}`"))),
None => Ok(s.feature_sources.get(None)),
}
}
async fn get_mvt(
State(s): State<AppState>,
Path((z, x, y)): Path<(u8, u32, u32)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let tile = CoreTileId::new(z, x, y);
let Some(source) = requested_source(&s, &q)? else {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
};
let Some((bytes, src)) = fetch_mvt(&s, source, tile).await? else {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
};
if src != tile {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
}
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/vnd.mapbox-vector-tile")
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(Body::from(bytes.to_vec()))
.expect("response builder with valid headers + body never fails"))
}
async fn get_mvt_meta(
State(s): State<AppState>,
Path((z, x, y)): Path<(u8, u32, u32)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let tile = CoreTileId::new(z, x, y);
let Some(source) = requested_source(&s, &q)? else {
return Ok(Json(json!({ "layers": [] })));
};
let Some((bytes, _src)) = fetch_mvt(&s, source, tile).await? else {
return Ok(Json(json!({ "layers": [] })));
};
let decoded =
mvt::decode(&bytes).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let layers: Vec<_> = decoded
.layers
.iter()
.map(|l| {
let (mut p, mut ln, mut pg) = (false, false, false);
for f in &l.features {
if !f.geometry.points.is_empty() {
p = true;
}
if !f.geometry.lines.is_empty() {
ln = true;
}
if !f.geometry.polygons.is_empty() {
pg = true;
}
}
let mut geoms: Vec<&str> = Vec::new();
if p {
geoms.push("point");
}
if ln {
geoms.push("line");
}
if pg {
geoms.push("polygon");
}
json!({
"name": l.name,
"geometry_types": geoms,
"features": l.features.len(),
})
})
.collect();
Ok(Json(json!({ "layers": layers })))
}
async fn fetch_mvt(
s: &AppState,
source: &FeatureSource,
tile: CoreTileId,
) -> Result<Option<(bytes::Bytes, CoreTileId)>, (StatusCode, String)> {
source
.fetch(tile, s.sources.overzoom_levels)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
#[cfg(test)]
mod tests {
use std::path::{Path as FsPath, PathBuf};
use axum::http::Request;
use tower::ServiceExt;
use super::*;
use crate::serve::state::Sources;
use crate::source::{FeatureSources, TileByteCache};
fn testdata(name: &str) -> Vec<u8> {
let path = FsPath::new(env!("CARGO_MANIFEST_DIR"))
.join("../../go/testdata")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ezu-serve-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
std::fs::create_dir_all(&root).unwrap();
root
}
fn write_tile(root: &FsPath, dir: &str, (z, x, y): (u8, u32, u32), ext: &str, bytes: &[u8]) {
let d = root.join(dir).join(z.to_string()).join(x.to_string());
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(format!("{y}.{ext}")), bytes).unwrap();
}
async fn serve(style: &str, root: &FsPath) -> Result<AppState, String> {
serve_at(style, root, None, 1.0).await
}
async fn serve_at(
style: &str,
root: &FsPath,
tile_size: Option<u32>,
pixel_ratio: f32,
) -> Result<AppState, String> {
let doc = ezu::style::Document::from_json(style).map_err(|e| e.to_string())?;
let byte_cache = Arc::new(TileByteCache::new(1 << 20));
let feature_sources = FeatureSources::open(&doc, None, root)
.await
.map_err(|e| e.to_string())?
.with_byte_cache(&byte_cache, true);
let sources = Sources {
assets_dir: root.to_path_buf(),
feature_sources: Arc::new(feature_sources),
overzoom_levels: 0,
byte_cache,
tile_size,
pixel_ratio,
pad: None,
png_compression: Default::default(),
};
let snapshot = StyleSnapshot::build(style.to_string(), 1, &sources)
.await
.map_err(|e| e.to_string())?;
Ok(AppState::new(sources, snapshot))
}
async fn request(
state: &AppState,
method: &str,
uri: &str,
body: &str,
) -> (StatusCode, Vec<u8>) {
let response = router()
.with_state(state.clone())
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, bytes.to_vec())
}
const STITCHED: &str = r##"{
"name": "stitched",
"tile-size": 64,
"sources": { "bg": { "type": "mvt", "url": "file:bg/{z}/{x}/{y}.mvt" } },
"nodes": {
"f": { "op": "features", "source": "bg", "layer": "water", "stitch": "merge" },
"fill": { "op": "fill-solid", "features": "@f", "fill": "#204080" },
"out": { "op": "blur", "input": "@fill", "sigma": 3.0 }
},
"output": "@out"
}"##;
#[tokio::test]
async fn a_neighbour_that_does_not_decode_is_left_out() {
let root = scratch("neighbour");
let centre = (14, 14554, 6454);
write_tile(
&root,
"bg",
centre,
"mvt",
&testdata("basemap-14-14554-6454.mvt"),
);
let state = serve(STITCHED, &root).await.unwrap();
let uri = "/tiles/14/14554/6454.png";
let (status, alone) = request(&state, "GET", uri, "").await;
assert_eq!(status, StatusCode::OK);
write_tile(&root, "bg", (14, 14555, 6454), "mvt", b"not a tile");
let (status, beside) = request(&state, "GET", uri, "").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(alone, beside);
write_tile(&root, "bg", centre, "mvt", b"not a tile either");
let (status, _) = request(&state, "GET", uri, "").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
std::fs::remove_dir_all(&root).ok();
}
fn png_size(bytes: &[u8]) -> (u32, u32) {
let p = tiny_skia::Pixmap::decode_png(bytes).expect("a PNG");
(p.width(), p.height())
}
#[tokio::test]
async fn an_at_2x_tile_is_the_tile_at_twice_the_density() {
let root = scratch("density");
write_tile(
&root,
"bg",
(14, 14554, 6454),
"mvt",
&testdata("basemap-14-14554-6454.mvt"),
);
let state = serve(STITCHED, &root).await.unwrap();
let (status, plain) = request(&state, "GET", "/tiles/14/14554/6454.png", "").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(png_size(&plain), (64, 64));
let (status, one) = request(&state, "GET", "/tiles/14/14554/6454@1x.png", "").await;
assert_eq!((status, &one), (StatusCode::OK, &plain));
let (status, double) = request(&state, "GET", "/tiles/14/14554/6454@2x.png", "").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(png_size(&double), (128, 128));
let retina = serve_at(STITCHED, &root, None, 2.0).await.unwrap();
let (_, at_ratio) = request(&retina, "GET", "/tiles/14/14554/6454.png", "").await;
assert!(
double == at_ratio,
"@2x differs from a --pixel-ratio 2 tile"
);
let (_, back) = request(&retina, "GET", "/tiles/14/14554/6454@1x.png", "").await;
assert!(
back == plain,
"@1x on a --pixel-ratio 2 server is not the plain tile"
);
let big = serve_at(STITCHED, &root, Some(128), 1.0).await.unwrap();
let (_, at_128) = request(&big, "GET", "/tiles/14/14554/6454.png", "").await;
assert!(double != at_128, "@2x drew what a 128 px tile draws");
let (_, quad) = request(&big, "GET", "/tiles/14/14554/6454@2x.webp", "").await;
let quad = ezu::paint::host::decode_image_bytes(&quad).expect("a WebP");
assert_eq!((quad.width, quad.height), (256, 256));
for bad in ["@0x", "@5x", "@2", "@x", "@1.5x"] {
let uri = format!("/tiles/14/14554/6454{bad}.png");
assert_eq!(
request(&state, "GET", &uri, "").await.0,
StatusCode::BAD_REQUEST,
"{uri}"
);
}
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn a_string_param_rides_the_query_string() {
let root = scratch("string-param");
let style = r##"{
"name": "modes",
"tile-size": 16,
"params": {
"mode": { "type": "string", "enum": ["multiply", "screen"], "default": "multiply" }
},
"nodes": {
"base": { "op": "solid", "color": "#808080" },
"over": { "op": "solid", "color": "#ff4020" },
"out": { "op": "blend", "base": "@base", "over": "@over", "mode": "$mode" }
},
"output": "@out"
}"##;
let state = serve(style, &root).await.unwrap();
let (status, schema) = request(&state, "GET", "/style/params", "").await;
assert_eq!(status, StatusCode::OK);
let schema: serde_json::Value = serde_json::from_slice(&schema).unwrap();
assert_eq!(
schema["properties"]["mode"]["enum"],
serde_json::json!(["multiply", "screen"])
);
let (status, plain) = request(&state, "GET", "/tiles/3/1/2.png", "").await;
assert_eq!(status, StatusCode::OK);
let (status, same) = request(&state, "GET", "/tiles/3/1/2.png?mode=multiply", "").await;
assert_eq!((status, &same), (StatusCode::OK, &plain));
let (status, screen) = request(&state, "GET", "/tiles/3/1/2.png?mode=screen", "").await;
assert_eq!(status, StatusCode::OK);
assert_ne!(screen, plain);
let (status, body) = request(&state, "GET", "/tiles/3/1/2.png?mode=overlay", "").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
String::from_utf8_lossy(&body).contains("not one of"),
"{}",
String::from_utf8_lossy(&body)
);
std::fs::remove_dir_all(&root).ok();
}
fn dem_style(url: &str, read: bool) -> String {
let out = if read {
r#""dem": { "op": "dem", "source": "terrain" },
"out": { "op": "hillshade", "field": "@dem" }"#
} else {
r##""out": { "op": "solid", "color": "#336699" }"##
};
format!(
r##"{{
"name": "dem",
"tile-size": 64,
"sources": {{ "terrain": {{ "type": "dem", "url": "{url}", "encoding": "terrarium" }} }},
"nodes": {{ {out} }},
"output": "@out"
}}"##
)
}
#[tokio::test]
async fn a_dem_tile_that_does_not_decode_is_a_bad_gateway() {
let root = scratch("dem-decode");
let tile = (10, 909, 402);
write_tile(
&root,
"dem",
tile,
"webp",
&testdata("terrain-10-909-402.webp"),
);
let state = serve(&dem_style("file:dem/{z}/{x}/{y}.webp", true), &root)
.await
.unwrap();
let uri = "/tiles/10/909/402.png";
assert_eq!(request(&state, "GET", uri, "").await.0, StatusCode::OK);
write_tile(&root, "dem", tile, "webp", b"not an image");
assert_eq!(
request(&state, "GET", uri, "").await.0,
StatusCode::BAD_GATEWAY
);
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn a_dem_source_opens_only_when_a_tile_reads_it() {
let root = scratch("dem-lazy");
let unreachable = [
"http://127.0.0.1:9/terrain.json",
"http://127.0.0.1:9/terrain.pmtiles",
];
for url in unreachable {
let state = serve(&dem_style(url, false), &root)
.await
.unwrap_or_else(|e| panic!("{url}: the style failed to load: {e}"));
let (status, _) = request(&state, "GET", "/tiles/3/1/2.png", "").await;
assert_eq!(status, StatusCode::OK, "{url}");
let snap = state.style.read().await;
assert!(
snap.tiles.pyramids().iter().all(|p| !p.is_open()),
"{url}: a source no node reads was opened"
);
drop(snap);
let (status, body) = request(&state, "PUT", "/style", &dem_style(url, true)).await;
assert_eq!(
status,
StatusCode::OK,
"{url}: {}",
String::from_utf8_lossy(&body)
);
let (status, _) = request(&state, "GET", "/tiles/3/1/2.png", "").await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{url}");
}
std::fs::remove_dir_all(&root).ok();
}
}