From ebea9f71954223b13a50125d54d71d32333b4825 Mon Sep 17 00:00:00 2001 From: OpenDeernet Date: Thu, 5 Nov 2026 10:46:44 +0800 Subject: [PATCH 1/2] fix(response-headers): make reported Content-Length match the JSON body Fixes #34 --- src/handlers/response_inspection.rs | 88 +++++++++++++---------------- src/tests/status.rs | 26 +++++++++ 2 files changed, 64 insertions(+), 50 deletions(-) diff --git a/src/handlers/response_inspection.rs b/src/handlers/response_inspection.rs index b1fd291..4d0cf59 100644 --- a/src/handlers/response_inspection.rs +++ b/src/handlers/response_inspection.rs @@ -152,63 +152,51 @@ pub async fn response_headers_get_handler( return Ok(builder.body(body.clone())); } - // Build response with iterative header reflection like httpbin - let mut iteration_count = 0; - const MAX_ITERATIONS: usize = 10; // Prevent infinite loops - - loop { - let mut response_builder = HttpResponse::Ok(); - - // Add query parameters as actual response headers - for (key, values) in &multi_value_params { - for value in values { - response_builder.append_header((key.as_str(), value.as_str())); - } + // Prepare the headers map for the JSON response + let mut headers_map: BTreeMap = BTreeMap::new(); + for (key, values) in &multi_value_params { + if values.len() == 1 { + headers_map.insert(key.clone(), Value::String(values[0].clone())); + } else { + headers_map.insert( + key.clone(), + Value::Array(values.iter().map(|v| Value::String(v.clone())).collect()), + ); } + } - // Prepare the headers map for JSON response - let mut headers_map: BTreeMap = BTreeMap::new(); - - // Add query parameters to the JSON response - for (key, values) in &multi_value_params { - if values.len() == 1 { - headers_map.insert(key.clone(), Value::String(values[0].clone())); - } else { - headers_map.insert( - key.clone(), - Value::Array(values.iter().map(|v| Value::String(v.clone())).collect()), - ); - } - } - - // Add standard headers that would be set by the framework - headers_map.insert( - "Content-Type".to_string(), - Value::String("application/json".to_string()), - ); - - // Calculate content length for the JSON - let json_string = serde_json::to_string(&headers_map)?; + // Add standard headers that would be set by the framework + headers_map.insert( + "Content-Type".to_string(), + Value::String("application/json".to_string()), + ); + + // The body reports its own Content-Length, so re-serialize until the + // value matches the body size (like httpbin does). This settles within a + // couple of rounds; the cap is just a safety net. + const MAX_ITERATIONS: usize = 10; + let mut json_string = serde_json::to_string(&headers_map)?; + for _ in 0..MAX_ITERATIONS { let content_length = json_string.len().to_string(); - headers_map.insert( - "Content-Length".to_string(), - Value::String(content_length.clone()), - ); - - // Set Content-Length header on response - response_builder.append_header(("Content-Length", content_length)); - - // Check if we need another iteration (like httpbin does) - let _current_json = serde_json::to_string(&headers_map)?; + if headers_map.get("Content-Length") == Some(&Value::String(content_length.clone())) { + break; + } + headers_map.insert("Content-Length".to_string(), Value::String(content_length)); + json_string = serde_json::to_string(&headers_map)?; + } - iteration_count += 1; + let mut response_builder = HttpResponse::Ok(); - // For simplicity, we'll do a fixed number of iterations like httpbin's logic - // In httpbin, it iterates until response data doesn't change - if iteration_count >= 2 || iteration_count >= MAX_ITERATIONS { - return Ok(response_builder.json(headers_map)); + // Add query parameters as actual response headers + for (key, values) in &multi_value_params { + for value in values { + response_builder.append_header((key.as_str(), value.as_str())); } } + + Ok(response_builder + .content_type("application/json") + .body(json_string)) } pub async fn response_headers_post_handler( diff --git a/src/tests/status.rs b/src/tests/status.rs index 9140baa..533e7ee 100644 --- a/src/tests/status.rs +++ b/src/tests/status.rs @@ -121,3 +121,29 @@ async fn response_headers_post_sets_headers() { "bar" ); } + +/// The JSON body of /response-headers reports its own Content-Length, so the +/// value must match the actual body size (and the header). +#[actix_web::test] +async fn response_headers_content_length_matches_body() { + let app = test::init_service(create_app(cfg())).await; + for uri in [ + "/response-headers", + "/response-headers?X-Test=bar&X-Test=baz", + ] { + let req = test::TestRequest::get().uri(uri).to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let header = resp + .headers() + .get("content-length") + .map(|v| v.to_str().unwrap().to_string()); + let body = test::read_body(resp).await; + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let reported = json["Content-Length"].as_str().unwrap().to_string(); + assert_eq!(reported, body.len().to_string(), "{uri}"); + if let Some(header) = header { + assert_eq!(header, reported, "{uri}"); + } + } +} From c3ecfb4e45ebd186fea039194dfe8ab210d592cb Mon Sep 17 00:00:00 2001 From: OpenDeernet Date: Sun, 8 Nov 2026 21:56:22 +0800 Subject: [PATCH 2/2] fix(response-headers): preserve caller-supplied Content-Type --- src/handlers/response_inspection.rs | 22 +++++--- src/tests/status.rs | 82 ++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/handlers/response_inspection.rs b/src/handlers/response_inspection.rs index 4d0cf59..3fc53a1 100644 --- a/src/handlers/response_inspection.rs +++ b/src/handlers/response_inspection.rs @@ -165,11 +165,16 @@ pub async fn response_headers_get_handler( } } - // Add standard headers that would be set by the framework - headers_map.insert( - "Content-Type".to_string(), - Value::String("application/json".to_string()), - ); + // Only supply the JSON default when no Content-Type was requested. + let has_content_type = multi_value_params + .keys() + .any(|key| key.eq_ignore_ascii_case("Content-Type")); + if !has_content_type { + headers_map.insert( + "Content-Type".to_string(), + Value::String("application/json".to_string()), + ); + } // The body reports its own Content-Length, so re-serialize until the // value matches the body size (like httpbin does). This settles within a @@ -194,9 +199,10 @@ pub async fn response_headers_get_handler( } } - Ok(response_builder - .content_type("application/json") - .body(json_string)) + if !has_content_type { + response_builder.content_type("application/json"); + } + Ok(response_builder.body(json_string)) } pub async fn response_headers_post_handler( diff --git a/src/tests/status.rs b/src/tests/status.rs index d9d39df..966145a 100644 --- a/src/tests/status.rs +++ b/src/tests/status.rs @@ -130,20 +130,28 @@ async fn response_headers_content_length_matches_body() { for uri in [ "/response-headers", "/response-headers?X-Test=bar&X-Test=baz", + "/response-headers?Content-Type=text/plain", + "/response-headers?content-type=text/plain", + "/response-headers?CoNtEnT-TyPe=text/plain", ] { - let req = test::TestRequest::get().uri(uri).to_request(); - let resp = test::call_service(&app, req).await; - assert_eq!(resp.status(), StatusCode::OK); - let header = resp - .headers() - .get("content-length") - .map(|v| v.to_str().unwrap().to_string()); - let body = test::read_body(resp).await; - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - let reported = json["Content-Length"].as_str().unwrap().to_string(); - assert_eq!(reported, body.len().to_string(), "{uri}"); - if let Some(header) = header { - assert_eq!(header, reported, "{uri}"); + for method in [actix_web::http::Method::GET, actix_web::http::Method::POST] { + let req = test::TestRequest::default() + .method(method.clone()) + .uri(uri) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let header = resp + .headers() + .get("content-length") + .map(|v| v.to_str().unwrap().to_string()); + let body = test::read_body(resp).await; + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let reported = json["Content-Length"].as_str().unwrap().to_string(); + assert_eq!(reported, body.len().to_string(), "{method} {uri}"); + if let Some(header) = header { + assert_eq!(header, reported, "{method} {uri}"); + } } } } @@ -169,3 +177,51 @@ async fn status_out_of_range_code_is_bad_request() { } } } + +#[actix_web::test] +async fn response_headers_get_preserves_content_type() { + let app = test::init_service(create_app(cfg())).await; + for (uri, expected) in [ + ("/response-headers", "application/json"), + ("/response-headers?Content-Type=text/plain", "text/plain"), + ("/response-headers?content-type=text/plain", "text/plain"), + ("/response-headers?CoNtEnT-TyPe=text/plain", "text/plain"), + ] { + let req = test::TestRequest::get().uri(uri).to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(), + expected, + "{uri}" + ); + } +} + +#[actix_web::test] +async fn response_headers_post_preserves_content_type() { + let app = test::init_service(create_app(cfg())).await; + for (uri, expected) in [ + ("/response-headers", "application/json"), + ("/response-headers?Content-Type=text/plain", "text/plain"), + ("/response-headers?content-type=text/plain", "text/plain"), + ("/response-headers?CoNtEnT-TyPe=text/plain", "text/plain"), + ] { + let req = test::TestRequest::post().uri(uri).to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(), + expected, + "{uri}" + ); + } +}