From 8ef801675eb58107b3aa1696e00ed2b0fe27f0ae Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 8 Oct 2026 16:33:50 +0200 Subject: [PATCH] fix(http): add default Content-Type for non-empty HTTP::Tiny request bodies (GH-1678) System HTTP::Tiny sends `Content-Type: application/octet-stream` when a non-empty scalar is supplied as `content` and the caller does not supply a content-type header (_prepare_headers_and_cb lines 819/832). The Java-backed HTTP::Tiny shim omitted this default, causing servers to return different responses. MetaCPAN's reverse-dependencies endpoint returned a Latin-1 encoded body instead of UTF-8 JSON, which broke JSON decoding in MetaCPAN::Client tests (t/api/author.t, t/api/reverse-dependencies.t). Changes: - HttpTiny.java: before forwarding caller headers, check case-insensitively whether a content-type was supplied; if not and the body is non-empty, inject `Content-Type: application/octet-stream`, matching Perl HTTP::Tiny exactly. - HttpTinyBinaryResponseTest.java: three new @Tag("unit") tests using a local HttpServer that records incoming request headers: requestAddsDefaultContentTypeForNonEmptyBody requestPreservesCallerSuppliedContentType requestDoesNotAddContentTypeForEmptyBody Co-Authored-By: Claude Sonnet 4.6 --- .../runtime/perlmodule/HttpTiny.java | 13 ++- .../HttpTinyBinaryResponseTest.java | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/HttpTiny.java b/src/main/java/org/perlonjava/runtime/perlmodule/HttpTiny.java index 2c68c73ede..c12da2d335 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/HttpTiny.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/HttpTiny.java @@ -73,9 +73,18 @@ public static RuntimeList request(RuntimeArray args, int ctx) throws Exception { // Set headers requestBuilder.header("User-Agent", instanceHash.get("agent").toString()); - // requestBuilder.header("Connection", "keep-alive"); - // Add default and custom headers + // Replicate HTTP::Tiny _prepare_headers_and_cb: for a non-empty scalar body, + // send Content-Type: application/octet-stream unless the caller already supplied one. + if (!content.isEmpty()) { + boolean callerHasContentType = headers.elements.keySet().stream() + .anyMatch(k -> k.equalsIgnoreCase("content-type")); + if (!callerHasContentType) { + requestBuilder.header("Content-Type", "application/octet-stream"); + } + } + + // Add caller-supplied headers (override any defaults set above) headers.elements.forEach((key, value) -> requestBuilder.header(key, value.toString()) ); diff --git a/src/test/java/org/perlonjava/runtime/perlmodule/HttpTinyBinaryResponseTest.java b/src/test/java/org/perlonjava/runtime/perlmodule/HttpTinyBinaryResponseTest.java index c79223bacf..60f4bf5666 100644 --- a/src/test/java/org/perlonjava/runtime/perlmodule/HttpTinyBinaryResponseTest.java +++ b/src/test/java/org/perlonjava/runtime/perlmodule/HttpTinyBinaryResponseTest.java @@ -12,6 +12,7 @@ import java.net.InetSocketAddress; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class HttpTinyBinaryResponseTest { @@ -65,4 +66,107 @@ void requestPreservesBinaryResponseOctets() throws Exception { server.stop(0); } } + + @Tag("unit") + @Test + void requestAddsDefaultContentTypeForNonEmptyBody() throws Exception { + String[] capturedContentType = {null}; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/post", exchange -> { + capturedContentType[0] = exchange.getRequestHeaders().getFirst("Content-Type"); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + }); + server.start(); + try { + RuntimeHash instance = buildInstance(); + + RuntimeHash optHeaders = new RuntimeHash(); + RuntimeHash options = new RuntimeHash(); + options.put("content", new RuntimeScalar("hello")); + options.put("headers", optHeaders.createReference()); + + RuntimeList result = HttpTiny.request(buildArgs(instance, "POST", + "http://127.0.0.1:" + server.getAddress().getPort() + "/post", options), 0); + assertNotNull(result); + assertEquals("application/octet-stream", capturedContentType[0], + "non-empty scalar body without caller content-type gets default Content-Type"); + } finally { + server.stop(0); + } + } + + @Tag("unit") + @Test + void requestPreservesCallerSuppliedContentType() throws Exception { + String[] capturedContentType = {null}; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/post", exchange -> { + capturedContentType[0] = exchange.getRequestHeaders().getFirst("Content-Type"); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + }); + server.start(); + try { + RuntimeHash instance = buildInstance(); + + RuntimeHash optHeaders = new RuntimeHash(); + optHeaders.put("Content-Type", new RuntimeScalar("application/json")); + RuntimeHash options = new RuntimeHash(); + options.put("content", new RuntimeScalar("{\"k\":1}")); + options.put("headers", optHeaders.createReference()); + + HttpTiny.request(buildArgs(instance, "POST", + "http://127.0.0.1:" + server.getAddress().getPort() + "/post", options), 0); + assertEquals("application/json", capturedContentType[0], + "caller-supplied content-type is not overridden by the default"); + } finally { + server.stop(0); + } + } + + @Tag("unit") + @Test + void requestDoesNotAddContentTypeForEmptyBody() throws Exception { + String[] capturedContentType = {"(sentinel)"}; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/post", exchange -> { + String ct = exchange.getRequestHeaders().getFirst("Content-Type"); + capturedContentType[0] = ct; + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + }); + server.start(); + try { + RuntimeHash instance = buildInstance(); + + RuntimeHash options = new RuntimeHash(); + options.put("content", new RuntimeScalar("")); + + HttpTiny.request(buildArgs(instance, "POST", + "http://127.0.0.1:" + server.getAddress().getPort() + "/post", options), 0); + assertNull(capturedContentType[0], + "empty body does not get a default Content-Type header"); + } finally { + server.stop(0); + } + } + + private static RuntimeHash buildInstance() { + RuntimeHash instance = new RuntimeHash(); + instance.put("agent", new RuntimeScalar("PerlOnJava-test")); + instance.put("timeout", new RuntimeScalar(10)); + instance.put("verify_SSL", new RuntimeScalar(false)); + return instance; + } + + private static RuntimeArray buildArgs(RuntimeHash instance, String method, String url, + RuntimeHash options) { + RuntimeArray args = new RuntimeArray(); + args.elements.add(instance.createReference()); + args.elements.add(new RuntimeScalar(method)); + args.elements.add(new RuntimeScalar(url)); + args.elements.add(options.createReference()); + return args; + } }