From 5f48279a9cb322b5089e01c4371763bd1175a02a Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Fri, 6 Dec 2024 11:17:33 -0500 Subject: [PATCH] Adds support for send(byte[],int,int) to server responses (#9569) Adds support for send(byte[], int, int) to ServerResponse. Signed-off-by: Santiago Pericas-Geertsen --- .../webserver/http2/Http2ServerResponse.java | 30 ++++-- .../webserver/tests/http2/SendBytesTest.java | 91 +++++++++++++++++++ .../webserver/tests/SendBytesTest.java | 79 ++++++++++++++++ .../webserver/http/ServerResponse.java | 14 ++- .../webserver/http/ServerResponseBase.java | 21 ++++- .../webserver/http1/Http1ServerResponse.java | 35 ++++--- 6 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java create mode 100644 webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java index 374cdb5ba28..23a597d9b29 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java @@ -77,11 +77,17 @@ public Http2ServerResponse header(Header header) { @Override public void send(byte[] entityBytes) { + send(entityBytes, 0, entityBytes.length); + } + + + @Override + public void send(byte[] entityBytes, int position, int length) { try { if (outputStreamFilter != null) { // in this case we must honor user's request to filter the stream try (OutputStream os = outputStream()) { - os.write(entityBytes); + os.write(entityBytes, position, length); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -98,14 +104,21 @@ public void send(byte[] entityBytes) { isSent = true; // handle content encoding - byte[] bytes = entityBytes(entityBytes); + int actualLength = length; + int actualPosition = position; + byte[] actualBytes = entityBytes(entityBytes, position, length); + if (entityBytes != actualBytes) { // encoding happened, new byte array + actualPosition = 0; + actualLength = actualBytes.length; + } headers.setIfAbsent(HeaderValues.create(HeaderNames.CONTENT_LENGTH, true, false, - String.valueOf(bytes.length))); - headers.setIfAbsent(HeaderValues.create(HeaderNames.DATE, true, false, DateTime.rfc1123String())); - + String.valueOf(actualLength))); + headers.setIfAbsent(HeaderValues.create(HeaderNames.DATE, true, + false, + DateTime.rfc1123String())); Http2Headers http2Headers = Http2Headers.create(headers); http2Headers.status(status()); headers.remove(Http2Headers.STATUS_NAME, it -> ctx.log(LOGGER, @@ -113,10 +126,13 @@ public void send(byte[] entityBytes) { "Status must be configured on response, " + "do not set HTTP/2 pseudo headers")); - boolean sendTrailers = request.headers().contains(HeaderValues.TE_TRAILERS) || headers.contains(HeaderNames.TRAILER); + boolean sendTrailers = request.headers().contains(HeaderValues.TE_TRAILERS) + || headers.contains(HeaderNames.TRAILER); http2Headers.validateResponse(); - bytesWritten += stream.writeHeadersWithData(http2Headers, bytes.length, BufferData.create(bytes), !sendTrailers); + bytesWritten += stream.writeHeadersWithData(http2Headers, actualLength, + BufferData.create(actualBytes, actualPosition, actualLength), + !sendTrailers); if (sendTrailers) { bytesWritten += stream.writeTrailers(Http2Headers.create(trailers)); diff --git a/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java new file mode 100644 index 00000000000..ef7e3828733 --- /dev/null +++ b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.tests.http2; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import io.helidon.http.Status; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +class SendBytesTest { + private static final int START = 16; + private static final int LENGTH = 9; + private static final String ENTITY = "The quick brown fox jumps over the lazy dog"; + + private final HttpClient client; + private final URI uri; + + SendBytesTest(URI uri) { + this.uri = uri; + client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + @SetUpRoute + static void routing(HttpRules rules) { + rules.get("/sendAll", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8))) + .get("/sendPart", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8), START, LENGTH)); + } + + /** + * Test getting all the entity. + */ + @Test + void testAll() throws IOException, InterruptedException { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(uri.resolve("/sendAll")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode(), is(Status.OK_200.code())); + assertThat(response.version(), is(HttpClient.Version.HTTP_2)); + String entity = response.body(); + assertThat(entity, is(ENTITY)); + } + + /** + * Test getting part of the entity. + */ + @Test + void testPart() throws IOException, InterruptedException { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(uri.resolve("/sendPart")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode(), is(Status.OK_200.code())); + assertThat(response.version(), is(HttpClient.Version.HTTP_2)); + String entity = response.body(); + assertThat(entity, is(ENTITY.substring(START, START + LENGTH))); + } +} diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java new file mode 100644 index 00000000000..a250ff397a7 --- /dev/null +++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.tests; + +import java.nio.charset.StandardCharsets; + +import io.helidon.http.Status; +import io.helidon.webclient.api.HttpClientResponse; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests sending a part of a byte array. + */ +@ServerTest +class SendBytesTest { + private static final int START = 16; + private static final int LENGTH = 9; + private static final String ENTITY = "The quick brown fox jumps over the lazy dog"; + + private final Http1Client http1Client; + + SendBytesTest(Http1Client http1Client) { + this.http1Client = http1Client; + } + + @SetUpRoute + static void routing(HttpRules rules) { + rules.get("/sendAll", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8))) + .get("/sendPart", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8), START, LENGTH)); + } + + /** + * Test getting all the entity. + */ + @Test + void testAll() { + try (HttpClientResponse r = http1Client.get("/sendAll").request()) { + String s = r.entity().as(String.class); + assertThat(r.status(), is(Status.OK_200)); + assertThat(s, is(ENTITY)); + } + } + + /** + * Test getting part of the entity. + */ + @Test + void testPart() { + try (HttpClientResponse r = http1Client.get("/sendPart").request()) { + String s = r.entity().as(String.class); + assertThat(r.status(), is(Status.OK_200)); + assertThat(s, is(ENTITY.substring(START, START + LENGTH))); + } + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java index 0e29c829150..8f2820e2cba 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package io.helidon.webserver.http; import java.io.OutputStream; +import java.util.Arrays; import java.util.Optional; import java.util.function.UnaryOperator; @@ -112,6 +113,17 @@ default ServerResponse header(String name, String... values) { */ void send(byte[] bytes); + /** + * Send a byte array response. + * + * @param bytes bytes to send + * @param position starting position + * @param length number of bytes send + */ + default void send(byte[] bytes, int position, int length) { + send(Arrays.copyOfRange(bytes, position, length)); + } + /** * Send an entity, a {@link io.helidon.http.media.MediaContext} will be used to serialize the entity. * diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java index b5ee32e61b9..3939c1125b0 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java @@ -201,17 +201,30 @@ protected MediaContext mediaContext() { * if entity is empty. * * @param configuredEntity plain bytes - * @return encoded bytes + * @return encoded bytes or same entity array if encoding is disabled */ protected byte[] entityBytes(byte[] configuredEntity) { + return entityBytes(configuredEntity, 0, configuredEntity.length); + } + + /** + * Entity bytes encoded using content encoding. Does not attempt encoding + * if entity is empty. + * + * @param configuredEntity plain bytes + * @param position starting position + * @param length number of bytes + * @return encoded bytes or same entity array if encoding is disabled + */ + protected byte[] entityBytes(byte[] configuredEntity, int position, int length) { byte[] entity = configuredEntity; - if (contentEncodingContext.contentEncodingEnabled() && entity.length > 0) { + if (contentEncodingContext.contentEncodingEnabled() && length > 0) { ContentEncoder encoder = contentEncodingContext.encoder(requestHeaders); // we want to preserve optimization here, let's create a new byte array - ByteArrayOutputStream baos = new ByteArrayOutputStream(entity.length); + ByteArrayOutputStream baos = new ByteArrayOutputStream(length); OutputStream os = encoder.apply(baos); try { - os.write(entity); + os.write(entity, position, length); os.close(); } catch (IOException e) { throw new ServerConnectionException("Failed to write response", e); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java index 03abb2ce312..8336ebf0598 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java @@ -191,16 +191,22 @@ public Http1ServerResponse header(Header header) { */ @Override public void send(byte[] bytes) { + send(bytes, 0, bytes.length); + } + + @Override + public void send(byte[] bytes, int position, int length) { // if no entity status, we cannot send bytes here - if (isNoEntityStatus && bytes.length > 0) { + if (isNoEntityStatus && length > 0) { status(noEntityInternalError(status())); return; } // send bytes to writer if (outputStreamFilter == null && !headers.contains(HeaderNames.TRAILER)) { - byte[] entity = entityBytes(bytes); - BufferData bufferData = responseBuffer(entity); + byte[] entity = entityBytes(bytes, position, length); + BufferData bufferData = (bytes != entity) ? responseBuffer(entity) + : responseBuffer(entity, position, length); // no encoding, same length bytesWritten = bufferData.available(); isSent = true; request.reset(); @@ -208,9 +214,9 @@ public void send(byte[] bytes) { afterSend(); } else { // we should skip encoders if no data is written (e.g. for GZIP) - boolean skipEncoders = (bytes.length == 0); + boolean skipEncoders = (length == 0); try (OutputStream os = outputStream(skipEncoders)) { - os.write(bytes); + os.write(bytes, position, length); } catch (IOException e) { throw new ServerConnectionException("Failed to write response", e); } @@ -371,6 +377,10 @@ private static void writeHeaders(io.helidon.http.Headers headers, BufferData buf } private BufferData responseBuffer(byte[] bytes) { + return responseBuffer(bytes, 0, bytes.length); + } + + private BufferData responseBuffer(byte[] bytes, int position, int length) { if (isSent) { throw new IllegalStateException("Response already sent"); } @@ -379,7 +389,6 @@ private BufferData responseBuffer(byte[] bytes) { + ", do not call send()."); } - int contentLength = bytes.length; boolean forcedChunkedEncoding = false; headers.setIfAbsent(HeaderValues.CONNECTION_KEEP_ALIVE); @@ -387,10 +396,8 @@ private BufferData responseBuffer(byte[] bytes) { headers.remove(HeaderNames.CONTENT_LENGTH); // chunked enforced (and even if empty entity, will be used) forcedChunkedEncoding = true; - } else { - if (!headers.contains(HeaderNames.CONTENT_LENGTH)) { - headers.contentLength(contentLength); - } + } else if (!headers.contains(HeaderNames.CONTENT_LENGTH)) { + headers.contentLength(length); } Status usedStatus = status(); @@ -398,20 +405,20 @@ private BufferData responseBuffer(byte[] bytes) { sendListener.headers(ctx, headers); // give some space for code and headers + entity - BufferData responseBuffer = BufferData.growing(256 + bytes.length); + BufferData responseBuffer = BufferData.growing(256 + length); nonEntityBytes(headers, usedStatus, responseBuffer, keepAlive, validateHeaders); if (forcedChunkedEncoding) { - byte[] hex = Integer.toHexString(contentLength).getBytes(StandardCharsets.US_ASCII); + byte[] hex = Integer.toHexString(length).getBytes(StandardCharsets.US_ASCII); responseBuffer.write(hex); responseBuffer.write('\r'); responseBuffer.write('\n'); - responseBuffer.write(bytes); + responseBuffer.write(bytes, position, length); responseBuffer.write('\r'); responseBuffer.write('\n'); responseBuffer.write(TERMINATING_CHUNK); } else { - responseBuffer.write(bytes); + responseBuffer.write(bytes, position, length); } sendListener.data(ctx, responseBuffer);