From 2793dbd5bd00e2f7abba10ff3630da8190b3d50e Mon Sep 17 00:00:00 2001 From: kirovskii Date: Fri, 26 Jun 2026 23:29:26 +0300 Subject: [PATCH] Add request metadata capture for gRPC exchange attachments Co-authored-by: Cursor --- allure-grpc/README.md | 9 +- .../io/qameta/allure/grpc/AllureGrpc.java | 93 ++++++++++++++++--- .../io/qameta/allure/grpc/AllureGrpcTest.java | 92 ++++++++++++++++++ 3 files changed, 179 insertions(+), 15 deletions(-) diff --git a/allure-grpc/README.md b/allure-grpc/README.md index d826d1ae..efb35897 100644 --- a/allure-grpc/README.md +++ b/allure-grpc/README.md @@ -8,7 +8,7 @@ Use this module when your tests call gRPC services and you want method calls, me - Allure Java 3.x requires Java 17 or newer. - This module targets gRPC Java. -- The current build validates against gRPC Java 1.81.0 and Protobuf Java 4.35.0. +- The current build validates against gRPC Java 1.82.0 and Protobuf Java 4.35.1. ## Installation @@ -42,13 +42,15 @@ ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080) .build(); ``` -For advanced capture policy, use the constructor that accepts an HTTP exchange builder customizer: +Request metadata capture is disabled by default. To enable request and response metadata capture +and apply redaction rules, use the advanced constructor: ```java ClientInterceptor allure = new AllureGrpc( Allure.getLifecycle(), true, true, + true, exchange -> exchange.redactHeader("authorization") ); ``` @@ -56,5 +58,6 @@ ClientInterceptor allure = new AllureGrpc( ## Report Output - gRPC method calls as Allure steps. -- Request and response messages, metadata, status, and timing. +- Request and response messages, status, and timing. +- Optional request and response metadata when enabled through constructor flags. - Stream metadata for unary and streaming calls where available. diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index fac0fa2a..18226850 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -83,6 +83,7 @@ public class AllureGrpc implements ClientInterceptor { private final AllureLifecycle lifecycle; private final boolean markStepFailedOnNonZeroCode; + private final boolean interceptRequestMetadata; private final boolean interceptResponseMetadata; private final Consumer exchangeCustomizer; @@ -90,7 +91,7 @@ public class AllureGrpc implements ClientInterceptor { * Creates an Allure grpc with default configuration. */ public AllureGrpc() { - this(Allure.getLifecycle(), true, false); + this(Allure.getLifecycle(), true, false, false); } /** @@ -104,8 +105,7 @@ public AllureGrpc( final AllureLifecycle lifecycle, final boolean markStepFailedOnNonZeroCode, final boolean interceptResponseMetadata) { - this(lifecycle, markStepFailedOnNonZeroCode, interceptResponseMetadata, builder -> { - }); + this(lifecycle, markStepFailedOnNonZeroCode, false, interceptResponseMetadata); } /** @@ -113,16 +113,64 @@ public AllureGrpc( * * @param lifecycle the Allure lifecycle to use * @param markStepFailedOnNonZeroCode the mark step failed on non zero code + * @param interceptRequestMetadata the intercept request metadata + * @param interceptResponseMetadata the intercept response metadata + */ + public AllureGrpc( + final AllureLifecycle lifecycle, + final boolean markStepFailedOnNonZeroCode, + final boolean interceptRequestMetadata, + final boolean interceptResponseMetadata) { + this( + lifecycle, + markStepFailedOnNonZeroCode, + interceptRequestMetadata, + interceptResponseMetadata, + builder -> { + } + ); + } + + /** + * Creates an Allure grpc with the supplied values. + * + * @param lifecycle the Allure lifecycle to use + * @param markStepFailedOnNonZeroCode the mark step failed on non zero code + * @param interceptResponseMetadata the intercept response metadata + * @param exchangeCustomizer the HTTP exchange builder customizer + */ + public AllureGrpc( + final AllureLifecycle lifecycle, + final boolean markStepFailedOnNonZeroCode, + final boolean interceptResponseMetadata, + final Consumer exchangeCustomizer) { + this( + lifecycle, + markStepFailedOnNonZeroCode, + false, + interceptResponseMetadata, + exchangeCustomizer + ); + } + + /** + * Creates an Allure grpc with the supplied values. + * + * @param lifecycle the Allure lifecycle to use + * @param markStepFailedOnNonZeroCode the mark step failed on non zero code + * @param interceptRequestMetadata the intercept request metadata * @param interceptResponseMetadata the intercept response metadata * @param exchangeCustomizer the HTTP exchange builder customizer */ public AllureGrpc( final AllureLifecycle lifecycle, final boolean markStepFailedOnNonZeroCode, + final boolean interceptRequestMetadata, final boolean interceptResponseMetadata, final Consumer exchangeCustomizer) { this.lifecycle = lifecycle; this.markStepFailedOnNonZeroCode = markStepFailedOnNonZeroCode; + this.interceptRequestMetadata = interceptRequestMetadata; this.interceptResponseMetadata = interceptResponseMetadata; this.exchangeCustomizer = exchangeCustomizer == null ? builder -> { } : exchangeCustomizer; @@ -143,6 +191,7 @@ public ClientCall interceptCall( final long start = System.currentTimeMillis(); final List clientMessages = new ArrayList<>(); final List serverMessages = new ArrayList<>(); + final Map capturedRequestHeaders = new LinkedHashMap<>(); final Map initialHeaders = new LinkedHashMap<>(); final Map trailers = new LinkedHashMap<>(); final String authority = channel.authority(); @@ -164,6 +213,7 @@ public ClientCall interceptCall( ) { @Override public void start(final Listener responseListener, final Metadata requestHeaders) { + handleRequestHeaders(requestHeaders, capturedRequestHeaders); final Listener forwardingListener = new ForwardingClientCallListener() { @Override protected Listener delegate() { @@ -184,7 +234,7 @@ public void onMessage(final R message) { @Override public void onClose(final io.grpc.Status status, final Metadata responseTrailers) { - handleClose(status, responseTrailers, stepContext); + handleClose(status, responseTrailers, stepContext, capturedRequestHeaders); super.onClose(status, responseTrailers); } }; @@ -202,12 +252,13 @@ public void sendMessage(final T message) { private void handleClose( final io.grpc.Status status, final Metadata responseTrailers, - final StepContext stepContext) { + final StepContext stepContext, + final Map requestHeaders) { try { if (interceptResponseMetadata && responseTrailers != null) { - copyAsciiResponseMetadata(responseTrailers, stepContext.getTrailers()); + copyAsciiMetadata(responseTrailers, stepContext.getTrailers()); } - attachExchange(stepContext, status); + attachExchange(stepContext, status, requestHeaders); stepContext.getLifecycle().updateStep( stepContext.getStepUuid(), step -> step.setStatus(convertStatus(status)) @@ -226,13 +277,23 @@ private void handleClose( private void handleHeaders(final Metadata headers, final Map destination) { try { if (interceptResponseMetadata && headers != null) { - copyAsciiResponseMetadata(headers, destination); + copyAsciiMetadata(headers, destination); } } catch (Throwable throwable) { LOGGER.warn("Failed to capture response headers", throwable); } } + private void handleRequestHeaders(final Metadata headers, final Map destination) { + try { + if (interceptRequestMetadata && headers != null) { + copyAsciiMetadata(headers, destination); + } + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture request headers", throwable); + } + } + private void handleClientMessage(final T message, final List destination) { try { destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); @@ -253,10 +314,14 @@ private void handleServerMessage(final R message, final List destina } } - private void attachExchange(final StepContext stepContext, final io.grpc.Status status) { + private void attachExchange( + final StepContext stepContext, + final io.grpc.Status status, + final Map requestHeaders) { final HttpExchangeRequest request = buildRequest( stepContext.getMethodDescriptor(), stepContext.getClientMessages(), + requestHeaders, stepContext.getAuthority() ); final HttpExchangeResponse response = buildResponse( @@ -283,6 +348,7 @@ private HttpExchange.Builder exchangeBuilder(final HttpExchangeRequest request) private HttpExchangeRequest buildRequest( final MethodDescriptor methodDescriptor, final List clientMessages, + final Map requestHeaders, final String authority) { final HttpExchangeRequest.Builder builder = HttpExchangeRequest.builder( HTTP_METHOD, @@ -294,6 +360,9 @@ private HttpExchangeRequest buildRequest( if (authority != null) { builder.addHeader(":authority", authority); } + if (interceptRequestMetadata) { + requestHeaders.forEach(builder::addHeader); + } return builder .setBody(toHttpBody(clientMessages, isRequestStreaming(methodDescriptor.getType()))) .build(); @@ -429,9 +498,9 @@ private static boolean isResponseStreaming(final MethodDescriptor.MethodType met || methodType == MethodDescriptor.MethodType.BIDI_STREAMING; } - private static void copyAsciiResponseMetadata( - final Metadata source, - final Map target) { + private static void copyAsciiMetadata( + final Metadata source, + final Map target) { for (String key : source.keys()) { if (key == null) { continue; diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index eb0e9130..4c5bb685 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -17,8 +17,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ForwardingClientCall; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; @@ -56,6 +62,7 @@ class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; private static final String GRPC_EXCHANGE = "gRPC exchange"; + private static final Metadata.Key REQUEST_ID_HEADER = Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER); private static final ObjectMapper JSON = new ObjectMapper(); private ManagedChannel managedChannel; @@ -318,6 +325,42 @@ void unaryRequestBodyIsCapturedAsJsonObject() throws Exception { assertThat(exchange.at("/request/body/contentType").asText()).isEqualTo("application/grpc+json"); } + @Test + void unaryRequestMetadataIsCapturedWhenEnabled() throws Exception { + Request request = Request.newBuilder().setTopic("topic-with-metadata").build(); + Metadata requestHeaders = new Metadata(); + requestHeaders.put(REQUEST_ID_HEADER, "request-42"); + + AllureResults allureResults = executeUnaryWithMetadata( + request, + requestHeaders, + true + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValueByName(exchange.at("/request/headers"), REQUEST_ID_HEADER.name())) + .contains("request-42"); + } + + @Test + void unaryRequestMetadataIsNotCapturedWhenDisabled() throws Exception { + Request request = Request.newBuilder().setTopic("topic-without-metadata").build(); + Metadata requestHeaders = new Metadata(); + requestHeaders.put(REQUEST_ID_HEADER, "request-42"); + + AllureResults allureResults = executeUnaryWithMetadata( + request, + requestHeaders, + false + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValueByName(exchange.at("/request/headers"), REQUEST_ID_HEADER.name())) + .isEmpty(); + } + @Test void unaryResponseBodyIsCapturedAsJsonObject() throws Exception { GrpcMock.stubFor( @@ -473,4 +516,53 @@ protected final AllureResults executeUnaryExpectingException(Request request) { ); } + protected final AllureResults executeUnaryWithMetadata( + final Request request, + final Metadata requestHeaders, + final boolean interceptRequestMetadata) { + return Allure.step( + "Execute unary gRPC request with custom metadata and collect Allure results", + () -> runWithinTestContext(() -> { + try { + AllureGrpc interceptor = new RequestMetadataAllureGrpc( + requestHeaders, + interceptRequestMetadata + ); + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel) + .withInterceptors(interceptor); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); + } + }) + ); + } + + private static final class RequestMetadataAllureGrpc extends AllureGrpc { + + private final Metadata requestHeaders; + + RequestMetadataAllureGrpc(final Metadata requestHeaders, final boolean interceptRequestMetadata) { + super(Allure.getLifecycle(), true, interceptRequestMetadata, false); + this.requestHeaders = new Metadata(); + this.requestHeaders.merge(requestHeaders); + } + + @Override + public ClientCall interceptCall( + final MethodDescriptor method, + final CallOptions callOptions, + final Channel next) { + final ClientCall delegate = super.interceptCall(method, callOptions, next); + return new ForwardingClientCall.SimpleForwardingClientCall(delegate) { + @Override + public void start(final Listener responseListener, final Metadata headers) { + headers.merge(requestHeaders); + super.start(responseListener, headers); + } + }; + } + } + }