diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3d29f6ce..850e57d9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: - allure-hamcrest - allure-httpclient - allure-httpclient5 + - allure-java-httpclient - allure-java-commons - allure-java-commons-test - allure-jax-rs diff --git a/README.md b/README.md index 90c5da4e..4f9a9a5e 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Run your tests, then generate or serve the report from the produced Allure resul | Module | Use When | Captured Data | | --- | --- | --- | | [`allure-rest-assured`](allure-rest-assured/README.md) | REST Assured filters | HTTP requests and responses | +| [`allure-java-httpclient`](allure-java-httpclient/README.md) | Java built-in `HttpClient` wrapper | HTTP requests and responses | | [`allure-httpclient5`](allure-httpclient5/README.md) | Apache HttpClient 5 interceptors | HTTP requests and responses | | [`allure-httpclient`](allure-httpclient/README.md) | Apache HttpClient 4 interceptors | HTTP requests and responses | | [`allure-okhttp3`](allure-okhttp3/README.md) | OkHttp interceptors | HTTP requests and responses | diff --git a/allure-java-httpclient/README.md b/allure-java-httpclient/README.md new file mode 100644 index 00000000..662265da --- /dev/null +++ b/allure-java-httpclient/README.md @@ -0,0 +1,72 @@ +# allure-java-httpclient + +Java built-in HTTP Client integration for Allure Java. + +Use this module to capture requests and responses sent through `java.net.http.HttpClient` as structured HTTP exchange attachments in Allure Report. + +## Supported Versions + +- Allure Java 3.x requires Java 17 or newer. +- The wrapped HTTP client API is available since Java 11. +- Synchronous requests, asynchronous requests, and accepted HTTP/2 push promises are supported. + +## Installation + +Gradle: + +```kotlin +dependencies { + testImplementation(platform("io.qameta.allure:allure-bom:")) + testImplementation("io.qameta.allure:allure-java-httpclient") +} +``` + +Maven, with `allure-bom` imported in dependency management: + +```xml + + io.qameta.allure + allure-java-httpclient + test + +``` + +## Setup + +Wrap the client used by your tests with `io.qameta.allure.javahttpclient.AllureHttpClient`. + +```java +HttpClient httpClient = AllureHttpClient.wrap( + HttpClient.newBuilder().build() +); + +HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.test/api/items")) + .GET() + .build(); + +HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() +); +``` + +The wrapper passes calls through unchanged when no Allure test or fixture is running. + +Customize redaction and body limits before sharing the client: + +```java +HttpClient httpClient = AllureHttpClient.wrap(HttpClient.newHttpClient()) + .configureHttpExchange(exchange -> exchange + .redactHeader("X-Api-Key") + .redactQueryParameter("token") + .setMaxBodySize(64 * 1024)); +``` + +## Report Output + +- One `HTTP exchange` attachment for each completed request or accepted push promise. +- Request method, URL, HTTP version, headers, and captured body. +- Response status, HTTP version, headers, and captured body. +- Transport errors when a request completes exceptionally. + +Streaming response handlers remain streaming. The attachment contains the response bytes delivered by the time the response future completes; consume or close streaming bodies as required by `HttpClient`. diff --git a/allure-java-httpclient/build.gradle.kts b/allure-java-httpclient/build.gradle.kts new file mode 100644 index 00000000..7823b68c --- /dev/null +++ b/allure-java-httpclient/build.gradle.kts @@ -0,0 +1,28 @@ +description = "Allure Java HTTP Client Integration" + +dependencies { + api(project(":allure-java-commons")) + testImplementation("org.wiremock:wiremock") + testImplementation("org.assertj:assertj-core") + testImplementation(project(":allure-assertj")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.slf4j:slf4j-simple") + testImplementation(project(":allure-java-commons-test")) + testImplementation(project(":allure-junit-platform")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.jar { + manifest { + attributes( + mapOf( + "Automatic-Module-Name" to "io.qameta.allure.javahttpclient" + ) + ) + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/AllureHttpClient.java b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/AllureHttpClient.java new file mode 100644 index 00000000..7d827b4f --- /dev/null +++ b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/AllureHttpClient.java @@ -0,0 +1,381 @@ +/* + * Copyright 2016-2026 Qameta Software 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.qameta.allure.javahttpclient; + +import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AttachmentOptions; +import io.qameta.allure.http.HttpExchange; +import io.qameta.allure.http.HttpExchangeSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * An Allure-instrumented wrapper for the Java built-in {@link HttpClient}. + * + *

The wrapper records requests, responses, and transport errors as structured Allure HTTP exchange attachments. + * Calls made without a running Allure test or fixture are passed directly to the wrapped client.

+ */ +@SuppressWarnings("PMD.TooManyMethods") +public final class AllureHttpClient extends HttpClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(AllureHttpClient.class); + + private static final String ATTACHMENT_NAME = "HTTP exchange"; + + private final HttpClient delegate; + private final AllureLifecycle lifecycle; + + private final AtomicReference> exchangeCustomizer = new AtomicReference<>( + builder -> { + } + ); + + /** + * Wraps an HTTP client with Allure exchange capture. + * + * @param delegate the client to wrap + * @return the instrumented client + */ + public static AllureHttpClient wrap(final HttpClient delegate) { + return new AllureHttpClient(delegate); + } + + /** + * Creates an Allure-instrumented HTTP client using the current global lifecycle. + * + * @param delegate the client to wrap + */ + public AllureHttpClient(final HttpClient delegate) { + this(delegate, Allure.getLifecycle()); + } + + /** + * Creates an Allure-instrumented HTTP client using a supplied lifecycle. + * + * @param delegate the client to wrap + * @param lifecycle the lifecycle that owns captured attachments + */ + public AllureHttpClient(final HttpClient delegate, final AllureLifecycle lifecycle) { + this.delegate = Objects.requireNonNull(delegate, "delegate must not be null"); + this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must not be null"); + } + + /** + * Sets the shared HTTP exchange builder customizer. + * + * @param exchangeCustomizer the exchange builder customizer + * @return this instance for method chaining + */ + public AllureHttpClient configureHttpExchange(final Consumer exchangeCustomizer) { + this.exchangeCustomizer.set(Objects.requireNonNull(exchangeCustomizer, "exchangeCustomizer must not be null")); + return this; + } + + /** + * Returns the wrapped client. + * + * @return the wrapped client + */ + public HttpClient getDelegate() { + return delegate; + } + + /** + * {@inheritDoc} + */ + @Override + public Optional cookieHandler() { + return delegate.cookieHandler(); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional connectTimeout() { + return delegate.connectTimeout(); + } + + /** + * {@inheritDoc} + */ + @Override + public Redirect followRedirects() { + return delegate.followRedirects(); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional proxy() { + return delegate.proxy(); + } + + /** + * {@inheritDoc} + */ + @Override + public SSLContext sslContext() { + return delegate.sslContext(); + } + + /** + * {@inheritDoc} + */ + @Override + public SSLParameters sslParameters() { + return delegate.sslParameters(); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional authenticator() { + return delegate.authenticator(); + } + + /** + * {@inheritDoc} + */ + @Override + public Version version() { + return delegate.version(); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional executor() { + return delegate.executor(); + } + + /** + * {@inheritDoc} + */ + @Override + public WebSocket.Builder newWebSocketBuilder() { + return delegate.newWebSocketBuilder(); + } + + /** + * {@inheritDoc} + */ + @Override + public HttpResponse send(final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + final Optional parent = lifecycle.getCurrentExecutableKey(); + if (parent.isEmpty()) { + return delegate.send(request, responseBodyHandler); + } + + final HttpExchangeCapture capture = newCapture(request); + try { + final HttpResponse response = delegate.send( + capture.request(), + capture.bodyHandler(responseBodyHandler) + ); + attach(parent.get(), capture.exchange(response, null)); + return RestoredHttpResponse.restore(response); + } catch (IOException | InterruptedException | RuntimeException | Error throwable) { + attach(parent.get(), capture.exchange(null, throwable)); + throw throwable; + } + } + + /** + * {@inheritDoc} + */ + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + final Optional parent = lifecycle.getCurrentExecutableKey(); + if (parent.isEmpty()) { + return delegate.sendAsync(request, responseBodyHandler); + } + + return captureAsync( + parent.get(), + request, + capture -> delegate.sendAsync(capture.request(), capture.bodyHandler(responseBodyHandler)) + ); + } + + /** + * {@inheritDoc} + */ + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler, + final HttpResponse.PushPromiseHandler pushPromiseHandler) { + final Optional parent = lifecycle.getCurrentExecutableKey(); + if (parent.isEmpty()) { + return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler); + } + + return captureAsync( + parent.get(), + request, + capture -> delegate.sendAsync( + capture.request(), + capture.bodyHandler(responseBodyHandler), + capturingPushPromiseHandler(parent.get(), pushPromiseHandler) + ) + ); + } + + /** + * Delegates the Java 21 {@code HttpClient.shutdown()} lifecycle method when it is available. + */ + public void shutdown() { + HttpClientLifecycle.invoke(delegate, "shutdown"); + } + + /** + * Delegates the Java 21 {@code HttpClient.awaitTermination(Duration)} lifecycle method when it is available. + * + * @param duration the maximum duration to wait + * @return whether the client terminated before the duration elapsed + * @throws InterruptedException if the current thread is interrupted + */ + public boolean awaitTermination(final Duration duration) throws InterruptedException { + return HttpClientLifecycle.awaitTermination(delegate, duration); + } + + /** + * Delegates the Java 21 {@code HttpClient.isTerminated()} lifecycle method when it is available. + * + * @return whether the wrapped client has terminated + */ + public boolean isTerminated() { + return HttpClientLifecycle.invokeBoolean(delegate, "isTerminated"); + } + + /** + * Delegates the Java 21 {@code HttpClient.shutdownNow()} lifecycle method when it is available. + */ + public void shutdownNow() { + HttpClientLifecycle.invoke(delegate, "shutdownNow"); + } + + private HttpExchangeCapture newCapture(final HttpRequest request) { + return new HttpExchangeCapture(request, version(), exchangeCustomizer.get()); + } + + private CompletableFuture> captureAsync( + final AllureExternalKey parent, + final HttpRequest request, + final Function>> action) { + final HttpExchangeCapture capture = newCapture(request); + final CompletableFuture> response; + try { + response = action.apply(capture); + } catch (RuntimeException | Error throwable) { + attach(parent, capture.exchange(null, throwable)); + throw throwable; + } + + attachAsync(parent, capture, response); + return RestoredHttpResponse.restore(response); + } + + private HttpResponse.PushPromiseHandler capturingPushPromiseHandler( + final AllureExternalKey parent, + final HttpResponse.PushPromiseHandler handler) { + if (handler == null) { + return null; + } + return (initiatingRequest, pushPromiseRequest, acceptor) -> handler.applyPushPromise( + HttpExchangeCapture.unwrap(initiatingRequest), + pushPromiseRequest, + bodyHandler -> captureAsync( + parent, pushPromiseRequest, capture -> acceptor.apply( + capture.bodyHandler(bodyHandler) + ) + ) + ); + } + + private void attach(final AllureExternalKey parent, final HttpExchange exchange) { + try { + lifecycle.addAttachmentStep( + parent, + ATTACHMENT_NAME, + HttpExchange.CONTENT_TYPE, + serialized(exchange), + AttachmentOptions.empty() + ); + } catch (RuntimeException e) { + LOGGER.warn("Could not save Java HTTP Client exchange", e); + } + } + + private void attachAsync(final AllureExternalKey parent, + final HttpExchangeCapture capture, + final CompletableFuture> response) { + final CompletionStage content = response.handle( + (value, throwable) -> serialized(capture.exchange(value, throwable)) + ); + try { + lifecycle.addAttachmentStepAsync( + parent, + ATTACHMENT_NAME, + HttpExchange.CONTENT_TYPE, + content, + AttachmentOptions.empty() + ).exceptionally(throwable -> { + LOGGER.warn("Could not save asynchronous Java HTTP Client exchange", throwable); + return null; + }); + } catch (RuntimeException e) { + LOGGER.warn("Could not schedule asynchronous Java HTTP Client exchange", e); + } + } + + private static InputStream serialized(final HttpExchange exchange) { + return new ByteArrayInputStream(HttpExchangeSerializer.toJsonBytes(exchange)); + } +} diff --git a/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpClientLifecycle.java b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpClientLifecycle.java new file mode 100644 index 00000000..e1028613 --- /dev/null +++ b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpClientLifecycle.java @@ -0,0 +1,85 @@ +/* + * Copyright 2016-2026 Qameta Software 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.qameta.allure.javahttpclient; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.http.HttpClient; +import java.time.Duration; + +final class HttpClientLifecycle { + + private static final String JAVA_21_REQUIRED = "HTTP client lifecycle methods require Java 21 or newer"; + private static final String ACCESS_ERROR = "Could not access HTTP client lifecycle method "; + + private HttpClientLifecycle() { + throw new IllegalStateException("do not instantiate"); + } + + static void invoke(final HttpClient delegate, final String name) { + try { + method(name).invoke(delegate); + } catch (NoSuchMethodException e) { + throw new UnsupportedOperationException(JAVA_21_REQUIRED, e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(ACCESS_ERROR + name, e); + } catch (InvocationTargetException e) { + throwUnchecked(e.getCause()); + } + } + + static boolean invokeBoolean(final HttpClient delegate, final String name) { + try { + return (Boolean) method(name).invoke(delegate); + } catch (NoSuchMethodException e) { + throw new UnsupportedOperationException(JAVA_21_REQUIRED, e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(ACCESS_ERROR + name, e); + } catch (InvocationTargetException e) { + return throwUnchecked(e.getCause()); + } + } + + @SuppressWarnings("PMD.PreserveStackTrace") // InvocationTargetException wraps the checked delegate exception. + static boolean awaitTermination(final HttpClient delegate, final Duration duration) throws InterruptedException { + try { + return (Boolean) method("awaitTermination", Duration.class).invoke(delegate, duration); + } catch (NoSuchMethodException e) { + throw new UnsupportedOperationException(JAVA_21_REQUIRED, e); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not access HTTP client lifecycle method awaitTermination", e); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof InterruptedException interrupted) { + throw interrupted; + } + return throwUnchecked(e.getCause()); + } + } + + private static Method method(final String name, final Class... parameterTypes) throws NoSuchMethodException { + return HttpClient.class.getMethod(name, parameterTypes); + } + + private static T throwUnchecked(final Throwable throwable) { + if (throwable instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (throwable instanceof Error error) { + throw error; + } + throw new IllegalStateException("HTTP client lifecycle method failed", throwable); + } +} diff --git a/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpExchangeCapture.java b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpExchangeCapture.java new file mode 100644 index 00000000..4c0d58f1 --- /dev/null +++ b/allure-java-httpclient/src/main/java/io/qameta/allure/javahttpclient/HttpExchangeCapture.java @@ -0,0 +1,453 @@ +/* + * Copyright 2016-2026 Qameta Software 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.qameta.allure.javahttpclient; + +import io.qameta.allure.http.HttpExchange; +import io.qameta.allure.http.HttpExchangeBody; +import io.qameta.allure.http.HttpExchangeError; +import io.qameta.allure.http.HttpExchangeRequest; +import io.qameta.allure.http.HttpExchangeResponse; + +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +@SuppressWarnings("PMD.AvoidSynchronizedStatement") +final class HttpExchangeCapture { + + private static final String NO_BODY = "No body present"; + + private final HttpRequest originalRequest; + private final HttpClient.Version defaultVersion; + private final Consumer exchangeCustomizer; + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + private final long start = System.currentTimeMillis(); + + private final AtomicReference responseInfo = new AtomicReference<>(); + private final HttpRequest capturedRequest; + + HttpExchangeCapture(final HttpRequest request, + final HttpClient.Version defaultVersion, + final Consumer exchangeCustomizer) { + this.originalRequest = Objects.requireNonNull(request, "request must not be null"); + this.defaultVersion = Objects.requireNonNull(defaultVersion, "defaultVersion must not be null"); + this.exchangeCustomizer = Objects.requireNonNull(exchangeCustomizer, "exchangeCustomizer must not be null"); + this.capturedRequest = instrument(request); + } + + HttpRequest request() { + return capturedRequest; + } + + HttpResponse.BodyHandler bodyHandler(final HttpResponse.BodyHandler delegate) { + Objects.requireNonNull(delegate, "responseBodyHandler must not be null"); + return info -> { + responseInfo.set(info); + return new CapturingBodySubscriber<>(delegate.apply(info), this::captureResponseBody); + }; + } + + HttpExchange exchange(final HttpResponse response, final Throwable throwable) { + final HttpExchange.Builder builder = HttpExchange.builder(capturedRequest()) + .setStart(start) + .setStop(System.currentTimeMillis()); + exchangeCustomizer.accept(builder); + + final HttpExchangeResponse capturedResponse = capturedResponse(response); + if (capturedResponse != null) { + builder.setResponse(capturedResponse); + } + if (throwable != null) { + builder.setError(error(throwable)); + } + return builder.build(); + } + + static HttpRequest unwrap(final HttpRequest request) { + return request instanceof CapturingHttpRequest captured ? captured.delegate : request; + } + + static boolean isInstrumented(final HttpRequest request) { + return request instanceof CapturingHttpRequest; + } + + private HttpRequest instrument(final HttpRequest request) { + return request.bodyPublisher() + .map( + publisher -> new CapturingHttpRequest( + request, + new CapturingBodyPublisher(publisher, this::captureRequestBody) + ) + ) + .orElse(request); + } + + private HttpExchangeRequest capturedRequest() { + final HttpExchangeRequest.Builder builder = HttpExchangeRequest + .builder(originalRequest.method(), originalRequest.uri().toString()) + .setHttpVersion(version(originalRequest.version().orElse(defaultVersion))); + addHeaders(builder, originalRequest.headers()); + + final byte[] bytes = snapshot(requestBody); + if (bytes.length > 0) { + builder.setBody(body(originalRequest.headers(), bytes)); + } + return builder.build(); + } + + private HttpExchangeResponse capturedResponse(final HttpResponse response) { + final HttpResponse.ResponseInfo info = responseInfo.get(); + if (response == null && info == null) { + return null; + } + + final HttpExchangeResponse.Builder builder = HttpExchangeResponse.builder(); + if (response != null) { + builder.setStatus(response.statusCode()) + .setHttpVersion(version(response.version())); + addHeaders(builder, response.headers()); + } else { + builder.setStatus(info.statusCode()) + .setHttpVersion(version(info.version())); + addHeaders(builder, info.headers()); + } + + final HttpHeaders headers = response == null ? info.headers() : response.headers(); + final byte[] bytes = snapshot(responseBody); + builder.setBody(bytes.length == 0 ? HttpExchangeBody.utf8(NO_BODY) : body(headers, bytes)); + return builder.build(); + } + + private void captureRequestBody(final ByteBuffer buffer) { + capture(requestBody, buffer); + } + + private void captureResponseBody(final ByteBuffer buffer) { + capture(responseBody, buffer); + } + + private static void capture(final ByteArrayOutputStream destination, final ByteBuffer source) { + final ByteBuffer copy = source.asReadOnlyBuffer(); + final byte[] bytes = new byte[copy.remaining()]; + copy.get(bytes); + synchronized (destination) { + destination.writeBytes(bytes); + } + } + + private static byte[] snapshot(final ByteArrayOutputStream source) { + synchronized (source) { + return source.toByteArray(); + } + } + + private static HttpExchangeBody body(final HttpHeaders headers, final byte[] bytes) { + return new HttpExchangeBody( + headers.firstValue("Content-Type").orElse(null), + "utf8", + new String(bytes, StandardCharsets.UTF_8), + (long) bytes.length, + null, + null, + null, + null + ); + } + + private static HttpExchangeError error(final Throwable throwable) { + final Throwable unwrapped = unwrapFailure(throwable); + return new HttpExchangeError(unwrapped.getClass().getName(), unwrapped.getMessage(), null); + } + + private static Throwable unwrapFailure(final Throwable throwable) { + Throwable result = throwable; + while ((result instanceof CompletionException || result instanceof ExecutionException) + && result.getCause() != null) { + result = result.getCause(); + } + return result; + } + + private static String version(final HttpClient.Version version) { + return switch (version) { + case HTTP_1_1 -> "HTTP/1.1"; + case HTTP_2 -> "HTTP/2"; + }; + } + + private static void addHeaders(final HttpExchangeRequest.Builder builder, final HttpHeaders headers) { + headers.map().forEach( + (name, values) -> values.forEach(value -> builder.addHeader(name, value)) + ); + } + + private static void addHeaders(final HttpExchangeResponse.Builder builder, final HttpHeaders headers) { + headers.map().forEach( + (name, values) -> values.forEach(value -> builder.addHeader(name, value)) + ); + } + + private static final class CapturingHttpRequest extends HttpRequest { + private final HttpRequest delegate; + private final BodyPublisher bodyPublisher; + + private CapturingHttpRequest(final HttpRequest delegate, final BodyPublisher bodyPublisher) { + this.delegate = delegate; + this.bodyPublisher = bodyPublisher; + } + + @Override + public Optional bodyPublisher() { + return Optional.of(bodyPublisher); + } + + @Override + public String method() { + return delegate.method(); + } + + @Override + public Optional timeout() { + return delegate.timeout(); + } + + @Override + public boolean expectContinue() { + return delegate.expectContinue(); + } + + @Override + public URI uri() { + return delegate.uri(); + } + + @Override + public Optional version() { + return delegate.version(); + } + + @Override + public HttpHeaders headers() { + return delegate.headers(); + } + } + + private static final class CapturingBodyPublisher implements HttpRequest.BodyPublisher { + private final HttpRequest.BodyPublisher delegate; + private final Consumer capture; + private final AtomicBoolean firstSubscription = new AtomicBoolean(true); + + private CapturingBodyPublisher(final HttpRequest.BodyPublisher delegate, + final Consumer capture) { + this.delegate = delegate; + this.capture = capture; + } + + @Override + public long contentLength() { + return delegate.contentLength(); + } + + @Override + public void subscribe(final Flow.Subscriber subscriber) { + if (firstSubscription.compareAndSet(true, false)) { + delegate.subscribe(new CapturingPublisherSubscriber(subscriber, capture)); + } else { + delegate.subscribe(subscriber); + } + } + } + + private static final class CapturingPublisherSubscriber implements Flow.Subscriber { + private final Flow.Subscriber delegate; + private final Consumer capture; + + private CapturingPublisherSubscriber(final Flow.Subscriber delegate, + final Consumer capture) { + this.delegate = delegate; + this.capture = capture; + } + + @Override + public void onSubscribe(final Flow.Subscription subscription) { + delegate.onSubscribe(subscription); + } + + @Override + public void onNext(final ByteBuffer item) { + capture.accept(item); + delegate.onNext(item); + } + + @Override + public void onError(final Throwable throwable) { + delegate.onError(throwable); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + } + + private static final class CapturingBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate; + private final Consumer capture; + + private CapturingBodySubscriber(final HttpResponse.BodySubscriber delegate, + final Consumer capture) { + this.delegate = Objects.requireNonNull(delegate, "bodySubscriber must not be null"); + this.capture = capture; + } + + @Override + public CompletionStage getBody() { + return delegate.getBody(); + } + + @Override + public void onSubscribe(final Flow.Subscription subscription) { + delegate.onSubscribe(subscription); + } + + @Override + public void onNext(final List items) { + items.forEach(capture); + delegate.onNext(items); + } + + @Override + public void onError(final Throwable throwable) { + delegate.onError(throwable); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + } +} + +final class RestoredHttpResponse implements HttpResponse { + + private final HttpResponse delegate; + private final HttpRequest request; + + private RestoredHttpResponse(final HttpResponse delegate) { + this.delegate = delegate; + this.request = HttpExchangeCapture.unwrap(delegate.request()); + } + + static HttpResponse restore(final HttpResponse response) { + return HttpExchangeCapture.isInstrumented(response.request()) ? new RestoredHttpResponse<>(response) : response; + } + + static CompletableFuture> restore( + final CompletableFuture> response) { + final ForwardingFuture> restored = new ForwardingFuture<>(response); + response.whenComplete((value, throwable) -> { + if (throwable == null) { + restored.complete(restore(value)); + } else { + restored.completeExceptionally(throwable); + } + }); + return restored; + } + + @Override + public int statusCode() { + return delegate.statusCode(); + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return delegate.previousResponse().map(RestoredHttpResponse::restore); + } + + @Override + public HttpHeaders headers() { + return delegate.headers(); + } + + @Override + public T body() { + return delegate.body(); + } + + @Override + public Optional sslSession() { + return delegate.sslSession(); + } + + @Override + public URI uri() { + return delegate.uri(); + } + + @Override + public HttpClient.Version version() { + return delegate.version(); + } + + private static final class ForwardingFuture extends CompletableFuture { + private final CompletableFuture delegate; + + private ForwardingFuture(final CompletableFuture delegate) { + this.delegate = delegate; + } + + @Override + public boolean cancel(final boolean mayInterruptIfRunning) { + final boolean cancelled = super.cancel(mayInterruptIfRunning); + delegate.cancel(mayInterruptIfRunning); + return cancelled; + } + + @Override + public void obtrudeValue(final T value) { + throw new UnsupportedOperationException(); + } + + @Override + public void obtrudeException(final Throwable throwable) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/AllureHttpClientTest.java b/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/AllureHttpClientTest.java new file mode 100644 index 00000000..b0dabd61 --- /dev/null +++ b/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/AllureHttpClientTest.java @@ -0,0 +1,659 @@ +/* + * Copyright 2016-2026 Qameta Software 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.qameta.allure.javahttpclient; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.Description; +import io.qameta.allure.Issue; +import io.qameta.allure.model.Attachment; +import io.qameta.allure.test.AllureResults; +import io.qameta.allure.test.IsolatedLifecycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static io.qameta.allure.javahttpclient.HttpExchangeTestSupport.attachmentContent; +import static io.qameta.allure.javahttpclient.HttpExchangeTestSupport.executeWithAllure; +import static io.qameta.allure.javahttpclient.HttpExchangeTestSupport.httpExchangeAttachment; +import static io.qameta.allure.javahttpclient.HttpExchangeTestSupport.httpExchangeAttachments; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@IsolatedLifecycle +class AllureHttpClientTest { + + private static final String RESPONSE_BODY = "response-body"; + + private WireMockServer server; + + @BeforeEach + void setUp() { + server = new WireMockServer(options().dynamicPort()); + server.start(); + server.stubFor( + post(urlEqualTo("/items")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "text/plain") + .withHeader("X-Response-Id", "response-42") + .withBody(RESPONSE_BODY) + ) + ); + server.stubFor( + get(urlEqualTo("/async")) + .willReturn( + aResponse() + .withHeader("Content-Type", "text/plain") + .withBody("async-body") + ) + ); + } + + @AfterEach + void tearDown() { + if (Objects.nonNull(server)) { + server.stop(); + } + } + + /** + * Verifies that a synchronous POST produces one structured exchange while preserving the response body and + * original request identity for the caller. + */ + @Test + @Issue("957") + @Description + void shouldCaptureSynchronousRequestAndResponse() { + final AllureResults results = executeWithAllure(() -> { + final HttpRequest request = HttpRequest.newBuilder(uri("/items")) + .header("Content-Type", "text/plain") + .header("X-Request-Id", "request-42") + .POST(HttpRequest.BodyPublishers.ofString("request-body")) + .build(); + final HttpClient client = AllureHttpClient.wrap(HttpClient.newHttpClient()); + + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(201); + assertThat(response.body()).isEqualTo(RESPONSE_BODY); + assertThat(response.request()).isSameAs(request); + }); + + final Attachment attachment = httpExchangeAttachment(results); + final String exchange = attachmentContent(results, attachment); + + assertThat(attachment.getName()).isEqualTo("HTTP exchange"); + assertThat(exchange) + .contains("\"method\":\"POST\"") + .contains("\"url\":\"" + uri("/items") + "\"") + .contains("\"httpVersion\":\"HTTP/1.1\"") + .contains("\"name\":\"X-Request-Id\",\"value\":\"request-42\"") + .contains("\"value\":\"request-body\"") + .contains("\"status\":201") + .contains("\"name\":\"x-response-id\",\"value\":\"response-42\"") + .contains("\"value\":\"response-body\""); + } + + /** + * Verifies that an asynchronous response completed on the HTTP client executor remains attached to the Allure + * test that initiated it. + */ + @Test + @Issue("957") + @Description + void shouldCaptureAsynchronousExchangeOnTheInitiatingTest() { + final AllureResults results = executeWithAllure(() -> { + final HttpRequest request = HttpRequest.newBuilder(uri("/async")).GET().build(); + final HttpClient client = AllureHttpClient.wrap(HttpClient.newHttpClient()); + + final HttpResponse response = client + .sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .join(); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo("async-body"); + }); + + assertThat(attachmentContent(results, httpExchangeAttachment(results))) + .contains("\"url\":\"" + uri("/async") + "\"") + .contains("\"status\":200") + .contains("\"value\":\"async-body\""); + } + + /** + * Verifies that accepted HTTP/2 push promises produce their own exchange attachment without hiding the primary + * response. + */ + @Test + @Issue("957") + @Description + void shouldCaptureAcceptedPushPromises() { + final AllureResults results = executeWithAllure(() -> { + final HttpClient client = AllureHttpClient.wrap(new PushPromiseHttpClient()); + final HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.test/main")).GET().build(); + final List>> pushes = new ArrayList<>(); + + final HttpResponse response = client.sendAsync( + request, + HttpResponse.BodyHandlers.ofString(), + (initiating, push, acceptor) -> pushes.add(acceptor.apply(HttpResponse.BodyHandlers.ofString())) + ).join(); + + assertThat(response.body()).isEqualTo("main-body"); + assertThat(pushes).singleElement().satisfies( + push -> assertThat(push.join().body()).isEqualTo("push-body") + ); + }); + + final List exchanges = httpExchangeAttachments(results).stream() + .map(attachment -> attachmentContent(results, attachment)) + .toList(); + assertThat(exchanges) + .hasSize(2) + .anySatisfy( + exchange -> assertThat(exchange) + .contains("https://example.test/main") + .contains("main-body") + ) + .anySatisfy( + exchange -> assertThat(exchange) + .contains("https://example.test/pushed") + .contains("push-body") + ); + } + + /** + * Verifies that synchronous transport failures remain visible to the caller and are recorded as failed + * exchanges. + */ + @Test + @Issue("957") + @Description + void shouldCaptureSynchronousTransportErrors() { + final AllureResults results = executeWithAllure(() -> { + final HttpClient client = AllureHttpClient.wrap(new FailingHttpClient()); + final HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.test/failure")).GET().build(); + + assertThatThrownBy(() -> client.send(request, HttpResponse.BodyHandlers.ofString())) + .isInstanceOf(IOException.class) + .hasMessage("simulated transport failure"); + }); + + assertThat(attachmentContent(results, httpExchangeAttachment(results))) + .contains("\"url\":\"https://example.test/failure\"") + .contains("\"name\":\"java.io.IOException\"") + .contains("\"message\":\"simulated transport failure\""); + } + + /** + * Verifies that asynchronous transport failures remain visible through the returned future and are recorded as + * failed exchanges owned by the initiating test. + */ + @Test + @Issue("957") + @Description + void shouldCaptureAsynchronousTransportErrors() { + final AllureResults results = executeWithAllure(() -> { + final HttpClient client = AllureHttpClient.wrap(new FailingHttpClient()); + final HttpRequest request = HttpRequest.newBuilder( + URI.create("https://example.test/async-failure") + ).GET().build(); + + assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(IOException.class) + .hasRootCauseMessage("simulated transport failure"); + }); + + assertThat(attachmentContent(results, httpExchangeAttachment(results))) + .contains("\"url\":\"https://example.test/async-failure\"") + .contains("\"name\":\"java.io.IOException\"") + .contains("\"message\":\"simulated transport failure\""); + } + + /** + * Verifies that cancelling the future returned by the wrapper cancels the underlying HTTP exchange and records + * the cancellation. + */ + @Test + @Issue("957") + @Description + void shouldPropagateAsynchronousCancellation() { + final PendingHttpClient delegate = new PendingHttpClient(); + final AllureResults results = executeWithAllure(() -> { + final HttpClient client = AllureHttpClient.wrap(delegate); + final HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.test/pending")).GET().build(); + + final CompletableFuture> response = client.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(response.cancel(true)).isTrue(); + assertThat(delegate.response).isCancelled(); + }); + + assertThat(attachmentContent(results, httpExchangeAttachment(results))) + .contains("\"url\":\"https://example.test/pending\"") + .contains("\"name\":\"java.util.concurrent.CancellationException\""); + } + + /** + * Verifies that shared HTTP exchange options redact configured headers and limit captured bodies. + */ + @Test + @Issue("957") + @Description + void shouldApplyHttpExchangeCustomization() { + final AllureResults results = executeWithAllure(() -> { + final HttpRequest request = HttpRequest.newBuilder(uri("/items")) + .header("Content-Type", "text/plain") + .header("X-Api-Key", "very-secret") + .POST(HttpRequest.BodyPublishers.ofString("request-body")) + .build(); + final HttpClient client = AllureHttpClient.wrap(HttpClient.newHttpClient()) + .configureHttpExchange( + exchange -> exchange + .redactHeader("X-Api-Key") + .setMaxBodySize(5) + ); + + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.body()).isEqualTo(RESPONSE_BODY); + }); + + assertThat(attachmentContent(results, httpExchangeAttachment(results))) + .contains("__ALLURE_REDACTED__") + .doesNotContain("very-secret") + .contains("\"value\":\"reque\"") + .contains("\"value\":\"respo\"") + .contains("\"truncated\":true"); + } + + /** + * Verifies that the wrapper preserves exact delegate inputs and client configuration when no Allure executable + * is running. + */ + @Test + @Issue("957") + @Description + void shouldPassThroughWithoutAnAllureContext() throws Exception { + final RecordingHttpClient delegate = new RecordingHttpClient(); + final AllureHttpClient client = new AllureHttpClient(delegate, new AllureLifecycle()); + final HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.test/direct")).GET().build(); + final HttpResponse.BodyHandler handler = HttpResponse.BodyHandlers.ofString(); + + assertThat(client.send(request, handler)).isNull(); + assertThat(delegate.request).isSameAs(request); + assertThat(delegate.bodyHandler).isSameAs(handler); + assertThat(client.getDelegate()).isSameAs(delegate); + assertThat(client.version()).isEqualTo(delegate.version()); + assertThat(client.followRedirects()).isEqualTo(delegate.followRedirects()); + assertThat(client.newWebSocketBuilder()).isNotNull(); + } + + /** + * Verifies lifecycle delegation on runtimes that expose the Java 21 HTTP client lifecycle API and the documented + * unsupported result on older runtimes. + */ + @Test + @Issue("957") + @Description + void shouldDelegateRuntimeLifecycleMethods() throws Exception { + final LifecycleHttpClient delegate = new LifecycleHttpClient(); + final AllureHttpClient client = AllureHttpClient.wrap(delegate); + final Duration timeout = Duration.ofSeconds(1); + + if (Runtime.version().feature() < 21) { + assertThatThrownBy(client::shutdown).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(client::isTerminated).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(client::shutdownNow).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> client.awaitTermination(timeout)) + .isInstanceOf(UnsupportedOperationException.class); + return; + } + + client.shutdown(); + assertThat(delegate.shutdown).isTrue(); + assertThat(client.awaitTermination(timeout)).isTrue(); + assertThat(delegate.awaitTerminationTimeout).isEqualTo(timeout); + assertThat(client.isTerminated()).isTrue(); + client.shutdownNow(); + assertThat(delegate.shutdownNow).isTrue(); + } + + private URI uri(final String path) { + return URI.create(String.format("http://localhost:%d%s", server.port(), path)); + } + + private abstract static class StubHttpClient extends HttpClient { + private final HttpClient configuration = HttpClient.newHttpClient(); + + @Override + public Optional cookieHandler() { + return configuration.cookieHandler(); + } + + @Override + public Optional connectTimeout() { + return configuration.connectTimeout(); + } + + @Override + public Redirect followRedirects() { + return configuration.followRedirects(); + } + + @Override + public Optional proxy() { + return configuration.proxy(); + } + + @Override + public SSLContext sslContext() { + return configuration.sslContext(); + } + + @Override + public SSLParameters sslParameters() { + return configuration.sslParameters(); + } + + @Override + public Optional authenticator() { + return configuration.authenticator(); + } + + @Override + public Version version() { + return configuration.version(); + } + + @Override + public Optional executor() { + return configuration.executor(); + } + + @Override + public WebSocket.Builder newWebSocketBuilder() { + return configuration.newWebSocketBuilder(); + } + } + + private static final class FailingHttpClient extends StubHttpClient { + @Override + public HttpResponse send(final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) + throws IOException { + throw new IOException("simulated transport failure"); + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + return CompletableFuture.failedFuture(new IOException("simulated transport failure")); + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler, + final HttpResponse.PushPromiseHandler pushPromiseHandler) { + return sendAsync(request, responseBodyHandler); + } + } + + private static final class PendingHttpClient extends StubHttpClient { + private final CompletableFuture> response = new CompletableFuture<>(); + + @Override + public HttpResponse send(final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + return (CompletableFuture>) (CompletableFuture) response; + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler, + final HttpResponse.PushPromiseHandler pushPromiseHandler) { + return sendAsync(request, responseBodyHandler); + } + } + + private static class RecordingHttpClient extends StubHttpClient { + private HttpRequest request; + private HttpResponse.BodyHandler bodyHandler; + + @Override + public HttpResponse send(final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + this.request = request; + this.bodyHandler = responseBodyHandler; + return null; + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler, + final HttpResponse.PushPromiseHandler pushPromiseHandler) { + return sendAsync(request, responseBodyHandler); + } + } + + private static final class LifecycleHttpClient extends RecordingHttpClient { + private boolean shutdown; + private boolean shutdownNow; + private Duration awaitTerminationTimeout; + + public void shutdown() { + shutdown = true; + } + + public boolean awaitTermination(final Duration duration) { + awaitTerminationTimeout = duration; + return true; + } + + public boolean isTerminated() { + return shutdown; + } + + public void shutdownNow() { + shutdownNow = true; + } + } + + private static final class PushPromiseHttpClient extends StubHttpClient { + @Override + public HttpResponse send(final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler) { + return CompletableFuture.completedFuture(response(request, responseBodyHandler, "main-body")); + } + + @Override + public CompletableFuture> sendAsync( + final HttpRequest request, + final HttpResponse.BodyHandler responseBodyHandler, + final HttpResponse.PushPromiseHandler pushPromiseHandler) { + final HttpRequest pushRequest = HttpRequest.newBuilder( + URI.create("https://example.test/pushed") + ).GET().build(); + if (pushPromiseHandler != null) { + pushPromiseHandler.applyPushPromise( + request, + pushRequest, + handler -> CompletableFuture.completedFuture(response(pushRequest, handler, "push-body")) + ); + } + return sendAsync(request, responseBodyHandler); + } + + private static HttpResponse response(final HttpRequest request, + final HttpResponse.BodyHandler handler, + final String body) { + final HttpHeaders headers = HttpHeaders.of( + Map.of("Content-Type", List.of("text/plain")), + (name, value) -> true + ); + final HttpResponse.ResponseInfo info = new HttpResponse.ResponseInfo() { + @Override + public int statusCode() { + return 200; + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public Version version() { + return Version.HTTP_2; + } + }; + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + final CompletableFuture result = subscriber.getBody().toCompletableFuture(); + subscriber.onSubscribe(new NoopSubscription()); + subscriber.onNext(List.of(ByteBuffer.wrap(body.getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + return new StubResponse<>(request, headers, result.join()); + } + } + + private static final class NoopSubscription implements Flow.Subscription { + @Override + public void request(final long count) { + // the fake response body is delivered synchronously + } + + @Override + public void cancel() { + // the fake response body is already complete + } + } + + private static final class StubResponse implements HttpResponse { + private final HttpRequest request; + private final HttpHeaders headers; + private final T body; + + private StubResponse(final HttpRequest request, final HttpHeaders headers, final T body) { + this.request = request; + this.headers = headers; + this.body = body; + } + + @Override + public int statusCode() { + return 200; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public T body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_2; + } + } +} diff --git a/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/HttpExchangeTestSupport.java b/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/HttpExchangeTestSupport.java new file mode 100644 index 00000000..d612932f --- /dev/null +++ b/allure-java-httpclient/src/test/java/io/qameta/allure/javahttpclient/HttpExchangeTestSupport.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016-2026 Qameta Software 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.qameta.allure.javahttpclient; + +import io.qameta.allure.http.HttpExchange; +import io.qameta.allure.model.Attachment; +import io.qameta.allure.test.AllureResults; + +import java.util.List; + +import static io.qameta.allure.Allure.step; +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; + +final class HttpExchangeTestSupport { + + private HttpExchangeTestSupport() { + throw new IllegalStateException("do not instantiate"); + } + + static AllureResults executeWithAllure(final ThrowingRunnable runnable) { + return step("Execute Java HTTP Client request and collect Allure results", () -> runWithinTestContext(() -> { + try { + runnable.run(); + } catch (Exception e) { + throw new AssertionError(e); + } + })); + } + + static Attachment httpExchangeAttachment(final AllureResults results) { + final List attachments = httpExchangeAttachments(results); + assertThat(attachments).hasSize(1); + return attachments.get(0); + } + + static List httpExchangeAttachments(final AllureResults results) { + return results.getAttachmentsRecursively().stream() + .filter(attachment -> HttpExchange.CONTENT_TYPE.equals(attachment.getType())) + .toList(); + } + + static String attachmentContent(final AllureResults results, final Attachment attachment) { + return results.getAttachmentContentAsString(attachment); + } + + @FunctionalInterface + interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/allure-java-httpclient/src/test/resources/allure.properties b/allure-java-httpclient/src/test/resources/allure.properties new file mode 100644 index 00000000..def14eff --- /dev/null +++ b/allure-java-httpclient/src/test/resources/allure.properties @@ -0,0 +1,4 @@ +allure.results.directory=build/allure-results +allure.label.epic=#project.description# +allure.label.module=allure-java-httpclient +allure.link.issue.pattern=https://github.com/allure-framework/allure-java/issues/{} diff --git a/gradle/quality-configs/pmd/pmd.xml b/gradle/quality-configs/pmd/pmd.xml index d7388f65..970eb0af 100644 --- a/gradle/quality-configs/pmd/pmd.xml +++ b/gradle/quality-configs/pmd/pmd.xml @@ -18,11 +18,24 @@ + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index e2899c4b..375badae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ include("allure-grpc") include("allure-hamcrest") include("allure-httpclient") include("allure-httpclient5") +include("allure-java-httpclient") include("allure-java-commons") include("allure-java-commons-test") include("allure-jax-rs")