From 1edd0673f5c73e01f77b7c3b1388f5fed45a5098 Mon Sep 17 00:00:00 2001 From: oboard Date: Fri, 21 Aug 2026 15:05:46 +0800 Subject: [PATCH] fix: infer body responder Content-Type in HttpResponse::body/json HttpResponse::body previously serialized only the responder output, so custom-status responses like HttpResponse::new(BadRequest).body("...") kept their status and body but emitted no Content-Type header. Probe the body responder's options and carry only its default Content-Type into the response when no explicit Content-Type was set. Explicit headers still take precedence, the response status is preserved, and no other responder effects (headers, cookies, status) are merged. HttpResponse::json inherits the same rule via body(). Add black-box tests covering direct String/Json/Html responders, custom-status body values, HttpResponse::json, and explicit Content-Type overrides. Co-Authored-By: Claude Opus 4.8 (1M context) --- responder_test.mbt | 122 +++++++++++++++++++++++++++++++++++++++++++++ response.mbt | 21 ++++++++ 2 files changed, 143 insertions(+) create mode 100644 responder_test.mbt diff --git a/responder_test.mbt b/responder_test.mbt new file mode 100644 index 0000000..3e97096 --- /dev/null +++ b/responder_test.mbt @@ -0,0 +1,122 @@ +///| +async test "direct string responder sets text content type" { + let app = new() + app.get("/text", _ => "hello") + let response = dispatch_http(app, "GET", "/text", {}, b"") + inspect(response.status_code.to_int(), content="200") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("text/plain; charset=utf-8"), + ) + let body : String = response.read_body() + @test.assert_eq(body, "hello") +} + +///| +async test "direct json responder sets json content type" { + let app = new() + app.get("/json", _ => ({ "ok": true } : Json)) + let response = dispatch_http(app, "GET", "/json", {}, b"") + inspect(response.status_code.to_int(), content="200") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("application/json; charset=utf-8"), + ) + let body : Json = response.read_body() + json_inspect(body, content={ "ok": true }) +} + +///| +async test "direct html responder sets html content type" { + let app = new() + app.get("/html", _ => html("

Hello

")) + let response = dispatch_http(app, "GET", "/html", {}, b"") + inspect(response.status_code.to_int(), content="200") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("text/html; charset=utf-8"), + ) + let body : String = response.read_body() + @test.assert_eq(body, "

Hello

") +} + +///| +async test "custom status body infers text content type" { + let app = new() + app.get("/bad-request", _ => { + HttpResponse::new(BadRequest).body("Invalid JSON") + }) + let response = dispatch_http(app, "GET", "/bad-request", {}, b"") + inspect(response.status_code.to_int(), content="400") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("text/plain; charset=utf-8"), + ) + let body : String = response.read_body() + @test.assert_eq(body, "Invalid JSON") +} + +///| +async test "custom status html body infers html content type" { + let app = new() + app.get("/not-found", _ => { + HttpResponse::new(NotFound).body(html("

404

")) + }) + let response = dispatch_http(app, "GET", "/not-found", {}, b"") + inspect(response.status_code.to_int(), content="404") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("text/html; charset=utf-8"), + ) + let body : String = response.read_body() + @test.assert_eq(body, "

404

") +} + +///| +async test "json response infers json content type" { + let app = new() + app.get("/created", _ => { + HttpResponse::new(Created).json({ "ok": true }) + }) + let response = dispatch_http(app, "GET", "/created", {}, b"") + inspect(response.status_code.to_int(), content="201") + @test.assert_eq( + response.headers.get("Content-Type"), + Some("application/json; charset=utf-8"), + ) + let body : Json = response.read_body() + json_inspect(body, content={ "ok": true }) +} + +///| +async test "explicit content type wins over inferred body type" { + let app = new() + app.get("/custom-body", _ => { + HttpResponse::new( + OK, + headers={ "Content-Type": "application/vnd.custom" }, + ).body("plain text") + }) + app.get("/custom-json", _ => { + HttpResponse::new( + OK, + headers={ "Content-Type": "application/vnd.custom+json" }, + ).json({ "ok": true }) + }) + + let body_response = dispatch_http(app, "GET", "/custom-body", {}, b"") + @test.assert_eq( + body_response.headers.get("Content-Type"), + Some("application/vnd.custom"), + ) + let body : String = body_response.read_body() + @test.assert_eq(body, "plain text") + + let json_response = dispatch_http(app, "GET", "/custom-json", {}, b"") + @test.assert_eq( + json_response.headers.get("Content-Type"), + Some("application/vnd.custom+json"), + ) + let json : Json = json_response.read_body() + json_inspect(json, content={ "ok": true }) +} diff --git a/response.mbt b/response.mbt index 9584449..42c0412 100644 --- a/response.mbt +++ b/response.mbt @@ -32,10 +32,25 @@ pub fn HttpResponse::new( } ///| +/// Sets the response body from any `Responder`. +/// +/// The body responder's default `Content-Type` is carried into this +/// response only when no explicit `"Content-Type"` header was set on this +/// response. Explicit response headers always take precedence. +/// +/// Only the default `Content-Type` is inferred. The responder's status, +/// other headers, cookies, and any other responder effects are not merged, +/// so `self.status_code` is preserved. pub fn HttpResponse::body( self : HttpResponse, body : &Responder, ) -> HttpResponse { + let probe = HttpResponse::new(OK) + body.options(probe) + if probe.headers.get("Content-Type") is Some(content_type) && + !self.headers.contains("Content-Type") { + self.headers["Content-Type"] = content_type + } let buf = Buffer() body.output(buf) self.raw_body = buf.to_bytes() @@ -43,6 +58,12 @@ pub fn HttpResponse::body( } ///| +/// Sets a JSON response body. +/// +/// Infers `Content-Type: application/json; charset=utf-8` under the same +/// rule as `HttpResponse::body`: an explicit `"Content-Type"` header already +/// present on this response takes precedence, and `self.status_code` is +/// preserved. pub fn HttpResponse::json(self : HttpResponse, obj : &ToJson) -> HttpResponse { self.body(obj.to_json()) }