diff --git a/src/handlers/response_inspection.rs b/src/handlers/response_inspection.rs index b1fd291..3fc53a1 100644 --- a/src/handlers/response_inspection.rs +++ b/src/handlers/response_inspection.rs @@ -152,63 +152,57 @@ 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 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()), - ); - } + // 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()), + ); } + } - // Add standard headers that would be set by the framework + // 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()), ); + } - // Calculate content length for the JSON - let json_string = serde_json::to_string(&headers_map)?; + // 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())); } } + + 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 7513338..966145a 100644 --- a/src/tests/status.rs +++ b/src/tests/status.rs @@ -122,6 +122,40 @@ async fn response_headers_post_sets_headers() { ); } +/// 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", + "/response-headers?Content-Type=text/plain", + "/response-headers?content-type=text/plain", + "/response-headers?CoNtEnT-TyPe=text/plain", + ] { + 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}"); + } + } + } +} + /// Codes outside 100-999 can't be sent as a real status, so they are /// rejected like any other invalid code instead of falling back to 200. #[actix_web::test] @@ -143,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}" + ); + } +}