diff --git a/.github/workflows/e2e-httpclient-tests.yml b/.github/workflows/e2e-httpclient-tests.yml index c50c3bd395c..7946fa9cbb8 100644 --- a/.github/workflows/e2e-httpclient-tests.yml +++ b/.github/workflows/e2e-httpclient-tests.yml @@ -39,7 +39,7 @@ jobs: fail-fast: false matrix: kubernetes: [v1.33.0,v1.32.4,v1.31.8,v1.30.12,v1.29.14] - httpclient: [jdk,jetty,okhttp] + httpclient: [jdk,jetty,okhttp,vertx-5] steps: - name: Checkout uses: actions/checkout@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e333e44179..86cdcd06188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Fix #7266: bump jackson-bom from 2.19.2 to 2.20.0, fix overrides and handle jackson-annotations v2.20 #### New Features +* Fix #7174: Added Vert.x 5 HTTP client implementation with improved async handling and WebSocket separation #### _**Note**_: Breaking changes * #7266: bump jackson-bom from 2.19.2 to 2.20.0, fix overrides and handle jackson-annotations v2.20 diff --git a/httpclient-vertx-5/README.md b/httpclient-vertx-5/README.md new file mode 100644 index 00000000000..a211aebe751 --- /dev/null +++ b/httpclient-vertx-5/README.md @@ -0,0 +1,65 @@ +# Vert.x 5 HTTP Client For Fabric8 Kubernetes Client + +This module provides Vert.x 5.x HTTP client implementation for the Fabric8 Kubernetes Client, featuring improved async handling and WebSocket separation introduced in Vert.x 5. + +## Features + +- **Vert.x 5.0.1**: Uses latest stable Vert.x 5.x release +- **Async Operations**: Enhanced async HTTP request handling +- **WebSocket Separation**: Vert.x 5's separate HTTP and WebSocket client architecture +- **Backpressure Support**: Built-in flow control for streaming operations +- **Stack-based Recursion Guard**: Memory-safe recursion protection without ThreadLocal + +## Usage + +Add the dependency to your project: + +```xml + + io.fabric8 + kubernetes-httpclient-vertx-5 + ${fabric8.version} + +``` + +The HTTP client will be automatically discovered via service loader. + +## Testing + +### Running Integration Tests + +To run integration tests specifically with the Vert.x 5 HTTP client: + +```bash +# Run all integration tests with Vert.x 5 +mvn -Phttpclient-vertx-5 -Pitests verify -Dtest.httpclient=vertx-5 + +# Run specific test with Vert.x 5 +mvn -Phttpclient-vertx-5 test -Dtest=ConfigMapIT -Dtest.httpclient=vertx-5 + +# Run WebSocket tests with Vert.x 5 +mvn -Phttpclient-vertx-5 test -Dtest=WatchIT -Dtest.httpclient=vertx-5 +``` + +### Version Validation Test + +A special test (`VertxVersionValidationIT`) validates that Vert.x 5.0.1 is actually being used: + +```bash +mvn -Phttpclient-vertx-5 test -Dtest=VertxVersionValidationIT -Dtest.httpclient=vertx-5 +``` + +### Dependency Verification + +Verify the correct Vert.x version is being used: + +```bash +mvn -Phttpclient-vertx-5 dependency:tree | grep vertx +# Should show: vertx-core:jar:5.0.1:compile +``` + +## Architecture Notes + +- **WebSocket Client Separation**: Unlike Vert.x 4, Vert.x 5 uses separate HTTP and WebSocket clients +- **Dependency Management**: This module overrides parent POM's Vert.x version to ensure 5.0.1 is used +- **Profile Isolation**: The httpclient-vertx-5 profile ensures no conflicts with default Vert.x 4 usage diff --git a/httpclient-vertx-5/pom.xml b/httpclient-vertx-5/pom.xml new file mode 100644 index 00000000000..a1f1a55178a --- /dev/null +++ b/httpclient-vertx-5/pom.xml @@ -0,0 +1,235 @@ + + + + 4.0.0 + + kubernetes-client-project + io.fabric8 + 7.5-SNAPSHOT + + + kubernetes-httpclient-vertx-5 + jar + Fabric8 :: Kubernetes :: HttpClient :: Vert.x 5 + + + 5.0.1 + + osgi.extender; + filter:="(osgi.extender=osgi.serviceloader.registrar)", + + + osgi.serviceloader; + osgi.serviceloader=io.fabric8.kubernetes.client.http.HttpClient$Factory + + + !android.util*, + *, + + + io.fabric8.kubernetes.client.vertx*;-noimport:=true, + + + + + + + + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web-client + ${vertx.version} + + + io.vertx + vertx-web-common + ${vertx.version} + + + io.vertx + vertx-auth-common + ${vertx.version} + + + io.vertx + vertx-core-logging + ${vertx.version} + + + io.vertx + vertx-web + ${vertx.version} + + + io.vertx + vertx-bridge-common + ${vertx.version} + + + io.vertx + vertx-uri-template + ${vertx.version} + + + + + + + io.fabric8 + kubernetes-client-api + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web-client + ${vertx.version} + + + io.fabric8 + kubernetes-client-api + test-jar + test + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.mockito + mockito-inline + + + io.fabric8 + mockwebserver + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 1 + + + + org.codehaus.mojo + exec-maven-plugin + + + + java + + + + + test + + + + org.jacoco + jacoco-maven-plugin + + + report-aggregate + verify + + report-aggregate + + + + + + org.apache.felix + maven-bundle-plugin + ${maven.bundle.plugin.version} + + + bundle + package + + bundle + + + + ${project.name} + ${project.groupId}.${project.artifactId} + ${osgi.export} + ${osgi.import} + ${osgi.dynamic.import} + ${osgi.require-capability} + ${osgi.provide-capability} + ${osgi.private} + ${osgi.bundles} + ${osgi.activator} + ${osgi.export.service} + + /META-INF/services/io.fabric8.kubernetes.client.http.HttpClient$Factory=target/classes/META-INF/services/io.fabric8.kubernetes.client.http.HttpClient$Factory, + + + bundle + + + + + + + + diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/AsyncInputReader.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/AsyncInputReader.java new file mode 100644 index 00000000000..ff608ccb6a9 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/AsyncInputReader.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import lombok.RequiredArgsConstructor; + +import java.io.InputStream; + +@RequiredArgsConstructor +class AsyncInputReader { + + private static final int CHUNK_SIZE = 2048; + + private final Vertx vertx; + private final InputStream inputStream; + private byte[] readBuffer; + + Future readNextChunk() { + return vertx.executeBlocking(() -> { + if (readBuffer == null) { + readBuffer = new byte[CHUNK_SIZE]; + } + final int bytesRead = inputStream.read(readBuffer); + if (bytesRead == -1) { + return null; // EOF + } + return Buffer.buffer().appendBytes(readBuffer, 0, bytesRead); + }); + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStream.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStream.java new file mode 100644 index 00000000000..2548737aa53 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStream.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.streams.ReadStream; + +import java.io.InputStream; + +class InputStreamReadStream implements ReadStream { + + private final VertxHttpRequest vertxHttpRequest; + private final HttpClientRequest request; + private final AsyncInputReader reader; + private final StackBasedRecursionGuard recursionGuard; + private final StreamFlowController flowController; + + private Handler exceptionHandler; + + InputStreamReadStream(VertxHttpRequest vertxHttpRequest, InputStream inputStream, HttpClientRequest request) { + this.vertxHttpRequest = vertxHttpRequest; + this.request = request; + this.reader = new AsyncInputReader(vertxHttpRequest.vertx, inputStream); + this.recursionGuard = new StackBasedRecursionGuard(); + this.flowController = new StreamFlowController(); + } + + @Override + public ReadStream exceptionHandler(final Handler handler) { + exceptionHandler = handler; + return this; + } + + @Override + public ReadStream handler(final Handler handler) { + final boolean shouldStart = !flowController.isInitialized() && handler != null; + + if (shouldStart) { + flowController.initialize(vertxHttpRequest.vertx.getOrCreateContext(), this::readChunk); + } + + flowController.configureDataHandler(handler); + + if (shouldStart) { + readChunk(); + } + + return this; + } + + private void readChunk() { + if (recursionGuard.enter()) { + try { + executeBlockingRead(); + } finally { + recursionGuard.exit(); + } + } else { + vertxHttpRequest.vertx.runOnContext(v -> readChunk()); + } + } + + private void executeBlockingRead() { + reader.readNextChunk().onComplete(this::handleReadResult); + } + + private void handleReadResult(final io.vertx.core.AsyncResult result) { + if (result.succeeded()) { + final Buffer chunk = result.result(); + if (chunk != null) { + final boolean canContinueWriting = flowController.writeChunk(chunk); + if (canContinueWriting) { + readChunk(); + } + // If buffer is full, readChunk will be called by drain handler + } else { + flowController.writeEndSentinel(); + // No cleanup needed with stack-based approach + } + } else { + handleReadError(result.cause()); + } + } + + private void handleReadError(final Throwable cause) { + request.reset(0, cause); + if (exceptionHandler != null) { + exceptionHandler.handle(cause); + } + } + + @Override + public ReadStream endHandler(Handler handler) { + flowController.setEndHandler(handler); + return this; + } + + @Override + public ReadStream pause() { + flowController.pause(); + return this; + } + + @Override + public ReadStream resume() { + flowController.resume(); + return this; + } + + @Override + public ReadStream fetch(long amount) { + flowController.fetch(amount); + return this; + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuard.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuard.java new file mode 100644 index 00000000000..15dab72caef --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuard.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +/** + * Stack-based recursion guard that prevents stack overflow by tracking call depth. + * + *

+ * This implementation uses a simple instance variable to track recursion depth, + * eliminating ThreadLocal usage and potential memory leaks. Each instance is tied + * to a specific component (like InputStreamReadStream) and tracks recursion for + * that component only. + *

+ * + *

+ * Usage pattern: + *

+ *
{@code
+ * private void recursiveMethod() {
+ *   if (recursionGuard.enter()) {
+ *     try {
+ *       // Recursive logic here
+ *       recursiveMethod(); // May call this method again
+ *     } finally {
+ *       recursionGuard.exit();
+ *     }
+ *   } else {
+ *     // Schedule async execution to avoid stack overflow
+ *     vertx.runOnContext(v -> recursiveMethod());
+ *   }
+ * }
+ * }
+ * + * @since 7.4.0 + */ +class StackBasedRecursionGuard { + + /** + * Maximum recursion depth before requiring async scheduling. + * Value preserved from original Vert.x 4 implementation. + */ + private static final int MAX_RECURSION_DEPTH = 8; + + /** + * Current recursion depth for this instance. + * No synchronization needed as this is tied to single component instance. + */ + private int depth = 0; + + /** + * Attempts to enter a recursive call. + * + * @return {@code true} if recursion is within safe limits and can proceed synchronously, + * {@code false} if recursion depth is too high and caller should use async scheduling + */ + boolean enter() { + return depth++ < MAX_RECURSION_DEPTH; + } + + /** + * Exits a recursive call, decreasing the depth counter. + * Must be called in a finally block to ensure proper cleanup. + */ + void exit() { + depth--; + } + + /** + * Returns current recursion depth. + * Useful for testing and debugging. + * + * @return current depth count + */ + int getCurrentDepth() { + return depth; + } +} \ No newline at end of file diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StreamFlowController.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StreamFlowController.java new file mode 100644 index 00000000000..363f307b1e1 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/StreamFlowController.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Context; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.streams.impl.InboundBuffer; + +class StreamFlowController { + + private final Buffer endSentinel; + private InboundBuffer inboundBuffer; + private Handler endHandler; + + StreamFlowController() { + this.endSentinel = Buffer.buffer(); + } + + void initialize(final Context context, final Runnable drainHandler) { + inboundBuffer = new InboundBuffer<>(context); + inboundBuffer.drainHandler(v -> drainHandler.run()); + } + + boolean isInitialized() { + return inboundBuffer != null; + } + + void configureDataHandler(final Handler dataHandler) { + if (dataHandler != null) { + inboundBuffer.handler(buffer -> { + if (buffer == endSentinel) { + if (endHandler != null) { + endHandler.handle(null); + } + } else { + dataHandler.handle(buffer); + } + }); + } else { + inboundBuffer.handler(null); + } + } + + void setEndHandler(final Handler handler) { + this.endHandler = handler; + } + + boolean writeChunk(final Buffer chunk) { + return inboundBuffer.write(chunk); + } + + void writeEndSentinel() { + inboundBuffer.write(endSentinel); + } + + void pause() { + if (inboundBuffer != null) { + inboundBuffer.pause(); + } + } + + void resume() { + if (inboundBuffer != null) { + inboundBuffer.resume(); + } + } + + void fetch(final long amount) { + if (inboundBuffer != null) { + inboundBuffer.fetch(amount); + } + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBody.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBody.java new file mode 100644 index 00000000000..ed698246cb3 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBody.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.vertx.core.http.HttpClientResponse; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Vert.x 5 implementation of {@link AsyncBody} that bridges Vert.x streaming to Fabric8's async body interface. + * + *

+ * Handles backpressure by starting in a paused state - consumption must be explicitly triggered + * via {@link #consume()}. Converts Vert.x buffers to ByteBuffers for the Fabric8 consumer. + * + *

+ * Must be consumed or cancelled to prevent resource leaks. + */ +final class VertxAsyncBody implements AsyncBody { + /** The underlying Vert.x response for streaming control. */ + private final HttpClientResponse resp; + + /** Completion future tracking when streaming is finished or cancelled. */ + private final CompletableFuture done = new CompletableFuture<>(); + + /** + * Creates a new VertxAsyncBody bridging Vert.x streaming to Fabric8's AsyncBody interface. + * Sets up handlers for data, end, and exception events. Response starts paused. + * + * @param resp the Vert.x response to stream from + * @param consumer the consumer to receive ByteBuffer data + */ + VertxAsyncBody(final HttpClientResponse resp, final AsyncBody.Consumer> consumer) { + this.resp = resp; + + resp.handler(buf -> { + try { + consumer.consume(List.of(ByteBuffer.wrap(buf.getBytes())), this); + } catch (Exception e) { + resp.request().reset(); + done.completeExceptionally(e); + } + }).endHandler(v -> done.complete(null)) + .exceptionHandler(done::completeExceptionally); + } + + /** + * Requests the next chunk of data from the Vert.x response. + * Implements backpressure by requesting one buffer at a time. + */ + @Override + public void consume() { + resp.fetch(1); + } + + /** + * Returns a future that completes when streaming is finished or fails. + * + * @return completion future + */ + @Override + public CompletableFuture done() { + return done; + } + + /** + * Cancels the async body by clearing handlers, resetting the connection, + * and cancelling the completion future. + */ + @Override + public void cancel() { + resp.handler(null); + resp.endHandler(null); + resp.request().reset(); + done.cancel(false); + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClient.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClient.java new file mode 100644 index 00000000000..a63c6130ade --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClient.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.StandardHttpClient; +import io.fabric8.kubernetes.client.http.StandardHttpRequest; +import io.fabric8.kubernetes.client.http.StandardWebSocketBuilder; +import io.fabric8.kubernetes.client.http.WebSocket; +import io.fabric8.kubernetes.client.http.WebSocketResponse; +import io.fabric8.kubernetes.client.http.WebSocketUpgradeResponse; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.http.UpgradeRejectedException; +import io.vertx.core.http.WebSocketClient; +import io.vertx.core.http.WebSocketClientOptions; +import io.vertx.core.http.WebSocketConnectOptions; +import lombok.Getter; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static io.fabric8.kubernetes.client.vertx.VertxHttpRequest.toHeadersMap; + +public class VertxHttpClient + extends StandardHttpClient, F, VertxHttpClientBuilder> { + + private final Vertx vertx; + @Getter + private final HttpClient httpClient; + private final WebSocketClient webSocketClient; + private final boolean closeVertx; + + /** + * Create a new VertxHttpClient instance using configuration object. + * This eliminates constructor ambiguity and provides clear instantiation path. + * + * @param config configuration containing all required parameters + */ + VertxHttpClient(final VertxHttpClientConfiguration config) { + super(config.getClientBuilder(), config.getClosed()); + this.vertx = config.getClientBuilder().vertx; + this.httpClient = config.getHttpClient(); + this.webSocketClient = createWebSocketClient(config); + this.closeVertx = config.isCloseVertx(); + } + + /** + * Creates WebSocket client based on configuration. + * Uses custom options if provided, otherwise creates with defaults. + */ + private WebSocketClient createWebSocketClient(final VertxHttpClientConfiguration config) { + final WebSocketClientOptions options = config.getWebSocketOptions(); + return options != null + ? vertx.createWebSocketClient(options) + : vertx.createWebSocketClient(); + } + + /** + * Creates VertxHttpClient with default WebSocket configuration. + * For internal use by VertxHttpClientBuilder. + */ + static VertxHttpClient createWithDefaults( + final VertxHttpClientBuilder builder, + final AtomicBoolean closed, + final HttpClient httpClient, + final boolean closeVertx) { + + final VertxHttpClientConfiguration config = VertxHttpClientConfiguration.withDefaultWebSocket(builder, closed, + httpClient, closeVertx); + return new VertxHttpClient<>(config); + } + + /** + * Creates VertxHttpClient with custom WebSocket configuration. + * For internal use by VertxHttpClientBuilder. + */ + static VertxHttpClient createWithWebSocketOptions( + final VertxHttpClientBuilder builder, + final AtomicBoolean closed, + final HttpClient httpClient, + final WebSocketClientOptions wsOptions, + final boolean closeVertx) { + + final VertxHttpClientConfiguration config = VertxHttpClientConfiguration.withCustomWebSocket( + builder, closed, httpClient, wsOptions, closeVertx); + return new VertxHttpClient<>(config); + } + + @Override + public CompletableFuture buildWebSocketDirect(final StandardWebSocketBuilder standardWebSocketBuilder, + WebSocket.Listener listener) { + WebSocketConnectOptions options = new WebSocketConnectOptions(); + + if (standardWebSocketBuilder.getSubprotocol() != null) { + options.setSubProtocols(Collections.singletonList(standardWebSocketBuilder.getSubprotocol())); + } + + final StandardHttpRequest request = standardWebSocketBuilder.asHttpRequest(); + + if (request.getTimeout() != null) { + options.setTimeout(request.getTimeout().toMillis()); + } + + request.headers().forEach((key, value) -> value.forEach(v -> options.addHeader(key, v))); + options.setAbsoluteURI(WebSocket.toWebSocketUri(request.uri()).toString()); + + final CompletableFuture response = new CompletableFuture<>(); + + webSocketClient + .connect(options) + .onSuccess(ws -> { + final VertxWebSocket ret = new VertxWebSocket(ws, listener); + ret.initHandlers(); + response.complete(new WebSocketResponse(new WebSocketUpgradeResponse(request), ret)); + }).onFailure(t -> { + if (t instanceof UpgradeRejectedException) { + final UpgradeRejectedException handshake = (UpgradeRejectedException) t; + final WebSocketUpgradeResponse upgradeResponse = new WebSocketUpgradeResponse( + request, handshake.getStatus(), toHeadersMap(handshake.getHeaders())); + response.complete(new WebSocketResponse(upgradeResponse, handshake)); + } + response.completeExceptionally(t); + }); + return response; + } + + @Override + public CompletableFuture> consumeBytesDirect(final StandardHttpRequest request, + final AsyncBody.Consumer> consumer) { + + final var options = new RequestOptions(); + + request.headers().forEach((k, l) -> l.forEach(v -> options.addHeader(k, v))); + options.setAbsoluteURI(request.uri().toString()); + options.setMethod(HttpMethod.valueOf(request.method())); + + if (request.getTimeout() != null) { + options.setTimeout(request.getTimeout().toMillis()); + } + + Optional.ofNullable(request.getContentType()) + .ifPresent(s -> options.putHeader(HttpHeaders.CONTENT_TYPE, s)); + + if (request.isExpectContinue()) { + options.putHeader(HttpHeaders.EXPECT, HttpHeaders.CONTINUE); + } + + return new VertxHttpRequest(vertx, options, request).consumeBytes(this.httpClient, consumer); + } + + @Override + public void doClose() { + try { + httpClient.close(); + } finally { + if (closeVertx) { + vertx.close(); + } + } + } + +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilder.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilder.java new file mode 100644 index 00000000000..1cb454c7df9 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilder.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.StandardHttpClientBuilder; +import io.fabric8.kubernetes.client.http.TlsVersion; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.IdentityCipherSuiteFilter; +import io.netty.handler.ssl.JdkSslContext; +import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; +import io.vertx.core.file.FileSystemOptions; +import io.vertx.core.http.HttpVersion; +import io.vertx.core.http.PoolOptions; +import io.vertx.core.http.WebSocketClientOptions; +import io.vertx.core.net.JdkSSLEngineOptions; +import io.vertx.core.net.ProxyOptions; +import io.vertx.core.net.ProxyType; +import io.vertx.core.spi.tls.SslContextFactory; +import io.vertx.ext.web.client.WebClientOptions; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import static io.fabric8.kubernetes.client.utils.HttpClientUtils.decodeBasicCredentials; +import static io.vertx.core.impl.SysProps.DISABLE_DNS_RESOLVER; + +/** + * Builder for creating Vert.x-based HTTP clients used in Kubernetes API communication. + * + *

+ * Extends {@link StandardHttpClientBuilder} to provide Vert.x-specific configuration + * including connection pooling, SSL/TLS options, proxy settings, and WebSocket support. + * + * @param the factory type for creating HTTP clients + */ +public class VertxHttpClientBuilder + extends StandardHttpClientBuilder, F, VertxHttpClientBuilder> { + + private static final int MAX_CONNECTIONS = 8192; + + // the default for etcd seems to be 3 MB, but we'll default to unlimited, so we have the same behavior across clients + private static final int MAX_WS_MESSAGE_SIZE = Integer.MAX_VALUE; + + final Vertx vertx; + private final boolean closeVertx; + + /** + * Creates a builder with optional shared Vert.x instance. + * If no shared instance is provided, creates a new one. + * + * @param clientFactory factory for creating HTTP clients + * @param sharedVertx optional shared Vert.x instance + */ + public VertxHttpClientBuilder(final F clientFactory, final Vertx sharedVertx) { + this( + clientFactory, + sharedVertx != null ? sharedVertx : createVertxInstance(), + sharedVertx == null); + } + + /** + * Creates a builder with explicit Vert.x instance and lifecycle management flag. + * + * @param clientFactory factory for creating HTTP clients + * @param vertx Vert.x instance to use + * @param closeVertx whether this builder should close the Vert.x instance + */ + VertxHttpClientBuilder(final F clientFactory, final Vertx vertx, final boolean closeVertx) { + super(clientFactory); + this.vertx = vertx; + this.closeVertx = closeVertx; + } + + /* ------------------------------------------------------------------------- */ + /** Assemble connection pool options. */ + private static PoolOptions createPoolOptions() { + return new PoolOptions() + .setHttp1MaxSize(MAX_CONNECTIONS) + .setHttp2MaxSize(MAX_CONNECTIONS); + } + + /** Apply time‑outs, redirects, HTTP/1 preference. */ + private void applyBasicHttpSettings(final WebClientOptions options) { + if (this.connectTimeout != null) { + options.setConnectTimeout((int) this.connectTimeout.toMillis()); + } + options.setFollowRedirects(this.followRedirects); + if (this.preferHttp11) { + options.setProtocolVersion(HttpVersion.HTTP_1_1); + } + } + + /** Configure proxy and potential auth interceptor. */ + private void applyProxy(final WebClientOptions options) { + if (this.proxyType == HttpClient.ProxyType.DIRECT || this.proxyAddress == null) { + return; + } + final ProxyOptions proxyOptions = new ProxyOptions() + .setHost(this.proxyAddress.getHostName()) + .setPort(this.proxyAddress.getPort()) + .setType(convertProxyType()); + + final String[] userPassword = decodeBasicCredentials(this.proxyAuthorization); + if (userPassword != null) { + proxyOptions.setUsername(userPassword[0]).setPassword(userPassword[1]); + } else { + addProxyAuthInterceptor(); + } + options.setProxyOptions(proxyOptions); + } + + /** Translate requested TLS versions (if any). */ + private String[] resolveProtocols() { + if (tlsVersions == null || tlsVersions.length == 0) { + return new String[0]; + } + return Stream.of(tlsVersions).map(TlsVersion::javaName).toArray(String[]::new); + } + + /** + * Builds the configured VertxHttpClient instance. + * + * @return configured VertxHttpClient ready for use + */ + @Override + public VertxHttpClient build() { + if (this.client != null) { + return VertxHttpClient.createWithDefaults( + this, + this.client.getClosed(), + this.client.getHttpClient(), + closeVertx); + } + + final PoolOptions poolOptions = createPoolOptions(); + final WebClientOptions httpOptions = new WebClientOptions(); + applyBasicHttpSettings(httpOptions); + applyProxy(httpOptions); + + final String[] protocols = resolveProtocols(); + if (protocols.length > 0) { + httpOptions.setEnabledSecureTransportProtocols(new HashSet<>(Arrays.asList(protocols))); + } + + if (this.sslContext != null) { + httpOptions.setSsl(true); + httpOptions.setSslEngineOptions(createSslEngineOptions(protocols)); + } + + final WebSocketClientOptions wsOptions = createWebSocketClientOptions(protocols); + + return VertxHttpClient.createWithWebSocketOptions( + this, + new AtomicBoolean(), + vertx.createHttpClient(httpOptions, poolOptions), + wsOptions, + closeVertx); + } + + /** Creates WebSocket client options with appropriate limits for Kubernetes API communication. */ + private WebSocketClientOptions createWebSocketClientOptions(final String[] protocols) { + final WebSocketClientOptions wsOptions = new WebSocketClientOptions(); + + wsOptions.setMaxConnections(MAX_CONNECTIONS); + + // the api-server does not seem to fragment messages, so the frames can be very large + wsOptions.setMaxFrameSize(MAX_WS_MESSAGE_SIZE); + wsOptions.setMaxMessageSize(MAX_WS_MESSAGE_SIZE); + + if (this.sslContext != null) { + wsOptions.setSsl(true); + wsOptions.setSslEngineOptions(createSslEngineOptions(protocols)); + } + + return wsOptions; + } + + /** + * Creates a new builder instance sharing the same Vert.x instance. + * + * @param clientFactory factory for creating HTTP clients + * @return new builder instance + */ + @Override + protected VertxHttpClientBuilder newInstance(final F clientFactory) { + return new VertxHttpClientBuilder<>(clientFactory, vertx, closeVertx); + } + + /** Maps client proxy types to Vert.x proxy types. */ + private ProxyType convertProxyType() { + switch (proxyType) { + case HTTP: + return ProxyType.HTTP; + case SOCKS4: + return ProxyType.SOCKS4; + case SOCKS5: + return ProxyType.SOCKS5; + default: + throw new KubernetesClientException("Unsupported proxy type"); + } + } + + /** Creates Vert.x instance with DNS resolver disabled to prevent Vault resolution issues. */ + private static Vertx createVertxInstance() { + // We must disable the async DNS resolver as it can cause issues when resolving the Vault instance. + // This is done using the DISABLE_DNS_RESOLVER_PROP_NAME system property. + // The DNS resolver used by vert.x is configured during the (synchronous) initialization. + // So, we just need to disable the async resolver around the Vert.x instance creation. + final String originalValue = DISABLE_DNS_RESOLVER.get(); + Vertx vertx; + try { + System.setProperty(DISABLE_DNS_RESOLVER.name, "true"); + vertx = Vertx.vertx(new VertxOptions() + .setFileSystemOptions(new FileSystemOptions().setFileCachingEnabled(false).setClassPathResolvingEnabled(false)) + .setUseDaemonThread(true)); + } finally { + // Restore the original value + if (originalValue == null) { + System.clearProperty(DISABLE_DNS_RESOLVER.name); + } else { + System.setProperty(DISABLE_DNS_RESOLVER.name, originalValue); + } + } + return vertx; + } + + /** Utility that converts the builder’s {@link #sslContext} + protocols to Vert.x {@link JdkSSLEngineOptions}. */ + private JdkSSLEngineOptions createSslEngineOptions(final String[] protocols) { + return new JdkSSLEngineOptions() { + @Override + public JdkSSLEngineOptions copy() { + return this; // immutable + } + + @Override + public SslContextFactory sslContextFactory() { + return () -> new JdkSslContext( + sslContext, + true, + null, + IdentityCipherSuiteFilter.INSTANCE, + ApplicationProtocolConfig.DISABLED, + io.netty.handler.ssl.ClientAuth.NONE, + protocols, + false); + } + }; + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientConfiguration.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientConfiguration.java new file mode 100644 index 00000000000..6719997d3b8 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientConfiguration.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.WebSocketClientOptions; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Configuration object for VertxHttpClient instantiation. + * + *

+ * Encapsulates all client creation parameters to eliminate constructor ambiguity + * that existed between two constructors with similar parameter signatures. + * This approach provides a clear and extensible way to configure VertxHttpClient instances. + *

+ * + *

+ * This class is package-private and used internally by the VertxHttpClient factory methods. + * External users should use {@link VertxHttpClientBuilder} instead of creating instances directly. + *

+ * + * @param the factory type for creating HTTP clients + * @since 7.4.0 + */ +@Builder +@Getter +class VertxHttpClientConfiguration { + + @NonNull + private final VertxHttpClientBuilder clientBuilder; + + @NonNull + private final AtomicBoolean closed; + + @NonNull + private final HttpClient httpClient; + + private final WebSocketClientOptions webSocketOptions; + + private final boolean closeVertx; + + /** + * Creates a configuration with default WebSocket options. + * + * @param clientBuilder the builder that created the client + * @param closed atomic boolean indicating if client is closed + * @param httpClient the Vert.x HTTP client instance + * @param closeVertx whether to close Vert.x instance when client closes + * @return configuration with default WebSocket settings + */ + static VertxHttpClientConfiguration withDefaultWebSocket( + VertxHttpClientBuilder clientBuilder, + AtomicBoolean closed, + HttpClient httpClient, + boolean closeVertx) { + return VertxHttpClientConfiguration. builder() + .clientBuilder(clientBuilder) + .closed(closed) + .httpClient(httpClient) + .closeVertx(closeVertx) + .build(); + } + + /** + * Creates a configuration with custom WebSocket options. + * + * @param clientBuilder the builder that created the client + * @param closed atomic boolean indicating if client is closed + * @param httpClient the Vert.x HTTP client instance + * @param webSocketOptions custom WebSocket client options + * @param closeVertx whether to close Vert.x instance when client closes + * @return configuration with custom WebSocket settings + */ + static VertxHttpClientConfiguration withCustomWebSocket( + VertxHttpClientBuilder clientBuilder, + AtomicBoolean closed, + HttpClient httpClient, + WebSocketClientOptions webSocketOptions, + boolean closeVertx) { + return VertxHttpClientConfiguration. builder() + .clientBuilder(clientBuilder) + .closed(closed) + .httpClient(httpClient) + .webSocketOptions(webSocketOptions) + .closeVertx(closeVertx) + .build(); + } +} \ No newline at end of file diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientFactory.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientFactory.java new file mode 100644 index 00000000000..20b111b7d3e --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.vertx.core.Vertx; + +/** + * Vert.x implementation of {@link io.fabric8.kubernetes.client.http.HttpClient.Factory}. + * + *

+ * When constructed with a non‑null {@link Vertx} the same instance is reused for every + * client; otherwise each {@link #newBuilder()} call will lazily create (and own) its own + * Vert.x runtime. + *

+ */ +public class VertxHttpClientFactory implements HttpClient.Factory { + + final Vertx vertx; + + /** + * Return a factory that reuses the supplied Vert.x instance. + */ + public VertxHttpClientFactory() { + this(null); + } + + /** + * Create a new instance of the factory that will reuse the provided {@link Vertx} instance. + *

+ * It's the user's responsibility to manage the lifecycle of the provided Vert.x instance. + * Operations such as close, and so on are left on hands of the user. + * + * @param vertx the Vertx instance to use. + */ + public VertxHttpClientFactory(final Vertx vertx) { + this.vertx = vertx; + } + + /** + * {@inheritDoc} – reuses the shared Vert.x when present, otherwise defers creation to the + * builder. + */ + @Override + public VertxHttpClientBuilder newBuilder() { + return new VertxHttpClientBuilder<>(this, vertx); + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequest.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequest.java new file mode 100644 index 00000000000..112d019f3ba --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequest.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.StandardHttpRequest; +import io.fabric8.kubernetes.client.http.StandardHttpRequest.BodyContent; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.streams.ReadStream; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Vert.x HTTP request implementation that bridges Fabric8's StandardHttpRequest to Vert.x HttpClient. + * Handles various body content types and provides async response processing with back-pressure control. + */ +class VertxHttpRequest { + + /** Vert.x instance for async operations */ + final Vertx vertx; + /** HTTP request options including URL, method, headers */ + private final RequestOptions options; + /** Source request containing body and metadata */ + private final StandardHttpRequest request; + + /** + * Creates a new Vert.x HTTP request wrapper. + * + * @param vertx Vert.x instance for async operations + * @param options HTTP request options (URL, method, headers) + * @param request source request with body and metadata + */ + public VertxHttpRequest(Vertx vertx, RequestOptions options, StandardHttpRequest request) { + this.vertx = vertx; + this.options = options; + this.request = request; + } + + /** + * Executes the HTTP request asynchronously and returns a response with streaming body support. + * + * @param client HTTP client to use for the request + * @param consumer consumer for processing response body chunks + * @return CompletableFuture containing the HTTP response with async body + */ + public CompletableFuture> consumeBytes(HttpClient client, + AsyncBody.Consumer> consumer) { + return client.request(options) + .compose(req -> { + // If the caller asked for 100‑continue semantics we first flush the headers, + // wait for the server to acknowledge, then stream the body. + if (request.isExpectContinue()) { + io.vertx.core.Promise promise = io.vertx.core.Promise.promise(); + + // Vert.x will invoke this handler when the server replies with 100-Continue + req.continueHandler(v -> sendBody(req, request.body()).onComplete(promise)); + + // Send just the headers – body will follow from the handler above + req.sendHead().onFailure(promise::fail); + + return promise.future(); + } + + // Normal request – send headers and body in one go + return sendBody(req, request.body()); + }) + .map(resp -> toFabricResponse(resp, consumer)) + .toCompletionStage() + .toCompletableFuture(); + } + + /** + * Sends the HTTP request with the appropriate body content type. + * Supports string, byte array, and input stream body content. + * + * @param req the Vert.x HTTP client request + * @param body the body content to send, or null for no body + * @return Future containing the HTTP response + */ + private Future sendBody(HttpClientRequest req, BodyContent body) { + if (body == null) { + return req.send(); + } + + if (body instanceof StandardHttpRequest.StringBodyContent) { + StandardHttpRequest.StringBodyContent s = (StandardHttpRequest.StringBodyContent) body; + return req.send(Buffer.buffer(s.getContent())); + } + if (body instanceof StandardHttpRequest.ByteArrayBodyContent) { + StandardHttpRequest.ByteArrayBodyContent b = (StandardHttpRequest.ByteArrayBodyContent) body; + return req.send(Buffer.buffer(b.getContent())); + } + if (body instanceof StandardHttpRequest.InputStreamBodyContent) { + StandardHttpRequest.InputStreamBodyContent i = (StandardHttpRequest.InputStreamBodyContent) body; + InputStream is = i.getContent(); + ReadStream stream = new InputStreamReadStream(this, is, req); + return req.send(stream); + } + return Future.failedFuture(new IllegalArgumentException("Unsupported body content: " + body.getClass())); + } + + /** + * Converts a Vert.x HTTP response to Fabric8's HttpResponse with async body support. + * Pauses the response stream initially to allow back-pressure control from the AsyncBody. + * + * @param resp the Vert.x HTTP response + * @param consumer consumer for processing response body chunks + * @return HttpResponse with async body bridge + */ + private HttpResponse toFabricResponse(HttpClientResponse resp, + AsyncBody.Consumer> consumer) { + + resp.pause(); // we drive back‑pressure from the AsyncBody + VertxAsyncBody asyncBody = new VertxAsyncBody(resp, consumer); + return new VertxHttpResponse(asyncBody, resp, request); + } + + /** + * Converts Vert.x MultiMap headers to standard Map> format. + * Preserves multiple values for the same header key. + * + * @param multiMap Vert.x MultiMap containing headers + * @return Map with header names as keys and value lists + */ + static Map> toHeadersMap(MultiMap multiMap) { + Map> headers = new LinkedHashMap<>(); + multiMap.names().forEach(k -> headers.put(k, multiMap.getAll(k))); + return headers; + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpResponse.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpResponse.java new file mode 100644 index 00000000000..802b182ab2c --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxHttpResponse.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.StandardHttpHeaders; +import io.vertx.core.http.HttpClientResponse; + +import java.util.Optional; + +/** + * Vert.x 5 implementation of {@link HttpResponse} for the Fabric8 Kubernetes Client. + * + *

+ * Bridges Vert.x 5's {@link HttpClientResponse} with Fabric8's HTTP abstraction, + * providing case-insensitive headers and async body streaming for Kubernetes operations + * like pod logs, exec sessions, and watch operations. + * + *

+ * Thread-safe and immutable once constructed. The {@link AsyncBody} must be + * consumed or cancelled to prevent resource leaks. + * + * @since 6.0.0 + */ +public class VertxHttpResponse extends StandardHttpHeaders implements HttpResponse { + /** The async body for streaming consumption. Must be consumed or cancelled. */ + private final AsyncBody result; + + /** The underlying Vert.x response providing status code and headers. */ + private final HttpClientResponse resp; + + /** The original request for correlation and debugging. */ + private final HttpRequest request; + + /** + * Constructs a VertxHttpResponse bridging Vert.x 5 and Fabric8 client APIs. + * Headers are converted to case-insensitive format. + * + * @param result the async body, must be consumed or cancelled + * @param resp the underlying Vert.x response + * @param request the original request for correlation + */ + VertxHttpResponse(AsyncBody result, HttpClientResponse resp, HttpRequest request) { + super(VertxHttpRequest.toHeadersMap(resp.headers())); + this.result = result; + this.resp = resp; + this.request = request; + } + + /** + * Returns the HTTP status code from the underlying Vert.x response. + * + * @return the HTTP status code (e.g., 200, 404, 500) + */ + @Override + public int code() { + return resp.statusCode(); + } + + /** + * Returns the async response body for streaming consumption. + * + *

+ * Provides non-blocking access with backpressure support, ideal for + * streaming Kubernetes operations like pod logs, exec sessions, and watch operations. + * The body must be consumed or cancelled to prevent resource leaks. + * + * @return the async body for streaming data consumption + */ + @Override + public AsyncBody body() { + return result; + } + + /** + * Returns the original HTTP request that initiated this response. + * Useful for debugging, correlation, and retry logic. + * + * @return the original HTTP request + */ + @Override + public HttpRequest request() { + return request; + } + + /** + * Returns the previous response in an HTTP redirect chain. + * + *

+ * Currently always returns empty as redirect chain tracking is not implemented. + * Redirects are rare in Kubernetes API contexts. + * + * @return empty Optional + */ + @Override + public Optional> previousResponse() { + return Optional.empty(); + } +} diff --git a/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxWebSocket.java b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxWebSocket.java new file mode 100644 index 00000000000..ee5efc7fff6 --- /dev/null +++ b/httpclient-vertx-5/src/main/java/io/fabric8/kubernetes/client/vertx/VertxWebSocket.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.WebSocket; +import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClosedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.ProtocolException; +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Vert.x 5 WebSocket adapter implementing Fabric8's {@link WebSocket} SPI with backpressure support. + * + *

+ * This class bridges Vert.x's native WebSocket implementation to Fabric8's WebSocket abstraction, + * enabling Kubernetes WebSocket operations like exec, port-forward, logs streaming, and watch APIs. + * + *

Thread Safety

+ *

+ * Thread-safe for concurrent access. All Vert.x callbacks execute on the event-loop thread, + * while public methods may be called from any thread. Internal state is protected using atomic operations. + * + *

Backpressure Protocol

+ *

+ * Implements flow control to prevent overwhelming the consumer: + *

    + *
  • WebSocket pauses after each message delivery
  • + *
  • Consumer must call {@link #request()} to receive the next frame
  • + *
  • Violating this protocol will cause the connection to stall
  • + *
+ * + *

Error Handling

+ *

+ * Translates Vert.x-specific exceptions to standard Java types: + *

    + *
  • {@link CorruptedWebSocketFrameException} → {@link ProtocolException}
  • + *
  • {@link HttpClosedException} → {@link IOException}
  • + *
+ */ +class VertxWebSocket implements WebSocket { + + private static final Logger LOG = LoggerFactory.getLogger(VertxWebSocket.class); + + /** The underlying Vert.x WebSocket providing the network transport */ + private final io.vertx.core.http.WebSocket webSocket; + + /** Atomic counter tracking bytes pending transmission for backpressure control */ + private final AtomicInteger pendingBytesCount = new AtomicInteger(); + + /** Callback handler for WebSocket lifecycle events and message delivery */ + private final Listener eventListener; + + /** + * Creates a new Vert.x WebSocket adapter. + * + * @param webSocket the underlying Vert.x WebSocket connection + * @param eventListener callback handler for WebSocket events and messages + */ + VertxWebSocket(final io.vertx.core.http.WebSocket webSocket, final Listener eventListener) { + this.webSocket = webSocket; + this.eventListener = eventListener; + } + + /** + * Initializes WebSocket handlers and notifies the listener that the connection is ready. + * Called by the owning HttpClient once the underlying Vert.x WebSocket is established. + */ + void initHandlers() { + setupEventHandlers(); + eventListener.onOpen(this); + } + + /** + * Registers Vert.x event handlers and translates them to Fabric8 WebSocket callbacks. + * Implements backpressure by pausing after each message until {@link #request()} is called. + */ + private void setupEventHandlers() { + // Handle binary messages (e.g., exec terminal data) + webSocket.binaryMessageHandler(buffer -> { + webSocket.pause(); + eventListener.onMessage(this, ByteBuffer.wrap(buffer.getBytes())); + }); + + // Handle text messages (e.g., JSON watch events, logs) + webSocket.textMessageHandler(text -> { + webSocket.pause(); + eventListener.onMessage(this, text); + }); + + // Handle connection close (use end handler for proper frame ordering) + webSocket.endHandler(unused -> eventListener.onClose(this, webSocket.closeStatusCode(), webSocket.closeReason())); + + // Handle connection errors with proper cleanup + webSocket.exceptionHandler(error -> { + try { + eventListener.onError(this, mapVertxErrorToStandardException(error)); + } finally { + // Ensure connection is closed after error notification + if (!webSocket.isClosed()) { + webSocket.close(); + } + } + }); + } + + /** + * Maps Vert.x-specific exceptions to standard Java exception types expected by Fabric8 SPI. + * + * @param vertxError the original Vert.x exception + * @return a standard Java exception suitable for the WebSocket SPI + */ + private static Throwable mapVertxErrorToStandardException(final Throwable vertxError) { + if (vertxError instanceof CorruptedWebSocketFrameException) { + return new ProtocolException(vertxError.getMessage()).initCause(vertxError); + } + if (vertxError instanceof HttpClosedException) { + return new IOException("WebSocket connection closed unexpectedly", vertxError); + } + return vertxError; + } + + /** + * Logs failures from asynchronous WebSocket operations that cannot be propagated to the caller. + * + * @param asyncOperation the asynchronous operation to monitor for failures + */ + private void logAsyncOperationFailures(final Future asyncOperation) { + asyncOperation.onFailure(error -> LOG.error("Asynchronous WebSocket operation failed", error)); + } + + /** + * Sends binary data over the WebSocket connection. + * + *

+ * Optimizes for zero-copy when possible by using array-backed ByteBuffers directly. + * For direct ByteBuffers, copies data to avoid blocking the event loop. + * + *

+ * Updates the pending bytes counter for backpressure monitoring and supports + * both synchronous and asynchronous transmission completion. + * + * @param buffer the binary data to send; position will be advanced to limit + * @return true if the message was queued successfully, false if immediate failure occurred + */ + @Override + public boolean send(final ByteBuffer buffer) { + final Buffer vertxBuffer = convertToVertxBuffer(buffer); + final int messageLength = vertxBuffer.length(); + + // Track pending bytes for backpressure + pendingBytesCount.addAndGet(messageLength); + + final Future writeOperation = webSocket.writeBinaryMessage(vertxBuffer); + + // Handle immediate failure + if (writeOperation.failed()) { + pendingBytesCount.addAndGet(-messageLength); + LOG.error("WebSocket binary message send failed immediately", writeOperation.cause()); + return false; + } + + // Handle synchronous success + if (writeOperation.isComplete()) { + pendingBytesCount.addAndGet(-messageLength); + return true; + } + + // Handle asynchronous completion + writeOperation.onComplete(result -> { + if (result.failed()) { + LOG.error("WebSocket binary message send failed asynchronously", result.cause()); + } + pendingBytesCount.addAndGet(-messageLength); + }); + + return true; + } + + /** + * Converts a Java ByteBuffer to a Vert.x Buffer, optimizing for zero-copy when possible. + * + * @param buffer the source ByteBuffer; position will be advanced to limit + * @return a Vert.x Buffer containing the data + */ + private Buffer convertToVertxBuffer(final ByteBuffer buffer) { + if (buffer.hasArray()) { + // Zero-copy optimization for array-backed buffers + final Buffer vertxBuffer = Buffer.buffer(buffer.remaining()).setBytes(0, buffer); + buffer.position(buffer.limit()); + return vertxBuffer; + } else { + // Copy for direct buffers to avoid blocking event loop + final Buffer vertxBuffer = Buffer.buffer(buffer.remaining()); + final byte[] tempArray = new byte[buffer.remaining()]; + buffer.get(tempArray); + return vertxBuffer.appendBytes(tempArray); + } + } + + /** + * Initiates a graceful WebSocket close handshake with the specified status code and reason. + * + *

+ * Sends a close frame to the remote endpoint and ensures the end handler can fire + * by requesting one final frame. This allows proper cleanup and close event delivery. + * + * @param code the WebSocket close status code (e.g., 1000 for normal closure) + * @param reason human-readable explanation for the closure (maybe null) + * @return true if close was initiated, false if connection was already closed + */ + @Override + public boolean sendClose(final int code, final String reason) { + if (webSocket.isClosed()) { + return false; + } + + logAsyncOperationFailures(webSocket.close((short) code, reason)); + + // Request one frame to ensure the end handler fires for proper cleanup + webSocket.fetch(1); + + return true; + } + + /** + * Returns the number of bytes currently queued for transmission. + * + *

+ * This value represents the backpressure state of the WebSocket connection. + * High values indicate the remote endpoint may be slower at consuming data + * than this endpoint is producing it. + * + * @return the number of bytes pending transmission + */ + @Override + public long queueSize() { + return pendingBytesCount.get(); + } + + /** + * Requests delivery of the next WebSocket frame, resuming the paused connection. + * + *

+ * This method implements the backpressure protocol by allowing the consumer + * to control the flow of incoming messages. Must be called after each message + * is processed to receive later frames. + * + *

+ * Failure to call this method will cause the WebSocket to remain paused + * indefinitely after the first message delivery. + */ + @Override + public void request() { + webSocket.fetch(1); + } +} diff --git a/httpclient-vertx-5/src/main/resources/META-INF/services/io.fabric8.kubernetes.client.http.HttpClient$Factory b/httpclient-vertx-5/src/main/resources/META-INF/services/io.fabric8.kubernetes.client.http.HttpClient$Factory new file mode 100644 index 00000000000..d9bc49c64e0 --- /dev/null +++ b/httpclient-vertx-5/src/main/resources/META-INF/services/io.fabric8.kubernetes.client.http.HttpClient$Factory @@ -0,0 +1,17 @@ +# +# Copyright (C) 2015 Red Hat, Inc. +# +# 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. +# + +io.fabric8.kubernetes.client.vertx.VertxHttpClientFactory \ No newline at end of file diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/AsyncInputReaderTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/AsyncInputReaderTest.java new file mode 100644 index 00000000000..e0352575097 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/AsyncInputReaderTest.java @@ -0,0 +1,352 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AsyncInputReader") +class AsyncInputReaderTest { + + private Vertx vertx; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + } + + @AfterEach + void tearDown() { + if (vertx != null) { + vertx.close(); + } + } + + @Test + @DisplayName("Should read data chunk from InputStream") + void shouldReadDataChunk() throws Exception { + String testData = "Hello, World!"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + AtomicReference resultBuffer = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Future future = reader.readNextChunk(); + future.onComplete(result -> { + if (result.succeeded()) { + resultBuffer.set(result.result()); + } else { + errorRef.set(result.cause()); + } + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(errorRef.get()).isNull(); + assertThat(resultBuffer.get()).isNotNull(); + assertThat(resultBuffer.get().toString()).hasToString(testData); + } + + @Test + @DisplayName("Should return null on EOF") + void shouldReturnNullOnEOF() throws Exception { + InputStream inputStream = new ByteArrayInputStream(new byte[0]); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + AtomicReference resultBuffer = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Future future = reader.readNextChunk(); + future.onComplete(result -> { + if (result.succeeded()) { + resultBuffer.set(result.result()); + } else { + errorRef.set(result.cause()); + } + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(errorRef.get()).isNull(); + assertThat(resultBuffer.get()).isNull(); + } + + @Test + @DisplayName("Should handle IOException from InputStream") + void shouldHandleIOException() throws Exception { + InputStream inputStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Test IO exception"); + } + + @Override + public int read(byte[] b) throws IOException { + throw new IOException("Test IO exception"); + } + }; + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + AtomicReference resultBuffer = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Future future = reader.readNextChunk(); + future.onComplete(result -> { + if (result.succeeded()) { + resultBuffer.set(result.result()); + } else { + errorRef.set(result.cause()); + } + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(resultBuffer.get()).isNull(); + assertThat(errorRef.get()).isNotNull(); + assertThat(errorRef.get()).isInstanceOf(IOException.class); + assertThat(errorRef.get().getMessage()).isEqualTo("Test IO exception"); + } + + @Test + @DisplayName("Should handle large data streams in chunks") + void shouldHandleLargeDataInChunks() throws Exception { + // Create 5KB of test data (larger than CHUNK_SIZE of 2048) + byte[] largeData = new byte[5 * 1024]; + for (int i = 0; i < largeData.length; i++) { + largeData[i] = (byte) (i % 256); + } + InputStream inputStream = new ByteArrayInputStream(largeData); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + AtomicReference resultBuffer = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Future future = reader.readNextChunk(); + future.onComplete(result -> { + if (result.succeeded()) { + resultBuffer.set(result.result()); + } else { + errorRef.set(result.cause()); + } + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(errorRef.get()).isNull(); + assertThat(resultBuffer.get()).isNotNull(); + // Should read-only up to CHUNK_SIZE (2048 bytes) + assertThat(resultBuffer.get().length()).isEqualTo(2048); + } + + @Test + @DisplayName("Should read multiple chunks sequentially") + void shouldReadMultipleChunksSequentially() throws Exception { + String testData = "A".repeat(4096); // 4KB of data (2 chunks) + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + // Read first chunk + AtomicReference firstBuffer = new AtomicReference<>(); + CountDownLatch firstLatch = new CountDownLatch(1); + + reader.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + firstBuffer.set(result.result()); + } + firstLatch.countDown(); + }); + + assertThat(firstLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(firstBuffer.get()).isNotNull(); + assertThat(firstBuffer.get().length()).isEqualTo(2048); + + // Read second chunk + AtomicReference secondBuffer = new AtomicReference<>(); + CountDownLatch secondLatch = new CountDownLatch(1); + + reader.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + secondBuffer.set(result.result()); + } + secondLatch.countDown(); + }); + + assertThat(secondLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondBuffer.get()).isNotNull(); + assertThat(secondBuffer.get().length()).isEqualTo(2048); + + // Read third chunk (should be EOF) + AtomicReference thirdBuffer = new AtomicReference<>(); + CountDownLatch thirdLatch = new CountDownLatch(1); + + reader.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + thirdBuffer.set(result.result()); + } + thirdLatch.countDown(); + }); + + assertThat(thirdLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(thirdBuffer.get()).isNull(); // EOF + } + + @Test + @DisplayName("Should reuse read buffer across multiple reads") + void shouldReuseReadBuffer() throws Exception { + String testData1 = "First chunk data"; + String testData2 = "Second chunk data"; + + // First read + InputStream inputStream1 = new ByteArrayInputStream(testData1.getBytes()); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream1); + + AtomicReference firstResult = new AtomicReference<>(); + CountDownLatch firstLatch = new CountDownLatch(1); + + reader.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + firstResult.set(result.result()); + } + firstLatch.countDown(); + }); + + assertThat(firstLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(firstResult.get()).isNotNull(); + + // Create new reader with different stream but same instance should reuse buffer + InputStream inputStream2 = new ByteArrayInputStream(testData2.getBytes()); + AsyncInputReader reader2 = new AsyncInputReader(vertx, inputStream2); + + AtomicReference secondResult = new AtomicReference<>(); + CountDownLatch secondLatch = new CountDownLatch(1); + + reader2.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + secondResult.set(result.result()); + } + secondLatch.countDown(); + }); + + assertThat(secondLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondResult.get()).isNotNull(); + assertThat(secondResult.get().toString()).hasToString(testData2); + } + + @Test + @DisplayName("Should handle partial reads correctly") + void shouldHandlePartialReads() throws Exception { + // Custom InputStream that returns only 1 byte at a time + AsyncInputReader reader = getAsyncInputReader(); + + AtomicReference resultBuffer = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + reader.readNextChunk().onComplete(result -> { + if (result.succeeded()) { + resultBuffer.set(result.result()); + } + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(resultBuffer.get()).isNotNull(); + assertThat(resultBuffer.get().length()).isEqualTo(1); + assertThat(resultBuffer.get().toString()).hasToString("H"); + } + + private AsyncInputReader getAsyncInputReader() { + InputStream inputStream = new InputStream() { + private final byte[] data = "Hello".getBytes(); + private int position = 0; + + @Override + public int read() { + return (position < data.length ? data[position++] : -1) & 0xFF; + } + + @Override + public int read(byte[] b) { + if (position >= data.length) { + return -1; + } + // Only return 1 byte at a time + b[0] = data[position++]; + return 1; + } + + @Override + public int read(byte[] b, int off, int len) { + return read(b); // Delegate to the single-byte read method + } + }; + + return new AsyncInputReader(vertx, inputStream); + } + + @Test + @DisplayName("Should handle concurrent reads safely") + void shouldHandleConcurrentReadsSafely() throws Exception { + String testData = "Concurrent test data"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + AsyncInputReader reader = new AsyncInputReader(vertx, inputStream); + + CountDownLatch completionLatch = new CountDownLatch(2); + AtomicReference result1 = new AtomicReference<>(); + AtomicReference result2 = new AtomicReference<>(); + + // Start two concurrent reads + reader.readNextChunk().onComplete(ar -> { + if (ar.succeeded()) { + result1.set(ar.result()); + } + completionLatch.countDown(); + }); + + reader.readNextChunk().onComplete(ar -> { + if (ar.succeeded()) { + result2.set(ar.result()); + } + completionLatch.countDown(); + }); + + assertThat(completionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // One of the reads should succeed with data, the other should get EOF or different data + // This tests that the buffer is handled safely under concurrent access + boolean oneSucceeded = (result1.get() != null && result1.get().length() > 0) || + (result2.get() != null && result2.get().length() > 0); + assertThat(oneSucceeded).isTrue(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/HttpBodyStreamTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/HttpBodyStreamTest.java new file mode 100644 index 00000000000..9c36ee50fdb --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/HttpBodyStreamTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.StreamResetException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class HttpBodyStreamTest { + + private Vertx vertx; + private HttpServer server; + private volatile Handler requestHandler; + private HttpClient.Factory clientFactory = new VertxHttpClientFactory(); + + @BeforeEach + public void before() throws Exception { + vertx = Vertx.vertx(); + server = vertx.createHttpServer().requestHandler(req -> { + Handler handler = requestHandler; + if (handler != null) { + handler.handle(req); + } else { + req.response().setStatusCode(404).end(); + } + }); + server.listen(8080).toCompletionStage().toCompletableFuture().get(20, TimeUnit.SECONDS); + } + + @AfterEach + public void after() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(20, TimeUnit.SECONDS); + } + + @Test + void testStreamFlowControl() throws Exception { + + AtomicInteger bytesSent = new AtomicInteger(); + AtomicInteger data = new AtomicInteger('A'); + AtomicInteger bytesReceived = new AtomicInteger(); + + requestHandler = req -> { + req.pause(); + AtomicInteger last = new AtomicInteger(); + vertx.setPeriodic(100, id -> { + int val = bytesSent.get(); + if (data.get() != -1 && val - last.get() == 0) { + req.resume(); + data.set(-1); + } + last.set(val); + }); + req.handler(chunk -> { + bytesReceived.addAndGet(chunk.length()); + }); + req.endHandler(v -> { + req.response().end(); + }); + }; + HttpClient.Builder builder = clientFactory.newBuilder(); + HttpClient client = builder.build(); + + HttpRequest request = client.newHttpRequestBuilder().uri("http://localhost:8080").post("text/plain", new InputStream() { + @Override + public int read() throws IOException { + int ret = data.get(); + if (ret != -1) { + bytesSent.incrementAndGet(); + } + return ret; + } + }, -1L).build(); + HttpResponse resp = client.sendAsync(request, String.class).get(10, TimeUnit.SECONDS); + assertEquals(bytesSent.get(), bytesReceived.get()); + } + + @Test + void testStreamError() throws Exception { + + requestHandler = req -> { + }; + HttpClient.Builder builder = clientFactory.newBuilder(); + HttpClient client = builder.build(); + + HttpRequest request = client.newHttpRequestBuilder().uri("http://localhost:8080").post("text/plain", new InputStream() { + int bytesSent = 0; + + @Override + public int read() throws IOException { + if (bytesSent++ < 10_000) { + return 'A'; + } else { + throw new IOException("Cannot read"); + } + } + }, -1L).build(); + try { + client.sendAsync(request, String.class).get(10, TimeUnit.SECONDS); + fail(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + assertEquals(StreamResetException.class, cause.getClass()); + } + } + + @Test + void testStackOverflow() throws Exception { + + requestHandler = req -> { + AtomicInteger size = new AtomicInteger(); + req.handler(buff -> size.addAndGet(buff.length())); + req.endHandler(v -> { + req.response().end("" + size); + }); + }; + HttpClient.Builder builder = clientFactory.newBuilder(); + HttpClient client = builder.build(); + + int contentLength = 128_000; + InputStream is = new InputStream() { + int remaining = contentLength; + + @Override + public int read() { + if (remaining == 0) { + return -1; + } + return 'A'; + } + + @Override + public int read(byte[] b, int off, int len) { + if (remaining > 0) { + int size = Math.min(len, remaining); + remaining -= size; + for (int i = 0; i < size; i++) { + b[off++] = 'A'; + } + return size; + } else { + return -1; + } + } + }; + + HttpRequest request = client.newHttpRequestBuilder().uri("http://localhost:8080").post("text/plain", is, -1).build(); + HttpResponse resp = client.sendAsync(request, String.class).get(10, TimeUnit.SECONDS); + int val = Integer.parseInt(resp.body()); + assertEquals(contentLength, val); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStreamTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStreamTest.java new file mode 100644 index 00000000000..c320071c128 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/InputStreamReadStreamTest.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.streams.ReadStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DisplayName("InputStreamReadStream") +class InputStreamReadStreamTest { + + private Vertx vertx; + private VertxHttpRequest vertxHttpRequest; + + @Mock + private HttpClientRequest httpRequest; + + @Captor + private ArgumentCaptor resetCodeCaptor; + + @Captor + private ArgumentCaptor resetCauseCaptor; + + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + vertx = Vertx.vertx(); + vertxHttpRequest = new VertxHttpRequest(vertx, null, null); + } + + @AfterEach + void tearDown() throws Exception { + if (mocks != null) { + mocks.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + @Test + @DisplayName("Should read data chunks from InputStream and emit them as buffers") + void shouldReadDataChunksAndEmitBuffers() throws Exception { + String testData = "Hello, World! This is test data for the InputStreamReadStream."; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .handler(receivedBuffers::add) + .endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + + // Verify that all data was read correctly + StringBuilder result = new StringBuilder(); + for (Buffer buffer : receivedBuffers) { + result.append(buffer.toString()); + } + assertThat(result.toString()).hasToString(testData); + } + + @Test + @DisplayName("Should handle empty InputStream") + void shouldHandleEmptyInputStream() throws Exception { + InputStream inputStream = new ByteArrayInputStream(new byte[0]); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .handler(receivedBuffers::add) + .endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isEmpty(); + } + + @Test + @DisplayName("Should handle IOException from InputStream") + void shouldHandleIOException() throws Exception { + InputStream inputStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Test IO exception"); + } + }; + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + AtomicReference exceptionRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .exceptionHandler(ex -> { + exceptionRef.set(ex); + latch.countDown(); + }) + .handler(buffer -> { + // Should not be called + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(exceptionRef.get()).isNotNull(); + + await().atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> verify(httpRequest, times(1)).reset(anyLong(), any(Throwable.class))); + } + + @Test + @DisplayName("Should support pause and resume operations") + void shouldSupportPauseAndResume() throws Exception { + String testData = "Test data for pause/resume functionality"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + readStream.handler(receivedBuffers::add); + readStream.endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + // Pause immediately (this should still work) + readStream.pause(); + + // Resume to continue reading + readStream.resume(); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + } + + @Test + @DisplayName("Should support fetch operation") + void shouldSupportFetch() throws Exception { + String testData = "Test data for fetch functionality"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .handler(receivedBuffers::add) + .endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + readStream.fetch(10); // Fetch some amount + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + } + + @Test + @DisplayName("Should handle large data streams") + void shouldHandleLargeDataStreams() throws Exception { + // Create 10KB of test data + byte[] largeData = new byte[10 * 1024]; + for (int i = 0; i < largeData.length; i++) { + largeData[i] = (byte) (i % 256); + } + InputStream inputStream = new ByteArrayInputStream(largeData); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .handler(receivedBuffers::add) + .endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + + // Verify all data was read + int totalBytes = receivedBuffers.stream().mapToInt(Buffer::length).sum(); + assertThat(totalBytes).isEqualTo(largeData.length); + } + + @Test + @DisplayName("Should allow setting null handler to stop reading") + void shouldAllowNullHandler() { + String testData = "Test data"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + // Set a handler first, then set to null + readStream.handler(buffer -> { + // Initial handler + }); + + ReadStream result = readStream.handler(null); + + assertThat(result).isSameAs(readStream); + // No exception should be thrown + } + + @Test + @DisplayName("Should call exception handler when provided") + void shouldCallExceptionHandlerWhenProvided() throws Exception { + InputStream inputStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Simulated IO error"); + } + }; + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + AtomicReference caughtException = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + readStream + .exceptionHandler(ex -> { + caughtException.set(ex); + latch.countDown(); + }) + .handler(buffer -> { + // Should not be called due to exception + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(caughtException.get()).isNotNull(); + assertThat(caughtException.get()).isInstanceOf(IOException.class); + + await().atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> verify(httpRequest).reset(resetCodeCaptor.capture(), resetCauseCaptor.capture())); + assertThat(resetCodeCaptor.getValue()).isZero(); + assertThat(resetCauseCaptor.getValue()).isSameAs(caughtException.get()); + } + + @Test + @DisplayName("Should handle backpressure correctly") + void shouldHandleBackpressure() throws Exception { + // Slow consuming input stream + String testData = "A".repeat(5000); // 5KB of data + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + //Handler with artificial delay to simulate slow consumption + readStream + .handler(buffer -> { + handlerCallCount.incrementAndGet(); + receivedBuffers.add(buffer); + // Small delay to simulate processing + await().pollDelay(Duration.ofMillis(1)).until(() -> true); + }) + .endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + assertThat(handlerCallCount.get()).isGreaterThan(0); + } + + @Test + @DisplayName("Should handle pause and resume with proper flow control") + void shouldHandlePauseResumeFlowControl() throws Exception { + String testData = "Test data for pause functionality"; + InputStream inputStream = new ByteArrayInputStream(testData.getBytes()); + InputStreamReadStream readStream = new InputStreamReadStream(vertxHttpRequest, inputStream, httpRequest); + + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + // Set handler and end handler first + readStream.handler(receivedBuffers::add); + readStream.endHandler(v -> { + endCalled.set(true); + latch.countDown(); + }); + + // Pause and resume to test flow control + readStream.pause(); + readStream.resume(); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endCalled.get()).isTrue(); + assertThat(receivedBuffers).isNotEmpty(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/SslTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/SslTest.java new file mode 100644 index 00000000000..2649b5cd0ba --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/SslTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.net.SelfSignedCertificate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SslTest { + + private Vertx vertx; + private HttpServer server; + private volatile Handler requestHandler; + private HttpClient.Factory clientFactory = new VertxHttpClientFactory(); + private TrustManager[] trustManagers; + + @BeforeEach + public void before() throws Exception { + SelfSignedCertificate cert = SelfSignedCertificate.create(); + vertx = Vertx.vertx(); + server = vertx.createHttpServer(new HttpServerOptions() + .setSsl(true) + .setKeyCertOptions(cert.keyCertOptions())) + .requestHandler(req -> { + Handler handler = requestHandler; + if (handler != null) { + handler.handle(req); + } else { + req.response().setStatusCode(404).end(); + } + }); + server.listen(8443).toCompletionStage().toCompletableFuture().get(20, TimeUnit.SECONDS); + TrustManagerFactory tmf = cert.trustOptions().getTrustManagerFactory(vertx); + trustManagers = tmf.getTrustManagers(); + } + + @AfterEach + public void after() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(20, TimeUnit.SECONDS); + } + + @Test + void testGet() throws Exception { + requestHandler = req -> { + req.response().end("OK"); + }; + HttpClient.Builder builder = clientFactory.newBuilder().sslContext(null, trustManagers); + HttpClient client = builder.build(); + HttpRequest request = client.newHttpRequestBuilder().uri("https://localhost:8443").build(); + HttpResponse resp = client.sendAsync(request, String.class).get(10, TimeUnit.SECONDS); + assertEquals(200, resp.code()); + assertEquals("OK", resp.bodyString()); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuardTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuardTest.java new file mode 100644 index 00000000000..dc704938a3c --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StackBasedRecursionGuardTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("StackBasedRecursionGuard") +class StackBasedRecursionGuardTest { + + @Test + @DisplayName("allows recursion below the limit") + void belowLimit() { + StackBasedRecursionGuard guard = new StackBasedRecursionGuard(); + try { + assertThat(guard.enter()).isTrue(); + assertThat(guard.getCurrentDepth()).isEqualTo(1); + } finally { + guard.exit(); + } + assertThat(guard.getCurrentDepth()).isEqualTo(0); + } + + @Test + @DisplayName("returns false when depth exceeds limit") + void exceedsLimit() { + StackBasedRecursionGuard guard = new StackBasedRecursionGuard(); + AtomicBoolean hitFalse = new AtomicBoolean(false); + + // Manually create a scenario where we exceed the limit + for (int i = 0; i < 8; i++) { + assertThat(guard.enter()).isTrue(); + } + + // The 9th enter should return false + assertThat(guard.enter()).isFalse(); + hitFalse.set(true); + + // Clean up all levels (including the failed one) + for (int i = 0; i < 9; i++) { + guard.exit(); + } + + assertThat(hitFalse.get()).isTrue(); + assertThat(guard.getCurrentDepth()).isEqualTo(0); + } + + @Test + @DisplayName("tracks depth correctly") + void tracksDepth() { + StackBasedRecursionGuard guard = new StackBasedRecursionGuard(); + + assertThat(guard.getCurrentDepth()).isZero(); + + assertThat(guard.enter()).isTrue(); // depth = 1 + assertThat(guard.getCurrentDepth()).isEqualTo(1); + + assertThat(guard.enter()).isTrue(); // depth = 2 + assertThat(guard.getCurrentDepth()).isEqualTo(2); + + guard.exit(); // depth = 1 + assertThat(guard.getCurrentDepth()).isEqualTo(1); + + guard.exit(); // depth = 0 + assertThat(guard.getCurrentDepth()).isZero(); + } + + @Test + @DisplayName("handles nested enter/exit correctly") + void nestedEnterExit() { + StackBasedRecursionGuard guard = new StackBasedRecursionGuard(); + + // First level + assertThat(guard.enter()).isTrue(); + try { + assertThat(guard.getCurrentDepth()).isEqualTo(1); + + // Second level + assertThat(guard.enter()).isTrue(); + try { + assertThat(guard.getCurrentDepth()).isEqualTo(2); + + // Third level + assertThat(guard.enter()).isTrue(); + try { + assertThat(guard.getCurrentDepth()).isEqualTo(3); + } finally { + guard.exit(); + } + assertThat(guard.getCurrentDepth()).isEqualTo(2); + } finally { + guard.exit(); + } + assertThat(guard.getCurrentDepth()).isEqualTo(1); + } finally { + guard.exit(); + } + assertThat(guard.getCurrentDepth()).isZero(); + } + + @Test + @DisplayName("resets depth after failed enter") + void resetsAfterFailedEnter() { + StackBasedRecursionGuard guard = new StackBasedRecursionGuard(); + + // Fill up to the limit + for (int i = 0; i < 8; i++) { + assertThat(guard.enter()).isTrue(); + } + assertThat(guard.getCurrentDepth()).isEqualTo(8); + + // Next enter should fail but still increment depth internally + assertThat(guard.enter()).isFalse(); + assertThat(guard.getCurrentDepth()).isEqualTo(9); + + // Exit all levels + for (int i = 0; i < 9; i++) { + guard.exit(); + } + assertThat(guard.getCurrentDepth()).isZero(); + + // Should work again + assertThat(guard.enter()).isTrue(); + guard.exit(); + assertThat(guard.getCurrentDepth()).isZero(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StreamFlowControllerTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StreamFlowControllerTest.java new file mode 100644 index 00000000000..77419d7d704 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/StreamFlowControllerTest.java @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("StreamFlowController") +class StreamFlowControllerTest { + + private Vertx vertx; + private Context context; + private StreamFlowController controller; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + context = vertx.getOrCreateContext(); + controller = new StreamFlowController(); + } + + @AfterEach + void tearDown() { + if (vertx != null) { + vertx.close(); + } + } + + @Test + @DisplayName("Should not be initialized before initialize() is called") + void shouldNotBeInitializedBeforeInitialize() { + assertThat(controller.isInitialized()).isFalse(); + } + + @Test + @DisplayName("Should be initialized after initialize() is called") + void shouldBeInitializedAfterInitialize() { + controller.initialize(context, () -> { + }); + + assertThat(controller.isInitialized()).isTrue(); + } + + @Test + @DisplayName("Should handle data chunks and end handler in Vert.x context") + void shouldHandleDataChunksAndEndHandlerInVertxContext() throws Exception { + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endHandlerCalled = new AtomicBoolean(false); + CountDownLatch dataLatch = new CountDownLatch(2); + CountDownLatch endLatch = new CountDownLatch(1); + + vertx.runOnContext(v -> { + controller.initialize(context, () -> { + }); + controller.configureDataHandler(buffer -> { + receivedBuffers.add(buffer); + dataLatch.countDown(); + }); + controller.setEndHandler(h -> { + endHandlerCalled.set(true); + endLatch.countDown(); + }); + + // Write data chunks + controller.writeChunk(Buffer.buffer("Hello")); + controller.writeChunk(Buffer.buffer("World")); + + // Write end sentinel + controller.writeEndSentinel(); + }); + + assertThat(dataLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedBuffers).hasSize(2); + assertThat(receivedBuffers.get(0).toString()).hasToString("Hello"); + assertThat(receivedBuffers.get(1).toString()).hasToString("World"); + assertThat(endHandlerCalled.get()).isTrue(); + } + + @Test + @DisplayName("Should handle null handlers gracefully") + void shouldHandleNullHandlersGracefully() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + + vertx.runOnContext(v -> { + controller.initialize(context, () -> { + }); + controller.configureDataHandler(null); + controller.setEndHandler(null); + + // Should not throw exceptions + controller.writeChunk(Buffer.buffer("test")); + controller.writeEndSentinel(); + + latch.countDown(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + @DisplayName("Should handle multiple end sentinels") + void shouldHandleMultipleEndSentinels() throws Exception { + AtomicBoolean endHandlerCalled = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + vertx.runOnContext(v -> { + controller.initialize(context, () -> { + }); + controller.configureDataHandler(buffer -> { + }); + controller.setEndHandler(h -> { + endHandlerCalled.set(true); + latch.countDown(); + }); + + // Write multiple end sentinels - should call handler for first one + controller.writeEndSentinel(); + controller.writeEndSentinel(); + }); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endHandlerCalled.get()).isTrue(); + } + + @Test + @DisplayName("Should handle complex flow control operations") + void shouldHandleComplexFlowControlOperations() throws Exception { + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endHandlerCalled = new AtomicBoolean(false); + CountDownLatch dataLatch = new CountDownLatch(3); + CountDownLatch endLatch = new CountDownLatch(1); + + vertx.runOnContext(v -> { + controller.initialize(context, () -> { + }); + controller.configureDataHandler(buffer -> { + receivedBuffers.add(buffer); + dataLatch.countDown(); + }); + controller.setEndHandler(h -> { + endHandlerCalled.set(true); + endLatch.countDown(); + }); + + // Complex sequence of operations + controller.writeChunk(Buffer.buffer("first")); + controller.pause(); + controller.writeChunk(Buffer.buffer("second")); + controller.resume(); + controller.fetch(1); + controller.writeChunk(Buffer.buffer("third")); + controller.writeEndSentinel(); + }); + + assertThat(dataLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedBuffers).hasSize(3); + assertThat(receivedBuffers.get(0).toString()).hasToString("first"); + assertThat(receivedBuffers.get(1).toString()).hasToString("second"); + assertThat(receivedBuffers.get(2).toString()).hasToString("third"); + assertThat(endHandlerCalled.get()).isTrue(); + } + + @Test + @DisplayName("Should properly separate end sentinel from data") + void shouldProperlySeparateEndSentinelFromData() throws Exception { + List receivedBuffers = new ArrayList<>(); + AtomicBoolean endHandlerCalled = new AtomicBoolean(false); + CountDownLatch dataLatch = new CountDownLatch(2); + CountDownLatch endLatch = new CountDownLatch(1); + + vertx.runOnContext(v -> { + controller.initialize(context, () -> { + }); + controller.configureDataHandler(buffer -> { + // Should only receive actual data, not the end sentinel + receivedBuffers.add(buffer); + dataLatch.countDown(); + }); + controller.setEndHandler(h -> { + endHandlerCalled.set(true); + endLatch.countDown(); + }); + + // Write data and then end sentinel + controller.writeChunk(Buffer.buffer("data1")); + controller.writeChunk(Buffer.buffer("data2")); + controller.writeEndSentinel(); + }); + + assertThat(dataLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(endLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Should only have received data chunks, not the end sentinel + assertThat(receivedBuffers).hasSize(2); + assertThat(receivedBuffers.get(0).toString()).hasToString("data1"); + assertThat(receivedBuffers.get(1).toString()).hasToString("data2"); + assertThat(endHandlerCalled.get()).isTrue(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBodyTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBodyTest.java new file mode 100644 index 00000000000..5b896446be6 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxAsyncBodyTest.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("VertxAsyncBody") +class VertxAsyncBodyTest { + + @Mock + private HttpClientResponse response; + + @Mock + private HttpClientRequest request; + + @Mock + private AsyncBody.Consumer> consumer; + + @Captor + private ArgumentCaptor> dataHandlerCaptor; + + @Captor + private ArgumentCaptor> endHandlerCaptor; + + @Captor + private ArgumentCaptor> exceptionHandlerCaptor; + + @Captor + private ArgumentCaptor> listBufferCaptor; + + private VertxAsyncBody asyncBody; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + when(response.handler(dataHandlerCaptor.capture())).thenReturn(response); + when(response.endHandler(endHandlerCaptor.capture())).thenReturn(response); + when(response.exceptionHandler(exceptionHandlerCaptor.capture())).thenReturn(response); + when(response.request()).thenReturn(request); + } + + @Test + @DisplayName("Should setup data, end, and exception handlers during construction") + void constructor_shouldSetupHandlersCorrectly() { + asyncBody = new VertxAsyncBody(response, consumer); + + verify(response).handler(Mockito.any()); + verify(response).endHandler(Mockito.any()); + verify(response).exceptionHandler(Mockito.any()); + + assertThat(dataHandlerCaptor.getValue()).isNotNull(); + assertThat(endHandlerCaptor.getValue()).isNotNull(); + assertThat(exceptionHandlerCaptor.getValue()).isNotNull(); + } + + @Test + @DisplayName("Should call fetch(1) on response when consume is called") + void consume_shouldCallFetchOnResponse() { + asyncBody = new VertxAsyncBody(response, consumer); + + asyncBody.consume(); + + verify(response).fetch(1); + } + + @Test + @DisplayName("Should convert Vert.x buffer to ByteBuffer and call consumer when data arrives") + void dataHandler_shouldConsumeBufferAsByteBuffer() throws Exception { + asyncBody = new VertxAsyncBody(response, consumer); + Buffer buffer = Buffer.buffer("test data"); + + dataHandlerCaptor.getValue().handle(buffer); + + verify(consumer).consume(listBufferCaptor.capture(), Mockito.eq(asyncBody)); + + List capturedBuffers = listBufferCaptor.getValue(); + assertThat(capturedBuffers).hasSize(1); + + ByteBuffer capturedBuffer = capturedBuffers.get(0); + byte[] capturedBytes = new byte[capturedBuffer.remaining()]; + capturedBuffer.get(capturedBytes); + assertThat(new String(capturedBytes)).isEqualTo("test data"); + } + + @Test + @DisplayName("Should reset request and complete exceptionally when consumer throws exception") + void dataHandler_shouldCompleteExceptionallyOnConsumerException() { + RuntimeException consumerException = new RuntimeException("Consumer failed"); + asyncBody = new VertxAsyncBody(response, (data, body) -> { + throw consumerException; + }); + + dataHandlerCaptor.getValue().handle(Buffer.buffer("test")); + + verify(request).reset(); + + CompletableFuture done = asyncBody.done(); + assertThat(done).isCompletedExceptionally(); + + assertThatThrownBy(() -> done.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasCause(consumerException); + } + + @Test + @DisplayName("Should complete done future when end handler is triggered") + void endHandler_shouldCompleteDoneFuture() throws Exception { + asyncBody = new VertxAsyncBody(response, consumer); + + endHandlerCaptor.getValue().handle(null); + + CompletableFuture done = asyncBody.done(); + assertThat(done.get(1, TimeUnit.SECONDS)).isNull(); + assertThat(done).isCompleted(); + } + + @Test + @DisplayName("Should complete exceptionally when response exception occurs") + void exceptionHandler_shouldCompleteExceptionallyOnResponseException() { + asyncBody = new VertxAsyncBody(response, consumer); + RuntimeException responseException = new RuntimeException("Response failed"); + + exceptionHandlerCaptor.getValue().handle(responseException); + + CompletableFuture done = asyncBody.done(); + assertThat(done).isCompletedExceptionally(); + + assertThatThrownBy(() -> done.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasCause(responseException); + } + + @Test + @DisplayName("Should return same CompletableFuture instance on multiple done() calls") + void done_shouldReturnSameCompletableFuture() { + asyncBody = new VertxAsyncBody(response, consumer); + + CompletableFuture done1 = asyncBody.done(); + CompletableFuture done2 = asyncBody.done(); + + assertThat(done1).isSameAs(done2); + } + + @Test + @DisplayName("Should clear handlers, reset request, and cancel future when cancelled") + void cancel_shouldClearHandlersAndResetRequest() { + asyncBody = new VertxAsyncBody(response, consumer); + + asyncBody.cancel(); + + verify(response).handler(isNull()); + verify(response).endHandler(isNull()); + verify(request).reset(); + + CompletableFuture done = asyncBody.done(); + assertThat(done).isCancelled(); + } + + @Test + @DisplayName("Should cancel done future when cancel is called") + void cancel_shouldCancelDoneFuture() { + asyncBody = new VertxAsyncBody(response, consumer); + CompletableFuture done = asyncBody.done(); + + asyncBody.cancel(); + + assertThat(done.isCancelled()).isTrue(); + } + + @Test + @DisplayName("Should call consumer for each data chunk and complete on end") + void multipleDataChunks_shouldCallConsumerForEach() throws Exception { + asyncBody = new VertxAsyncBody(response, consumer); + + dataHandlerCaptor.getValue().handle(Buffer.buffer("chunk1")); + dataHandlerCaptor.getValue().handle(Buffer.buffer("chunk2")); + endHandlerCaptor.getValue().handle(null); + + // Then – the consumer must be invoked once per chunk (2 times total) + verify(consumer, Mockito.times(2)) + .consume(listBufferCaptor.capture(), Mockito.eq(asyncBody)); + + // We captured two separate lists (one per chunk) + List> allBuffers = listBufferCaptor.getAllValues(); + assertThat(allBuffers).hasSize(2); + assertThat(allBuffers.get(0).get(0).remaining()).isGreaterThan(0); + assertThat(allBuffers.get(1).get(0).remaining()).isGreaterThan(0); + + CompletableFuture done = asyncBody.done(); + assertThat(done.get(1, TimeUnit.SECONDS)).isNull(); + } + + @Test + @DisplayName("Should request one buffer at a time for backpressure control") + void backpressureHandling_shouldOnlyConsumeOneBufferAtATime() { + asyncBody = new VertxAsyncBody(response, consumer); + + asyncBody.consume(); + asyncBody.consume(); + asyncBody.consume(); + + verify(response, times(3)).fetch(1); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientAsyncBodyTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientAsyncBodyTest.java new file mode 100644 index 00000000000..7b3edeb31dc --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientAsyncBodyTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractAsyncBodyTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientAsyncBodyTest extends AbstractAsyncBodyTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilderTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilderTest.java new file mode 100644 index 00000000000..78932989c92 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientBuilderTest.java @@ -0,0 +1,378 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.TlsVersion; +import io.vertx.core.Vertx; +import io.vertx.core.impl.VertxImpl; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@DisplayName("VertxHttpClientBuilder") +class VertxHttpClientBuilderTest { + + @Test + @DisplayName("Should build client with zero timeout without issues") + void testZeroTimeouts() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + HttpClient.Builder builder = factory.newBuilder(); + + // should build and be usable without an issue + try (HttpClient client = builder.connectTimeout(0, TimeUnit.MILLISECONDS).build()) { + assertNotNull(client.newHttpRequestBuilder().uri("http://localhost").build()); + } + } + + @Test + @DisplayName("Should reuse shared Vertx instance") + void reusesVertxInstanceWhenSharedVertx() { + Vertx vertx = Vertx.vertx(); + try (HttpClient client = new VertxHttpClientFactory(vertx).newBuilder().build()) { + assertThat(client) + .isInstanceOf(VertxHttpClient.class) + .extracting("vertx") + .isSameAs(vertx); + } finally { + vertx.close(); + } + } + + @Test + @DisplayName("Should create new Vertx instance when no shared instance provided") + void createsVertxInstanceWhenNoSharedVertx() { + try (HttpClient client = new VertxHttpClientFactory().newBuilder().build()) { + assertThat(client) + .isInstanceOf(VertxHttpClient.class) + .extracting("vertx") + .isNotNull(); + } + } + + @Test + @DisplayName("Should not close shared Vertx instance when client is closed") + void doesntCloseSharedVertxInstanceWhenClientIsClosed() { + final Vertx vertx = Vertx.vertx(); + final var builder = new VertxHttpClientFactory(vertx).newBuilder(); + builder.build().close(); + assertThat(builder.vertx) + .asInstanceOf(InstanceOfAssertFactories.type(VertxImpl.class)) + .returns(false, vi -> vi.closeFuture().isClosed()); + vertx.close(); + } + + @Test + @DisplayName("Should close owned Vertx instance when client is closed") + void closesVertxInstanceWhenClientIsClosed() { + final var builder = new VertxHttpClientFactory().newBuilder(); + builder.build().close(); + assertThat(builder.vertx) + .asInstanceOf(InstanceOfAssertFactories.type(VertxImpl.class)) + .returns(true, vi -> vi.closeFuture().isClosed()); + } + + @Nested + @DisplayName("Proxy Configuration") + class ProxyConfigurationTests { + + @Test + @DisplayName("Should configure HTTP proxy correctly") + void httpProxy_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.proxyAddress(new InetSocketAddress("proxy.example.com", 8080)); + builder.proxyType(HttpClient.ProxyType.HTTP); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure SOCKS4 proxy correctly") + void socks4Proxy_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.proxyAddress(new InetSocketAddress("socks.example.com", 1080)); + builder.proxyType(HttpClient.ProxyType.SOCKS4); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure SOCKS5 proxy correctly") + void socks5Proxy_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.proxyAddress(new InetSocketAddress("socks5.example.com", 1080)); + builder.proxyType(HttpClient.ProxyType.SOCKS5); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should handle direct connection when proxy type is DIRECT") + void directProxy_shouldSkipProxyConfiguration() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.proxyType(HttpClient.ProxyType.DIRECT); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure proxy with basic authentication") + void proxyWithBasicAuth_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.proxyAddress(new InetSocketAddress("proxy.example.com", 8080)); + builder.proxyType(HttpClient.ProxyType.HTTP); + builder.proxyAuthorization("Basic dXNlcjpwYXNz"); // user:pass in base64 + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + } + + @Nested + @DisplayName("SSL/TLS Configuration") + class SslTlsConfigurationTests { + + @Test + @DisplayName("Should configure SSL context correctly") + void sslContext_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.sslContext(null, null); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure TLS versions correctly") + void tlsVersions_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.sslContext(null, null); + builder.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_3); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should handle empty TLS versions array") + void emptyTlsVersions_shouldNotFailBuild() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.tlsVersions(); // empty array + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should handle null TLS versions") + void nullTlsVersions_shouldNotFailBuild() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + } + + @Nested + @DisplayName("Timeout and HTTP Settings") + class TimeoutAndHttpSettingsTests { + + @Test + @DisplayName("Should configure connect timeout correctly") + void connectTimeout_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.connectTimeout(5000, TimeUnit.MILLISECONDS); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure follow redirects correctly") + void followRedirects_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.followAllRedirects(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure HTTP/1.1 preference correctly") + void preferHttp11_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.preferHttp11(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should handle null connect timeout") + void nullConnectTimeout_shouldNotFailBuild() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + } + + @Nested + @DisplayName("WebSocket Configuration") + class WebSocketConfigurationTests { + + @Test + @DisplayName("Should configure WebSocket options with SSL") + void webSocketWithSsl_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + builder.sslContext(null, null); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + + @Test + @DisplayName("Should configure WebSocket options without SSL") + void webSocketWithoutSsl_shouldConfigureCorrectly() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + } + } + } + + @Nested + @DisplayName("Builder Instance Management") + class BuilderInstanceManagementTests { + + @Test + @DisplayName("Should create new instance with shared Vertx") + void newInstance_shouldShareVertx() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder originalBuilder = factory.newBuilder(); + VertxHttpClientBuilder newBuilder = originalBuilder.newInstance(factory); + + assertThat(newBuilder.vertx).isSameAs(originalBuilder.vertx); + } + + @Test + @DisplayName("Should handle multiple builds from same builder") + void multipleBuildFromSameBuilder_shouldSucceed() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + try (HttpClient client1 = builder.build(); + HttpClient client2 = builder.build()) { + assertThat(client1).isNotNull(); + assertThat(client2).isNotNull(); + assertThat(client1).isNotSameAs(client2); + } + } + } + + @Nested + @DisplayName("Error Handling and Edge Cases") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle build with all default settings") + void buildWithDefaults_shouldSucceed() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + assertThat(client).isInstanceOf(VertxHttpClient.class); + } + } + + @Test + @DisplayName("Should handle complex configuration combination") + void complexConfiguration_shouldSucceed() { + VertxHttpClientFactory factory = new VertxHttpClientFactory(); + VertxHttpClientBuilder builder = factory.newBuilder(); + + InetSocketAddress proxyAddress = new InetSocketAddress("proxy.example.com", 8080); + + builder + .connectTimeout(10000, TimeUnit.MILLISECONDS) + .sslContext(null, null) + .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_3) + .proxyAddress(proxyAddress) + .proxyType(HttpClient.ProxyType.HTTP) + .followAllRedirects() + .preferHttp11(); + + try (HttpClient client = builder.build()) { + assertThat(client).isNotNull(); + assertThat(client).isInstanceOf(VertxHttpClient.class); + } + } + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientGetTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientGetTest.java new file mode 100644 index 00000000000..a38278e7cae --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientGetTest.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpGetTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +import java.net.ConnectException; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientGetTest extends AbstractHttpGetTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + + @Override + protected Class getConnectionFailedExceptionType() { + return ConnectException.class; + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientInterceptorTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientInterceptorTest.java new file mode 100644 index 00000000000..009dc1e8366 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientInterceptorTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractInterceptorTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientInterceptorTest extends AbstractInterceptorTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientNewWebSocketBuilderTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientNewWebSocketBuilderTest.java new file mode 100644 index 00000000000..be291a4e976 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientNewWebSocketBuilderTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpClientNewWebSocketBuilderTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientNewWebSocketBuilderTest extends AbstractHttpClientNewWebSocketBuilderTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPostTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPostTest.java new file mode 100644 index 00000000000..c243ecc564f --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPostTest.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpPostTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +import java.net.ConnectException; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientPostTest extends AbstractHttpPostTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + + @Override + protected Class getConnectionFailedExceptionType() { + return ConnectException.class; + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyHttpsTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyHttpsTest.java new file mode 100644 index 00000000000..c93b80b9789 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyHttpsTest.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpClientProxyHttpsTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientProxyHttpsTest extends AbstractHttpClientProxyHttpsTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyTest.java new file mode 100644 index 00000000000..463451e943b --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientProxyTest.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpClientProxyTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientProxyTest extends AbstractHttpClientProxyTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPutTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPutTest.java new file mode 100644 index 00000000000..cc5141efe14 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientPutTest.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpPutTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +import java.net.ConnectException; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientPutTest extends AbstractHttpPutTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + + @Override + protected Class getConnectionFailedExceptionType() { + return ConnectException.class; + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientSimultaneousConnectionsTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientSimultaneousConnectionsTest.java new file mode 100644 index 00000000000..34b955f66a8 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientSimultaneousConnectionsTest.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractSimultaneousConnectionsTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientSimultaneousConnectionsTest extends AbstractSimultaneousConnectionsTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientWebSocketSendTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientWebSocketSendTest.java new file mode 100644 index 00000000000..65438332ca3 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpClientWebSocketSendTest.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractWebSocketSendTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpClientWebSocketSendTest extends AbstractWebSocketSendTest { + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpConfiguredClientTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpConfiguredClientTest.java new file mode 100644 index 00000000000..123ce41b380 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpConfiguredClientTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractConfiguredClientTest; +import io.fabric8.kubernetes.client.http.HttpClient.Factory; + +class VertxHttpConfiguredClientTest extends AbstractConfiguredClientTest { + + @Override + protected Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } + +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpLoggingInterceptorTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpLoggingInterceptorTest.java new file mode 100644 index 00000000000..e37ef08f5e7 --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpLoggingInterceptorTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AbstractHttpLoggingInterceptorTest; +import io.fabric8.kubernetes.client.http.HttpClient; + +@SuppressWarnings("java:S2187") +public class VertxHttpLoggingInterceptorTest extends AbstractHttpLoggingInterceptorTest { + + @Override + protected HttpClient.Factory getHttpClientFactory() { + return new VertxHttpClientFactory(); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequestTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequestTest.java new file mode 100644 index 00000000000..1b95ec6e25b --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxHttpRequestTest.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.AsyncBody; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.StandardHttpRequest; +import io.fabric8.kubernetes.client.http.StandardHttpRequest.BodyContent; +import io.fabric8.kubernetes.client.http.StandardHttpRequest.ByteArrayBodyContent; +import io.fabric8.kubernetes.client.http.StandardHttpRequest.InputStreamBodyContent; +import io.fabric8.kubernetes.client.http.StandardHttpRequest.StringBodyContent; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.streams.ReadStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("VertxHttpRequest") +class VertxHttpRequestTest { + + @Mock + private Vertx vertx; + + @Mock + private RequestOptions options; + + @Mock + private StandardHttpRequest request; + + @Mock + private HttpClient httpClient; + + @Mock + private HttpClientRequest httpClientRequest; + + @Mock + private HttpClientResponse httpClientResponse; + + @Mock + private AsyncBody.Consumer> consumer; + + @Mock + private StringBodyContent stringBodyContent; + + @Mock + private ByteArrayBodyContent byteArrayBodyContent; + + @Mock + private InputStreamBodyContent inputStreamBodyContent; + + @Mock + private BodyContent unsupportedBodyContent; + + @Mock + private MultiMap multiMap; + + @Captor + private ArgumentCaptor bufferCaptor; + + @Captor + private ArgumentCaptor> readStreamCaptor; + + private VertxHttpRequest vertxHttpRequest; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + vertxHttpRequest = new VertxHttpRequest(vertx, options, request); + + when(httpClientResponse.handler(any())).thenReturn(httpClientResponse); + when(httpClientResponse.endHandler(any())).thenReturn(httpClientResponse); + when(httpClientResponse.exceptionHandler(any())).thenReturn(httpClientResponse); + + when(httpClientResponse.headers()).thenReturn(multiMap); + when(multiMap.names()).thenReturn(java.util.Set.of()); + } + + @Test + @DisplayName("Should execute request flow and return CompletableFuture") + void consumeBytes_shouldExecuteRequestFlowSuccessfully() throws Exception { + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(null); + when(httpClientRequest.send()).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull().isInstanceOf(VertxHttpResponse.class); + + verify(httpClientResponse).pause(); + } + + @Test + @DisplayName("Should handle failed client request") + void consumeBytes_shouldHandleFailedClientRequest() { + RuntimeException clientException = new RuntimeException("Client request failed"); + when(httpClient.request(options)).thenReturn(Future.failedFuture(clientException)); + when(request.body()).thenReturn(null); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(() -> result.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasCause(clientException); + } + + @Test + @DisplayName("Should send request without body when body is null") + void sendBody_shouldSendWithoutBodyWhenBodyIsNull() throws Exception { + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(null); + when(httpClientRequest.send()).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull(); + verify(httpClientRequest).send(); + } + + @Test + @DisplayName("Should send string body content as Buffer") + void sendBody_shouldSendStringBodyContentAsBuffer() throws Exception { + String content = "test string content"; + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(stringBodyContent); + when(stringBodyContent.getContent()).thenReturn(content); + when(httpClientRequest.send(any(Buffer.class))).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull(); + verify(httpClientRequest).send(bufferCaptor.capture()); + + Buffer capturedBuffer = bufferCaptor.getValue(); + assertThat(capturedBuffer).hasToString(content); + } + + @Test + @DisplayName("Should send byte array body content as Buffer") + void sendBody_shouldSendByteArrayBodyContentAsBuffer() throws Exception { + byte[] content = "test byte array".getBytes(); + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(byteArrayBodyContent); + when(byteArrayBodyContent.getContent()).thenReturn(content); + when(httpClientRequest.send(any(Buffer.class))).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull(); + verify(httpClientRequest).send(bufferCaptor.capture()); + + Buffer capturedBuffer = bufferCaptor.getValue(); + assertThat(capturedBuffer.getBytes()).isEqualTo(content); + } + + @Test + @DisplayName("Should send InputStream body content as ReadStream") + void sendBody_shouldSendInputStreamBodyContentAsReadStream() throws Exception { + byte[] content = "test input stream".getBytes(); + InputStream inputStream = new ByteArrayInputStream(content); + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(inputStreamBodyContent); + when(inputStreamBodyContent.getContent()).thenReturn(inputStream); + when(httpClientRequest.send(any(ReadStream.class))).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull(); + verify(httpClientRequest).send(readStreamCaptor.capture()); + + ReadStream capturedStream = readStreamCaptor.getValue(); + assertThat(capturedStream).isInstanceOf(InputStreamReadStream.class); + } + + @Test + @DisplayName("Should return failed future for unsupported body content type") + void sendBody_shouldReturnFailedFutureForUnsupportedBodyContent() { + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(unsupportedBodyContent); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(() -> result.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage("Unsupported body content: " + unsupportedBodyContent.getClass()); + } + + @Test + @DisplayName("Should create VertxHttpResponse with paused response and VertxAsyncBody") + void toFabricResponse_shouldCreateResponseWithPausedResponseAndAsyncBody() throws Exception { + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(null); + when(httpClientRequest.send()).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isInstanceOf(VertxHttpResponse.class); + verify(httpClientResponse).pause(); + + VertxHttpResponse vertxResponse = (VertxHttpResponse) response; + assertThat(vertxResponse.body()).isInstanceOf(VertxAsyncBody.class); + } + + @Test + @DisplayName("Should convert MultiMap to headers map correctly") + void toHeadersMap_shouldConvertMultiMapToHeadersMap() { + when(multiMap.names()).thenReturn(java.util.Set.of("Content-Type", "Authorization", "Custom-Header")); + when(multiMap.getAll("Content-Type")).thenReturn(List.of("application/json")); + when(multiMap.getAll("Authorization")).thenReturn(List.of("Bearer token123")); + when(multiMap.getAll("Custom-Header")).thenReturn(List.of("value1", "value2")); + + Map> result = VertxHttpRequest.toHeadersMap(multiMap); + + assertThat(result).hasSize(3); + assertThat(result.get("Content-Type")).containsExactly("application/json"); + assertThat(result.get("Authorization")).containsExactly("Bearer token123"); + assertThat(result.get("Custom-Header")).containsExactly("value1", "value2"); + } + + @Test + @DisplayName("Should handle empty MultiMap correctly") + void toHeadersMap_shouldHandleEmptyMultiMap() { + when(multiMap.names()).thenReturn(java.util.Set.of()); + + Map> result = VertxHttpRequest.toHeadersMap(multiMap); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should preserve header order using LinkedHashMap") + void toHeadersMap_shouldPreserveHeaderOrderUsingLinkedHashMap() { + java.util.LinkedHashSet orderedNames = new java.util.LinkedHashSet<>(); + orderedNames.add("First"); + orderedNames.add("Second"); + orderedNames.add("Third"); + when(multiMap.names()).thenReturn(orderedNames); + when(multiMap.getAll("First")).thenReturn(List.of("1")); + when(multiMap.getAll("Second")).thenReturn(List.of("2")); + when(multiMap.getAll("Third")).thenReturn(List.of("3")); + + Map> result = VertxHttpRequest.toHeadersMap(multiMap); + + String[] headerNames = result.keySet().toArray(new String[0]); + assertThat(headerNames).containsExactly("First", "Second", "Third"); + } + + @Test + @DisplayName("Should handle single header with multiple values correctly") + void toHeadersMap_shouldHandleSingleHeaderWithMultipleValues() { + when(multiMap.names()).thenReturn(java.util.Set.of("Accept")); + when(multiMap.getAll("Accept")).thenReturn(List.of("application/json", "text/plain", "*/*")); + + Map> result = VertxHttpRequest.toHeadersMap(multiMap); + + assertThat(result).hasSize(1); + assertThat(result.get("Accept")).containsExactly("application/json", "text/plain", "*/*"); + } + + @Test + @DisplayName("Should handle send body failure in consumeBytes") + void consumeBytes_shouldHandleSendBodyFailure() { + RuntimeException sendBodyException = new RuntimeException("Send body failed"); + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(stringBodyContent); + when(stringBodyContent.getContent()).thenReturn("test"); + when(httpClientRequest.send(any(Buffer.class))).thenReturn(Future.failedFuture(sendBodyException)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(() -> result.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasCause(sendBodyException); + } + + @Test + @DisplayName("Should handle response mapping in consumeBytes") + void consumeBytes_shouldHandleResponseMapping() throws Exception { + when(httpClient.request(options)).thenReturn(Future.succeededFuture(httpClientRequest)); + when(request.body()).thenReturn(stringBodyContent); + when(stringBodyContent.getContent()).thenReturn("test content"); + when(httpClientRequest.send(any(Buffer.class))).thenReturn(Future.succeededFuture(httpClientResponse)); + + CompletableFuture> result = vertxHttpRequest.consumeBytes(httpClient, consumer); + + HttpResponse response = result.get(1, TimeUnit.SECONDS); + assertThat(response).isNotNull().isInstanceOf(VertxHttpResponse.class); + + verify(httpClientResponse).pause(); + verify(httpClientRequest).send(any(Buffer.class)); + } +} diff --git a/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxWebSocketTest.java b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxWebSocketTest.java new file mode 100644 index 00000000000..b88f01dc74d --- /dev/null +++ b/httpclient-vertx-5/src/test/java/io/fabric8/kubernetes/client/vertx/VertxWebSocketTest.java @@ -0,0 +1,431 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.vertx; + +import io.fabric8.kubernetes.client.http.WebSocket; +import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException; +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClosedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.net.ProtocolException; +import java.nio.ByteBuffer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("VertxWebSocket") +class VertxWebSocketTest { + + @Mock + private io.vertx.core.http.WebSocket vertxWebSocket; + + @Mock + private WebSocket.Listener listener; + + @Captor + private ArgumentCaptor> binaryMessageHandlerCaptor; + + @Captor + private ArgumentCaptor> textMessageHandlerCaptor; + + @Captor + private ArgumentCaptor> endHandlerCaptor; + + @Captor + private ArgumentCaptor> exceptionHandlerCaptor; + + private VertxWebSocket webSocket; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + // Setup mock chaining for handler registration + when(vertxWebSocket.binaryMessageHandler(binaryMessageHandlerCaptor.capture())).thenReturn(vertxWebSocket); + when(vertxWebSocket.textMessageHandler(textMessageHandlerCaptor.capture())).thenReturn(vertxWebSocket); + when(vertxWebSocket.endHandler(endHandlerCaptor.capture())).thenReturn(vertxWebSocket); + when(vertxWebSocket.exceptionHandler(exceptionHandlerCaptor.capture())).thenReturn(vertxWebSocket); + + webSocket = new VertxWebSocket(vertxWebSocket, listener); + } + + @Test + @DisplayName("Should setup event handlers and notify listener on initHandlers") + void initHandlers_shouldSetupHandlersAndNotifyListener() { + webSocket.initHandlers(); + + verify(vertxWebSocket).binaryMessageHandler(any()); + verify(vertxWebSocket).textMessageHandler(any()); + verify(vertxWebSocket).endHandler(any()); + verify(vertxWebSocket).exceptionHandler(any()); + verify(listener).onOpen(webSocket); + + assertThat(binaryMessageHandlerCaptor.getValue()).isNotNull(); + assertThat(textMessageHandlerCaptor.getValue()).isNotNull(); + assertThat(endHandlerCaptor.getValue()).isNotNull(); + assertThat(exceptionHandlerCaptor.getValue()).isNotNull(); + } + + @Test + @DisplayName("Should handle binary message with pause and listener callback") + void binaryMessageHandler_shouldHandleBinaryMessageWithBackpressure() { + webSocket.initHandlers(); + + byte[] testData = "binary test data".getBytes(); + Buffer buffer = Buffer.buffer(testData); + + binaryMessageHandlerCaptor.getValue().handle(buffer); + + verify(vertxWebSocket).pause(); + + ArgumentCaptor byteBufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); + verify(listener).onMessage(eq(webSocket), byteBufferCaptor.capture()); + + ByteBuffer capturedBuffer = byteBufferCaptor.getValue(); + byte[] capturedBytes = new byte[capturedBuffer.remaining()]; + capturedBuffer.get(capturedBytes); + assertThat(capturedBytes).isEqualTo(testData); + } + + @Test + @DisplayName("Should handle text message with pause and listener callback") + void textMessageHandler_shouldHandleTextMessageWithBackpressure() { + webSocket.initHandlers(); + + String testMessage = "text test message"; + + textMessageHandlerCaptor.getValue().handle(testMessage); + + verify(vertxWebSocket).pause(); + verify(listener).onMessage(webSocket, testMessage); + } + + @Test + @DisplayName("Should handle connection end with close status and reason") + void endHandler_shouldHandleConnectionEndWithStatusAndReason() { + webSocket.initHandlers(); + + short closeCode = 1000; + String closeReason = "Normal closure"; + when(vertxWebSocket.closeStatusCode()).thenReturn(closeCode); + when(vertxWebSocket.closeReason()).thenReturn(closeReason); + + endHandlerCaptor.getValue().handle(null); + + verify(listener).onClose(webSocket, closeCode, closeReason); + } + + @Test + @DisplayName("Should handle exception with error mapping and connection cleanup") + void exceptionHandler_shouldHandleExceptionWithMappingAndCleanup() { + webSocket.initHandlers(); + + RuntimeException originalError = new RuntimeException("Connection error"); + when(vertxWebSocket.isClosed()).thenReturn(false); + + exceptionHandlerCaptor.getValue().handle(originalError); + + verify(listener).onError(webSocket, originalError); + verify(vertxWebSocket).close(); + } + + @Test + @DisplayName("Should not close already closed socket in exception handler") + void exceptionHandler_shouldNotCloseAlreadyClosedSocket() { + webSocket.initHandlers(); + + RuntimeException originalError = new RuntimeException("Connection error"); + when(vertxWebSocket.isClosed()).thenReturn(true); + + exceptionHandlerCaptor.getValue().handle(originalError); + + verify(listener).onError(webSocket, originalError); + verify(vertxWebSocket, never()).close(); + } + + @Test + @DisplayName("Should map CorruptedWebSocketFrameException to ProtocolException") + void exceptionHandler_shouldMapCorruptedFrameException() { + webSocket.initHandlers(); + + var corruptedFrame = new CorruptedWebSocketFrameException(WebSocketCloseStatus.INVALID_PAYLOAD_DATA, "Corrupted frame", + null); + when(vertxWebSocket.isClosed()).thenReturn(false); + + exceptionHandlerCaptor.getValue().handle(corruptedFrame); + + ArgumentCaptor errorCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(listener).onError(eq(webSocket), errorCaptor.capture()); + + Throwable mappedException = errorCaptor.getValue(); + assertThat(mappedException).isInstanceOf(ProtocolException.class); + assertThat(mappedException.getCause()).isSameAs(corruptedFrame); + assertThat(mappedException.getMessage()).isEqualTo("Corrupted frame"); + } + + @Test + @DisplayName("Should map HttpClosedException to IOException") + void exceptionHandler_shouldMapHttpClosedException() { + webSocket.initHandlers(); + + HttpClosedException httpClosed = new HttpClosedException("Connection closed"); + when(vertxWebSocket.isClosed()).thenReturn(false); + + exceptionHandlerCaptor.getValue().handle(httpClosed); + + ArgumentCaptor errorCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(listener).onError(eq(webSocket), errorCaptor.capture()); + + Throwable mappedException = errorCaptor.getValue(); + assertThat(mappedException).isInstanceOf(IOException.class); + assertThat(mappedException.getCause()).isSameAs(httpClosed); + assertThat(mappedException.getMessage()).isEqualTo("WebSocket connection closed unexpectedly"); + } + + @Test + @DisplayName("Should send array-backed ByteBuffer with zero-copy optimization") + void send_shouldSendArrayBackedByteBufferWithZeroCopy() { + byte[] testData = "test data for array buffer".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(testData); + + Future successfulWrite = Future.succeededFuture(); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))).thenReturn(successfulWrite); + + boolean result = webSocket.send(buffer); + + assertThat(result).isTrue(); + assertThat(buffer.position()).isEqualTo(buffer.limit()); // Buffer consumed + assertThat(webSocket.queueSize()).isZero(); // Synchronous completion + + ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(vertxWebSocket).writeBinaryMessage(bufferCaptor.capture()); + + Buffer sentBuffer = bufferCaptor.getValue(); + assertThat(sentBuffer.getBytes()).isEqualTo(testData); + } + + @Test + @DisplayName("Should send direct ByteBuffer with array copy") + void send_shouldSendDirectByteBufferWithCopy() { + ByteBuffer buffer = ByteBuffer.allocateDirect(16); + buffer.put("direct buffer".getBytes()); + buffer.flip(); + + Future successfulWrite = Future.succeededFuture(); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))).thenReturn(successfulWrite); + + boolean result = webSocket.send(buffer); + + assertThat(result).isTrue(); + assertThat(buffer.position()).isEqualTo(buffer.limit()); // Buffer consumed + assertThat(webSocket.queueSize()).isZero(); // Synchronous completion + + verify(vertxWebSocket).writeBinaryMessage(any(Buffer.class)); + } + + @Test + @DisplayName("Should handle immediate send failure") + void send_shouldHandleImmediateSendFailure() { + ByteBuffer buffer = ByteBuffer.wrap("test data".getBytes()); + + RuntimeException writeError = new RuntimeException("Write failed"); + Future failedWrite = Future.failedFuture(writeError); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))).thenReturn(failedWrite); + + boolean result = webSocket.send(buffer); + + assertThat(result).isFalse(); + assertThat(webSocket.queueSize()).isZero(); // Should not track failed writes + } + + @Test + @DisplayName("Should handle asynchronous send completion") + void send_shouldHandleAsynchronousSendCompletion() { + ByteBuffer buffer = ByteBuffer.wrap("test data".getBytes()); + int expectedLength = buffer.remaining(); + + Promise promise = Promise.promise(); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))).thenReturn(promise.future()); + + boolean result = webSocket.send(buffer); + + assertThat(result).isTrue(); + assertThat(webSocket.queueSize()).isEqualTo(expectedLength); // Should track pending write + + promise.tryComplete(); + + assertThat(webSocket.queueSize()).isZero(); // Should clear pending count + } + + @Test + @DisplayName("Should handle asynchronous send failure") + void send_shouldHandleAsynchronousSendFailure() { + ByteBuffer buffer = ByteBuffer.wrap("test data".getBytes()); + int expectedLength = buffer.remaining(); + + Promise writePromise = Promise.promise(); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))).thenReturn(writePromise.future()); + + boolean result = webSocket.send(buffer); + + // Should accept the send and track bytes + assertThat(result).isTrue(); + assertThat(webSocket.queueSize()).isEqualTo(expectedLength); + + // Fail the async operation + writePromise.tryFail("Async write failed"); + + // Pending counter must be decremented + assertThat(webSocket.queueSize()).isZero(); + } + + @Test + @DisplayName("Should track queue size correctly across multiple sends") + void queueSize_shouldTrackMultiplePendingOperations() { + ByteBuffer buffer1 = ByteBuffer.wrap("first message".getBytes()); + ByteBuffer buffer2 = ByteBuffer.wrap("second message".getBytes()); + + Promise writePromise1 = Promise.promise(); + Promise writePromise2 = Promise.promise(); + when(vertxWebSocket.writeBinaryMessage(any(Buffer.class))) + .thenReturn(writePromise1.future()) + .thenReturn(writePromise2.future()); + + // Send the first message + webSocket.send(buffer1); + int queueAfterFirst = (int) webSocket.queueSize(); + + // Send the second message + webSocket.send(buffer2); + int queueAfterSecond = (int) webSocket.queueSize(); + + assertThat(queueAfterFirst).isEqualTo("first message".length()); + assertThat(queueAfterSecond).isEqualTo("first message".length() + "second message".length()); + + // Complete first async write + writePromise1.tryComplete(); + assertThat(webSocket.queueSize()).isEqualTo("second message".length()); + + // Complete second async write + writePromise2.tryComplete(); + assertThat(webSocket.queueSize()).isZero(); + } + + @Test + @DisplayName("Should send close frame and fetch final frame when not closed") + void sendClose_shouldSendCloseFrameAndFetchFinalFrame() { + int closeCode = 1000; + String closeReason = "Normal closure"; + + when(vertxWebSocket.isClosed()).thenReturn(false); + when(vertxWebSocket.close((short) closeCode, closeReason)).thenReturn(Future.succeededFuture()); + + boolean result = webSocket.sendClose(closeCode, closeReason); + + assertThat(result).isTrue(); + verify(vertxWebSocket).close((short) closeCode, closeReason); + verify(vertxWebSocket).fetch(1); + } + + @Test + @DisplayName("Should return false when trying to close already closed socket") + void sendClose_shouldReturnFalseWhenAlreadyClosed() { + int closeCode = 1000; + String closeReason = "Normal closure"; + + when(vertxWebSocket.isClosed()).thenReturn(true); + + boolean result = webSocket.sendClose(closeCode, closeReason); + + assertThat(result).isFalse(); + verify(vertxWebSocket, never()).close(any(Short.class), any(String.class)); + verify(vertxWebSocket, never()).fetch(1); + } + + @Test + @DisplayName("Should handle close operation failure gracefully") + void sendClose_shouldHandleCloseOperationFailureGracefully() { + int closeCode = 1000; + String closeReason = "Normal closure"; + + when(vertxWebSocket.isClosed()).thenReturn(false); + when(vertxWebSocket.close((short) closeCode, closeReason)) + .thenReturn(Future.failedFuture("Close failed")); + + boolean result = webSocket.sendClose(closeCode, closeReason); + + assertThat(result).isTrue(); // Should still return true for attempted close + verify(vertxWebSocket).close((short) closeCode, closeReason); + verify(vertxWebSocket).fetch(1); + } + + @Test + @DisplayName("Should request next frame by calling fetch") + void request_shouldCallFetchOnVertxWebSocket() { + webSocket.request(); + + verify(vertxWebSocket).fetch(1); + } + + @Test + @DisplayName("Should maintain backpressure protocol across message handling") + void backpressureProtocol_shouldMaintainFlowControl() { + webSocket.initHandlers(); + + var inOrder = inOrder(vertxWebSocket); + + // Send first message + binaryMessageHandlerCaptor.getValue().handle(Buffer.buffer("message1")); + // After a frame is delivered, Vert.x socket should pause + inOrder.verify(vertxWebSocket).pause(); + + // Application requests next frame + webSocket.request(); + inOrder.verify(vertxWebSocket).fetch(1); + + // Send the second message + textMessageHandlerCaptor.getValue().handle("message2"); + + // The socket should pause again after delivering the second frame + inOrder.verify(vertxWebSocket).pause(); + + // No further interactions expected + inOrder.verifyNoMoreInteractions(); + } + + @Test + @DisplayName("Should return zero queue size initially") + void queueSize_shouldReturnZeroInitially() { + assertThat(webSocket.queueSize()).isZero(); + } +} diff --git a/junit/mockwebserver/src/main/java/io/fabric8/mockwebserver/vertx/ServerWebSocketHandler.java b/junit/mockwebserver/src/main/java/io/fabric8/mockwebserver/vertx/ServerWebSocketHandler.java index ceec5a94fdf..61644905716 100644 --- a/junit/mockwebserver/src/main/java/io/fabric8/mockwebserver/vertx/ServerWebSocketHandler.java +++ b/junit/mockwebserver/src/main/java/io/fabric8/mockwebserver/vertx/ServerWebSocketHandler.java @@ -37,10 +37,23 @@ public ServerWebSocketHandler(RecordedRequest request, Response response) { public void handle(ServerWebSocket serverWebSocket) { final WebSocketListener wsListener = response.getWebSocketListener(); final VertxMockWebSocket mockWebSocket = new VertxMockWebSocket(request, serverWebSocket); - // Important to call onBeforeAccept before sending accept so that WebSockets get registered by dispatchers, handlers, and so on + // Important to call onBeforeAccept before configuring so that WebSockets get registered by dispatchers, handlers, and so on wsListener.onBeforeAccept(mockWebSocket, response); - serverWebSocket.textMessageHandler(text -> wsListener.onMessage(mockWebSocket, text)); - serverWebSocket.binaryMessageHandler(buff -> wsListener.onMessage(mockWebSocket, buff.getBytes())); + configureWebSocket(serverWebSocket, mockWebSocket, wsListener); + wsListener.onOpen(mockWebSocket, response); + serverWebSocket.fetch(1); + } + + public void configureWebSocket(ServerWebSocket serverWebSocket, VertxMockWebSocket mockWebSocket, + WebSocketListener wsListener) { + serverWebSocket.textMessageHandler(text -> { + wsListener.onMessage(mockWebSocket, text); + serverWebSocket.fetch(1); + }); + serverWebSocket.binaryMessageHandler(buff -> { + wsListener.onMessage(mockWebSocket, buff.getBytes()); + serverWebSocket.fetch(1); + }); serverWebSocket.frameHandler(frame -> { if (frame.isClose()) { wsListener.onClosing(mockWebSocket, frame.closeStatusCode(), frame.closeReason()); @@ -54,8 +67,5 @@ public void handle(ServerWebSocket serverWebSocket) { : serverWebSocket.closeStatusCode(), serverWebSocket.closeReason())); serverWebSocket.exceptionHandler(err -> wsListener.onFailure(mockWebSocket, err, response)); - serverWebSocket.accept(); - wsListener.onOpen(mockWebSocket, response); - serverWebSocket.fetch(1); } } diff --git a/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/DefaultMockServerTest.groovy b/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/DefaultMockServerTest.groovy index 4e8b5b64245..12623cd196d 100644 --- a/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/DefaultMockServerTest.groovy +++ b/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/DefaultMockServerTest.groovy @@ -346,12 +346,12 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq =wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener" - wsReq.onComplete { ws -> - ws.result().textMessageHandler { text -> + wsReq.onSuccess { ws -> + ws.textMessageHandler { text -> receivedMessages.add(text) } - ws.result().closeHandler { _ -> - ws.result().close() + ws.closeHandler { _ -> + ws.close() } } and: "An instance of PollingConditions" @@ -385,12 +385,12 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq = wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener" - wsReq.onComplete { ws -> - ws.result().binaryMessageHandler { buffer -> + wsReq.onSuccess { ws -> + ws.binaryMessageHandler { buffer -> receivedMessages.add(buffer.getBytes(0, buffer.length())) } - ws.result().closeHandler { _ -> - ws.result().close() + ws.closeHandler { _ -> + ws.close() } } and: "An instance of PollingConditions" @@ -421,12 +421,12 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq = wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener" - wsReq.onComplete { ws -> - ws.result().textMessageHandler { text -> + wsReq.onSuccess { ws -> + ws.textMessageHandler { text -> receivedMessages.add(text) } - ws.result().writeTextMessage("create root") - ws.result().writeTextMessage("delete root") + ws.writeTextMessage("create root") + ws.writeTextMessage("delete root") } and: "An instance of PollingConditions" def conditions = new PollingConditions(timeout: 10) @@ -452,8 +452,8 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq = wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener" - wsReq.onComplete { ws -> - ws.result().writeTextMessage("unexpected message") + wsReq.onSuccess { ws -> + ws.writeTextMessage("unexpected message") } and: "An instance of PollingConditions" def conditions = new PollingConditions(timeout: 10) @@ -498,7 +498,7 @@ class DefaultMockServerTest extends Specification { def "when using a body provider it should work as for static responses"() { given: "A counter" - def counter = new AtomicInteger(0); + def counter = new AtomicInteger(0) and: "An expectation with body provider" server.expect().get().withPath("/api/v1/users") .andReply(200, {req -> "admin-" + counter.getAndIncrement()}) @@ -529,7 +529,7 @@ class DefaultMockServerTest extends Specification { given: "An expectation with response provider" server.expect().get().withPath("/api/v1/users") .andReply(new ResponseProvider() { - def counter = new AtomicInteger(0); + def counter = new AtomicInteger(0) def headers = new Headers.Builder().build() int getStatusCode(RecordedRequest request) { @@ -613,13 +613,13 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq = wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener" - wsReq.andThen { ws -> - ws.result().textMessageHandler { text -> + wsReq.onSuccess { ws -> + ws.textMessageHandler { text -> receivedMessages.add(text) } } and: "HTTP requests after WS connection initiated" - wsReq.onComplete { + wsReq.onSuccess { ws -> client.get(server.port, server.getHostName(), "/api/v1/create").send() .compose { _ -> client.get(server.port, server.getHostName(), "/api/v1/delete").send() } } @@ -651,8 +651,8 @@ class DefaultMockServerTest extends Specification { and: "A WebSocket request" def wsReq = wsClient.webSocket().connect(server.port, server.getHostName(), "/api/v1/users/watch") and: "A WebSocket listener that sends an HTTP request after WS connection initiated" - wsReq.andThen { ws -> - ws.result().textMessageHandler { text -> + wsReq.onSuccess { ws -> + ws.textMessageHandler { text -> if (text == "READY") { client.get(server.port, server.getHostName(), "/api/v1/create").send() } else { diff --git a/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/MockWebServerWebSocketTest.groovy b/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/MockWebServerWebSocketTest.groovy index 2caa8c4cb1e..8680ab78ec8 100644 --- a/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/MockWebServerWebSocketTest.groovy +++ b/junit/mockwebserver/src/test/groovy/io/fabric8/mockwebserver/MockWebServerWebSocketTest.groovy @@ -63,8 +63,8 @@ class MockWebServerWebSocketTest extends Specification { def condition = new PollingConditions(timeout: 10) when: "The request is sent and a text message is written" - wsReq.onComplete { ws -> - ws.result().writeTextMessage("A text message from the client") + wsReq.onSuccess { ws -> + ws.writeTextMessage("A text message from the client") } then: "Expect the message to be received" @@ -92,12 +92,12 @@ class MockWebServerWebSocketTest extends Specification { when: "The request is sent and a text message is expected from server" String receivedMessage = null - wsReq.onComplete { ws -> - ws.result().textMessageHandler { text -> + wsReq.onSuccess { ws -> + ws.textMessageHandler { text -> receivedMessage = text } - ws.result().fetch(Long.MAX_VALUE) - ws.result().writeTextMessage("Send me something") + ws.fetch(Long.MAX_VALUE) + ws.writeTextMessage("Send me something") } then: "Expect the message to be received by the client" @@ -124,8 +124,8 @@ class MockWebServerWebSocketTest extends Specification { def condition = new PollingConditions(timeout: 10) when: "The request is sent and a binary message is written" - wsReq.onComplete { ws -> - ws.result().writeBinaryMessage(Buffer.buffer([1, 2, 3] as byte[])) + wsReq.onSuccess { ws -> + ws.writeBinaryMessage(Buffer.buffer([1, 2, 3] as byte[])) } then: "Expect the message to be received" @@ -145,7 +145,7 @@ class MockWebServerWebSocketTest extends Specification { @Override void onMessage(WebSocket webSocket, String text) { // Send the message after we make sure that the client wsRequest is established - wsReq.onComplete { + wsReq.onSuccess { webSocket.send([1, 2, 3] as byte[]) } } @@ -155,12 +155,12 @@ class MockWebServerWebSocketTest extends Specification { when: "The request is sent and a binary message is expected from server" byte[] receivedMessage - wsReq.onComplete { ws -> - ws.result().binaryMessageHandler { buffer -> + wsReq.onSuccess { ws -> + ws.binaryMessageHandler { buffer -> receivedMessage = buffer.getBytes() } - ws.result().fetch(Long.MAX_VALUE) - ws.result().writeTextMessage("Send me something") + ws.fetch(Long.MAX_VALUE) + ws.writeTextMessage("Send me something") } then: "Expect the message to be received by the client" @@ -189,8 +189,8 @@ class MockWebServerWebSocketTest extends Specification { def condition = new PollingConditions(timeout: 10) when: "The request is sent and a close message is written" - wsReq.onComplete { ws -> - ws.result().close(1000 as short, "Normal closure") + wsReq.onSuccess { ws -> + ws.close(1000 as short, "Normal closure") } then: "Expect the message to be received" diff --git a/kubernetes-itests/pom.xml b/kubernetes-itests/pom.xml index fa734fc085d..81660a698b8 100644 --- a/kubernetes-itests/pom.xml +++ b/kubernetes-itests/pom.xml @@ -1,160 +1,231 @@ - - - - - 4.0.0 - - kubernetes-client-project - io.fabric8 - 7.5-SNAPSHOT - - - kubernetes-itests - jar - Fabric8 :: Kubernetes :: Regression :: Tests - - - true - - - - - org.junit.jupiter - junit-jupiter-engine - test - - - org.junit.jupiter - junit-jupiter-params - test - - - io.fabric8 - kubernetes-junit-jupiter-autodetected - test - - - io.fabric8 - openshift-client - test - - - io.fabric8 - kubernetes-client-api - test-jar - - - org.assertj - assertj-core - - - org.awaitility - awaitility - - - org.slf4j - slf4j-simple - test - - - org.bouncycastle - bcprov-jdk18on - test - - - org.bouncycastle - bcpkix-jdk18on - test - - - - - - enable-snapshots - - - central-portal-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - false - true - - - - - httpclient-jdk - - - io.fabric8 - openshift-client - test - - - io.fabric8 - kubernetes-httpclient-vertx - - - - - io.fabric8 - kubernetes-httpclient-jdk - - - - - httpclient-jetty - - - io.fabric8 - openshift-client - test - - - io.fabric8 - kubernetes-httpclient-vertx - - - - - io.fabric8 - kubernetes-httpclient-jetty - - - - - httpclient-okhttp - - - io.fabric8 - openshift-client - test - - - io.fabric8 - kubernetes-httpclient-vertx - - - - - io.fabric8 - kubernetes-httpclient-okhttp - - - - - + + + + + 4.0.0 + + kubernetes-client-project + io.fabric8 + 7.5-SNAPSHOT + + + kubernetes-itests + jar + Fabric8 :: Kubernetes :: Regression :: Tests + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + io.fabric8 + kubernetes-junit-jupiter-autodetected + test + + + io.fabric8 + openshift-client + test + + + io.fabric8 + kubernetes-client-api + test-jar + + + org.assertj + assertj-core + + + org.awaitility + awaitility + + + org.slf4j + slf4j-simple + test + + + org.bouncycastle + bcprov-jdk18on + test + + + org.bouncycastle + bcpkix-jdk18on + test + + + + + + enable-snapshots + + + central-portal-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + false + true + + + + + httpclient-jdk + + + io.fabric8 + openshift-client + test + + + io.fabric8 + kubernetes-httpclient-vertx + + + + + io.fabric8 + kubernetes-httpclient-jdk + + + + + httpclient-jetty + + + io.fabric8 + openshift-client + test + + + io.fabric8 + kubernetes-httpclient-vertx + + + + + io.fabric8 + kubernetes-httpclient-jetty + + + + + httpclient-okhttp + + + io.fabric8 + openshift-client + test + + + io.fabric8 + kubernetes-httpclient-vertx + + + + + io.fabric8 + kubernetes-httpclient-okhttp + + + + + httpclient-vertx-5 + + + + 5.0.1 + + + + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web-client + ${vertx.version} + + + io.vertx + vertx-web-common + ${vertx.version} + + + io.vertx + vertx-auth-common + ${vertx.version} + + + io.vertx + vertx-core-logging + ${vertx.version} + + + io.vertx + vertx-web + ${vertx.version} + + + io.vertx + vertx-bridge-common + ${vertx.version} + + + io.vertx + vertx-uri-template + ${vertx.version} + + + + + + io.fabric8 + openshift-client + test + + + io.fabric8 + kubernetes-httpclient-vertx + + + + + io.fabric8 + kubernetes-httpclient-vertx-5 + + + + + diff --git a/pom.xml b/pom.xml index dfd61f5d419..023076086e6 100644 --- a/pom.xml +++ b/pom.xml @@ -218,6 +218,7 @@ httpclient-jetty httpclient-okhttp httpclient-vertx + httpclient-vertx-5 kubernetes-client junit/mockwebserver junit/kubernetes-junit-jupiter @@ -487,6 +488,11 @@ kubernetes-httpclient-vertx ${project.version} + + io.fabric8 + kubernetes-httpclient-vertx-5 + ${project.version} + io.fabric8 openshift-client-api