$ use axum::{routing::{any, get}, Router};
use crate::logging;
use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests))
}
use axum::{routing::{any, get}, Router};
use crate::logging;
use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests))
}
use axum::{routing::{any, get}, Router};
use crate::logging;
use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests))
}
pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes))
}
pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes))
}
pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes))
}
fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), }
} fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control()
}
fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), }
} fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control()
}
fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), }
} fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control()
}
pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string())
}
pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string())
}
pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string())
}
#[test]
fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9");
} #[test]
fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7");
}
#[test]
fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9");
} #[test]
fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7");
}
#[test]
fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9");
} #[test]
fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7");
}
pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped }))
}
pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped }))
}
pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped }))
}
#[tokio::test]
async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17);
}
#[tokio::test]
async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17);
}
#[tokio::test]
async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17);
}
async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, }
}
async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, }
}
async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, }
}
-weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo
-weight: 500;">docker build -t axum-echo .
-weight: 500;">docker run --rm -p 8000:8000 axum-echo
-weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo
-weight: 500;">docker build -t axum-echo .
-weight: 500;">docker run --rm -p 8000:8000 axum-echo
-weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo
-weight: 500;">docker build -t axum-echo .
-weight: 500;">docker run --rm -p 8000:8000 axum-echo
-weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq
-weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq
-weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq
-weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418
-weight: 500;">curl -s localhost:8000/delay/500 | jq
-weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq
-weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq
-weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq
-weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418
-weight: 500;">curl -s localhost:8000/delay/500 | jq
-weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq
-weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq
-weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq
-weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418
-weight: 500;">curl -s localhost:8000/delay/500 | jq - Behind a corporate proxy that MITMs TLS and strips headers. You can't tell what your ingress stripped from what the corporate proxy stripped.
- Inside a Kubernetes cluster where the pods can't reach the outside internet, and you want to verify that your Service → Ingress → NetworkPolicy chain actually delivers packets.
- In CI, where every external call costs flaky test time and sometimes gets rate-limited because 500 other people on your CI provider had the same idea.
- Against a reverse proxy you're configuring, where you need to iterate on proxy_set_header directives and see the result in under a second.
- For load testing, where hitting httpbin.org from 10 000 concurrent connections is an abuse pattern you'd rather not be the face of. - The router is a single Router struct with .route("/path", handler) calls. Nothing to discover.
- Handlers are plain async functions. Axum uses Rust's type system to figure out what to extract from the request — Json<T> pulls out a JSON body, Path<T> pulls out URL segments, HeaderMap pulls out headers. No macros, no attribute soup.
- tower middleware composes cleanly. The whole logging pipeline is one axum::middleware::from_fn call.
- It sits on top of hyper and tokio, so you inherit all the performance work done there without seeing it. - Pull in axum-test or reqwest and spin up a real TcpListener for each test.
- Call Router::oneshot(request) directly via tower::ServiceExt::oneshot. - No HTTPS. Terminate TLS at your ingress or sidecar. A debug -weight: 500;">service that ships its own TLS stack is a debug -weight: 500;">service that has to think about certificate rotation, and that's not the job.
- No HTTP/2 or HTTP/3. axum::serve uses hyper's HTTP/1.1 server. Good enough for everything I've ever debugged with it. If you need to test HTTP/2 behavior specifically, use a different tool.
- No WebSocket echo. axum supports it via axum::extract::ws, but it'd double the test surface and WebSocket debugging has different needs (you want wire-level tools like wscat, not JSON echoes).
- No request body limit. /echo reads the whole body into memory with usize::MAX. If you POST a 10 GB file to /echo, you will OOM the pod. The corollary is /bytes/:n, which is capped at 10 MB because a 10 GB random response will OOM your client too.
- No authentication. This is a debug -weight: 500;">service. Put it behind network policy.
- X-Forwarded-For is trusted unconditionally. Covered above — this is the correct behavior for a reporting tool, but you must not repurpose the response for access control.