use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::header::LOCATION;
use axum::http::{HeaderValue, Method, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
#[derive(Debug, Clone)]
pub struct RedirectRule {
pub to: String,
pub permanent: bool,
}
#[derive(Debug, Default, Clone)]
pub struct RedirectMap {
rules: HashMap<String, RedirectRule>,
}
impl RedirectMap {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn add(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
self.rules.insert(
from.into(),
RedirectRule {
to: to.into(),
permanent: false,
},
);
self
}
#[must_use]
pub fn add_permanent(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
self.rules.insert(
from.into(),
RedirectRule {
to: to.into(),
permanent: true,
},
);
self
}
#[must_use]
pub fn get(&self, path: &str) -> Option<&RedirectRule> {
self.rules.get(path)
}
#[must_use]
pub fn len(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn from_csv(csv: &str) -> Self {
let mut map = Self::new();
for (line_no, raw) in csv.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.splitn(3, ',').map(str::trim);
let (Some(from), Some(to)) = (parts.next(), parts.next()) else {
tracing::warn!(line_no = line_no + 1, "redirect CSV: missing from/to");
continue;
};
let permanent = parts
.next()
.map(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false);
map.rules.insert(
from.to_owned(),
RedirectRule {
to: to.to_owned(),
permanent,
},
);
}
map
}
}
#[must_use]
pub fn build_redirect_response(rule: &RedirectRule, query: Option<&str>) -> Response {
redirect(rule, query, false)
}
fn redirect(rule: &RedirectRule, query: Option<&str>, keep_method: bool) -> Response {
let status = match (rule.permanent, keep_method) {
(true, false) => StatusCode::MOVED_PERMANENTLY,
(false, false) => StatusCode::FOUND,
(true, true) => StatusCode::PERMANENT_REDIRECT,
(false, true) => StatusCode::TEMPORARY_REDIRECT,
};
let mut response = (status, ()).into_response();
if let Ok(hv) = HeaderValue::from_str(&location(&rule.to, query)) {
response.headers_mut().insert(LOCATION, hv);
}
response
}
fn location(to: &str, query: Option<&str>) -> String {
let Some(q) = query.filter(|q| !q.is_empty()) else {
return to.to_owned();
};
let (base, frag) = to.split_once('#').map_or((to, None), |(b, f)| (b, Some(f)));
let sep = match base.find('?') {
None => "?",
Some(i) if i + 1 == base.len() || base.ends_with('&') => "",
Some(_) => "&",
};
let mut loc = format!("{base}{sep}{q}");
if let Some(f) = frag {
loc.push('#');
loc.push_str(f);
}
loc
}
pub async fn redirects_middleware(
State(map): State<Arc<RedirectMap>>,
req: Request<Body>,
next: Next,
) -> Response {
let uri = req.uri();
if let Some(rule) = map.get(uri.path()) {
let keep_method = !matches!(*req.method(), Method::GET | Method::HEAD);
return redirect(rule, uri.query(), keep_method);
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::middleware::from_fn_with_state;
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
fn app(map: Arc<RedirectMap>) -> Router {
Router::new()
.route("/alive", get(|| async { "ok" }))
.layer(from_fn_with_state(map, redirects_middleware))
}
async fn req(app: &Router, path: &str) -> Response {
app.clone()
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap()
}
fn loc(resp: &Response) -> &str {
resp.headers()
.get(LOCATION)
.map(|v| v.to_str().unwrap())
.unwrap_or("")
}
#[test]
fn map_builder_chains() {
let m = RedirectMap::new().add("/a", "/A").add_permanent("/b", "/B");
assert_eq!(m.len(), 2);
assert!(!m.is_empty());
assert_eq!(m.get("/a").unwrap().to, "/A");
assert!(!m.get("/a").unwrap().permanent);
assert_eq!(m.get("/b").unwrap().to, "/B");
assert!(m.get("/b").unwrap().permanent);
}
#[test]
fn map_get_misses_return_none() {
let m = RedirectMap::new().add("/x", "/y");
assert!(m.get("/z").is_none());
assert!(
m.get("/x/").is_none(),
"trailing-slash variants are distinct"
);
}
#[test]
fn map_from_csv_parses_lines() {
let csv = "
# Header comment ignored
/old-blog,/blog,true
/foo,/bar
/baz,/qux,1
/quux,/quark,yes
# Trailing comment also ignored
";
let m = RedirectMap::from_csv(csv);
assert_eq!(m.len(), 4);
assert!(m.get("/old-blog").unwrap().permanent);
assert!(!m.get("/foo").unwrap().permanent); assert!(m.get("/baz").unwrap().permanent); assert!(m.get("/quux").unwrap().permanent); }
#[test]
fn map_from_csv_skips_malformed_lines() {
let csv = "
/only-one-field
/good,/dest,true
";
let m = RedirectMap::from_csv(csv);
assert_eq!(m.len(), 1);
assert!(m.get("/good").is_some());
}
#[test]
fn build_response_uses_status_and_location() {
let r = build_redirect_response(
&RedirectRule {
to: "/new".into(),
permanent: true,
},
None,
);
assert_eq!(r.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(loc(&r), "/new");
}
#[test]
fn build_response_uses_302_for_non_permanent() {
let r = build_redirect_response(
&RedirectRule {
to: "/new".into(),
permanent: false,
},
None,
);
assert_eq!(r.status(), StatusCode::FOUND);
}
#[test]
fn build_response_preserves_query_string() {
let r = build_redirect_response(
&RedirectRule {
to: "/new".into(),
permanent: false,
},
Some("ref=email&utm=2025"),
);
assert_eq!(loc(&r), "/new?ref=email&utm=2025");
}
#[test]
fn build_response_omits_empty_query() {
let r = build_redirect_response(
&RedirectRule {
to: "/new".into(),
permanent: false,
},
Some(""),
);
assert_eq!(loc(&r), "/new", "empty query stripped");
}
#[tokio::test]
async fn middleware_redirects_matching_path() {
let map = Arc::new(RedirectMap::new().add_permanent("/old", "/new"));
let app = app(map);
let r = req(&app, "/old").await;
assert_eq!(r.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(loc(&r), "/new");
}
#[tokio::test]
async fn middleware_passes_through_unmatched_path() {
let map = Arc::new(RedirectMap::new().add("/old", "/new"));
let app = app(map);
let r = req(&app, "/alive").await;
assert_eq!(r.status(), StatusCode::OK);
}
#[tokio::test]
async fn middleware_preserves_query_on_redirect() {
let map = Arc::new(RedirectMap::new().add("/old", "/new"));
let app = app(map);
let r = req(&app, "/old?utm=test&ref=mail").await;
assert_eq!(r.status(), StatusCode::FOUND);
assert_eq!(loc(&r), "/new?utm=test&ref=mail");
}
#[test]
fn query_merges_into_the_targets_query_and_precedes_its_fragment() {
let rule = |to: &str| RedirectRule {
to: to.into(),
permanent: false,
};
let at = |to, q| loc(&build_redirect_response(&rule(to), q)).to_owned();
assert_eq!(at("/new?ref=x", Some("a=1")), "/new?ref=x&a=1");
assert_eq!(at("/new#top", Some("a=1")), "/new?a=1#top");
assert_eq!(at("/new?ref=x#top", Some("a=1")), "/new?ref=x&a=1#top");
assert_eq!(at("/new?", Some("a=1")), "/new?a=1");
assert_eq!(at("/new#top", None), "/new#top");
}
#[tokio::test]
async fn a_post_gets_a_method_preserving_redirect() {
let map = Arc::new(RedirectMap::new().add_permanent("/p", "/P").add("/t", "/T"));
let app = app(map);
for (path, status) in [
("/p", StatusCode::PERMANENT_REDIRECT),
("/t", StatusCode::TEMPORARY_REDIRECT),
] {
let r = app
.clone()
.oneshot(Request::post(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(r.status(), status, "{path}");
}
let head = app
.clone()
.oneshot(Request::head("/p").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(head.status(), StatusCode::MOVED_PERMANENTLY);
}
#[tokio::test]
async fn empty_map_is_a_no_op() {
let map = Arc::new(RedirectMap::new());
let app = app(map);
let r = req(&app, "/alive").await;
assert_eq!(r.status(), StatusCode::OK);
}
}