use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router}; use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::{info_span, Level}; use crate::{ error_middleware::normalize_error_response, health::health_check, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::AppState, }; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { Router::new() .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { health_check(Extension(request_context)).await }), ) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 .layer(middleware::from_fn(propagate_request_id_header)) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); info_span!( "http.request", method = %request.method(), uri = %request.uri(), request_id = %request_id, ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response(DefaultOnResponse::new().level(Level::INFO)) .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) // request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。 .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[cfg(test)] mod tests { use axum::{ body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use serde_json::Value; use tower::ServiceExt; use crate::{config::AppConfig, state::AppState}; use super::build_router; #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { let app = build_router(AppState::new(AppConfig::default())); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-legacy") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers().get("x-request-id").and_then(|value| value.to_str().ok()), Some("req-health-legacy") ); assert_eq!( response.headers().get("x-api-version").and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert_eq!( response.headers().get("x-route-version").and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert!(response.headers().contains_key("x-response-time-ms")); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["service"], Value::String("genarrative-node-server".to_string()) ); } #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default())); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-envelope") .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["data"]["service"], Value::String("genarrative-node-server".to_string()) ); assert_eq!( payload["meta"]["requestId"], Value::String("req-health-envelope".to_string()) ); } }