From f40c8c61a1b123743f887cd7250dd9ce78045b1f Mon Sep 17 00:00:00 2001 From: SBushmelev Date: Sun, 20 Jul 2025 18:32:50 +0400 Subject: [PATCH] Add openfeign module --- README.md | 18 +++ allure-openfeign/build.gradle.kts | 28 ++++ .../openfeign/AllureResponseDecoder.java | 123 ++++++++++++++++++ .../openfeign/AllureResponseDecoderTests.java | 108 +++++++++++++++ settings.gradle.kts | 1 + 5 files changed, 278 insertions(+) create mode 100644 allure-openfeign/build.gradle.kts create mode 100644 allure-openfeign/src/main/java/io/qameta/allure/openfeign/AllureResponseDecoder.java create mode 100644 allure-openfeign/src/test/java/io/qameta/allure/openfeign/AllureResponseDecoderTests.java diff --git a/README.md b/README.md index e452787b..b8f0c915 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,24 @@ final HttpClientBuilder builder = HttpClientBuilder.create() .addResponseInterceptorLast(new AllureHttpClient5Response("your-response-template-attachment.ftl")); ``` +## OpenFeign +OpenFeign wrapper over decoder for automatically captures traffic as Allure attachments for comprehensive API test reporting. +```xml + + io.qameta.allure + allure-open-feign + $LATEST_VERSION + +``` + +Usage example with GsonDecoder implementation: +```java +MyClient myClient = Feign.builder() + .decoder(new AllureResponseDecoder(new GsonDecoder())) + .encoder(new GsonEncoder()) + .target(MyClient.class, "https://test.url"); +``` + ## JAX-RS Filter Filter that can be used with JAX-RS compliant clients such as RESTeasy and Jersey diff --git a/allure-openfeign/build.gradle.kts b/allure-openfeign/build.gradle.kts new file mode 100644 index 00000000..ef158d66 --- /dev/null +++ b/allure-openfeign/build.gradle.kts @@ -0,0 +1,28 @@ +description = "Allure OpenFeign Integration" + +dependencies { + implementation("io.github.openfeign:feign-core:13.6") + testImplementation("io.github.openfeign:feign-gson:13.6") + api(project(":allure-attachments")) + testImplementation("com.github.tomakehurst:wiremock") + testImplementation("org.assertj:assertj-core") + testImplementation("org.jboss.resteasy:resteasy-client") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.mockito:mockito-core") + testImplementation("org.slf4j:slf4j-simple") + testImplementation(project(":allure-java-commons-test")) + testImplementation(project(":allure-junit-platform")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.jar { + manifest { + attributes(mapOf( + "Automatic-Module-Name" to "io.qameta.allure.openfeign" + )) + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/allure-openfeign/src/main/java/io/qameta/allure/openfeign/AllureResponseDecoder.java b/allure-openfeign/src/main/java/io/qameta/allure/openfeign/AllureResponseDecoder.java new file mode 100644 index 00000000..a3f6b2f7 --- /dev/null +++ b/allure-openfeign/src/main/java/io/qameta/allure/openfeign/AllureResponseDecoder.java @@ -0,0 +1,123 @@ +/* + * 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.openfeign; + +import feign.Request; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.attachment.http.HttpRequestAttachment; +import io.qameta.allure.attachment.http.HttpResponseAttachment; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * @author sbushmelev (Sergei Bushmelev) + */ +public class AllureResponseDecoder implements Decoder { + + private final Decoder decoder; + + /** + * Creates a new AllureResponseDecoder wrapping the specified decoder. + * + * @param decoder the underlying decoder to delegate actual decoding to + */ + public AllureResponseDecoder(final Decoder decoder) { + this.decoder = decoder; + } + + @Override + public Object decode(final Response response, final Type type) throws IOException { + final Request request = response.request(); + + final HttpRequestAttachment.Builder requestAttachmentBuilder = HttpRequestAttachment + .Builder.create("Request", request.url()) + .setMethod(request.httpMethod().name()) + .setHeaders(headers(request.headers())); + + if (Objects.nonNull(request.body())) { + final Charset charset = request.charset() == null ? StandardCharsets.UTF_8 : request.charset(); + requestAttachmentBuilder.setBody(new String(request.body(), charset)); + } + + new DefaultAttachmentProcessor().addAttachment( + requestAttachmentBuilder.build(), + new FreemarkerAttachmentRenderer("http-request.ftl") + ); + + final HttpResponseAttachment.Builder responseAttachmentBuilder = HttpResponseAttachment + .Builder.create("Response") + .setResponseCode(response.status()) + .setHeaders(headers(response.headers())); + + final Response.Builder builder = response.toBuilder(); + + if (Objects.nonNull(response.body())) { + try (InputStream bodyStream = response.body().asInputStream()) { + final byte[] body = readAllBytes(bodyStream); + final Charset charset = response.charset() == null ? StandardCharsets.UTF_8 : response.charset(); + responseAttachmentBuilder.setBody(new String(body, charset)); + builder.body(body); + } catch (IOException e) { + throw new DecodeException(response.status(), "Failed to read response body", request, e); + } + } + + new DefaultAttachmentProcessor().addAttachment( + responseAttachmentBuilder.build(), + new FreemarkerAttachmentRenderer("http-response.ftl") + ); + + return decoder.decode(builder.build(), type); + } + + private byte[] readAllBytes(final InputStream inputStream) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int byteRead; + while ((byteRead = inputStream.read()) != -1) { + buffer.write(byteRead); + } + return buffer.toByteArray(); + } + + private Map headers(final Map> headers) { + if (headers == null) { + return new HashMap<>(); + } else { + return headers.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> "Set-Cookie".equalsIgnoreCase(entry.getKey()) + ? String.join("\n", entry.getValue()) + : String.join(", ", entry.getValue()) + )); + } + } + +} diff --git a/allure-openfeign/src/test/java/io/qameta/allure/openfeign/AllureResponseDecoderTests.java b/allure-openfeign/src/test/java/io/qameta/allure/openfeign/AllureResponseDecoderTests.java new file mode 100644 index 00000000..922a00af --- /dev/null +++ b/allure-openfeign/src/test/java/io/qameta/allure/openfeign/AllureResponseDecoderTests.java @@ -0,0 +1,108 @@ +/* + * 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.openfeign; + +import com.github.tomakehurst.wiremock.WireMockServer; +import feign.Feign; +import feign.RequestLine; +import feign.gson.GsonDecoder; +import feign.gson.GsonEncoder; +import io.qameta.allure.model.Attachment; +import io.qameta.allure.test.AllureResults; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +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.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AllureResponseDecoderTests { + + static WireMockServer wireMockServer; + + @BeforeAll + static void setUp() { + wireMockServer = new WireMockServer(options().dynamicPort()); + wireMockServer.start(); + + wireMockServer.stubFor( + get(urlEqualTo("/api/v1/json")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\":\"Hello World\"}"))); + } + + @Test + void jsonBodyTest() { + AtomicReference helloWorldRecord = new AtomicReference<>(); + + AllureResults allureResults = runWithinTestContext(() -> { + helloWorldRecord.set(Feign.builder() + .decoder(new AllureResponseDecoder(new GsonDecoder())) + .encoder(new GsonEncoder()) + .target(HelloWorldFeignClient.class, wireMockServer.baseUrl()) + .getJsonHelloWorld()); + }); + + List attachmentNames = allureResults.getTestResults().stream() + .flatMap(testResult -> testResult.getAttachments().stream()) + .map(Attachment::getName).collect(Collectors.toList()); + + assertAll( + () -> assertEquals(new HelloWorldRecord("Hello World").getMessage(), helloWorldRecord.get().getMessage()), + () -> assertTrue(attachmentNames.contains("Response"), "Cannot find attachment with name \"Response\""), + () -> assertTrue(attachmentNames.contains("Request"), "Cannot find attachment with name \"Request\"") + ); + } + + interface HelloWorldFeignClient { + + @RequestLine("GET /api/v1/json") + HelloWorldRecord getJsonHelloWorld(); + + } + + static class HelloWorldRecord { + + private String message; + + public HelloWorldRecord() { + } + + public HelloWorldRecord(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 20eaaa8e..6e90c780 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ include("allure-spock2") include("allure-spring-web") include("allure-test-filter") include("allure-testng") +include("allure-openfeign") pluginManagement { repositories {