Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for send(byte[],int,int) to server responses #9569

Merged
merged 2 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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