diff --git a/src/main/java/org/metricshub/http/HttpClient.java b/src/main/java/org/metricshub/http/HttpClient.java index 038fb81..f72cdf5 100644 --- a/src/main/java/org/metricshub/http/HttpClient.java +++ b/src/main/java/org/metricshub/http/HttpClient.java @@ -363,7 +363,8 @@ public static HttpResponse sendRequest( } } } - response.appendBody(new String(bodyBytes.toByteArray(), charset)); + // Decode straight from the buffer (toByteArray() would copy it first) + response.appendBody(bodyBytes.toString(charset.name())); // Return return response; diff --git a/src/main/java/org/metricshub/http/HttpResponse.java b/src/main/java/org/metricshub/http/HttpResponse.java index 0a0ccdb..24e7809 100644 --- a/src/main/java/org/metricshub/http/HttpResponse.java +++ b/src/main/java/org/metricshub/http/HttpResponse.java @@ -31,7 +31,8 @@ public class HttpResponse { private int statusCode; - private StringBuilder body; + // The body is kept as a String: a StringBuilder would copy it once when appended and once more in getBody() + private String body; private StringBuilder header; /** @@ -40,7 +41,7 @@ public class HttpResponse { public HttpResponse() { statusCode = 0; header = new StringBuilder(); - body = new StringBuilder(); + body = ""; } /** @@ -86,16 +87,19 @@ public void appendHeader(String name, String value) { * @return the body of the HTTP response */ public String getBody() { - return body.toString(); + return body; } /** * Append content to the body of the HTTP response + *

+ * Each call after the first copies the whole body: the body is meant to be set by a single call. * - * @param data Data to append + * @param data Data to append ("null" is appended for null, as StringBuilder.append() does) */ public void appendBody(String data) { - body.append(data); + String value = String.valueOf(data); + body = body.isEmpty() ? value : body.concat(value); } /** @@ -103,6 +107,7 @@ public void appendBody(String data) { */ @Override public String toString() { - return new StringBuilder().append(header).append("\n").append(body).toString(); + // concat() copies the body once, where a StringBuilder copies it twice + return header.toString().concat("\n").concat(body); } } diff --git a/src/test/java/org/metricshub/http/HttpClientTest.java b/src/test/java/org/metricshub/http/HttpClientTest.java new file mode 100644 index 0000000..8394f02 --- /dev/null +++ b/src/test/java/org/metricshub/http/HttpClientTest.java @@ -0,0 +1,138 @@ +package org.metricshub.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Reading of the response body, against a local server + */ +class HttpClientTest { + + private static final String TEXT = "{\"name\":\"café 中 😀\",\"list\":[1,2,3]}"; + + private static HttpServer server; + private static String baseUrl; + + @BeforeAll + static void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + respond("/length", 200, "application/json", null, TEXT.getBytes(StandardCharsets.UTF_8), false); + respond("/chunked", 200, "application/json", null, TEXT.getBytes(StandardCharsets.UTF_8), true); + respond( + "/latin1", + 200, + "text/plain; charset=ISO-8859-1", + null, + "café".getBytes(StandardCharsets.ISO_8859_1), + false + ); + respond("/gzip", 200, "application/json", "gzip", gzip(TEXT.getBytes(StandardCharsets.UTF_8)), true); + respond("/empty", 200, "application/json", null, new byte[0], false); + respond("/error", 404, "text/plain", null, "not found".getBytes(StandardCharsets.UTF_8), false); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterAll + static void stopServer() { + server.stop(0); + } + + private static void respond( + String path, + int status, + String contentType, + String contentEncoding, + byte[] content, + boolean chunked + ) { + server.createContext( + path, + exchange -> { + exchange.getResponseHeaders().add("Content-Type", contentType); + if (contentEncoding != null) { + exchange.getResponseHeaders().add("Content-Encoding", contentEncoding); + } + // Length 0 means chunked, -1 means no body + exchange.sendResponseHeaders(status, chunked ? 0 : content.length == 0 ? -1 : content.length); + try (OutputStream os = exchange.getResponseBody()) { + if (content.length > 0) { + os.write(content); + } + } + } + ); + } + + private static byte[] gzip(byte[] content) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(content); + } + return out.toByteArray(); + } + + private static HttpResponse get(String path) throws IOException { + return HttpClient.sendRequest( + baseUrl + path, + "GET", + null, + null, + null, + null, + 0, + null, + null, + null, + null, + null, + 10, + null + ); + } + + @Test + void testBodyWithContentLength() throws IOException { + HttpResponse response = get("/length"); + assertEquals(200, response.getStatusCode()); + assertEquals(TEXT, response.getBody()); + } + + @Test + void testBodyChunked() throws IOException { + assertEquals(TEXT, get("/chunked").getBody()); + } + + @Test + void testBodyCharsetFromContentType() throws IOException { + assertEquals("café", get("/latin1").getBody()); + } + + @Test + void testBodyGzip() throws IOException { + assertEquals(TEXT, get("/gzip").getBody()); + } + + @Test + void testBodyEmpty() throws IOException { + assertEquals("", get("/empty").getBody()); + } + + @Test + void testBodyOfError() throws IOException { + HttpResponse response = get("/error"); + assertEquals(404, response.getStatusCode()); + assertEquals("not found", response.getBody()); + assertEquals(response.getHeader() + "\nnot found", response.toString()); + } +} diff --git a/src/test/java/org/metricshub/http/HttpResponseTest.java b/src/test/java/org/metricshub/http/HttpResponseTest.java index 8b1d1eb..d6ab5de 100644 --- a/src/test/java/org/metricshub/http/HttpResponseTest.java +++ b/src/test/java/org/metricshub/http/HttpResponseTest.java @@ -1,6 +1,7 @@ package org.metricshub.http; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -35,6 +36,16 @@ void testGetBody() { r.appendBody("abc"); r.appendBody("def"); assertEquals("abcdef", r.getBody()); + r.appendBody(null); + r.appendBody(""); + assertEquals("abcdefnull", r.getBody()); + + // The body is not copied + HttpResponse single = new HttpResponse(); + String body = "single"; + single.appendBody(body); + assertSame(body, single.getBody()); + assertSame(single.getBody(), single.getBody()); } @Test