use actix_middleware_etag::Etag;
use actix_web::http::Uri;
use actix_web::http::header::{ContentType, LOCATION};
use actix_web::middleware::Compress;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse, route};
use martin_core::styles::StyleSources;
use serde::Deserialize;
use tracing::{error, instrument, warn};
use crate::config::file::srv::SrvConfig;
use crate::maplibre_style::Style;
use crate::srv::server::DebouncedWarning;
#[derive(Deserialize, Debug)]
#[cfg_attr(feature = "unstable-schemas", derive(utoipa::IntoParams))]
#[cfg_attr(feature = "unstable-schemas", into_params(parameter_in = Path))]
struct StyleRequest {
style_id: String,
}
#[cfg_attr(
feature = "unstable-schemas",
utoipa::path(
get,
path = "/style/{style_id}",
params(StyleRequest),
responses(
(status = 200, description = "MapLibre Style Spec JSON document", content_type = "application/json"),
(status = 400, description = "Style file is malformed"),
(status = 404, description = "No matching style"),
),
)
)]
#[route(
"/style/{style_id}",
method = "GET",
wrap = "Etag::default()",
wrap = "Compress::default()"
)]
#[hotpath::measure]
#[instrument(level = "debug", skip_all, fields(style.id = %path.style_id))]
pub async fn get_style_json(
req: HttpRequest,
path: Path<StyleRequest>,
styles: Data<StyleSources>,
srv_config: Data<SrvConfig>,
) -> HttpResponse {
let style_id = &path.style_id;
let Some(path) = styles.style_json_path(style_id) else {
return HttpResponse::NotFound()
.content_type(ContentType::plaintext())
.body("No such style exists");
};
let Ok(style_content) = tokio::fs::read_to_string(&path).await else {
return HttpResponse::NotFound()
.content_type(ContentType::plaintext())
.body("No such style exists");
};
match serde_json::from_str::<Style>(&style_content) {
Ok(mut style) => {
let prefix = path_prefix(&req, &srv_config);
let info = req.connection_info();
let base_url = format!("{}://{}{prefix}", info.scheme(), info.host());
style.expand_relative_urls(&base_url);
HttpResponse::Ok().json(style)
}
Err(e) => {
error!(
"Failed to parse style JSON {e:?} for style {style_id} at {:?}",
path.display()
);
HttpResponse::BadRequest()
.content_type(ContentType::plaintext())
.body(format!(
"The requested style {style_id} is malformed: {e:?}"
))
}
}
}
fn path_prefix(req: &HttpRequest, srv_config: &SrvConfig) -> String {
if let Some(prefix) = srv_config.public_path_prefix() {
prefix.to_string()
} else {
req.headers()
.get("X-Forwarded-Prefix")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<Uri>().ok())
.map(|v| v.path().trim_end_matches('/').to_string())
.unwrap_or_default()
}
}
#[route("/styles/{style_id}", method = "GET", method = "HEAD")]
pub(crate) async fn redirect_styles(path: Path<StyleRequest>) -> HttpResponse {
static WARNING: DebouncedWarning = DebouncedWarning::new();
let StyleRequest { style_id } = path.as_ref();
WARNING
.once_per_hour(|| {
warn!(
"Request to /styles/{style_id} caused unnecessary redirect. Use /style/{style_id} to avoid extra round-trip latency."
);
})
.await;
HttpResponse::MovedPermanently()
.insert_header((LOCATION, format!("/style/{style_id}")))
.finish()
}
#[cfg(test)]
mod tests {
use actix_web::test::TestRequest;
use super::*;
fn cfg(base_path: Option<&str>, route_prefix: Option<&str>) -> SrvConfig {
SrvConfig {
base_path: base_path.map(ToString::to_string),
route_prefix: route_prefix.map(ToString::to_string),
..Default::default()
}
}
#[test]
fn path_prefix_empty_when_nothing_configured() {
let req = TestRequest::default().to_http_request();
assert_eq!(path_prefix(&req, &cfg(None, None)), "");
}
#[test]
fn path_prefix_uses_base_path_first() {
let req = TestRequest::default()
.insert_header(("X-Forwarded-Prefix", "/header"))
.to_http_request();
assert_eq!(
path_prefix(&req, &cfg(Some("/from_base"), Some("/from_route"))),
"/from_base"
);
}
#[test]
fn path_prefix_falls_back_to_route_prefix() {
let req = TestRequest::default()
.insert_header(("X-Forwarded-Prefix", "/header"))
.to_http_request();
assert_eq!(
path_prefix(&req, &cfg(None, Some("/from_route"))),
"/from_route"
);
}
#[test]
fn path_prefix_falls_back_to_forwarded_prefix_header() {
let req = TestRequest::default()
.insert_header(("X-Forwarded-Prefix", "/from_header"))
.to_http_request();
assert_eq!(path_prefix(&req, &cfg(None, None)), "/from_header");
}
#[test]
fn path_prefix_strips_trailing_slash_from_header() {
let req = TestRequest::default()
.insert_header(("X-Forwarded-Prefix", "/from_header/"))
.to_http_request();
assert_eq!(path_prefix(&req, &cfg(None, None)), "/from_header");
}
}