Skip to content

Commit

Permalink
Adds support for send(byte[],int,int) to server responses (#9569)
Browse files Browse the repository at this point in the history
Adds support for send(byte[], int, int) to ServerResponse.

Signed-off-by: Santiago Pericas-Geertsen <[email protected]>
  • Loading branch information
spericas authored Dec 6, 2024
1 parent 253e8a0 commit 5f48279
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -98,25 +104,35 @@ 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,
System.Logger.Level.WARNING,
"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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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)));
}
}
Original file line number Diff line number Diff line change
@@ -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)));
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,26 +191,32 @@ 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();
dataWriter.write(bufferData);
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);
}
Expand Down Expand Up @@ -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");
}
Expand All @@ -379,39 +389,36 @@ private BufferData responseBuffer(byte[] bytes) {
+ ", do not call send().");
}

int contentLength = bytes.length;
boolean forcedChunkedEncoding = false;
headers.setIfAbsent(HeaderValues.CONNECTION_KEEP_ALIVE);

if (headers.contains(HeaderValues.TRANSFER_ENCODING_CHUNKED)) {
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();
sendListener.status(ctx, usedStatus);
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);
Expand Down

0 comments on commit 5f48279

Please sign in to comment.