diff --git a/docs/config/io_helidon_webserver_SocketConfiguration.adoc b/docs/config/io_helidon_webserver_SocketConfiguration.adoc index 6f0c63d3b67..88562e951b8 100644 --- a/docs/config/io_helidon_webserver_SocketConfiguration.adoc +++ b/docs/config/io_helidon_webserver_SocketConfiguration.adoc @@ -91,6 +91,9 @@ Type: link:{javadoc-base-url}/io.helidon.webserver/io/helidon/webserver/SocketCo server socket. If `0` then use implementation default. + +|`continue-immediately`|boolean |`false` |When true answers to expect continue with 100 continue immediately, not waiting for user to actually request the data. + Default is `false` |`requested-uri-discovery.enabled` |boolean |`true if 'types' or 'trusted-proxies' is set; false otherwise` |Sets whether requested URI discovery is enabled for the socket. |`requested-uri-discovery.trusted-proxies` |xref:{rootdir}/config/io_helidon_common_configurable_AllowList.adoc[AllowList] |{nbsp} |Assigns the settings governing the acceptance and rejection of forwarded headers from incoming requests to this socket. This setting automatically enables discovery for the socket. diff --git a/docs/config/io_helidon_webserver_WebServer.adoc b/docs/config/io_helidon_webserver_WebServer.adoc index 4b158ae6768..701c59811b9 100644 --- a/docs/config/io_helidon_webserver_WebServer.adoc +++ b/docs/config/io_helidon_webserver_WebServer.adoc @@ -87,6 +87,9 @@ This is a standalone configuration type, prefix from configuration root: `server server socket. If `0` then use implementation default. + +|`continue-immediately`|boolean |`false` |When true WebServer answers to expect continue with 100 continue immediately, not waiting for user to actually request the data. + Default is `false` |`requested-uri-discovery.enabled` |boolean |`true if 'types' or 'trusted-proxies' is set; false otherwise` |Sets whether requested URI discovery is enabled for the socket. |`requested-uri-discovery.trusted-proxies` |xref:{rootdir}/config/io_helidon_common_configurable_AllowList.adoc[AllowList] |{nbsp} |Assigns the settings governing the acceptance and rejection of forwarded headers from incoming requests to this socket. This setting automatically enables discovery for the socket. diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/BareResponseImpl.java b/webserver/webserver/src/main/java/io/helidon/webserver/BareResponseImpl.java index 3952c0d08e6..0c8425dfb1d 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/BareResponseImpl.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/BareResponseImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2022 Oracle and/or its affiliates. + * Copyright (c) 2017, 2023 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. @@ -69,6 +69,7 @@ class BareResponseImpl implements BareResponse { private final AtomicBoolean internallyClosed = new AtomicBoolean(false); private final CompletableFuture responseFuture; private final CompletableFuture headersFuture; + private final CompletableFuture entityRequested; private final RequestContext requestContext; private final long requestId; private final String http2StreamId; @@ -98,21 +99,22 @@ class BareResponseImpl implements BareResponse { * @param requestId the correlation ID that is added to the log statements */ BareResponseImpl(ChannelHandlerContext ctx, + CompletableFuture entityRequested, HttpRequest request, RequestContext requestContext, CompletableFuture prevRequestChunk, CompletableFuture requestEntityAnalyzed, - long backpressureBufferSize, - BackpressureStrategy backpressureStrategy, + SocketConfiguration soConfig, long requestId) { + this.entityRequested = entityRequested; this.requestContext = requestContext; this.originalEntityAnalyzed = requestEntityAnalyzed; this.requestEntityAnalyzed = requestEntityAnalyzed; - this.backpressureStrategy = backpressureStrategy; - this.backpressureBufferSize = backpressureBufferSize; + this.backpressureStrategy = soConfig.backpressureStrategy(); + this.backpressureBufferSize = soConfig.backpressureBufferSize(); this.responseFuture = new CompletableFuture<>(); this.headersFuture = new CompletableFuture<>(); - this.channel = new NettyChannel(ctx.channel()); + this.channel = new NettyChannel(ctx); this.requestId = requestId; this.keepAlive = HttpUtil.isKeepAlive(request); this.requestHeaders = request.headers(); @@ -168,6 +170,14 @@ public void writeStatusAndHeaders(Http.ResponseStatus status, Map publisherRef = new IndirectReference<>(publisher, queues, queue); + CompletableFuture entityRequested = new CompletableFuture<>(); + // Set up read strategy for channel based on consumer demand publisher.onRequest((n, demand) -> { + entityRequested.complete(true); if (publisher.isUnbounded()) { LOGGER.finest(() -> formatMsg("Netty autoread: true", ctx)); ctx.channel().config().setAutoRead(true); @@ -399,12 +402,12 @@ private boolean channelReadHttpRequest(ChannelHandlerContext ctx, Context reques // Create response and handler for its completion BareResponseImpl bareResponse = new BareResponseImpl(ctx, + entityRequested, request, requestContext, prevRequestFuture, requestEntityAnalyzed, - soConfig.backpressureBufferSize(), - soConfig.backpressureStrategy(), + soConfig, requestId); prevRequestFuture = new CompletableFuture<>(); CompletableFuture thisResp = prevRequestFuture; @@ -412,7 +415,7 @@ private boolean channelReadHttpRequest(ChannelHandlerContext ctx, Context reques .thenRun(() -> { // Mark response completed in context requestContextRef.responseCompleted(true); - + entityRequested.complete(false); // Consume and release any buffers in publisher publisher.clearAndRelease(); @@ -430,12 +433,19 @@ private boolean channelReadHttpRequest(ChannelHandlerContext ctx, Context reques LOGGER.fine(formatMsg("Response complete: %s", ctx, System.identityHashCode(msg))); } }); - /* - TODO we should only send continue in case the entity is request (e.g. we found a route and user started reading it) - This would solve connection close for 404 for requests with entity - */ - if (HttpUtil.is100ContinueExpected(request)) { - send100Continue(ctx, request); + + + if (soConfig.continueImmediately()) { + if (HttpUtil.is100ContinueExpected(request)) { + send100Continue(ctx, request); + } + } else { + // Send 100 continue only when entity is actually requested + entityRequested.thenAccept(requestedByUser -> { + if (requestedByUser && HttpUtil.is100ContinueExpected(request)) { + send100Continue(ctx, request); + } + }); } // If a problem during routing, return 400 response diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/NettyChannel.java b/webserver/webserver/src/main/java/io/helidon/webserver/NettyChannel.java index 54dcf482815..5e6cff39bac 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/NettyChannel.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/NettyChannel.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2022 Oracle and/or its affiliates. + * Copyright (c) 2021, 2023 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. @@ -21,7 +21,9 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelId; +import io.netty.handler.codec.http.HttpExpectationFailedEvent; import io.netty.util.concurrent.Future; /** @@ -40,10 +42,12 @@ */ class NettyChannel { private final Channel channel; + private final ChannelHandlerContext ctx; private CompletionStage writeFuture = CompletableFuture.completedFuture(null); - NettyChannel(Channel channel) { - this.channel = channel; + NettyChannel(ChannelHandlerContext ctx) { + this.ctx = ctx; + this.channel = ctx.channel(); } /** @@ -133,6 +137,14 @@ static void completeFuture(Future nettyFuture, CompletableFutu } } + + /** + * Reset HttpObjectDecoder to not expect data. + */ + void expectationFailed(){ + ctx.pipeline().fireUserEventTriggered(HttpExpectationFailedEvent.INSTANCE); + } + @Override public String toString() { return "NettyChannel{" diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/RequestContext.java b/webserver/webserver/src/main/java/io/helidon/webserver/RequestContext.java index 246807d7aac..5d56138883d 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/RequestContext.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/RequestContext.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2022 Oracle and/or its affiliates. + * Copyright (c) 2017, 2023 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. @@ -36,13 +36,18 @@ class RequestContext { private final HttpRequestScopedPublisher publisher; private final HttpRequest request; private final Context scope; + private final SocketConfiguration socketConfiguration; private volatile boolean responseCompleted; private volatile boolean emitted; - RequestContext(HttpRequestScopedPublisher publisher, HttpRequest request, Context scope) { + RequestContext(HttpRequestScopedPublisher publisher, + HttpRequest request, + Context scope, + SocketConfiguration socketConfiguration) { this.publisher = publisher; this.request = request; this.scope = scope; + this.socketConfiguration = socketConfiguration; } Multi publisher() { @@ -124,4 +129,8 @@ void responseCompleted(boolean responseCompleted) { boolean responseCompleted() { return responseCompleted; } + + SocketConfiguration socketConfiguration() { + return socketConfiguration; + } } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java index 4aed600c27f..c3a5d001275 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java @@ -223,6 +223,7 @@ static class SocketConfig implements SocketConfiguration { private final long maxPayloadSize; private final long backpressureBufferSize; private final BackpressureStrategy backpressureStrategy; + private final boolean continueImmediately; private final int maxUpgradeContentLength; private final List requestedUriDiscoveryTypes; private final AllowList trustedProxies; @@ -248,6 +249,7 @@ static class SocketConfig implements SocketConfiguration { this.maxPayloadSize = builder.maxPayloadSize(); this.backpressureBufferSize = builder.backpressureBufferSize(); this.backpressureStrategy = builder.backpressureStrategy(); + this.continueImmediately = builder.continueImmediately(); this.maxUpgradeContentLength = builder.maxUpgradeContentLength(); WebServerTls webServerTls = builder.tlsConfig(); this.webServerTls = webServerTls.enabled() ? webServerTls : null; @@ -366,6 +368,11 @@ public BackpressureStrategy backpressureStrategy() { return backpressureStrategy; } + @Override + public boolean continueImmediately() { + return continueImmediately; + } + @Override public List requestedUriDiscoveryTypes() { return requestedUriDiscoveryTypes; diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java index ec158563ec5..b82fa1ab604 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java @@ -553,6 +553,21 @@ public Builder backpressureStrategy(BackpressureStrategy backpressureStrategy) { return this; } + /** + * When true WebServer answers to expect continue with 100 continue immediately, + * not waiting for user to actually request the data. + *

+ * Default is {@code false} + * + * @param continueImmediately , answer with 100 continue immediately after expect continue, default is false + * @return this builder + */ + @Override + public Builder continueImmediately(boolean continueImmediately) { + defaultSocketBuilder().continueImmediately(continueImmediately); + return this; + } + /** * Set a maximum length of the content of an upgrade request. *

diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java index 61d1e585651..1e9169d42f3 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java @@ -247,6 +247,17 @@ default BackpressureStrategy backpressureStrategy() { return BackpressureStrategy.LINEAR; } + /** + * When true WebServer answers with 100 continue immediately, + * not waiting for user to actually request the data. + * False is default value. + * + * @return strategy identifier for applying backpressure + */ + default boolean continueImmediately() { + return false; + } + /** * Initial size of the buffer used to parse HTTP line and headers. * @@ -505,6 +516,18 @@ default B tls(Supplier tlsConfig) { @ConfiguredOption("LINEAR") B backpressureStrategy(BackpressureStrategy backpressureStrategy); + /** + * When true WebServer answers to expect continue with 100 continue immediately, + * not waiting for user to actually request the data. + *

+ * Default is {@code false} + * + * @param continueImmediately , answer with 100 continue immediately after expect continue, default is false + * @return this builder + */ + @ConfiguredOption("false") + B continueImmediately(boolean continueImmediately); + /** * Set a maximum length of the content of an upgrade request. *

@@ -653,6 +676,7 @@ final class Builder implements SocketConfigurationBuilder, io.helidon.c private boolean enableCompression = false; private long maxPayloadSize = -1; private BackpressureStrategy backpressureStrategy = BackpressureStrategy.LINEAR; + private boolean continueImmediately = false; private int maxUpgradeContentLength = 64 * 1024; private long maxBufferSize = 5 * 1024 * 1024; private final List requestedUriDiscoveryTypes = new ArrayList<>(); @@ -844,6 +868,12 @@ public Builder backpressureStrategy(BackpressureStrategy backpressureStrategy) { return this; } + @Override + public Builder continueImmediately(boolean continueImmediately) { + this.continueImmediately = continueImmediately; + return this; + } + @Override public Builder maxUpgradeContentLength(int size) { this.maxUpgradeContentLength = size; @@ -1037,6 +1067,10 @@ BackpressureStrategy backpressureStrategy() { return backpressureStrategy; } + boolean continueImmediately() { + return continueImmediately; + } + int maxUpgradeContentLength() { return maxUpgradeContentLength; } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java index 570edc9197b..830d2ffb897 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java @@ -603,6 +603,12 @@ public Builder backpressureStrategy(BackpressureStrategy backpressureStrategy) { return this; } + @Override + public Builder continueImmediately(boolean continueImmediately) { + configurationBuilder.continueImmediately(continueImmediately); + return this; + } + @Override public Builder maxUpgradeContentLength(int size) { configurationBuilder.maxUpgradeContentLength(size); diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/BareResponseSubscriberTckTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/BareResponseSubscriberTckTest.java index 412e2639721..65a7f993e33 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/BareResponseSubscriberTckTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/BareResponseSubscriberTckTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2022 Oracle and/or its affiliates. + * Copyright (c) 2021, 2023 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. @@ -51,13 +51,15 @@ public Flow.Subscriber createFlowSubscriber(WhiteboxSubscriberProbe> BLOCKER_MAP = new ConcurrentHashMap<>(); + private static WebServer webServer; + private static int defaultPort; + private static int contImmediatelyPort; + + @BeforeAll + static void beforeAll() { + Logger.getLogger("io.helidon.webserver.HttpInitializer").setLevel(Level.FINE); + LogConfig.configureRuntime(); + + Routing routing = Routing.builder() + .any("/redirect", (req, res) -> + res.status(301) + .addHeader(Http.Header.LOCATION, "/") + // force 301 to not use chunked encoding + // https://github.com/helidon-io/helidon/issues/5713 + .addHeader(Http.Header.CONTENT_LENGTH, "0") + .send() + ) + .any("/", (req, res) -> { + if (Boolean.parseBoolean(req.headers().first("test-fail-before-read").orElse("false"))) { + res.status(Http.Status.EXPECTATION_FAILED_417).send(); + return; + } + + Optional blockId = req.headers().first("test-block-id"); + // Block request content dump if blocker assigned + Single.create(blockId.map(BLOCKER_MAP::get).orElse(CompletableFuture.completedFuture(""))) + .flatMapSingle(unused -> req.content().as(String.class)) + // Requesting date from content triggers 100 continue by default + .forSingle(s -> { + res.send("Got " + s.getBytes().length + " bytes of data"); + }); + }).build(); + + webServer = WebServer.builder() + .routing(() -> routing) + .addSocket(SocketConfiguration.builder() + .name(CONTINUE_IMMEDIATELY_SOCKET) + .continueImmediately(true) + .build(), routing) + .build() + .start() + .await(TEST_TIMEOUT); + + defaultPort = webServer.port(); + contImmediatelyPort = webServer.port(CONTINUE_IMMEDIATELY_SOCKET); + } + + @AfterAll + static void afterAll() { + webServer.shutdown().await(TEST_TIMEOUT); + } + + @Test + void continue100Post() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()) + .awaitResponse("HTTP/1.1 100 Continue", "\n\n") + .continuePayload(content); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 200 OK")); + assertThat(received, endsWith("Got 19 bytes of data")); + } + } + @Test + void continue100ImmediatelyPost() throws Exception { + String blockId = "continue100ImmediatelyPost - " + UUID.randomUUID(); + BLOCKER_MAP.put(blockId, new CompletableFuture<>()); + try (SocketHttpClient socketHttpClient = new SocketHttpClient(contImmediatelyPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + test-block-id: %s + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, contImmediatelyPort, blockId, content.length()) + // Needs to respond continue before request content is requested + .awaitResponse("HTTP/1.1 100 Continue", "\n\n") + // Unblock request data dump + .then(sc -> BLOCKER_MAP.get(blockId).complete(blockId)) + .continuePayload(content); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 200 OK")); + assertThat(received, endsWith("Got 19 bytes of data")); + } + } + + @Test + void redirect() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST /redirect HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()) + .awaitResponse("HTTP/1.1 301 Moved Permanently\n", "\n\n"); + socketHttpClient + .manualRequest(""" + POST / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()) + .awaitResponse("HTTP/1.1 100 Continue", "\n\n") + .continuePayload(content); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 200 OK")); + assertThat(received, endsWith("Got 19 bytes of data")); + } + } + + /** + * RFC9110 10.1.1 + * + * A client that sends a 100-continue expectation is not required to wait for any specific length of time; + * such a client MAY proceed to send the content even if it has not yet received a response. Furthermore, + * since 100 (Continue) responses cannot be sent through an HTTP/1.0 intermediary, such a client SHOULD NOT + * wait for an indefinite period before sending the content. + * + * @throws Exception + */ + @Test + void continueWithoutContinue() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()) + // Don't wait for continue + .continuePayload(content) + // Skip continue + .awaitResponse("HTTP/1.1 100 Continue", "\n\n"); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 200 OK")); + assertThat(received, endsWith("Got 19 bytes of data")); + } + } + @Test + void continue100Put() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + PUT / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()) + .awaitResponse("HTTP/1.1 100 Continue", "\n\n") + .continuePayload(content); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 200 OK")); + assertThat(received, endsWith("Got 19 bytes of data")); + } + } + + @Test + void expectationFailed() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST / HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: true + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 417")); + } + } + + @Test + void notFound404() throws Exception { + try (SocketHttpClient socketHttpClient = new SocketHttpClient(defaultPort)) { + String content = "looong payload data"; + socketHttpClient + .manualRequest(""" + POST /test HTTP/1.1 + Host: localhost:%d + Expect: 100-continue + Accept: */* + test-fail-before-read: false + Content-Length: %d + Content-Type: application/x-www-form-urlencoded + + """, defaultPort, content.length()); + + String received = socketHttpClient.receive(); + assertThat(received, startsWith("HTTP/1.1 404")); + } + } +} diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/utils/SocketHttpClient.java b/webserver/webserver/src/test/java/io/helidon/webserver/utils/SocketHttpClient.java index 3f33b1d6103..07c5e2bb360 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/utils/SocketHttpClient.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/utils/SocketHttpClient.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2022 Oracle and/or its affiliates. + * Copyright (c) 2017, 2023 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. @@ -29,6 +29,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -51,7 +52,7 @@ public class SocketHttpClient implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(SocketHttpClient.class.getName()); - private static final String EOL = "\r\n"; + static final String EOL = "\r\n"; private static final Pattern FIRST_LINE_PATTERN = Pattern.compile("HTTP/\\d+\\.\\d+ (\\d\\d\\d) (.*)"); private final Socket socket; @@ -69,6 +70,18 @@ public SocketHttpClient(WebServer webServer) throws IOException { socketReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); } + /** + * Creates the instance linked with the provided webserver. + * + * @param port the port to link this client with + * @throws IOException in case of an error + */ + public SocketHttpClient(int port) throws IOException { + socket = new Socket("localhost", port); + socket.setSoTimeout(10000); + socketReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + } + /** * A helper method that sends the given payload with the provided method to the webserver. * @@ -292,6 +305,42 @@ public String receive() throws IOException { return sb.toString(); } + /** + * Execute immediately given consumer with this + * socket client as an argument. + * + * @param exec consumer to execute + * @return this http client + */ + public SocketHttpClient then(Consumer exec){ + exec.accept(this); + return this; + } + + /** + * Wait for text coming from socket. + * + * @param expectedStartsWith expected beginning + * @param expectedEndsWith expected end + * @return this http client + */ + public SocketHttpClient awaitResponse(String expectedStartsWith, String expectedEndsWith) { + StringBuilder sb = new StringBuilder(); + String t; + while (true) { + try { + if ((t = socketReader.readLine()) == null) break; + } catch (IOException e) { + throw new RuntimeException(e); + } + sb.append(t).append("\n"); + if (sb.toString().startsWith(expectedStartsWith) && sb.toString().endsWith(expectedEndsWith)) { + break; + } + } + return this; + } + /** * Sends a request to the webserver. * @@ -382,6 +431,38 @@ public void request(String method, String path, String protocol, String host, It pw.flush(); } + /** + * Send supplied text manually to socket. + * + * @param formatString text to send + * @param args format arguments + * @return this http client + * @throws IOException + */ + public SocketHttpClient manualRequest(String formatString, Object... args) throws IOException { + PrintWriter pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + pw.printf(formatString.replaceAll("\\n",EOL), args); + pw.flush(); + return this; + } + + + /** + * Continue sending more to text to socket. + * @param payload text to be sent + * @return this http client + * @throws IOException + */ + public SocketHttpClient continuePayload(String payload) + throws IOException { + PrintWriter pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + if (payload != null) { + pw.print(payload); + } + pw.flush(); + return this; + } + /** * Override this to send a specific payload. * diff --git a/webserver/webserver/src/test/resources/logging-test.properties b/webserver/webserver/src/test/resources/logging-test.properties index e9529a432ea..af755a43368 100644 --- a/webserver/webserver/src/test/resources/logging-test.properties +++ b/webserver/webserver/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2017, 2020 Oracle and/or its affiliates. +# Copyright (c) 2017, 2023 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.