Improve EntityInputStream#isEmpty handling for the Servlet case. (#5806) Signed-off-by: Maxim Nesen <maxim.nesen@oracle.com>
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java new file mode 100644 index 0000000..fcbee2d --- /dev/null +++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java
@@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024, 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.servlet; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import org.glassfish.jersey.innate.io.StreamListener; +import org.glassfish.jersey.innate.io.StreamListenerCouple; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +abstract class ServletEntityInputStream implements StreamListenerCouple { + + private final boolean waitForInputEnable; + private final long waitForInputTimeOut; + + private final StreamListener listener = new StreamListener() { + + @Override + public boolean isEmpty() { + try { + return getWrappedStream().available() == 0 + || getWrappedStream().isFinished(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public boolean isReady() { + return processReadiness(); + } + + boolean processReadiness() { + + final AtomicBoolean ready = new AtomicBoolean(getWrappedStream().isReady()); + if (waitForInputEnable && !ready.get()) { + + final CountDownLatch latch = new CountDownLatch(1); + + getWrappedStream().setReadListener(new ReadListener() { + @Override + public void onDataAvailable() { + ready.set(true); + latch.countDown(); + } + + @Override + public void onAllDataRead() { + ready.set(false); + latch.countDown(); + } + + @Override + public void onError(Throwable t) { + ready.set(false); + latch.countDown(); + } + + }); + if (!ready.get()) { + try { + latch.await(waitForInputTimeOut, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + ready.set(getWrappedStream().isReady()); + } + } + } + return ready.get() || getWrappedStream().isReady(); + } + + }; + + public ServletEntityInputStream(boolean waitForInputEnable, long waitForInputTimeOut) { + this.waitForInputEnable = waitForInputEnable; + this.waitForInputTimeOut = waitForInputTimeOut; + } + + protected abstract ServletInputStream getWrappedStream(); + + @Override + public StreamListener getListener() { + return listener; + } + + @Override + public InputStream getExternalStream() { + return getWrappedStream(); + } +}
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java index 3689e62..bb3a52a 100644 --- a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java +++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java
@@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2020 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -139,6 +139,30 @@ public static final String QUERY_PARAMS_AS_FORM_PARAMS_DISABLED = "jersey.config.servlet.form.queryParams.disabled"; /** + * Indicates if any input stream is expected and shall be waited while using POST method. It can happen that a delay + * appear between the time the request is received and the time the input stream is available. In this case, the + * server will wait for the input stream to be available before processing the request. + * + * @since 3.1.11 + */ + public static final String WAIT_FOR_INPUT = "jersey.config.servlet.expect.input"; + + /** + * Timeout which shall be respected while waiting for the input stream to be available. + * The timeout is in milliseconds. + * + * @since 3.1.11 + */ + public static final String WAIT_FOR_INPUT_TIMEOUT = "jersey.config.servlet.input.timeout"; + + /** + * Default timeout which shall be respected while waiting for the input stream to be available. + * + * @since 3.1.11 + */ + public static final Long WAIT_FOR_INPUT_DEFAULT_TIMEOUT = 100L; + + /** * Identifies the object that will be used as a parent {@code HK2 ServiceLocator} in the Jersey * {@link WebComponent}. * <p></p>
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java index f35c720..e3c6344 100644 --- a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java +++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java
@@ -17,7 +17,6 @@ package org.glassfish.jersey.servlet; import java.io.IOException; -import java.io.InputStream; import java.io.UncheckedIOException; import java.lang.reflect.Type; import java.net.URI; @@ -57,7 +56,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.glassfish.jersey.innate.io.InputStreamWrapper; import org.glassfish.jersey.internal.ServiceFinderBinder; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.internal.inject.InjectionManager; @@ -425,11 +423,15 @@ final ResponseWriter responseWriter) throws IOException { try { - requestContext.setEntityStream(new InputStreamWrapper() { - + boolean waitForInputEnable = requestContext.resolveProperty(ServletProperties.WAIT_FOR_INPUT, Boolean.TRUE); + long waitForInputTimeOut = requestContext.resolveProperty(ServletProperties.WAIT_FOR_INPUT_TIMEOUT, + ServletProperties.WAIT_FOR_INPUT_DEFAULT_TIMEOUT); + requestContext.wrapEntityInputStream(new ServletEntityInputStream(waitForInputEnable, + waitForInputTimeOut) { private ServletInputStream wrappedStream; + @Override - protected InputStream getWrapped() { + protected ServletInputStream getWrappedStream() { if (wrappedStream == null) { try { wrappedStream = servletRequest.getInputStream();
diff --git a/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java new file mode 100644 index 0000000..406c765 --- /dev/null +++ b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java
@@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.innate.io; + +import org.glassfish.jersey.message.internal.EntityInputStream; + +import java.util.EventListener; + +/** + * Provides possibility to check whether an input stream for an entity is empty or not. + * <p> + * Is being used in the {@link EntityInputStream#isEmpty()} check + * </p> + */ +public interface StreamListener extends EventListener { + + /** + * Provides information if the underlying stream is empty + * + * @return true if the underlying stream is empty + */ + boolean isEmpty(); + + /** + * Can be used to provide readiness information. + * <p> + * If the stream is not ready the calling check in the {@link EntityInputStream#isEmpty()} method will validate + * the underlying stream as not empty. + * </p> + * <p> + * Throws: + * IllegalStateException - if one of the following conditions is true + * the associated request is neither upgraded nor the async started + * underlying setReadListener is called more than once within the scope of the same request. + * </p> + * @return true if the underlying stream is ready. + */ + boolean isReady(); +}
diff --git a/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java new file mode 100644 index 0000000..34d141d --- /dev/null +++ b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java
@@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.innate.io; + +import java.io.InputStream; + +/** + * Couples stream and its listener (if any). + * Could be used in connectors to help determine the stream's emptiness. + */ +public interface StreamListenerCouple { + + /** + * Provides a listener for the underlying input stream. The listener can reflect a particular state which + * helps to determine whether the underlying stream is empty or not. + * + * @return listener + */ + StreamListener getListener(); + + /** + * Provides underlying input stream. + * @return underlying input stream + */ + InputStream getExternalStream(); + +}
diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java index 2610e17..ab5a3ec 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java
@@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2024 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -22,6 +22,8 @@ import jakarta.ws.rs.ProcessingException; +import org.glassfish.jersey.innate.io.StreamListener; +import org.glassfish.jersey.innate.io.StreamListenerCouple; import org.glassfish.jersey.innate.io.InputStreamWrapper; import org.glassfish.jersey.internal.LocalizationMessages; @@ -37,6 +39,9 @@ public class EntityInputStream extends InputStreamWrapper { private InputStream input; + + private StreamListener listener; + private boolean closed = false; /** @@ -93,13 +98,12 @@ */ @Override public void close() throws ProcessingException { - final InputStream in = input; - if (in == null) { + if (input == null) { return; } if (!closed) { try { - in.close(); + input.close(); } catch (IOException ex) { // This e.g. means that the underlying socket stream got closed by other thread somehow... throw new ProcessingException(LocalizationMessages.MESSAGE_CONTENT_INPUT_STREAM_CLOSE_FAILED(), ex); @@ -119,43 +123,46 @@ */ public boolean isEmpty() { ensureNotClosed(); - - final InputStream in = input; - if (in == null) { + if (input == null) { return true; } try { // Try #markSupported first - #available on WLS waits until socked timeout is reached when chunked encoding is used. - if (in.markSupported()) { - in.mark(1); - int i = in.read(); - in.reset(); + if (input.markSupported()) { + input.mark(1); + int i = input.read(); + input.reset(); return i == -1; } else { + int availableBytes = 0; try { - if (in.available() > 0) { - return false; - } + availableBytes = input.available(); } catch (IOException ioe) { // NOOP. Try other approaches as this can fail on WLS. } - int b = in.read(); - if (b == -1) { - return true; + if (availableBytes > 0) { + return false; } - PushbackInputStream pbis; - if (in instanceof PushbackInputStream) { - pbis = (PushbackInputStream) in; - } else { - pbis = new PushbackInputStream(in, 1); - input = pbis; + if (listener != null) { + try { + if (!listener.isReady()) { + return false; + } + return listener.isEmpty(); + } catch (IllegalStateException ex) { + // NOOP. Listener failed to process the emptiness, the final method to be applied + } } - pbis.unread(b); - return false; + final PushbackInputStream in = (input instanceof PushbackInputStream) ? (PushbackInputStream) input + : new PushbackInputStream(input); + int i = in.read(); + in.unread(i); + input = in; + return i == -1; } } catch (IOException ex) { throw new ProcessingException(ex); @@ -188,7 +195,7 @@ * @return wrapped input stream instance. */ public final InputStream getWrappedStream() { - return input; + return getWrapped(); } /** @@ -201,7 +208,21 @@ } @Override - protected InputStream getWrapped() { + public InputStream getWrapped() { return input; } + + /** + * Decomposes existing {@link EntityInputStream} into this input stream + * @param stream instance of the {@link EntityInputStream} + */ + public void wrapExternalStream(StreamListenerCouple stream) { + input = new InputStreamWrapper() { + @Override + public InputStream getWrapped() { + return stream.getExternalStream(); + } + }; + listener = stream.getListener(); + } }
diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java index 46446a4..0d88f13 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java
@@ -45,6 +45,7 @@ import javax.xml.transform.Source; +import org.glassfish.jersey.innate.io.StreamListenerCouple; import org.glassfish.jersey.innate.io.SafelyClosable; import org.glassfish.jersey.internal.LocalizationMessages; import org.glassfish.jersey.internal.PropertiesDelegate; @@ -599,6 +600,16 @@ } /** + * Provides the whole {@link EntityInputStream} to the request + * + * @param stream the whole input stream entity + */ + public void wrapEntityInputStream(StreamListenerCouple stream) { + this.entityContent.wrapExternalStream(stream); + this.entityContent.buffered = false; + } + + /** * Read entity from a context entity input stream. * * @param <T> entity Java object type.
diff --git a/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java b/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java index 6d9fba2..65b033e 100644 --- a/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java +++ b/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java
@@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -48,6 +48,7 @@ import jakarta.ws.rs.ext.WriterInterceptor; import org.glassfish.jersey.http.HttpHeaders; +import org.glassfish.jersey.innate.io.StreamListenerCouple; import org.glassfish.jersey.internal.PropertiesDelegate; import org.glassfish.jersey.internal.guava.Preconditions; import org.glassfish.jersey.internal.PropertiesResolver; @@ -547,6 +548,12 @@ } @Override + public void wrapEntityInputStream(final StreamListenerCouple input) { + Preconditions.checkState(!inResponseProcessingPhase, ERROR_REQUEST_SET_ENTITY_STREAM_IN_RESPONSE_PHASE); + super.wrapEntityInputStream(input); + } + + @Override public Request getRequest() { return this; }
diff --git a/docs/src/main/docbook/appendix-properties.xml b/docs/src/main/docbook/appendix-properties.xml index 43b5bed..c4d66aa 100644 --- a/docs/src/main/docbook/appendix-properties.xml +++ b/docs/src/main/docbook/appendix-properties.xml
@@ -1022,6 +1022,25 @@ </para> </entry> </row> + <row> + <entry>&jersey.servlet.ServletProperties.WAIT_FOR_INPUT;</entry> + <entry><literal>jersey.config.servlet.expect.input</literal></entry> + <entry> + <para> + If <literal>true</literal> then Jersey will wait for input when the request is a POST or PUT. + </para> + </entry> + </row> + <row> + <entry>&jersey.servlet.ServletProperties.WAIT_FOR_INPUT_TIMEOUT;</entry> + <entry><literal>jersey.config.servlet.expect.input</literal></entry> + <entry> + <para> + The timeout in milliseconds for waiting for input when the request is a POST or PUT. + Default value is <literal>100</literal> milliseconds. + </para> + </entry> + </row> </tbody> </tgroup> </table>
diff --git a/docs/src/main/docbook/jersey.ent b/docs/src/main/docbook/jersey.ent index 614c765..905b8d1 100644 --- a/docs/src/main/docbook/jersey.ent +++ b/docs/src/main/docbook/jersey.ent
@@ -726,6 +726,8 @@ <!ENTITY jersey.servlet.ServletProperties.PROVIDER_WEB_APP "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#PROVIDER_WEB_APP'>ServletProperties.PROVIDER_WEB_APP</link>"> <!ENTITY jersey.servlet.ServletProperties.QUERY_PARAMS_AS_FORM_PARAMS_DISABLED "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#QUERY_PARAMS_AS_FORM_PARAMS_DISABLED'>ServletProperties.QUERY_PARAMS_AS_FORM_PARAMS_DISABLED</link>"> <!ENTITY jersey.servlet.ServletProperties.SERVICE_LOCATOR "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#SERVICE_LOCATOR'>ServletProperties.SERVICE_LOCATOR</link>"> +<!ENTITY jersey.servlet.ServletProperties.WAIT_FOR_INPUT "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#WAIT_FOR_INPUT'>ServletProperties.WAIT_FOR_INPUT</link>"> +<!ENTITY jersey.servlet.ServletProperties.WAIT_FOR_INPUT_TIMEOUT "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#WAIT_FOR_INPUT_TIMEOUT'>ServletProperties.WAIT_FOR_INPUT_TIMEOUT</link>"> <!ENTITY jersey.simple.SimpleContainer "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainer.html'>SimpleContainer</link>"> <!ENTITY jersey.simple.SimpleContainerFactory "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainerFactory.html'>SimpleContainerFactory</link>"> <!ENTITY jersey.simple.SimpleContainerProvider "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainerProvider.html'>SimpleContainerProvider</link>">
diff --git a/tests/e2e-client/pom.xml b/tests/e2e-client/pom.xml index 3dce2ff..90426d5 100644 --- a/tests/e2e-client/pom.xml +++ b/tests/e2e-client/pom.xml
@@ -112,6 +112,11 @@ <scope>test</scope> </dependency> <dependency> + <groupId>org.glassfish.jersey.media</groupId> + <artifactId>jersey-media-json-jackson</artifactId> + <scope>test</scope> + </dependency> + <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-entity-filtering</artifactId> <scope>test</scope> @@ -240,6 +245,12 @@ <groupId>org.glassfish.jersey.test-framework.providers</groupId> <artifactId>jersey-test-framework-provider-jetty</artifactId> </dependency> + <dependency> + <groupId>org.eclipse.jetty.ee10</groupId> + <artifactId>jetty-ee10-servlet</artifactId> + <version>${jetty.version}</version> + <scope>test</scope> + </dependency> </dependencies> </profile> <profile> @@ -332,6 +343,7 @@ <testExclude>org/glassfish/jersey/tests/e2e/client/connector/proxy/Proxy*Test.java</testExclude> <testExclude>org/glassfish/jersey/tests/e2e/client/connector/NoContentLengthTest.java</testExclude> <testExclude>org/glassfish/jersey/tests/e2e/client/nettyconnector/Expect100ContinueTest.java</testExclude> + <testExclude>org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java</testExclude> </testExcludes> </configuration> <goals>
diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java new file mode 100644 index 0000000..2633d6b --- /dev/null +++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java
@@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.tests.e2e.client; + +import jakarta.inject.Inject; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.jetty.JettyHttpContainerFactory; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.servlet.ServletContainer; +import org.glassfish.jersey.test.JerseyTest; +import org.glassfish.jersey.test.spi.TestContainer; +import org.glassfish.jersey.test.spi.TestContainerException; +import org.glassfish.jersey.test.spi.TestContainerFactory; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests the presence of the first byte in very large stream in each of 1000 requests + * Particularly for {@link org.glassfish.jersey.message.internal.EntityInputStream#isEmpty} method + * <p> + * Also introduces mixture of the Jetty server and servlet holder in which the error was reproduced. + */ +public class ResponseReadEntityStreamTest extends JerseyTest { + + @Override + protected TestContainerFactory getTestContainerFactory() throws TestContainerException { + return (baseUri, deploymentContext) -> { + final Server server = JettyHttpContainerFactory.createServer(baseUri, false); + final ServerConnector connector = new ServerConnector(server); + connector.setPort(9001); + server.addConnector(connector); + + final ResourceConfig resConfig = new ResourceConfig(Analyze.class); + + final ServletContainer jerseyServletContainer = new ServletContainer(resConfig); + final ServletHolder jettyServletHolder = new ServletHolder(jerseyServletContainer); + + final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + context.setContextPath("/"); + + context.addServlet(jettyServletHolder, "/api/*"); + + server.setHandler(context); + return new TestContainer() { + @Override + public ClientConfig getClientConfig() { + return new ClientConfig(); + } + + @Override + public URI getBaseUri() { + return baseUri; + } + + @Override + public void start() { + try { + server.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void stop() { + try { + server.stop(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + }; + } + + @Override + protected Application configure() { + return new ResourceConfig(ResponseReadEntityStreamTest.Analyze.class); + } + + private static void generateJson(final String filePath, int recordCount) throws IOException { + + try (final JsonGenerator generator = new JsonFactory().createGenerator(new FileWriter(filePath))) { + generator.writeStartArray(); + + for (int i = 0; i < recordCount; i++) { + generator.writeStartObject(); + generator.writeNumberField("id", i); + generator.writeStringField("name", "User" + i); + // Add more fields as needed + generator.writeEndObject(); + + if (i % 10000 == 0) { + generator.flush(); + } + } + + generator.writeEndArray(); + } + } + + @Test + public void readEntityTest() throws IOException { + final Invocation.Builder requestBuilder = target("/api/v1/analyze").request(); + //iterate 1000 requests to be sure the first byte is not lost + final String fileName = "bigFile.json"; + final String path = "target/" + fileName; + + final java.nio.file.Path pathResource = Paths.get(path); + + final java.nio.file.Path realFilePath = Files.createFile(pathResource.toAbsolutePath()); + + try { + + generateJson(realFilePath.toString(), 1000000); // 33Mb real file size + + final File bigFile = realFilePath.toFile(); + + for (int i = 1; i < 1000; i++) { + try (final FileInputStream stream = new FileInputStream(bigFile)) { + final Response response = requestBuilder.post(Entity.entity(stream, + MediaType.APPLICATION_FORM_URLENCODED_TYPE)); + assertEquals(200, response.getStatus()); + response.close(); + } + } + } finally { + Files.deleteIfExists(pathResource); + } + + } + + @Path("/v1") + @Produces(MediaType.APPLICATION_JSON) + public static class Analyze { + + @Inject + private HttpServletRequest request; + + @POST + @Path("/analyze") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response analyze() { + + try (final ServletInputStream inputStream = request.getInputStream()) { + final byte[] content = inputStream.readAllBytes(); + + if (content[0] != 91 /* character [ */) { // https://www.ascii-code.com/91 + throw new Exception("Oops"); + } + + return Response.ok("{\"status\":\"OK\"}").build(); + + } catch (Exception e) { + return Response.serverError().build(); + } + + } + } + +}