diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e0d99fc81..f49976b59 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,9 +3,14 @@ name: "Set theme labels" on: - pull_request_target +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml index 0c18ecb77..7315a905a 100644 --- a/.github/workflows/labels-verify.yml +++ b/.github/workflows/labels-verify.yml @@ -4,9 +4,14 @@ on: pull_request_target: types: [opened, labeled, unlabeled, synchronize] +permissions: + contents: none + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - uses: baev/action-label-verify@main with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f98763d24..97d4d7fbd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,9 @@ on: release: types: [ published ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e31f9b54..6b08eb0e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,14 @@ on: description: "The next version in . format WITHOUT SNAPSHOT SUFFIX" required: true +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: "Check release version" run: | diff --git a/allure-grpc/build.gradle.kts b/allure-grpc/build.gradle.kts index 4bc8d3567..259fc7e3d 100644 --- a/allure-grpc/build.gradle.kts +++ b/allure-grpc/build.gradle.kts @@ -8,14 +8,16 @@ description = "Allure gRPC Integration" val agent: Configuration by configurations.creating -val grpcVersion = "1.57.2" -val protobufVersion = "4.27.3" +val grpcVersion = "1.75.0" +val protobufVersion = "4.32.1" +val jacksonVersion = "2.17.2" dependencies { agent("org.aspectj:aspectjweaver") api(project(":allure-attachments")) + compileOnly("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") compileOnly("com.google.protobuf:protobuf-java-util:$protobufVersion") - compileOnly("io.grpc:grpc-core:$grpcVersion") + compileOnly("io.grpc:grpc-api:$grpcVersion") testImplementation("com.google.protobuf:protobuf-java-util:$protobufVersion") testImplementation("com.google.protobuf:protobuf-java:$protobufVersion") testImplementation("io.grpc:grpc-core:$grpcVersion") 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 35fc4977e..d79b20d49 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 @@ -27,180 +27,327 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; import io.qameta.allure.attachment.AttachmentData; -import io.qameta.allure.attachment.AttachmentProcessor; -import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.model.Attachment; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; -import io.qameta.allure.util.ResultsUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static java.util.Objects.requireNonNull; -/** - * Allure interceptor logger for gRPC. - * - * @author dtuchs (Dmitry Tuchs). - */ -@SuppressWarnings({ - "checkstyle:ClassFanOutComplexity", - "checkstyle:AnonInnerLength", - "checkstyle:JavaNCSS" -}) +@SuppressWarnings("all") public class AllureGrpc implements ClientInterceptor { private static final Logger LOGGER = LoggerFactory.getLogger(AllureGrpc.class); - private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer(); + private static final String UNKNOWN = "unknown"; + private static final JsonFormat.Printer GRPC_TO_JSON_PRINTER = JsonFormat.printer(); + private final AllureLifecycle lifecycle; + private final boolean markStepFailedOnNonZeroCode; + private final boolean interceptResponseMetadata; + private final String requestTemplatePath; + private final String responseTemplatePath; + + public AllureGrpc() { + this(Allure.getLifecycle(), true, false, + "grpc-request.ftl", "grpc-response.ftl"); + } + + public AllureGrpc(AllureLifecycle lifecycle, + boolean markStepFailedOnNonZeroCode, + boolean interceptResponseMetadata, + String requestTemplatePath, + String responseTemplatePath) { + this.lifecycle = lifecycle; + this.markStepFailedOnNonZeroCode = markStepFailedOnNonZeroCode; + this.interceptResponseMetadata = interceptResponseMetadata; + this.requestTemplatePath = requestTemplatePath; + this.responseTemplatePath = responseTemplatePath; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor methodDescriptor, + CallOptions callOptions, + Channel nextChannel + ) { + final AllureLifecycle current = lifecycle; + final String parent = current.getCurrentTestCaseOrStep().orElse(null); + final String stepUuid = UUID.randomUUID().toString(); + final List clientMessages = new ArrayList<>(); + final List serverMessages = new ArrayList<>(); + final Map initialHeaders = new LinkedHashMap<>(); + final Map trailers = new LinkedHashMap<>(); - private String requestTemplatePath = "grpc-request.ftl"; - private String responseTemplatePath = "grpc-response.ftl"; + final String stepName = buildStepName(nextChannel, methodDescriptor); + if (parent != null) current.startStep(parent, stepUuid, new StepResult().setName(stepName)); + else current.startStep(stepUuid, new StepResult().setName(stepName)); - private boolean markStepFailedOnNonZeroCode = true; - private boolean interceptResponseMetadata; + final StepContext stepContext = new StepContext<>( + stepUuid, methodDescriptor, current, clientMessages, serverMessages, initialHeaders, trailers + ); - public AllureGrpc setRequestTemplate(final String templatePath) { - this.requestTemplatePath = templatePath; - return this; + return new ForwardingClientCall.SimpleForwardingClientCall( + nextChannel.newCall(methodDescriptor, callOptions) + ) { + @Override + public void start(final Listener responseListener, final Metadata requestHeaders) { + final Listener forwardingListener = new ForwardingClientCallListener() { + @Override protected Listener delegate() { return responseListener; } + @Override public void onHeaders(final Metadata headers) { + handleHeaders(headers, stepContext.initialHeaders); + super.onHeaders(headers); + } + @Override public void onMessage(final R message) { + handleServerMessage(message, stepContext.serverMessages); + super.onMessage(message); + } + @Override public void onClose(final io.grpc.Status status, final Metadata responseTrailers) { + handleClose(status, responseTrailers, stepContext); + super.onClose(status, responseTrailers); + } + }; + super.start(forwardingListener, requestHeaders); + } + @Override + public void sendMessage(final T message) { + handleClientMessage(message, stepContext.clientMessages); + super.sendMessage(message); + } + }; } - public AllureGrpc setResponseTemplate(final String templatePath) { - this.responseTemplatePath = templatePath; - return this; + private static final class StepContext { + final String stepUuid; + final MethodDescriptor methodDescriptor; + final AllureLifecycle lifecycle; + final List clientMessages; + final List serverMessages; + final Map initialHeaders; + final Map trailers; + StepContext(String stepUuid, + MethodDescriptor methodDescriptor, + AllureLifecycle lifecycle, + List clientMessages, + List serverMessages, + Map initialHeaders, + Map trailers) { + this.stepUuid = stepUuid; + this.methodDescriptor = methodDescriptor; + this.lifecycle = lifecycle; + this.clientMessages = clientMessages; + this.serverMessages = serverMessages; + this.initialHeaders = initialHeaders; + this.trailers = trailers; + } } - public AllureGrpc markStepFailedOnNonZeroCode(final boolean value) { - this.markStepFailedOnNonZeroCode = value; - return this; + private void handleClose( + final io.grpc.Status status, + final Metadata responseTrailers, + final StepContext stepContext + ) { + try { + if (interceptResponseMetadata && responseTrailers != null) { + copyAsciiResponseMetadata(responseTrailers, stepContext.trailers); + } + attachRequestIfPresent(stepContext.stepUuid, stepContext.methodDescriptor, + stepContext.clientMessages, stepContext.lifecycle); + attachResponse(stepContext.stepUuid, stepContext.serverMessages, status, + stepContext.initialHeaders, stepContext.trailers, stepContext.lifecycle); + stepContext.lifecycle.updateStep(stepContext.stepUuid, step -> step.setStatus(convertStatus(status))); + } catch (Throwable throwable) { + LOGGER.error("Failed to finalize Allure step for gRPC call", throwable); + stepContext.lifecycle.updateStep(stepContext.stepUuid, step -> step.setStatus(Status.BROKEN)); + } finally { + stopStepSafely(stepContext.lifecycle, stepContext.stepUuid); + } } - public AllureGrpc interceptResponseMetadata(final boolean value) { - this.interceptResponseMetadata = value; - return this; + private void handleHeaders(final Metadata headers, final Map destination) { + try { + if (interceptResponseMetadata && headers != null) + copyAsciiResponseMetadata(headers, destination); + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture response headers", throwable); + } } - @Override - public ClientCall interceptCall(final MethodDescriptor method, - final CallOptions callOptions, - final Channel next) { - final AttachmentProcessor processor = new DefaultAttachmentProcessor(); + private void handleClientMessage(final T message, final List destination) { + try { + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC request message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC request message", throwable); + } + } - return new ForwardingClientCall.SimpleForwardingClientCall( - next.newCall(method, callOptions.withoutWaitForReady())) { + private void handleServerMessage(final R message, final List destination) { + try { + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC response message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC response message", throwable); + } + } - private String stepUuid; - private final List parsedResponses = new ArrayList<>(); + private void attachRequestIfPresent( + final String stepUuid, + final MethodDescriptor methodDescriptor, + final List clientMessages, + final AllureLifecycle lifecycle + ) { + final String body = toJsonBody(clientMessages); + if (body == null) { + return; + } + final String name = clientMessages.size() > 1 + ? "gRPC request (collection of elements from Client stream)" + : "gRPC request"; + final GrpcRequestAttachment requestAttachment = GrpcRequestAttachment.Builder + .create(name, methodDescriptor.getFullMethodName()) + .setBody(body) + .build(); + addRenderedAttachmentToStep(stepUuid, requestAttachment.getName(), requestAttachment, requestTemplatePath, lifecycle); + } - @Override - public void sendMessage(final T message) { - stepUuid = UUID.randomUUID().toString(); - Allure.getLifecycle().startStep(stepUuid, (new StepResult()).setName( - "Send gRPC request to " - + next.authority() - + trimGrpcMethodName(method.getFullMethodName()) - )); - try { - final GrpcRequestAttachment rpcRequestAttach = GrpcRequestAttachment.Builder - .create("gRPC request", method.getFullMethodName()) - .setBody(JSON_PRINTER.print((MessageOrBuilder) message)) - .build(); - processor.addAttachment(rpcRequestAttach, new FreemarkerAttachmentRenderer(requestTemplatePath)); - super.sendMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC request", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(stepResult -> - stepResult.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } - } + private void attachResponse( + final String stepUuid, + final List serverMessages, + final io.grpc.Status status, + final Map initialHeaders, + final Map trailers, + final AllureLifecycle lifecycle + ) { + final String body = toJsonBody(serverMessages); + final String name = serverMessages.size() > 1 + ? "gRPC response (collection of elements from Server stream)" + : "gRPC response"; - @Override - public void start(final Listener responseListener, final Metadata headers) { - final ClientCall.Listener listener = new ForwardingClientCallListener() { - @Override - protected Listener delegate() { - return responseListener; - } + final Map metadata = new LinkedHashMap<>(); + if (interceptResponseMetadata) { + metadata.putAll(initialHeaders); + metadata.putAll(trailers); + } - @Override - public void onClose(final io.grpc.Status status, final Metadata trailers) { - GrpcResponseAttachment.Builder responseAttachmentBuilder = null; - - if (parsedResponses.size() == 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response") - .setBody(parsedResponses.iterator().next()); - } else if (parsedResponses.size() > 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response (collection of elements from Server stream)") - .setBody("[" + String.join(",\n", parsedResponses) + "]"); - } - if (!status.isOk()) { - String description = status.getDescription(); - if (description == null) { - description = "No description provided"; - } - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create(status.getCode().name()) - .setStatus(description); - } - - requireNonNull(responseAttachmentBuilder).setStatus(status.toString()); - if (interceptResponseMetadata) { - for (String key : headers.keys()) { - requireNonNull(responseAttachmentBuilder).setMetadata( - key, - headers.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) - ); - } - } - processor.addAttachment( - requireNonNull(responseAttachmentBuilder).build(), - new FreemarkerAttachmentRenderer(responseTemplatePath) - ); - - if (status.isOk() || !markStepFailedOnNonZeroCode) { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.PASSED)); - } else { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.FAILED)); - } - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - super.onClose(status, trailers); - } + final GrpcResponseAttachment.Builder builder = GrpcResponseAttachment.Builder + .create(name) + .setStatus(status.toString()); + if (body != null) { + builder.setBody(body); + } + if (!metadata.isEmpty()) { + builder.addMetadata(metadata); + } + final GrpcResponseAttachment responseAttachment = builder.build(); + addRenderedAttachmentToStep(stepUuid, responseAttachment.getName(), + responseAttachment, responseTemplatePath, lifecycle); + } - @Override - public void onMessage(final A message) { - try { - parsedResponses.add(JSON_PRINTER.print((MessageOrBuilder) message)); - super.onMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC response", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(step -> - step.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } - } - }; - super.start(listener, headers); - } + private void stopStepSafely(final AllureLifecycle lifecycle, final String stepUuid) { + try { + lifecycle.stopStep(stepUuid); + } catch (Throwable throwable) { + LOGGER.warn("Failed to stop Allure step {}", stepUuid, throwable); + } + } - private String trimGrpcMethodName(final String source) { - return source.substring(source.lastIndexOf('/')); + private Status convertStatus(final io.grpc.Status grpcStatus) { + if (grpcStatus.isOk() || !markStepFailedOnNonZeroCode) { + return Status.PASSED; + } + return Status.FAILED; + } + + private static String buildStepName( + final Channel channel, + final MethodDescriptor methodDescriptor + ) { + final String authority = channel != null ? channel.authority() : null; + final String safeAuthority = authority != null ? authority : UNKNOWN; + final String type = toSnakeCase(methodDescriptor.getType()); + return "Send " + type + " gRPC request to " + safeAuthority + "/" + methodDescriptor.getFullMethodName(); + } + + private static String toSnakeCase(final MethodDescriptor.MethodType methodType) { + if (methodType == null) { + return UNKNOWN; + } + return methodType.name().toLowerCase(Locale.ROOT); + } + + private void addRenderedAttachmentToStep( + final String stepUuid, + final String attachmentName, + final AttachmentData data, + final String templatePath, + final AllureLifecycle lifecycle + ) { + final AttachmentRenderer renderer = new FreemarkerAttachmentRenderer(templatePath); + final io.qameta.allure.attachment.AttachmentContent content; + try { + content = renderer.render(data); + } catch (Throwable throwable) { + LOGGER.warn("Could not render attachment '{}' using template '{}'", attachmentName, templatePath, throwable); + return; + } + if (content == null || content.getContent() == null) { + LOGGER.warn("Rendered attachment '{}' is empty; skipping", attachmentName); + return; + } + String fileExtension = content.getFileExtension(); + if (fileExtension == null || fileExtension.isEmpty()) { + fileExtension = ".html"; + } + final String source = UUID.randomUUID() + fileExtension; + lifecycle.updateStep( + stepUuid, + step -> step.getAttachments().add( + new Attachment() + .setName(attachmentName) + .setSource(source) + .setType(content.getContentType() != null ? content.getContentType() : "text/html") + ) + ); + lifecycle.writeAttachment(source, new ByteArrayInputStream(content.getContent().getBytes(StandardCharsets.UTF_8))); + } + + private static String toJsonBody(final List items) { + if (items == null || items.isEmpty()) { + return null; + } + if (items.size() == 1) { + return items.get(0); + } + final String joined = String.join(",\n", items); + return "[" + joined + "]"; + } + + private static void copyAsciiResponseMetadata(final Metadata source, final Map target) { + for (String key : source.keys()) { + if (key == null) { + continue; } - }; + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + continue; + } + final Metadata.Key keyAscii = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + final String value = source.get(keyAscii); + if (value != null) { + target.put(key, value); + } + } } } 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 6c83b9f03..48ddf574e 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 @@ -1,24 +1,13 @@ -/* - * Copyright 2016-2024 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.grpc; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import io.qameta.allure.Allure; import io.qameta.allure.model.Attachment; import io.qameta.allure.model.StepResult; import io.qameta.allure.test.AllureResults; @@ -29,135 +18,440 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; +import java.util.Map; import java.util.Optional; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.grpcmock.GrpcMock.bidiStreamingMethod; +import static org.grpcmock.GrpcMock.clientStreamingMethod; import static org.grpcmock.GrpcMock.serverStreamingMethod; import static org.grpcmock.GrpcMock.unaryMethod; -/** - * @author dtuchs (Dmitry Tuchs). - */ @ExtendWith(GrpcMockExtension.class) class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; + private static final ObjectMapper JSON = new ObjectMapper(); - private ManagedChannel channel; - private TestServiceGrpc.TestServiceBlockingStub blockingStub; + private ManagedChannel managedChannel; @BeforeEach - void configureMock() { - channel = ManagedChannelBuilder.forAddress("localhost", GrpcMock.getGlobalPort()) - .usePlaintext() - .build(); - blockingStub = TestServiceGrpc.newBlockingStub(channel) - .withInterceptors(new AllureGrpc()); + void configureMockServer() { + managedChannel = ManagedChannelBuilder + .forAddress("localhost", GrpcMock.getGlobalPort()) + .usePlaintext() + .directExecutor() + .build(); GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + GrpcMock.stubFor(serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) - .willReturn(asList( - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() - ))); + .willReturn(asList( + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() + ))); + + GrpcMock.stubFor(clientStreamingMethod(TestServiceGrpc.getCalculateClientStreamMethod()) + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + + GrpcMock.stubFor(bidiStreamingMethod(TestServiceGrpc.getCalculateBidiStreamMethod()) + .willProxyTo(responseObserver -> new StreamObserver<>() { + @Override + public void onNext(Request request) { + responseObserver.onNext(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + })); } @AfterEach void shutdownChannel() { - Optional.ofNullable(channel).ifPresent(ManagedChannel::shutdownNow); + Optional.ofNullable(managedChannel).ifPresent(ManagedChannel::shutdown); } @Test void shouldCreateRequestAttachment() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); + + Status errorStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(errorStatus)); + + AllureResults allureResults = executeUnaryExpectingException(request); - final AllureResults results = execute(request); + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC request"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC request", "gRPC response"); } @Test void shouldCreateResponseAttachment() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); - final AllureResults results = execute(request); + AllureResults allureResults = executeUnary(request); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC response"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response"); } @Test void shouldCreateResponseAttachmentForServerStreamingResponse() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); - final AllureResults results = executeStreaming(request); + AllureResults allureResults = executeServerStreaming(request); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC response (collection of elements from Server stream)"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response (collection of elements from Server stream)"); } @Test void shouldCreateResponseAttachmentOnStatusException() { - final Status status = Status.NOT_FOUND; + Status notFoundStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(notFoundStatus)); + + Request request = Request.newBuilder() + .setTopic("2") + .build(); + + AllureResults allureResults = executeUnaryExpectingException(request); + + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); + + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response"); + } + + @Test + void shouldCreateAttachmentsForClientStreamingWithAsynchronousStub() { + Request firstClientRequest = Request.newBuilder().setTopic("A").build(); + Request secondClientRequest = Request.newBuilder().setTopic("B").build(); + + runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = + TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + final List receivedResponses = new ArrayList<>(); + + Allure.step("async-root-client-stream", () -> { + StreamObserver responseObserver = new StreamObserver<>() { + @Override + public void onNext(Response value) { + receivedResponses.add(value); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateClientStream(responseObserver); + requestObserver.onNext(firstClientRequest); + requestObserver.onNext(secondClientRequest); + requestObserver.onCompleted(); + }); + + assertThat(receivedResponses).hasSize(1); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + }); + } + + @Test + void shouldCreateAttachmentsForBidirectionalStreamingWithAsynchronousStub() { + Request firstBidirectionalRequest = Request.newBuilder().setTopic("C").build(); + Request secondBidirectionalRequest = Request.newBuilder().setTopic("D").build(); + + runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = + TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + List receivedResponses = new ArrayList<>(); + + Allure.step("async-root-bidi-stream", () -> { + StreamObserver responseObserver = new StreamObserver<>() { + @Override public void onNext(Response value) { receivedResponses.add(value); } + @Override public void onError(Throwable throwable) { } + @Override public void onCompleted() { } + }; + + StreamObserver requestObserver = asynchronousStub.calculateBidiStream(responseObserver); + requestObserver.onNext(firstBidirectionalRequest); + requestObserver.onNext(secondBidirectionalRequest); + requestObserver.onCompleted(); + }); + + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + assertThat(receivedResponses.get(1).getMessage()).isEqualTo(RESPONSE_MESSAGE); + }); + } + + @Test + void unaryRequestBodyIsCapturedAsJsonObject() throws Exception { + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage("ok").build())); + + Request request = Request.newBuilder().setTopic("topic-1").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }); + + String attachmentHtmlContent = readAttachmentContentByName(allureResults, "gRPC request"); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("topic", "topic-1"); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } + + @Test + void unaryResponseBodyIsCapturedAsJsonObject() throws Exception { GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(status)); + .willReturn(Response.newBuilder().setMessage("hello-world").build())); + + Request request = Request.newBuilder().setTopic("x").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("hello-world"); + }); + + String attachmentHtmlContent = readAttachmentContentByName(allureResults, "gRPC response"); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("message", "hello-world"); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } + + @Test + void serverStreamingResponseBodyIsJsonArrayInOrder() throws Exception { + GrpcMock.stubFor(serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) + .willReturn(asList( + Response.newBuilder().setMessage("first").build(), + Response.newBuilder().setMessage("second").build() + ))); + + Request request = Request.newBuilder().setTopic("stream-topic").build(); - final Request request = Request.newBuilder() - .setTopic("2") - .build(); + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("first"); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("second"); + assertThat(responseIterator.hasNext()).isFalse(); + }); - final AllureResults results = executeException(request); + String attachmentHtmlContent = readAttachmentContentByName( + allureResults, + "gRPC response (collection of elements from Server stream)" + ); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonArray = JSON.readTree(jsonPayload); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains(status.getCode().name()); + assertThat(actualJsonArray.isArray()).isTrue(); + assertThat(actualJsonArray.size()).isEqualTo(2); + assertThat(actualJsonArray.get(0)).isEqualTo(JSON.createObjectNode().put("message", "first")); + assertThat(actualJsonArray.get(1)).isEqualTo(JSON.createObjectNode().put("message", "second")); } - protected final AllureResults execute(final Request request) { + protected final AllureResults executeUnary(Request request) { return runWithinTestContext(() -> { try { - final Response response = blockingStub.calculate(request); + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeStreaming(final Request request) { + + protected final AllureResults executeServerStreaming(Request request) { return runWithinTestContext(() -> { try { - Iterator responseIterator = blockingStub.calculateServerStream(request); + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); + int responseCount = 0; while (responseIterator.hasNext()) { assertThat(responseIterator.next().getMessage()).isEqualTo(RESPONSE_MESSAGE); + responseCount++; } - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + assertThat(responseCount).isEqualTo(2); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeException(final Request request) { - return runWithinTestContext(() -> { - assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> blockingStub.calculate(request)); - }); + protected final AllureResults executeUnaryExpectingException(Request request) { + return runWithinTestContext(() -> + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }) + ); + } + + private static String readAttachmentContentByName(AllureResults allureResults, String attachmentName) { + var test = allureResults.getTestResults().get(0); + + Attachment matchedAttachment = flattenSteps(test.getSteps()).stream() + .flatMap(step -> step.getAttachments().stream()) + .filter(attachment -> attachmentName.equals(attachment.getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Attachment not found: " + attachmentName)); + + String attachmentSourceKey = matchedAttachment.getSource(); + Map attachmentsContent = allureResults.getAttachments(); + byte[] rawAttachmentContent = attachmentsContent.get(attachmentSourceKey); + if (rawAttachmentContent == null) { + throw new IllegalStateException("Attachment content not found by source: " + attachmentSourceKey); + } + return new String(rawAttachmentContent, StandardCharsets.UTF_8); + } + + private static String extractJsonPayload(String htmlContent) { + String textWithoutHtml = stripHtmlTags(unescapeHtml(htmlContent)); + int fullLength = textWithoutHtml.length(); + for (int currentIndex = 0; currentIndex < fullLength; currentIndex++) { + char currentChar = textWithoutHtml.charAt(currentIndex); + if (currentChar == '{' || currentChar == '[') { + int matchingBracketIndex = findMatchingBracket(textWithoutHtml, currentIndex); + if (matchingBracketIndex > currentIndex) { + String candidateJson = textWithoutHtml.substring(currentIndex, matchingBracketIndex + 1).trim(); + if (looksLikeJson(candidateJson) && canParseJson(candidateJson)) { + return candidateJson; + } + } + } + } + throw new IllegalStateException("JSON payload not found or not valid inside attachment"); + } + + private static boolean canParseJson(String candidateJson) { + try { + JSON.readTree(candidateJson); + return true; + } catch (Exception ignore) { + return false; + } + } + + private static boolean looksLikeJson(String input) { + if (input == null) { + return false; + } + String trimmed = input.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + return false; + } + return trimmed.matches("(?s).*\"[^\"]+\"\\s*:\\s*.*"); + } + + private static int findMatchingBracket(String input, int startIndex) { + char openingBracket = input.charAt(startIndex); + char closingBracket = (openingBracket == '{') ? '}' : ']'; + int nestingDepth = 0; + boolean insideString = false; + for (int index = startIndex; index < input.length(); index++) { + char symbol = input.charAt(index); + if (symbol == '"' && (index == 0 || input.charAt(index - 1) != '\\')) { + insideString = !insideString; + } + if (insideString) { + continue; + } + if (symbol == openingBracket) { + nestingDepth++; + } else if (symbol == closingBracket) { + nestingDepth--; + if (nestingDepth == 0) { + return index; + } + } + } + return -1; + } + + private static String stripHtmlTags(String input) { + String withoutTags = input.replaceAll("(?is)", "") + .replaceAll("(?is)", "") + .replaceAll("(?s)<[^>]*>", " "); + return withoutTags + .replace("\r", " ") + .replace("\n", " ") + .replaceAll("[ \\t\\x0B\\f\\r]+", " ") + .trim(); + } + + private static String unescapeHtml(String input) { + return input.replace(""", "\"") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace("{", "{") + .replace("}", "}") + .replace("[", "[") + .replace("]", "]") + .replace(":", ":") + .replace(",", ","); + } + + private static List flattenSteps(List rootSteps) { + List allSteps = new ArrayList<>(); + if (rootSteps == null) { + return allSteps; + } + for (StepResult step : rootSteps) { + allSteps.add(step); + allSteps.addAll(flattenSteps(step.getSteps())); + } + return allSteps; } } diff --git a/allure-grpc/src/test/proto/api.proto b/allure-grpc/src/test/proto/api.proto index 552e76f6c..8247d7911 100644 --- a/allure-grpc/src/test/proto/api.proto +++ b/allure-grpc/src/test/proto/api.proto @@ -2,10 +2,13 @@ syntax = "proto3"; option java_multiple_files = true; option java_package = "io.qameta.allure.grpc"; +option java_outer_classname = "Api"; service TestService { rpc Calculate (Request) returns (Response); rpc CalculateServerStream (Request) returns (stream Response); + rpc CalculateClientStream (stream Request) returns (Response); + rpc CalculateBidiStream (stream Request) returns (stream Response); } message Request {