From f04e8a4edd4b4a82141a17794ce8557a6c8ad54d Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Fri, 6 Feb 2026 19:20:20 -0300 Subject: [PATCH 01/12] [ci skip] updating versions to next development iteration 13.8-SNAPSHOT --- annotation-error-decoder/pom.xml | 2 +- apt-test-generator/pom.xml | 2 +- benchmark/pom.xml | 2 +- core/pom.xml | 2 +- dropwizard-metrics4/pom.xml | 2 +- dropwizard-metrics5/pom.xml | 2 +- example-github-with-coroutine/pom.xml | 2 +- example-github/pom.xml | 2 +- example-wikipedia-with-springboot/pom.xml | 2 +- example-wikipedia/pom.xml | 2 +- fastjson2/pom.xml | 2 +- form-spring/pom.xml | 2 +- form/pom.xml | 2 +- googlehttpclient/pom.xml | 2 +- gson/pom.xml | 2 +- hc5/pom.xml | 2 +- httpclient/pom.xml | 2 +- hystrix/pom.xml | 2 +- jackson-jaxb/pom.xml | 2 +- jackson-jr/pom.xml | 2 +- jackson/pom.xml | 2 +- jackson3/pom.xml | 2 +- jakarta/pom.xml | 2 +- java11/pom.xml | 2 +- jaxb-jakarta/pom.xml | 2 +- jaxb/pom.xml | 2 +- jaxrs/pom.xml | 2 +- jaxrs2/pom.xml | 2 +- jaxrs3/pom.xml | 2 +- jaxrs4/pom.xml | 2 +- json/pom.xml | 2 +- kotlin/pom.xml | 2 +- micrometer/pom.xml | 2 +- mock/pom.xml | 2 +- moshi/pom.xml | 2 +- okhttp/pom.xml | 2 +- pom.xml | 2 +- reactive/pom.xml | 2 +- ribbon/pom.xml | 2 +- sax/pom.xml | 2 +- slf4j/pom.xml | 2 +- soap-jakarta/pom.xml | 2 +- soap/pom.xml | 2 +- spring/pom.xml | 2 +- spring4/pom.xml | 2 +- vertx/feign-vertx/pom.xml | 2 +- vertx/feign-vertx4-test/pom.xml | 2 +- vertx/feign-vertx5-test/pom.xml | 2 +- vertx/pom.xml | 2 +- 49 files changed, 49 insertions(+), 49 deletions(-) diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml index 8a42cb1fcc..01dee937ff 100644 --- a/annotation-error-decoder/pom.xml +++ b/annotation-error-decoder/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-annotation-error-decoder diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml index a1c3199c8e..082abe0100 100644 --- a/apt-test-generator/pom.xml +++ b/apt-test-generator/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT io.github.openfeign.experimental diff --git a/benchmark/pom.xml b/benchmark/pom.xml index cc8e609cc4..d99666fbce 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-benchmark diff --git a/core/pom.xml b/core/pom.xml index 63fbeb8ac0..fba385c4df 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-core diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml index 1c2f49dd80..6bbef5d9f0 100644 --- a/dropwizard-metrics4/pom.xml +++ b/dropwizard-metrics4/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-dropwizard-metrics4 Feign Dropwizard Metrics4 diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml index 4a0230cd4d..b8f2705626 100644 --- a/dropwizard-metrics5/pom.xml +++ b/dropwizard-metrics5/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-dropwizard-metrics5 Feign Dropwizard Metrics5 diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml index c43a10293d..b07493cdca 100644 --- a/example-github-with-coroutine/pom.xml +++ b/example-github-with-coroutine/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-example-github-with-coroutine diff --git a/example-github/pom.xml b/example-github/pom.xml index 5fca1fb298..c9c4bfc882 100644 --- a/example-github/pom.xml +++ b/example-github/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-example-github diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml index 7547cb8c80..d9b5d2a81a 100644 --- a/example-wikipedia-with-springboot/pom.xml +++ b/example-wikipedia-with-springboot/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-example-wikipedia-with-springboot diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml index 67f5327186..01b3be0eed 100644 --- a/example-wikipedia/pom.xml +++ b/example-wikipedia/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT io.github.openfeign diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml index ffbd2880e8..62aef4c99b 100644 --- a/fastjson2/pom.xml +++ b/fastjson2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-fastjson2 diff --git a/form-spring/pom.xml b/form-spring/pom.xml index 0f3ad97b40..85a7e4210d 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-form-spring diff --git a/form/pom.xml b/form/pom.xml index 1b217e7f9d..fb646c21fa 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-form diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml index fa0c734d92..17b8bd66bd 100644 --- a/googlehttpclient/pom.xml +++ b/googlehttpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-googlehttpclient diff --git a/gson/pom.xml b/gson/pom.xml index e1428f5894..b8cf5cada4 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-gson diff --git a/hc5/pom.xml b/hc5/pom.xml index a493230e64..d736d9e7b7 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-hc5 diff --git a/httpclient/pom.xml b/httpclient/pom.xml index 4a28235fc4..d2a98601c7 100644 --- a/httpclient/pom.xml +++ b/httpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-httpclient diff --git a/hystrix/pom.xml b/hystrix/pom.xml index 76b6059215..21f0775b46 100644 --- a/hystrix/pom.xml +++ b/hystrix/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-hystrix diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml index b5c0a3c652..c7a5604d84 100644 --- a/jackson-jaxb/pom.xml +++ b/jackson-jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jackson-jaxb diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml index b1efd62542..e3b9e47219 100644 --- a/jackson-jr/pom.xml +++ b/jackson-jr/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jackson-jr diff --git a/jackson/pom.xml b/jackson/pom.xml index 70550e4787..0cfc8976aa 100644 --- a/jackson/pom.xml +++ b/jackson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jackson diff --git a/jackson3/pom.xml b/jackson3/pom.xml index 5e64cc6151..f615df00d0 100644 --- a/jackson3/pom.xml +++ b/jackson3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jackson3 diff --git a/jakarta/pom.xml b/jakarta/pom.xml index 51f7b28581..9709dffb7d 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jakarta diff --git a/java11/pom.xml b/java11/pom.xml index 26aba291bc..475134fc2e 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-java11 diff --git a/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml index 1ae19936aa..3a64c01612 100644 --- a/jaxb-jakarta/pom.xml +++ b/jaxb-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxb-jakarta diff --git a/jaxb/pom.xml b/jaxb/pom.xml index 91c2762644..5470fa747b 100644 --- a/jaxb/pom.xml +++ b/jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxb diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml index 1a9cce1ac7..9b1ec21ce1 100644 --- a/jaxrs/pom.xml +++ b/jaxrs/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxrs diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml index 6ba219ae14..dfee5d0a1c 100644 --- a/jaxrs2/pom.xml +++ b/jaxrs2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxrs2 diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml index f69b5c54e1..ac058a757e 100644 --- a/jaxrs3/pom.xml +++ b/jaxrs3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxrs3 diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml index 91ef735b8b..f07e902dea 100644 --- a/jaxrs4/pom.xml +++ b/jaxrs4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-jaxrs4 diff --git a/json/pom.xml b/json/pom.xml index 3243e5a0e4..a5ae81ec1d 100644 --- a/json/pom.xml +++ b/json/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-json diff --git a/kotlin/pom.xml b/kotlin/pom.xml index cf2b677593..3ef76e2a65 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-kotlin diff --git a/micrometer/pom.xml b/micrometer/pom.xml index 47aaaf5c78..c93a942d14 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-micrometer Feign Micrometer diff --git a/mock/pom.xml b/mock/pom.xml index 0bc1580024..565d9cf80d 100644 --- a/mock/pom.xml +++ b/mock/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-mock diff --git a/moshi/pom.xml b/moshi/pom.xml index 2186b4a714..6d160bbb64 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-moshi diff --git a/okhttp/pom.xml b/okhttp/pom.xml index dc0ca0dd6a..eab4785c97 100644 --- a/okhttp/pom.xml +++ b/okhttp/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-okhttp diff --git a/pom.xml b/pom.xml index cf075a3629..2dc5c2de58 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT pom Feign (Parent) diff --git a/reactive/pom.xml b/reactive/pom.xml index fe8e53a327..2a490e9a57 100644 --- a/reactive/pom.xml +++ b/reactive/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-reactive-wrappers diff --git a/ribbon/pom.xml b/ribbon/pom.xml index a7e91876b8..fc3ca30ec6 100644 --- a/ribbon/pom.xml +++ b/ribbon/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-ribbon diff --git a/sax/pom.xml b/sax/pom.xml index 35fdbf2d4a..10b52bb149 100644 --- a/sax/pom.xml +++ b/sax/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-sax diff --git a/slf4j/pom.xml b/slf4j/pom.xml index d10acb769e..7f2e335970 100644 --- a/slf4j/pom.xml +++ b/slf4j/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-slf4j diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml index 458eddd5f2..627b33be82 100644 --- a/soap-jakarta/pom.xml +++ b/soap-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-soap-jakarta diff --git a/soap/pom.xml b/soap/pom.xml index 5d6509594d..e25715e0ab 100644 --- a/soap/pom.xml +++ b/soap/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-soap diff --git a/spring/pom.xml b/spring/pom.xml index 9a3c7dc196..fdd0a763ec 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-spring diff --git a/spring4/pom.xml b/spring4/pom.xml index 60fd6ddb1d..998527191b 100644 --- a/spring4/pom.xml +++ b/spring4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-spring4 diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index 6e862bafb4..6119cea9ab 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7 + 13.8-SNAPSHOT feign-vertx diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 5a381fd8e9..4627c60a23 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7 + 13.8-SNAPSHOT feign-vertx4-test diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index 8909516d1b..f7941a4712 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7 + 13.8-SNAPSHOT feign-vertx5-test diff --git a/vertx/pom.xml b/vertx/pom.xml index cf2b82e9e2..d5be4fd4ed 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7 + 13.8-SNAPSHOT feign-vertx-parent From 5793c55e395f9e3fdeecc54c5437956bac643db8 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 6 Feb 2026 20:59:45 -0300 Subject: [PATCH 02/12] Upgrade Spring Boot 4.0.2, Spring Framework 7.0.3, Spring Cloud OpenFeign 5.0.1 (#3211) * Upgrade Spring Boot 4.0.2, Spring Framework 7.0.3, Spring Cloud OpenFeign 5.0.1 Signed-off-by: Marvin Froeder * Increase Http2ClientAsyncTest timeout from 1s to 10s Signed-off-by: Marvin Froeder --------- Signed-off-by: Marvin Froeder --- .../java/feign/form/feign/spring/Client.java | 9 ++---- .../form/feign/spring/DownloadClient.java | 32 ++++++------------- .../SpringManyMultipartFilesReaderTest.java | 6 ++-- .../test/Http2ClientAsyncTest.java | 2 +- pom.xml | 6 ++-- spring/pom.xml | 2 +- 6 files changed, 19 insertions(+), 38 deletions(-) diff --git a/form-spring/src/test/java/feign/form/feign/spring/Client.java b/form-spring/src/test/java/feign/form/feign/spring/Client.java index 14371fe89b..dbd2040a9c 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/Client.java +++ b/form-spring/src/test/java/feign/form/feign/spring/Client.java @@ -25,10 +25,9 @@ import feign.form.spring.SpringFormEncoder; import java.util.List; import java.util.Map; -import org.springframework.beans.factory.ObjectFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.FeignHttpMessageConverters; import org.springframework.cloud.openfeign.support.SpringEncoder; import org.springframework.context.annotation.Bean; import org.springframework.web.bind.annotation.PathVariable; @@ -91,10 +90,8 @@ String upload4( class ClientConfiguration { - @Autowired private ObjectFactory messageConverters; - @Bean - Encoder feignEncoder() { + Encoder feignEncoder(ObjectProvider messageConverters) { return new SpringFormEncoder(new SpringEncoder(messageConverters)); } diff --git a/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java b/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java index ce686675cc..4645d950c6 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java +++ b/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java @@ -18,14 +18,12 @@ import feign.Logger; import feign.codec.Decoder; import feign.form.spring.converter.SpringManyMultipartFilesReader; -import java.util.ArrayList; -import org.springframework.beans.factory.ObjectFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.FeignHttpMessageConverters; +import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer; import org.springframework.cloud.openfeign.support.SpringDecoder; import org.springframework.context.annotation.Bean; -import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.multipart.MultipartFile; @@ -41,26 +39,14 @@ interface DownloadClient { class ClientConfiguration { - @Autowired private ObjectFactory messageConverters; - @Bean - Decoder feignDecoder() { - var springConverters = messageConverters.getObject().getConverters(); - var decoderConverters = new ArrayList>(springConverters.size() + 1); - - decoderConverters.addAll(springConverters); - decoderConverters.add(new SpringManyMultipartFilesReader(4096)); - - var httpMessageConverters = new HttpMessageConverters(decoderConverters); - - return new SpringDecoder( - new ObjectFactory() { + HttpMessageConverterCustomizer multipartConverterCustomizer() { + return converters -> converters.add(new SpringManyMultipartFilesReader(4096)); + } - @Override - public HttpMessageConverters getObject() { - return httpMessageConverters; - } - }); + @Bean + Decoder feignDecoder(ObjectProvider messageConverters) { + return new SpringDecoder(messageConverters); } @Bean diff --git a/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java b/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java index ed551b23bd..d7f4d1b423 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java +++ b/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java @@ -15,7 +15,6 @@ */ package feign.form.feign.spring.converter; -import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; @@ -84,9 +83,8 @@ public InputStream getBody() throws IOException { @Override public HttpHeaders getHeaders() { var httpHeaders = new HttpHeaders(); - httpHeaders.put( - CONTENT_TYPE, - singletonList(MULTIPART_FORM_DATA_VALUE + "; boundary=" + DUMMY_MULTIPART_BOUNDARY)); + httpHeaders.set( + CONTENT_TYPE, MULTIPART_FORM_DATA_VALUE + "; boundary=" + DUMMY_MULTIPART_BOUNDARY); return httpHeaders; } } diff --git a/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java index 680276e57c..d0d00c3a36 100644 --- a/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java +++ b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java @@ -460,7 +460,7 @@ void configKeyUsesChildType() throws Exception { private T unwrap(CompletableFuture cf) throws Throwable { try { - return cf.get(1, TimeUnit.SECONDS); + return cf.get(10, TimeUnit.SECONDS); } catch (final ExecutionException e) { throw e.getCause(); } diff --git a/pom.xml b/pom.xml index 2dc5c2de58..5690e24bec 100644 --- a/pom.xml +++ b/pom.xml @@ -165,7 +165,7 @@ 1.15.2 2.0.17 20250517 - 3.5.6 + 4.0.2 6.0.2 2.21.0 @@ -237,9 +237,9 @@ 1.5.3 3.0.2 2025.1.1 - 4.3.0 + 5.0.1 7.0.3 - 6.2.11 + 7.0.3 2.3.23.Final 1.18.0 diff --git a/spring/pom.xml b/spring/pom.xml index fdd0a763ec..a441d7130f 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -31,7 +31,7 @@ 17 - 6.2.11 + 7.0.3 From b5903f0e1851d4f0ceba22a4cc4c96e966cc1c9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:01:16 +0000 Subject: [PATCH 03/12] build(deps): Bump micrometer.version from 1.16.1 to 1.16.2 Bumps `micrometer.version` from 1.16.1 to 1.16.2. Updates `io.micrometer:micrometer-core` from 1.16.1 to 1.16.2 - [Release notes](https://github.com/micrometer-metrics/micrometer/releases) - [Commits](https://github.com/micrometer-metrics/micrometer/compare/v1.16.1...v1.16.2) Updates `io.micrometer:micrometer-test` from 1.16.1 to 1.16.2 - [Release notes](https://github.com/micrometer-metrics/micrometer/releases) - [Commits](https://github.com/micrometer-metrics/micrometer/compare/v1.16.1...v1.16.2) --- updated-dependencies: - dependency-name: io.micrometer:micrometer-core dependency-version: 1.16.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.micrometer:micrometer-test dependency-version: 1.16.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- micrometer/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/micrometer/pom.xml b/micrometer/pom.xml index c93a942d14..c98d50beb6 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -28,7 +28,7 @@ Feign Micrometer Application Metrics - 1.16.1 + 1.16.2 From fe640f55a86973e85be93ccb23da8686ec51e7d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:01:17 +0000 Subject: [PATCH 04/12] build(deps): Bump org.apache.maven.plugins:maven-source-plugin Bumps [org.apache.maven.plugins:maven-source-plugin](https://github.com/apache/maven-source-plugin) from 3.3.1 to 3.4.0. - [Release notes](https://github.com/apache/maven-source-plugin/releases) - [Commits](https://github.com/apache/maven-source-plugin/compare/maven-source-plugin-3.3.1...maven-source-plugin-3.4.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-source-plugin dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5690e24bec..053a2406ac 100644 --- a/pom.xml +++ b/pom.xml @@ -178,7 +178,7 @@ 5.4 3.15.0 3.1.4 - 3.3.1 + 3.4.0 3.12.0 5.0.0 3.5.0 From f0e935f8a3f0b7af0c87df1bdf1047edb907736b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:01:17 +0000 Subject: [PATCH 05/12] build(deps): Bump org.json:json from 20250517 to 20251224 Bumps [org.json:json](https://github.com/douglascrockford/JSON-java) from 20250517 to 20251224. - [Release notes](https://github.com/douglascrockford/JSON-java/releases) - [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md) - [Commits](https://github.com/douglascrockford/JSON-java/commits) --- updated-dependencies: - dependency-name: org.json:json dependency-version: '20251224' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5690e24bec..6494db5cab 100644 --- a/pom.xml +++ b/pom.xml @@ -164,7 +164,7 @@ 2.13.2 1.15.2 2.0.17 - 20250517 + 20251224 4.0.2 6.0.2 From 20d2946cd4295b0d17c6337a415de440d19e21cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:01:19 +0000 Subject: [PATCH 06/12] build(deps): Bump org.apache.httpcomponents.client5:httpclient5 Bumps [org.apache.httpcomponents.client5:httpclient5](https://github.com/apache/httpcomponents-client) from 5.5.1 to 5.6. - [Changelog](https://github.com/apache/httpcomponents-client/blob/master/RELEASE_NOTES.txt) - [Commits](https://github.com/apache/httpcomponents-client/compare/rel/v5.5.1...rel/v5.6) --- updated-dependencies: - dependency-name: org.apache.httpcomponents.client5:httpclient5 dependency-version: '5.6' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5690e24bec..66e1e6b0e0 100644 --- a/pom.xml +++ b/pom.xml @@ -214,7 +214,7 @@ 0.23.0 4.5.0 4.5.14 - 5.5.1 + 5.6 1.5.18 3.1.0 4.0.0 From d62b64740473eab2ae31acd591adaf7e6b14af2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 05:35:04 +0000 Subject: [PATCH 07/12] build(deps): Bump com.alibaba.fastjson2:fastjson2 Bumps [com.alibaba.fastjson2:fastjson2](https://github.com/alibaba/fastjson2) from 2.0.60.android8 to 2.0.61.android8. - [Release notes](https://github.com/alibaba/fastjson2/releases) - [Commits](https://github.com/alibaba/fastjson2/compare/2.0.60.android8...2.0.61.android8) --- updated-dependencies: - dependency-name: com.alibaba.fastjson2:fastjson2 dependency-version: 2.0.61.android8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index dfeb93cb53..a110ef4e42 100644 --- a/pom.xml +++ b/pom.xml @@ -172,7 +172,7 @@ 3.0.2 3.27.7 5.21.0 - 2.0.60.android8 + 2.0.61.android8 1.5.3 5.4 From d74ef9995e7f965f86e257dacc2ef1fa9e86d6d7 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 9 Feb 2026 14:57:22 -0300 Subject: [PATCH 08/12] Add feign-graphql and feign-graphql-apt modules (#3212) * Add feign-graphql-apt module for GraphQL schema-based type generation Signed-off-by: Marvin Froeder * Generate Java records instead of POJOs in feign-graphql-apt Signed-off-by: Marvin Froeder * Apply Java 17/25 modernization, add READMEs, and update mindmap Signed-off-by: Marvin Froeder * Remove X-Feign-GraphQL headers, add decoder delegate support Signed-off-by: Marvin Froeder --------- Signed-off-by: Marvin Froeder --- .circleci/config.yml | 2 +- .gitignore | 1 + CLAUDE.md => AGENTS.md | 11 +- graphql-apt/README.md | 57 ++ graphql-apt/pom.xml | 89 +++ .../graphql/apt/GraphqlSchemaProcessor.java | 445 +++++++++++++++ .../feign/graphql/apt/GraphqlTypeMapper.java | 75 +++ .../feign/graphql/apt/QueryValidator.java | 61 +++ .../java/feign/graphql/apt/SchemaLoader.java | 64 +++ .../java/feign/graphql/apt/TypeGenerator.java | 401 ++++++++++++++ .../apt/GraphqlSchemaProcessorTest.java | 517 ++++++++++++++++++ .../test/resources/scalar-test-schema.graphql | 14 + .../src/test/resources/test-schema.graphql | 150 +++++ graphql/README.md | 147 +++++ graphql/pom.xml | 63 +++ .../java/feign/graphql/GraphqlContract.java | 100 ++++ .../java/feign/graphql/GraphqlDecoder.java | 129 +++++ .../java/feign/graphql/GraphqlEncoder.java | 80 +++ .../feign/graphql/GraphqlErrorException.java | 39 ++ .../main/java/feign/graphql/GraphqlQuery.java | 28 + .../java/feign/graphql/GraphqlSchema.java | 30 + .../src/main/java/feign/graphql/Scalar.java | 28 + .../java/feign/graphql/GraphqlClientTest.java | 190 +++++++ .../feign/graphql/GraphqlContractTest.java | 121 ++++ .../feign/graphql/GraphqlDecoderTest.java | 148 +++++ .../feign/graphql/GraphqlEncoderTest.java | 97 ++++ pom.xml | 10 + src/docs/overview-mindmap.iuml | 14 +- vertx/pom.xml | 1 + 29 files changed, 3106 insertions(+), 6 deletions(-) rename CLAUDE.md => AGENTS.md (90%) create mode 100644 graphql-apt/README.md create mode 100644 graphql-apt/pom.xml create mode 100644 graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java create mode 100644 graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java create mode 100644 graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java create mode 100644 graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java create mode 100644 graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java create mode 100644 graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java create mode 100644 graphql-apt/src/test/resources/scalar-test-schema.graphql create mode 100644 graphql-apt/src/test/resources/test-schema.graphql create mode 100644 graphql/README.md create mode 100644 graphql/pom.xml create mode 100644 graphql/src/main/java/feign/graphql/GraphqlContract.java create mode 100644 graphql/src/main/java/feign/graphql/GraphqlDecoder.java create mode 100644 graphql/src/main/java/feign/graphql/GraphqlEncoder.java create mode 100644 graphql/src/main/java/feign/graphql/GraphqlErrorException.java create mode 100644 graphql/src/main/java/feign/graphql/GraphqlQuery.java create mode 100644 graphql/src/main/java/feign/graphql/GraphqlSchema.java create mode 100644 graphql/src/main/java/feign/graphql/Scalar.java create mode 100644 graphql/src/test/java/feign/graphql/GraphqlClientTest.java create mode 100644 graphql/src/test/java/feign/graphql/GraphqlContractTest.java create mode 100644 graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java create mode 100644 graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 08b806e783..598323cc97 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -99,7 +99,7 @@ commands: name: 'Deploy Core Modules Sonatype' command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - ./mvnw -ntp -nsu -s .circleci/settings.xml -P release -pl -:feign-benchmark -DskipTests=true deploy + ./mvnw -ntp -nsu -s .circleci/settings.xml -P release -pl -:feign-benchmark,-:feign-vertx4-test,-:feign-vertx5-test -DskipTests=true deploy # our job defaults defaults: &defaults diff --git a/.gitignore b/.gitignore index d19b71053c..3302792f89 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ atlassian-ide-plugin.xml # maven versions *.versionsBackup .mvn/.develocity/develocity-workspace-id +.sdkmanrc diff --git a/CLAUDE.md b/AGENTS.md similarity index 90% rename from CLAUDE.md rename to AGENTS.md index 33b037e165..23a8257c03 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to AI coding assistants when working with code in this repository. ## Build Commands @@ -122,3 +122,10 @@ Some modules define the same Maven property name (e.g., `jersey.version`, `vertx 1. Add the dependency to the root entry's `ignore` list (fully ignored, not just major) 2. Add a per-directory entry for the new module with `allow` for the specific dependency and `ignore` for `version-update:semver-major` 3. Verify existing modules with the same property also have their own per-directory entries + +## Documentation Requirements + +- New modules must include a `README.md` with usage examples following the style of existing module READMEs (e.g., `jackson/README.md`, `graphql/README.md`) +- New public functionality (annotations, contracts, encoders, decoders) must be documented in the module's `README.md` +- README should include: Maven dependency coordinates, `Feign.builder()` configuration examples, and advanced usage if applicable +- Update this file's Integration Modules list when adding a new module diff --git a/graphql-apt/README.md b/graphql-apt/README.md new file mode 100644 index 0000000000..db67c2418a --- /dev/null +++ b/graphql-apt/README.md @@ -0,0 +1,57 @@ +Feign GraphQL APT +=================== + +Annotation processor for `feign-graphql` that generates Java records from GraphQL schemas at compile time. + +Given a `@GraphqlSchema`-annotated interface, this processor: + +- Parses the referenced `.graphql` schema file +- Validates all `@GraphqlQuery` strings against the schema +- Generates Java records for result types, input types, and enums +- Maps custom scalars to Java types via `@Scalar` annotations + +See the [feign-graphql README](../graphql/README.md) for usage examples. + +## Generated Output + +For a schema type like: + +```graphql +type User { + id: ID! + name: String! + email: String + status: Status! +} + +enum Status { + ACTIVE + INACTIVE +} +``` + +The processor generates: + +```java +public record User(String id, String name, String email, Status status) {} +``` + +```java +public enum Status { + ACTIVE, + INACTIVE +} +``` + +## Maven Configuration + +Add as a `provided` dependency so it runs during compilation but is not included at runtime: + +```xml + + io.github.openfeign.experimental + feign-graphql-apt + ${feign.version} + provided + +``` diff --git a/graphql-apt/pom.xml b/graphql-apt/pom.xml new file mode 100644 index 0000000000..4f96d5320d --- /dev/null +++ b/graphql-apt/pom.xml @@ -0,0 +1,89 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.8-SNAPSHOT + + + io.github.openfeign.experimental + feign-graphql-apt + Feign GraphQL APT + Feign annotation processor for GraphQL schema-based type generation + + + 17 + true + + + + + io.github.openfeign + feign-graphql + ${project.version} + + + + com.graphql-java + graphql-java + ${graphql-java.version} + + + + com.squareup + javapoet + ${javapoet.version} + + + + com.google.auto.service + auto-service + ${auto-service-annotations.version} + provided + + + + com.google.testing.compile + compile-testing + ${compile-testing.version} + test + + + junit + junit + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --add-opens jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + + + + + diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java new file mode 100644 index 0000000000..0f6e321088 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java @@ -0,0 +1,445 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import com.google.auto.service.AutoService; +import com.squareup.javapoet.TypeName; +import feign.graphql.GraphqlQuery; +import feign.graphql.GraphqlSchema; +import feign.graphql.Scalar; +import graphql.language.Document; +import graphql.language.Field; +import graphql.language.FieldDefinition; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.ObjectTypeDefinition; +import graphql.language.OperationDefinition; +import graphql.language.SelectionSet; +import graphql.language.Type; +import graphql.language.VariableDefinition; +import graphql.parser.Parser; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.TypeDefinitionRegistry; +import graphql.schema.idl.UnExecutableSchemaGenerator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.Processor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; + +@AutoService(Processor.class) +@SupportedAnnotationTypes("feign.graphql.GraphqlSchema") +@SupportedSourceVersion(SourceVersion.RELEASE_17) +public class GraphqlSchemaProcessor extends AbstractProcessor { + + private Filer filer; + private Messager messager; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.filer = processingEnv.getFiler(); + this.messager = processingEnv.getMessager(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + for (var element : roundEnv.getElementsAnnotatedWith(GraphqlSchema.class)) { + if (!(element instanceof TypeElement)) { + continue; + } + processInterface((TypeElement) element); + } + return true; + } + + private void processInterface(TypeElement typeElement) { + var schemaAnnotation = typeElement.getAnnotation(GraphqlSchema.class); + var schemaPath = schemaAnnotation.value(); + + var loader = new SchemaLoader(filer, messager); + var schemaContent = loader.load(schemaPath, typeElement); + if (schemaContent == null) { + return; + } + + var schemaParser = new SchemaParser(); + TypeDefinitionRegistry registry; + try { + registry = schemaParser.parse(schemaContent); + } catch (Exception e) { + messager.printMessage( + Diagnostic.Kind.ERROR, "Failed to parse GraphQL schema: " + e.getMessage(), typeElement); + return; + } + + var customScalars = collectScalarMappings(typeElement); + + if (!validateCustomScalars(registry, customScalars, typeElement)) { + return; + } + + var graphqlSchema = UnExecutableSchemaGenerator.makeUnExecutableSchema(registry); + + var generateTypes = schemaAnnotation.generateTypes(); + + var targetPackage = getPackageName(typeElement); + var typeMapper = new GraphqlTypeMapper(targetPackage, customScalars); + var validator = new QueryValidator(messager); + var generator = new TypeGenerator(filer, messager, registry, typeMapper, targetPackage); + + for (var enclosed : typeElement.getEnclosedElements()) { + if (!(enclosed instanceof ExecutableElement method)) { + continue; + } + var queryAnnotation = method.getAnnotation(GraphqlQuery.class); + if (queryAnnotation == null) { + continue; + } + + processMethod( + method, + queryAnnotation, + graphqlSchema, + registry, + generator, + validator, + generateTypes, + targetPackage); + } + } + + private Map collectScalarMappings(TypeElement typeElement) { + var scalars = new HashMap(); + collectScalarsFromType(typeElement, scalars); + collectScalarsFromParents(typeElement, scalars); + return scalars; + } + + private void collectScalarsFromType(TypeElement typeElement, Map scalars) { + for (var enclosed : typeElement.getEnclosedElements()) { + if (!(enclosed instanceof ExecutableElement method)) { + continue; + } + var scalarAnnotation = method.getAnnotation(Scalar.class); + if (scalarAnnotation == null) { + continue; + } + var scalarName = scalarAnnotation.value(); + var javaType = TypeName.get(method.getReturnType()); + scalars.put(scalarName, javaType); + } + } + + private void collectScalarsFromParents(TypeElement typeElement, Map scalars) { + for (var iface : typeElement.getInterfaces()) { + if (iface instanceof DeclaredType type) { + var element = type.asElement(); + if (element instanceof TypeElement parentType) { + collectScalarsFromType(parentType, scalars); + collectScalarsFromParents(parentType, scalars); + } + } + } + } + + private static final Set GRAPHQL_BUILT_IN_SCALARS = + Set.of("String", "Int", "Float", "Boolean", "ID"); + + private boolean validateCustomScalars( + TypeDefinitionRegistry registry, + Map customScalars, + TypeElement typeElement) { + var schemaScalars = registry.scalars(); + var valid = true; + for (var scalarName : schemaScalars.keySet()) { + if (GRAPHQL_BUILT_IN_SCALARS.contains(scalarName)) { + continue; + } + if (!customScalars.containsKey(scalarName)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Custom scalar '" + + scalarName + + "' is used in the schema but no @Scalar(\"" + + scalarName + + "\") method is defined. " + + "Add a @Scalar(\"" + + scalarName + + "\") default method to this interface or a parent interface.", + typeElement); + valid = false; + } + } + return valid; + } + + private void processMethod( + ExecutableElement method, + GraphqlQuery queryAnnotation, + GraphQLSchema graphqlSchema, + TypeDefinitionRegistry registry, + TypeGenerator generator, + QueryValidator validator, + boolean generateTypes, + String targetPackage) { + + var queryString = queryAnnotation.value(); + Document document; + try { + document = Parser.parse(queryString); + } catch (Exception e) { + messager.printMessage( + Diagnostic.Kind.ERROR, "Failed to parse GraphQL query: " + e.getMessage(), method); + return; + } + + if (!validator.validate(graphqlSchema, document, method) || !generateTypes) { + return; + } + + var operation = findOperation(document); + if (operation == null) { + messager.printMessage( + Diagnostic.Kind.ERROR, "No operation definition found in GraphQL query", method); + return; + } + + var returnTypeName = getSimpleTypeName(method.getReturnType()); + if (returnTypeName != null && !isExistingExternalType(method.getReturnType(), targetPackage)) { + var rootType = getRootType(operation, registry); + if (rootType != null) { + var rootField = findRootField(operation.getSelectionSet()); + if (rootField != null && rootField.getSelectionSet() != null) { + var rootFieldDef = findFieldDefinition(rootType, rootField.getName()); + if (rootFieldDef != null) { + var fieldTypeName = unwrapTypeName(rootFieldDef.getType()); + var fieldObjectType = + registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null); + if (fieldObjectType != null) { + generator.generateResultType( + returnTypeName, rootField.getSelectionSet(), fieldObjectType, method); + } + } + } + } + } + + var params = method.getParameters(); + var variableDefs = operation.getVariableDefinitions(); + + for (var param : params) { + var paramTypeName = getSimpleTypeName(param.asType()); + if (paramTypeName == null || isJavaBuiltIn(paramTypeName)) { + continue; + } + + if (isExistingExternalType(param.asType(), targetPackage)) { + continue; + } + + var graphqlInputTypeName = findGraphqlInputType(paramTypeName, variableDefs); + if (graphqlInputTypeName != null) { + generator.generateInputType(paramTypeName, graphqlInputTypeName, method); + } + } + } + + private OperationDefinition findOperation(Document document) { + for (var def : document.getDefinitions()) { + if (def instanceof OperationDefinition definition) { + return definition; + } + } + return null; + } + + private Field findRootField(SelectionSet selectionSet) { + if (selectionSet == null) { + return null; + } + for (var selection : selectionSet.getSelections()) { + if (selection instanceof Field field) { + return field; + } + } + return null; + } + + private FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { + for (var fd : typeDef.getFieldDefinitions()) { + if (fd.getName().equals(fieldName)) { + return fd; + } + } + return null; + } + + private ObjectTypeDefinition getRootType( + OperationDefinition operation, TypeDefinitionRegistry registry) { + var rootTypeName = + switch (operation.getOperation()) { + case MUTATION -> + registry + .schemaDefinition() + .flatMap( + sd -> + sd.getOperationTypeDefinitions().stream() + .filter(otd -> otd.getName().equals("mutation")) + .findFirst()) + .map(otd -> otd.getTypeName().getName()) + .orElse("Mutation"); + case SUBSCRIPTION -> + registry + .schemaDefinition() + .flatMap( + sd -> + sd.getOperationTypeDefinitions().stream() + .filter(otd -> otd.getName().equals("subscription")) + .findFirst()) + .map(otd -> otd.getTypeName().getName()) + .orElse("Subscription"); + default -> + registry + .schemaDefinition() + .flatMap( + sd -> + sd.getOperationTypeDefinitions().stream() + .filter(otd -> otd.getName().equals("query")) + .findFirst()) + .map(otd -> otd.getTypeName().getName()) + .orElse("Query"); + }; + return registry.getType(rootTypeName, ObjectTypeDefinition.class).orElse(null); + } + + private String findGraphqlInputType( + String javaParamTypeName, List variableDefs) { + for (var varDef : variableDefs) { + var graphqlTypeName = unwrapTypeName(varDef.getType()); + if (graphqlTypeName.equals(javaParamTypeName)) { + return graphqlTypeName; + } + } + return javaParamTypeName; + } + + private String unwrapTypeName(Type type) { + if (type instanceof NonNullType nullType) { + return unwrapTypeName(nullType.getType()); + } + if (type instanceof ListType listType) { + return unwrapTypeName(listType.getType()); + } + if (type instanceof graphql.language.TypeName name) { + return name.getName(); + } + return "String"; + } + + private static final Set JAVA_BUILT_INS = + Set.of( + "String", + "Integer", + "Long", + "Double", + "Float", + "Boolean", + "Object", + "Byte", + "Short", + "Character", + "BigDecimal", + "BigInteger"); + + private boolean isJavaBuiltIn(String typeName) { + return JAVA_BUILT_INS.contains(typeName); + } + + private String getSimpleTypeName(TypeMirror typeMirror) { + if (typeMirror instanceof DeclaredType declaredType) { + var typeElement = declaredType.asElement(); + var simpleName = typeElement.getSimpleName().toString(); + + if ("List".equals(simpleName)) { + var typeArgs = declaredType.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return getSimpleTypeName(typeArgs.get(0)); + } + } + + return simpleName; + } + return null; + } + + private boolean isExistingExternalType(TypeMirror typeMirror, String targetPackage) { + var unwrapped = unwrapListTypeMirror(typeMirror); + if (unwrapped.getKind() == TypeKind.ERROR) { + return false; + } + if (unwrapped instanceof DeclaredType type) { + var element = type.asElement(); + if (element instanceof TypeElement typeElement) { + var qualifiedName = typeElement.getQualifiedName().toString(); + var lastDot = qualifiedName.lastIndexOf('.'); + var typePkg = lastDot > 0 ? qualifiedName.substring(0, lastDot) : ""; + return !typePkg.equals(targetPackage); + } + } + return false; + } + + private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) { + if (typeMirror instanceof DeclaredType declaredType) { + var simpleName = declaredType.asElement().getSimpleName().toString(); + if ("List".equals(simpleName)) { + var typeArgs = declaredType.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return typeArgs.get(0); + } + } + } + return typeMirror; + } + + private String getPackageName(TypeElement typeElement) { + var enclosing = typeElement.getEnclosingElement(); + while (enclosing != null && !(enclosing instanceof PackageElement)) { + enclosing = enclosing.getEnclosingElement(); + } + if (enclosing instanceof PackageElement element) { + return element.getQualifiedName().toString(); + } + return ""; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java new file mode 100644 index 0000000000..d9be1fa18f --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java @@ -0,0 +1,75 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class GraphqlTypeMapper { + + private static final Map BUILT_IN_SCALARS = + Map.of( + "String", ClassName.get(String.class), + "Int", ClassName.get(Integer.class), + "Float", ClassName.get(Double.class), + "Boolean", ClassName.get(Boolean.class), + "ID", ClassName.get(String.class)); + + private final String targetPackage; + private final Map customScalars; + + public GraphqlTypeMapper(String targetPackage, Map customScalars) { + this.targetPackage = targetPackage; + this.customScalars = new HashMap<>(customScalars); + } + + public TypeName map(Type type) { + if (type instanceof NonNullType nullType) { + return map(nullType.getType()); + } + if (type instanceof ListType listType) { + var elementType = map(listType.getType()); + return ParameterizedTypeName.get(ClassName.get(List.class), elementType); + } + if (type instanceof graphql.language.TypeName name) { + return mapScalarOrNamed(name.getName()); + } + return ClassName.get(String.class); + } + + private TypeName mapScalarOrNamed(String name) { + var builtIn = BUILT_IN_SCALARS.get(name); + if (builtIn != null) { + return builtIn; + } + var custom = customScalars.get(name); + if (custom != null) { + return custom; + } + return ClassName.get(targetPackage, name); + } + + public boolean isScalar(String name) { + return BUILT_IN_SCALARS.containsKey(name) || customScalars.containsKey(name); + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java b/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java new file mode 100644 index 0000000000..17c5e304d0 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import graphql.GraphQLError; +import graphql.language.Document; +import graphql.schema.GraphQLSchema; +import graphql.validation.Validator; +import java.util.Locale; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.tools.Diagnostic; + +public class QueryValidator { + + private final Messager messager; + + public QueryValidator(Messager messager) { + this.messager = messager; + } + + public boolean validate(GraphQLSchema schema, Document document, Element methodElement) { + var validator = new Validator(); + var errors = validator.validateDocument(schema, document, Locale.ENGLISH); + + if (errors.isEmpty()) { + return true; + } + + for (GraphQLError error : errors) { + var locations = error.getLocations(); + if (locations != null && !locations.isEmpty()) { + var loc = locations.get(0); + messager.printMessage( + Diagnostic.Kind.ERROR, + "GraphQL validation error at line %d, column %d: %s" + .formatted(loc.getLine(), loc.getColumn(), error.getMessage()), + methodElement); + } else { + messager.printMessage( + Diagnostic.Kind.ERROR, + "GraphQL validation error: " + error.getMessage(), + methodElement); + } + } + return false; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java b/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java new file mode 100644 index 0000000000..16f971d0f0 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java @@ -0,0 +1,64 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.tools.Diagnostic; +import javax.tools.StandardLocation; + +public class SchemaLoader { + + private final Filer filer; + private final Messager messager; + + public SchemaLoader(Filer filer, Messager messager) { + this.filer = filer; + this.messager = messager; + } + + public String load(String path, Element element) { + StandardLocation[] locations = { + StandardLocation.CLASS_PATH, StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT + }; + + for (var location : locations) { + try { + var resource = filer.getResource(location, "", path); + try (var is = resource.openInputStream(); + Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + var sb = new StringBuilder(); + var buf = new char[4096]; + int read; + while ((read = reader.read(buf)) != -1) { + sb.append(buf, 0, read); + } + return sb.toString(); + } + } catch (IOException e) { + // try next location + } + } + + messager.printMessage(Diagnostic.Kind.ERROR, "GraphQL schema not found: " + path, element); + return null; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java new file mode 100644 index 0000000000..3b70e8480f --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java @@ -0,0 +1,401 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import graphql.language.EnumTypeDefinition; +import graphql.language.Field; +import graphql.language.FieldDefinition; +import graphql.language.InputObjectTypeDefinition; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.ObjectTypeDefinition; +import graphql.language.SelectionSet; +import graphql.language.Type; +import graphql.schema.idl.TypeDefinitionRegistry; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import javax.annotation.processing.Filer; +import javax.annotation.processing.FilerException; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.lang.model.element.Modifier; +import javax.tools.Diagnostic; + +public class TypeGenerator { + + private final Filer filer; + private final Messager messager; + private final TypeDefinitionRegistry registry; + private final GraphqlTypeMapper typeMapper; + private final String targetPackage; + private final Set generatedTypes = new HashSet<>(); + private final Queue pendingTypes = new ArrayDeque<>(); + + public TypeGenerator( + Filer filer, + Messager messager, + TypeDefinitionRegistry registry, + GraphqlTypeMapper typeMapper, + String targetPackage) { + this.filer = filer; + this.messager = messager; + this.registry = registry; + this.typeMapper = typeMapper; + this.targetPackage = targetPackage; + } + + public void generateResultType( + String className, + SelectionSet selectionSet, + ObjectTypeDefinition parentType, + Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var fields = new ArrayList(); + + for (var selection : selectionSet.getSelections()) { + if (!(selection instanceof Field)) { + continue; + } + var field = (Field) selection; + var fieldName = field.getName(); + + var schemaDef = findFieldDefinition(parentType, fieldName); + if (schemaDef == null) { + continue; + } + + var fieldType = schemaDef.getType(); + var rawTypeName = unwrapTypeName(fieldType); + + if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) { + var nestedClassName = capitalize(fieldName); + generateNestedResultType(nestedClassName, field.getSelectionSet(), rawTypeName, element); + var nestedType = wrapType(fieldType, ClassName.get(targetPackage, nestedClassName)); + fields.add(toRecordField(fieldName, nestedType)); + } else { + var javaType = typeMapper.map(fieldType); + fields.add(toRecordField(fieldName, javaType)); + enqueueIfNonScalar(rawTypeName); + } + } + + writeRecord(className, fields, element); + processPendingTypes(element); + } + + public void generateInputType(String className, String graphqlTypeName, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var maybeDef = registry.getType(graphqlTypeName, InputObjectTypeDefinition.class); + if (maybeDef.isEmpty()) { + messager.printMessage( + Diagnostic.Kind.ERROR, "GraphQL input type not found: " + graphqlTypeName, element); + return; + } + + var inputDef = maybeDef.get(); + var fields = new ArrayList(); + + for (var valueDef : inputDef.getInputValueDefinitions()) { + var fieldName = valueDef.getName(); + var fieldType = valueDef.getType(); + var javaType = typeMapper.map(fieldType); + fields.add(toRecordField(fieldName, javaType)); + + var rawTypeName = unwrapTypeName(fieldType); + enqueueIfNonScalar(rawTypeName); + } + + writeRecord(className, fields, element); + processPendingTypes(element); + } + + private void generateNestedResultType( + String className, SelectionSet selectionSet, String graphqlTypeName, Element element) { + if (generatedTypes.contains(className)) { + return; + } + + var maybeDef = registry.getType(graphqlTypeName, ObjectTypeDefinition.class); + if (maybeDef.isEmpty()) { + return; + } + + generateResultType(className, selectionSet, maybeDef.get(), element); + } + + private void processPendingTypes(Element element) { + while (!pendingTypes.isEmpty()) { + var typeName = pendingTypes.poll(); + if (generatedTypes.contains(typeName)) { + continue; + } + + var enumDef = registry.getType(typeName, EnumTypeDefinition.class); + if (enumDef.isPresent()) { + generateEnum(typeName, enumDef.get(), element); + continue; + } + + var inputDef = registry.getType(typeName, InputObjectTypeDefinition.class); + if (inputDef.isPresent()) { + generateInputType(typeName, typeName, element); + continue; + } + + var objectDef = registry.getType(typeName, ObjectTypeDefinition.class); + if (objectDef.isPresent()) { + generateFullObjectType(typeName, objectDef.get(), element); + } + } + } + + private void generateEnum(String className, EnumTypeDefinition enumDef, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var enumBuilder = TypeSpec.enumBuilder(className).addModifiers(Modifier.PUBLIC); + + for (var value : enumDef.getEnumValueDefinitions()) { + enumBuilder.addEnumConstant(value.getName()); + } + + writeType(enumBuilder.build(), element); + } + + private void generateFullObjectType( + String className, ObjectTypeDefinition objectDef, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var fields = new ArrayList(); + + for (var fieldDef : objectDef.getFieldDefinitions()) { + var fieldName = fieldDef.getName(); + var javaType = typeMapper.map(fieldDef.getType()); + fields.add(toRecordField(fieldName, javaType)); + + var rawTypeName = unwrapTypeName(fieldDef.getType()); + enqueueIfNonScalar(rawTypeName); + } + + writeRecord(className, fields, element); + processPendingTypes(element); + } + + private void enqueueIfNonScalar(String typeName) { + if (!typeMapper.isScalar(typeName) && !generatedTypes.contains(typeName)) { + pendingTypes.add(typeName); + } + } + + private FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { + for (var fd : typeDef.getFieldDefinitions()) { + if (fd.getName().equals(fieldName)) { + return fd; + } + } + return null; + } + + private String unwrapTypeName(Type type) { + if (type instanceof NonNullType nullType) { + return unwrapTypeName(nullType.getType()); + } + if (type instanceof ListType listType) { + return unwrapTypeName(listType.getType()); + } + if (type instanceof graphql.language.TypeName name) { + return name.getName(); + } + return "String"; + } + + private TypeName wrapType(Type schemaType, TypeName innerType) { + if (schemaType instanceof NonNullType type) { + return wrapType(type.getType(), innerType); + } + if (schemaType instanceof ListType type) { + return ParameterizedTypeName.get( + ClassName.get(List.class), wrapType(type.getType(), innerType)); + } + return innerType; + } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) { + return s; + } + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + private void writeType(TypeSpec typeSpec, Element element) { + try { + var javaFile = JavaFile.builder(targetPackage, typeSpec).build(); + javaFile.writeTo(filer); + } catch (FilerException e) { + // Type already generated by another interface in the same compilation round + } catch (IOException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to write generated type " + typeSpec.name + ": " + e.getMessage(), + element); + } + } + + private RecordField toRecordField(String name, TypeName typeName) { + var typeString = typeNameToString(typeName); + var importFqn = resolveImport(typeName); + return new RecordField(typeString, name, importFqn, typeName); + } + + private String typeNameToString(TypeName typeName) { + if (typeName instanceof ParameterizedTypeName parameterized) { + var raw = parameterized.rawType.simpleName(); + var typeArgs = + parameterized.typeArguments.stream() + .map(this::typeNameToString) + .collect(Collectors.joining(", ")); + return raw + "<" + typeArgs + ">"; + } + if (typeName instanceof ClassName name) { + return name.simpleName(); + } + return typeName.toString(); + } + + private String resolveImport(TypeName typeName) { + if (typeName instanceof ParameterizedTypeName parameterized) { + resolveImport(parameterized.rawType); + for (var typeArg : parameterized.typeArguments) { + resolveImport(typeArg); + } + return fqnIfNeeded(parameterized.rawType); + } + if (typeName instanceof ClassName name) { + return fqnIfNeeded(name); + } + return null; + } + + private String fqnIfNeeded(ClassName className) { + var pkg = className.packageName(); + if (pkg.equals("java.lang") || pkg.equals(targetPackage) || pkg.isEmpty()) { + return null; + } + return pkg + "." + className.simpleName(); + } + + private Set collectImports(List fields) { + var imports = new TreeSet(); + for (var field : fields) { + collectImportsFromTypeName(field.typeName, imports); + } + return imports; + } + + private void collectImportsFromTypeName(TypeName typeName, Set imports) { + if (typeName instanceof ParameterizedTypeName parameterized) { + collectImportsFromTypeName(parameterized.rawType, imports); + for (var typeArg : parameterized.typeArguments) { + collectImportsFromTypeName(typeArg, imports); + } + } else if (typeName instanceof ClassName name) { + var fqn = fqnIfNeeded(name); + if (fqn != null) { + imports.add(fqn); + } + } + } + + private void writeRecord(String className, List fields, Element element) { + var fqn = targetPackage.isEmpty() ? className : targetPackage + "." + className; + try { + var sourceFile = filer.createSourceFile(fqn, element); + try (var out = new PrintWriter(sourceFile.openWriter())) { + if (!targetPackage.isEmpty()) { + out.println("package " + targetPackage + ";"); + out.println(); + } + + var imports = collectImports(fields); + if (!imports.isEmpty()) { + for (var imp : imports) { + out.println("import " + imp + ";"); + } + out.println(); + } + + var params = + fields.stream().map(f -> f.typeString + " " + f.name).collect(Collectors.joining(", ")); + + out.println("public record " + className + "(" + params + ") {}"); + } + } catch (FilerException e) { + // Type already generated by another interface in the same compilation round + } catch (IOException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to write generated type " + className + ": " + e.getMessage(), + element); + } + } + + static class RecordField { + final String typeString; + final String name; + final String importFqn; + final TypeName typeName; + + RecordField(String typeString, String name, String importFqn) { + this.typeString = typeString; + this.name = name; + this.importFqn = importFqn; + this.typeName = null; + } + + RecordField(String typeString, String name, String importFqn, TypeName typeName) { + this.typeString = typeString; + this.name = name; + this.importFqn = importFqn; + this.typeName = typeName; + } + } +} diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java new file mode 100644 index 0000000000..cd272b2f25 --- /dev/null +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -0,0 +1,517 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql.apt; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; + +import com.google.testing.compile.JavaFileObjects; +import org.junit.jupiter.api.Test; + +class GraphqlSchemaProcessorTest { + + @Test + void validMutationGeneratesTypes() { + var source = + JavaFileObjects.forSourceString( + "test.MyApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MyApi { + @GraphqlQuery(\""" + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { id name email status } + }\""") + CreateUserResult createUser(CreateUserInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateUserResult"); + assertThat(compilation).generatedSourceFile("test.CreateUserInput"); + } + + @Test + void invalidQueryReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.BadApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface BadApi { + @GraphqlQuery("{ nonExistentField }") + BadResult query(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("GraphQL validation error"); + } + + @Test + void missingSchemaReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.NoSchemaApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("nonexistent-schema.graphql") + interface NoSchemaApi { + @GraphqlQuery("{ user(id: \\"1\\") { id } }") + Object query(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("GraphQL schema not found"); + } + + @Test + void nestedTypesAreGenerated() { + var source = + JavaFileObjects.forSourceString( + "test.NestedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface NestedApi { + @GraphqlQuery(\""" + { user(id: "1") { id name address { street city country } } } + \""") + UserResult getUser(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.UserResult"); + assertThat(compilation).generatedSourceFile("test.Address"); + } + + @Test + void enumsAreGeneratedAsJavaEnums() { + var source = + JavaFileObjects.forSourceString( + "test.EnumApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface EnumApi { + @GraphqlQuery(\""" + mutation updateStatus($id: ID!, $status: Status!) { + updateStatus(id: $id, status: $status) { id status } + }\""") + StatusResult updateStatus(String id, Status status); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.StatusResult"); + assertThat(compilation).generatedSourceFile("test.Status"); + } + + @Test + void listTypesMapToJavaList() { + var source = + JavaFileObjects.forSourceString( + "test.ListApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ListApi { + @GraphqlQuery("{ user(id: \\"1\\") { id name tags } }") + UserWithTagsResult getUser(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.UserWithTagsResult"); + } + + @Test + void multipleMethodsSharingInputType() { + var source = + JavaFileObjects.forSourceString( + "test.SharedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SharedApi { + @GraphqlQuery(\""" + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { id name } + }\""") + CreateResult1 createUser1(CreateUserInput input); + + @GraphqlQuery(\""" + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { id email } + }\""") + CreateResult2 createUser2(CreateUserInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateUserInput"); + assertThat(compilation).generatedSourceFile("test.CreateResult1"); + assertThat(compilation).generatedSourceFile("test.CreateResult2"); + } + + @Test + void deeplyNestedOrganizationQuery() { + var source = + JavaFileObjects.forSourceString( + "test.DeepApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface DeepApi { + @GraphqlQuery(\""" + { + organization(id: "1") { + id name + address { street city coordinates { latitude longitude } } + departments { + id name + lead { id name status } + members { id name email } + subDepartments { id name tags { key value } } + tags { key value } + } + metadata { foundedYear industry categories { name tags { key value } } } + } + }\""") + OrgResult getOrganization(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.OrgResult"); + assertThat(compilation).generatedSourceFile("test.Address"); + assertThat(compilation).generatedSourceFile("test.Coordinates"); + assertThat(compilation).generatedSourceFile("test.Departments"); + assertThat(compilation).generatedSourceFile("test.Metadata"); + } + + @Test + void complexMutationWithNestedInputs() { + var source = + JavaFileObjects.forSourceString( + "test.ComplexMutationApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ComplexMutationApi { + @GraphqlQuery(\""" + mutation createOrg($input: CreateOrgInput!) { + createOrganization(input: $input) { + id name + departments { id name subDepartments { id name } } + } + }\""") + CreateOrgResult createOrg(CreateOrgInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateOrgResult"); + assertThat(compilation).generatedSourceFile("test.CreateOrgInput"); + assertThat(compilation).generatedSourceFile("test.DepartmentInput"); + assertThat(compilation).generatedSourceFile("test.TagInput"); + assertThat(compilation).generatedSourceFile("test.AddressInput"); + assertThat(compilation).generatedSourceFile("test.OrgMetadataInput"); + assertThat(compilation).generatedSourceFile("test.CategoryInput"); + } + + @Test + void searchWithComplexFilterInput() { + var source = + JavaFileObjects.forSourceString( + "test.SearchApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SearchApi { + @GraphqlQuery(\""" + query searchOrgs($criteria: OrgSearchCriteria!) { + searchOrganizations(criteria: $criteria) { + id name + departments { id name lead { id name } tags { key value } } + metadata { foundedYear categories { name parentCategory { name } } } + } + }\""") + SearchResult searchOrganizations(OrgSearchCriteria criteria); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.SearchResult"); + assertThat(compilation).generatedSourceFile("test.OrgSearchCriteria"); + assertThat(compilation).generatedSourceFile("test.DepartmentFilterInput"); + assertThat(compilation).generatedSourceFile("test.TagInput"); + } + + @Test + void listReturnTypeGeneratesElementType() { + var source = + JavaFileObjects.forSourceString( + "test.ListReturnApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import java.util.List; + + @GraphqlSchema("test-schema.graphql") + interface ListReturnApi { + @GraphqlQuery(\""" + query listUsers($filter: UserFilter) { + users(filter: $filter) { id name email status } + }\""") + List listUsers(UserFilter filter); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.UserListResult"); + assertThat(compilation).generatedSourceFile("test.UserFilter"); + } + + @Test + void existingExternalTypeSkipsGeneration() { + var source = + JavaFileObjects.forSourceString( + "test.ExternalTypeApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ExternalTypeApi { + @GraphqlQuery(\""" + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { id name email } + }\""") + CreateResult createUser(feign.graphql.GraphqlQuery input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateResult"); + } + + @Test + void userWithOrganizationMultipleLevelReuse() { + var source = + JavaFileObjects.forSourceString( + "test.ReuseApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ReuseApi { + @GraphqlQuery(\""" + { + user(id: "1") { + id name status + address { street city country coordinates { latitude longitude } } + organization { + id name + address { street city coordinates { latitude longitude } } + departments { id name members { id name } } + } + } + }\""") + FullUserResult getFullUser(); + + @GraphqlQuery(\""" + query listUsers($filter: UserFilter) { + users(filter: $filter) { id name email tags } + }\""") + UserListResult listUsers(UserFilter filter); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.FullUserResult"); + assertThat(compilation).generatedSourceFile("test.Status"); + } + + @Test + void scalarAnnotationMapsCustomScalar() { + var source = + JavaFileObjects.forSourceString( + "test.ScalarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.Scalar; + + @GraphqlSchema("scalar-test-schema.graphql") + interface ScalarApi { + @Scalar("DateTime") + default String dateTime(String raw) { return raw; } + + @GraphqlQuery("{ event(id: \\"1\\") { id name startTime endTime } }") + EventResult getEvent(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.EventResult"); + } + + @Test + void missingScalarAnnotationReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.MissingScalarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("scalar-test-schema.graphql") + interface MissingScalarApi { + @GraphqlQuery("{ event(id: \\"1\\") { id name startTime } }") + EventResult getEvent(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Custom scalar 'DateTime'"); + assertThat(compilation).hadErrorContaining("@Scalar(\"DateTime\")"); + } + + @Test + void scalarFromParentInterfaceIsInherited() { + var parentSource = + JavaFileObjects.forSourceString( + "test.ScalarDefinitions", + """ + package test; + + import feign.graphql.Scalar; + + interface ScalarDefinitions { + @Scalar("DateTime") + default String dateTime(String raw) { return raw; } + } + """); + + var source = + JavaFileObjects.forSourceString( + "test.ChildApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("scalar-test-schema.graphql") + interface ChildApi extends ScalarDefinitions { + @GraphqlQuery("{ event(id: \\"1\\") { id name startTime } }") + EventResult getEvent(); + } + """); + + var compilation = + javac().withProcessors(new GraphqlSchemaProcessor()).compile(parentSource, source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.EventResult"); + } +} diff --git a/graphql-apt/src/test/resources/scalar-test-schema.graphql b/graphql-apt/src/test/resources/scalar-test-schema.graphql new file mode 100644 index 0000000000..d8aa82dd05 --- /dev/null +++ b/graphql-apt/src/test/resources/scalar-test-schema.graphql @@ -0,0 +1,14 @@ +"An RFC-3339 compliant DateTime Scalar" +scalar DateTime + +type Query { + event(id: ID!): Event + events: [Event] +} + +type Event { + id: ID! + name: String! + startTime: DateTime! + endTime: DateTime +} diff --git a/graphql-apt/src/test/resources/test-schema.graphql b/graphql-apt/src/test/resources/test-schema.graphql new file mode 100644 index 0000000000..ec88456d8a --- /dev/null +++ b/graphql-apt/src/test/resources/test-schema.graphql @@ -0,0 +1,150 @@ +type Query { + user(id: ID!): User + users(filter: UserFilter): [User] + organization(id: ID!): Organization + searchOrganizations(criteria: OrgSearchCriteria!): [Organization] +} + +type Mutation { + createUser(input: CreateUserInput!): User + updateStatus(id: ID!, status: Status!): User + createOrganization(input: CreateOrgInput!): Organization +} + +type User { + id: ID! + name: String! + email: String + status: Status! + address: Address + tags: [String] + organization: Organization +} + +type Organization { + id: ID! + name: String! + address: Address + departments: [Department] + metadata: OrgMetadata +} + +type Department { + id: ID! + name: String! + lead: User + members: [User] + subDepartments: [Department] + tags: [Tag] +} + +type Tag { + key: String! + value: String! +} + +type OrgMetadata { + foundedYear: Int + industry: String + categories: [Category] +} + +type Category { + name: String! + parentCategory: Category + tags: [Tag] +} + +type Address { + street: String + city: String + country: String + coordinates: Coordinates +} + +type Coordinates { + latitude: Float + longitude: Float +} + +input CreateUserInput { + name: String! + email: String! + status: Status + address: AddressInput + tags: [String] + orgId: ID +} + +input UserFilter { + nameContains: String + status: Status + tagFilter: TagFilterInput +} + +input TagFilterInput { + keys: [String] + matchAll: Boolean +} + +input AddressInput { + street: String + city: String + country: String + coordinates: CoordinatesInput +} + +input CoordinatesInput { + latitude: Float + longitude: Float +} + +input CreateOrgInput { + name: String! + address: AddressInput + departments: [DepartmentInput] + metadata: OrgMetadataInput +} + +input DepartmentInput { + name: String! + leadUserId: ID + subDepartments: [DepartmentInput] + tags: [TagInput] +} + +input TagInput { + key: String! + value: String! +} + +input OrgMetadataInput { + foundedYear: Int + industry: String + categories: [CategoryInput] +} + +input CategoryInput { + name: String! + parentCategoryName: String + tags: [TagInput] +} + +input OrgSearchCriteria { + nameContains: String + industries: [String] + minFoundedYear: Int + departmentFilter: DepartmentFilterInput +} + +input DepartmentFilterInput { + nameContains: String + minMembers: Int + tags: [TagInput] +} + +enum Status { + ACTIVE + INACTIVE + PENDING +} diff --git a/graphql/README.md b/graphql/README.md new file mode 100644 index 0000000000..0f654915eb --- /dev/null +++ b/graphql/README.md @@ -0,0 +1,147 @@ +Feign GraphQL +=================== + +This module adds support for declarative GraphQL clients using Feign. It provides a `GraphqlContract`, `GraphqlEncoder`, and `GraphqlDecoder` that transform annotated interfaces into fully functional GraphQL clients. + +The companion module `feign-graphql-apt` provides compile-time type generation from GraphQL schemas, producing Java records for query results and input types. + +## Dependencies + +Add both modules to use schema-driven type generation: + +```xml + + io.github.openfeign + feign-graphql + ${feign.version} + + + + io.github.openfeign.experimental + feign-graphql-apt + ${feign.version} + provided + +``` + +## Basic Usage + +Define a GraphQL schema in `src/main/resources`: + +```graphql +type Query { + user(id: ID!): User +} + +type User { + id: ID! + name: String! + email: String +} +``` + +Annotate your Feign interface with `@GraphqlSchema` pointing to the schema file and `@GraphqlQuery` on each method with the GraphQL query string: + +```java +@GraphqlSchema("my-schema.graphql") +interface UserApi { + + @GraphqlQuery("query { user(id: $id) { id name email } }") + User getUser(@Param("id") String id); +} +``` + +The annotation processor generates a Java record for `User` at compile time: + +```java +public record User(String id, String name, String email) {} +``` + +Build the client with `GraphqlContract`, `GraphqlEncoder`, and `GraphqlDecoder`: + +```java +UserApi api = Feign.builder() + .contract(new GraphqlContract()) + .encoder(new GraphqlEncoder(new JacksonEncoder())) + .decoder(new GraphqlDecoder()) + .target(UserApi.class, "https://api.example.com/graphql"); + +User user = api.getUser("123"); +``` + +## Mutations with Variables + +Methods with parameters are sent as GraphQL variables: + +```java +@GraphqlSchema("my-schema.graphql") +interface UserApi { + + @GraphqlQuery("mutation($input: CreateUserInput!) { createUser(input: $input) { id name } }") + User createUser(@Param("input") CreateUserInput input); +} +``` + +The processor generates a record for the input type as well: + +```java +public record CreateUserInput(String name, String email) {} +``` + +## Custom Scalars + +When your schema defines custom scalars, map them to Java types using `@Scalar` on default methods: + +```graphql +scalar DateTime + +type Event { + id: ID! + name: String! + startTime: DateTime! +} +``` + +```java +@GraphqlSchema("event-schema.graphql") +interface EventApi { + + @Scalar("DateTime") + default Instant dateTime() { return null; } + + @GraphqlQuery("query { events { id name startTime } }") + List getEvents(); +} +``` + +The processor maps `DateTime` fields to `java.time.Instant` in the generated record: + +```java +public record Event(String id, String name, Instant startTime) {} +``` + +## Disabling Type Generation + +If you provide your own model classes, disable automatic generation: + +```java +@GraphqlSchema(value = "my-schema.graphql", generateTypes = false) +interface UserApi { + // ... +} +``` + +Queries are still validated against the schema at compile time. + +## Error Handling + +GraphQL errors in the response throw `GraphqlErrorException`: + +```java +try { + User user = api.getUser("invalid-id"); +} catch (GraphqlErrorException e) { + String operation = e.operation(); + String errors = e.errors(); +} +``` diff --git a/graphql/pom.xml b/graphql/pom.xml new file mode 100644 index 0000000000..8fdff507fb --- /dev/null +++ b/graphql/pom.xml @@ -0,0 +1,63 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.8-SNAPSHOT + + + feign-graphql + Feign GraphQL + Feign GraphQL runtime support for declarative GraphQL clients + + + + ${project.groupId} + feign-core + + + + com.fasterxml.jackson.core + jackson-databind + + + + ${project.groupId} + feign-core + test-jar + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + + ${project.groupId} + feign-jackson + ${project.version} + test + + + diff --git a/graphql/src/main/java/feign/graphql/GraphqlContract.java b/graphql/src/main/java/feign/graphql/GraphqlContract.java new file mode 100644 index 0000000000..65caffc5ac --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java @@ -0,0 +1,100 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import feign.Contract; +import feign.Request.HttpMethod; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class GraphqlContract extends Contract.Default { + + private static final Pattern OPERATION_FIELD_PATTERN = + Pattern.compile("\\{\\s*(\\w+)\\s*[({]", Pattern.DOTALL); + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\s*(\\w+)\\s*:"); + + private final Map metadata = new ConcurrentHashMap<>(); + + public GraphqlContract() { + super.registerMethodAnnotation( + GraphqlQuery.class, + (annotation, data) -> { + String query = annotation.value(); + + if (data.template().method() == null) { + data.template().method(HttpMethod.POST); + data.template().uri("/"); + } + + String variableName = extractFirstVariable(query); + metadata.put(data.configKey(), new QueryMetadata(query, variableName)); + }); + } + + Map queryMetadata() { + return metadata; + } + + static String extractOperationField(String query) { + int braceCount = 0; + boolean inOperation = false; + + for (int i = 0; i < query.length(); i++) { + char c = query.charAt(i); + if (c == '{') { + braceCount++; + if (braceCount == 1) { + inOperation = true; + } else if (braceCount == 2 && inOperation) { + String prefix = query.substring(0, i).trim(); + Matcher m = OPERATION_FIELD_PATTERN.matcher(prefix + "{"); + if (m.find()) { + return m.group(1); + } + } + } else if (c == '}') { + braceCount--; + } + } + + Matcher m = OPERATION_FIELD_PATTERN.matcher(query); + if (m.find()) { + return m.group(1); + } + return null; + } + + static String extractFirstVariable(String query) { + Matcher m = VARIABLE_PATTERN.matcher(query); + if (m.find()) { + return m.group(1); + } + return null; + } + + static class QueryMetadata { + final String query; + final String variableName; + + QueryMetadata(String query, String variableName) { + this.query = query; + this.variableName = variableName; + } + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java new file mode 100644 index 0000000000..060890cb16 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -0,0 +1,129 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.Iterator; + +public class GraphqlDecoder implements Decoder { + + private final ObjectMapper mapper; + private final Decoder delegate; + + public GraphqlDecoder() { + this(new ObjectMapper(), null); + } + + public GraphqlDecoder(ObjectMapper mapper) { + this(mapper, null); + } + + public GraphqlDecoder(Decoder delegate) { + this(new ObjectMapper(), delegate); + } + + public GraphqlDecoder(ObjectMapper mapper, Decoder delegate) { + this.mapper = mapper; + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (response.status() == 404 || response.status() == 204) { + return Util.emptyValueOf(type); + } + if (response.body() == null) { + return null; + } + + Reader reader = response.body().asReader(response.charset()); + if (!reader.markSupported()) { + reader = new BufferedReader(reader, 1); + } + + JsonNode root = mapper.readTree(reader); + + JsonNode errorsNode = root.path("errors"); + if (!errorsNode.isMissingNode() && errorsNode.isArray() && !errorsNode.isEmpty()) { + String operationField = resolveOperationField(root, response); + throw new GraphqlErrorException( + response.status(), operationField, errorsNode.toString(), response.request()); + } + + JsonNode dataNode = root.path("data"); + if (dataNode.isMissingNode() || dataNode.isNull() || !dataNode.isObject()) { + return Util.emptyValueOf(type); + } + + Iterator fieldNames = dataNode.fieldNames(); + if (!fieldNames.hasNext()) { + return Util.emptyValueOf(type); + } + + String firstField = fieldNames.next(); + JsonNode operationData = dataNode.get(firstField); + if (operationData == null || operationData.isNull()) { + return Util.emptyValueOf(type); + } + + if (delegate != null) { + byte[] dataBytes = mapper.writeValueAsBytes(operationData); + Response dataResponse = + Response.builder() + .status(response.status()) + .reason(response.reason()) + .headers(response.headers()) + .request(response.request()) + .body(dataBytes) + .build(); + return delegate.decode(dataResponse, type); + } + + return mapper.readValue(mapper.treeAsTokens(operationData), mapper.constructType(type)); + } + + private String resolveOperationField(JsonNode root, Response response) { + JsonNode dataNode = root.path("data"); + if (!dataNode.isMissingNode() && dataNode.isObject()) { + Iterator names = dataNode.fieldNames(); + if (names.hasNext()) { + return names.next(); + } + } + + if (response.request() != null && response.request().body() != null) { + try { + JsonNode requestBody = mapper.readTree(response.request().body()); + String query = requestBody.path("query").asText(null); + if (query != null) { + return GraphqlContract.extractOperationField(query); + } + } catch (Exception e) { + // ignore parsing errors + } + } + + return "unknown"; + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlEncoder.java b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java new file mode 100644 index 0000000000..a3d1aaa30a --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java @@ -0,0 +1,80 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; + +public class GraphqlEncoder implements Encoder, RequestInterceptor { + + private final Encoder delegate; + private final Map queryMetadata; + + public GraphqlEncoder(Encoder delegate, GraphqlContract contract) { + this.delegate = delegate; + this.queryMetadata = contract.queryMetadata(); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + GraphqlContract.QueryMetadata meta = lookupMetadata(template); + if (meta == null) { + delegate.encode(object, bodyType, template); + return; + } + + Map graphqlBody = new LinkedHashMap<>(); + graphqlBody.put("query", meta.query); + + if (object != null && meta.variableName != null) { + Map variables = new LinkedHashMap<>(); + variables.put(meta.variableName, object); + graphqlBody.put("variables", variables); + } + + delegate.encode(graphqlBody, MAP_STRING_WILDCARD, template); + } + + @Override + public void apply(RequestTemplate template) { + if (template.body() != null) { + return; + } + + GraphqlContract.QueryMetadata meta = lookupMetadata(template); + if (meta == null) { + return; + } + + Map graphqlBody = new LinkedHashMap<>(); + graphqlBody.put("query", meta.query); + + delegate.encode(graphqlBody, MAP_STRING_WILDCARD, template); + } + + private GraphqlContract.QueryMetadata lookupMetadata(RequestTemplate template) { + if (template.methodMetadata() == null) { + return null; + } + return queryMetadata.get(template.methodMetadata().configKey()); + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlErrorException.java b/graphql/src/main/java/feign/graphql/GraphqlErrorException.java new file mode 100644 index 0000000000..942b96fed7 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlErrorException.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import feign.FeignException; +import feign.Request; + +public class GraphqlErrorException extends FeignException { + + private final String operation; + private final String errors; + + public GraphqlErrorException(int status, String operation, String errors, Request request) { + super(status, String.format("GraphQL %s operation failed: %s", operation, errors), request); + this.operation = operation; + this.errors = errors; + } + + public String operation() { + return operation; + } + + public String errors() { + return errors; + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlQuery.java b/graphql/src/main/java/feign/graphql/GraphqlQuery.java new file mode 100644 index 0000000000..beef534899 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlQuery.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface GraphqlQuery { + + String value(); +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlSchema.java b/graphql/src/main/java/feign/graphql/GraphqlSchema.java new file mode 100644 index 0000000000..785bdf19c2 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlSchema.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface GraphqlSchema { + + String value(); + + boolean generateTypes() default true; +} diff --git a/graphql/src/main/java/feign/graphql/Scalar.java b/graphql/src/main/java/feign/graphql/Scalar.java new file mode 100644 index 0000000000..1a9ba21e5d --- /dev/null +++ b/graphql/src/main/java/feign/graphql/Scalar.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Scalar { + + String value(); +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java new file mode 100644 index 0000000000..ce47c4e0fe --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java @@ -0,0 +1,190 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.Headers; +import feign.Param; +import feign.jackson.JacksonEncoder; +import java.util.List; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GraphqlClientTest { + + private final ObjectMapper mapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private MockWebServer server; + + public static class User { + public String id; + public String name; + public String email; + } + + public static class CreateUserInput { + public String name; + public String email; + } + + public static class CreateUserResult { + public String id; + public String name; + } + + @Headers("Content-Type: application/json") + interface TestApi { + + @GraphqlQuery( + "mutation createUser($input: CreateUserInput!) {" + + " createUser(input: $input) { id name } }") + CreateUserResult createUser(CreateUserInput input); + + @GraphqlQuery("query getUser($id: String!) {" + " getUser(id: $id) { id name email } }") + User getUser(String id); + + @GraphqlQuery("query listPending { listPending { id name } }") + List listPending(); + + @GraphqlQuery("query getUser($id: String!) {" + " getUser(id: $id) { id name email } }") + @Headers("Authorization: {auth}") + User getUserWithAuth(@Param("auth") String auth, String id); + } + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private TestApi buildClient() { + var contract = new GraphqlContract(); + var graphqlEncoder = new GraphqlEncoder(new JacksonEncoder(mapper), contract); + return Feign.builder() + .contract(contract) + .encoder(graphqlEncoder) + .decoder(new GraphqlDecoder(mapper)) + .requestInterceptor(graphqlEncoder) + .target(TestApi.class, server.url("/graphql").toString()); + } + + @Test + void mutationWithVariables() throws Exception { + server.enqueue( + new MockResponse() + .setBody("{\"data\":{\"createUser\":{\"id\":\"42\",\"name\":\"Alice\"}}}") + .addHeader("Content-Type", "application/json")); + + var input = new CreateUserInput(); + input.name = "Alice"; + input.email = "alice@example.com"; + + var result = buildClient().createUser(input); + + assertThat(result.id).isEqualTo("42"); + assertThat(result.name).isEqualTo("Alice"); + + var recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("POST"); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("query").asText()).contains("createUser"); + assertThat(body.get("variables").get("input").get("name").asText()).isEqualTo("Alice"); + } + + @Test + void queryWithStringVariable() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Bob\",\"email\":\"bob@test.com\"}}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().getUser("1"); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Bob"); + assertThat(user.email).isEqualTo("bob@test.com"); + + var recorded = server.takeRequest(); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("variables").get("id").asText()).isEqualTo("1"); + } + + @Test + void noVariableQuerySetsBodyViaInterceptor() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"listPending\":[{\"id\":\"1\",\"name\":\"A\"},{\"id\":\"2\",\"name\":\"B\"}]}}") + .addHeader("Content-Type", "application/json")); + + var users = buildClient().listPending(); + + assertThat(users).hasSize(2); + assertThat(users.getFirst().name).isEqualTo("A"); + + var recorded = server.takeRequest(); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("query").asText()).contains("listPending"); + assertThat(body.has("variables")).isFalse(); + } + + @Test + void graphqlErrorsThrowException() { + server.enqueue( + new MockResponse() + .setBody("{\"errors\":[{\"message\":\"Something went wrong\"}],\"data\":null}") + .addHeader("Content-Type", "application/json")); + + var input = new CreateUserInput(); + input.name = "Alice"; + + assertThatThrownBy(() -> buildClient().createUser(input)) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("createUser") + .hasMessageContaining("Something went wrong"); + } + + @Test + void authHeaderPassedThrough() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Bob\",\"email\":\"bob@test.com\"}}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().getUserWithAuth("Bearer mytoken", "1"); + + assertThat(user.id).isEqualTo("1"); + + var recorded = server.takeRequest(); + assertThat(recorded.getHeader("Authorization")).isEqualTo("Bearer mytoken"); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlContractTest.java b/graphql/src/test/java/feign/graphql/GraphqlContractTest.java new file mode 100644 index 0000000000..ec5846cf6f --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlContractTest.java @@ -0,0 +1,121 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Request.HttpMethod; +import org.junit.jupiter.api.Test; + +class GraphqlContractTest { + + private final GraphqlContract contract = new GraphqlContract(); + + interface MutationApi { + @GraphqlQuery( + "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) {" + + " backendUpdateRuntimeStatus(event: $event) { _uuid deploymentId } }") + Object updateStatus(Object event); + } + + interface QueryWithVariableApi { + @GraphqlQuery( + "query backendProjectsLookup($projectId: String!) {" + + " backendProjectsLookup(projectId: $projectId) { projectId orgId } }") + Object lookup(String projectId); + } + + interface NoVariableQueryApi { + @GraphqlQuery( + "query backendPendingDeployments {" + + " backendPendingDeployments { projectId environment } }") + Object pending(); + } + + @Test + void mutationSetsPostMethod() { + var metadata = contract.parseAndValidateMetadata(MutationApi.class); + assertThat(metadata).hasSize(1); + assertThat(metadata.getFirst().template().method()).isEqualTo(HttpMethod.POST.name()); + } + + @Test + void mutationStoresQueryMetadata() { + contract.parseAndValidateMetadata(MutationApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendUpdateRuntimeStatus"); + assertThat(meta.variableName).isEqualTo("event"); + } + + @Test + void queryWithVariableStoresMetadata() { + contract.parseAndValidateMetadata(QueryWithVariableApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendProjectsLookup"); + assertThat(meta.variableName).isEqualTo("projectId"); + } + + @Test + void noVariableQueryHasNullVariableName() { + contract.parseAndValidateMetadata(NoVariableQueryApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendPendingDeployments"); + assertThat(meta.variableName).isNull(); + } + + @Test + void noHeadersSetOnTemplate() { + var metadata = contract.parseAndValidateMetadata(MutationApi.class); + var headers = metadata.getFirst().template().headers(); + assertThat(headers).isEmpty(); + } + + @Test + void extractOperationFieldFromMutation() { + var query = + "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) {" + + " backendUpdateRuntimeStatus(event: $event) { _uuid } }"; + assertThat(GraphqlContract.extractOperationField(query)) + .isEqualTo("backendUpdateRuntimeStatus"); + } + + @Test + void extractOperationFieldFromSimpleQuery() { + var query = "query backendPendingDeployments { backendPendingDeployments { projectId } }"; + assertThat(GraphqlContract.extractOperationField(query)).isEqualTo("backendPendingDeployments"); + } + + @Test + void extractOperationFieldFromAnonymousQuery() { + var query = "{ user(id: \"1\") { id name } }"; + assertThat(GraphqlContract.extractOperationField(query)).isEqualTo("user"); + } + + @Test + void extractFirstVariableFromMutation() { + var query = "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) { x }"; + assertThat(GraphqlContract.extractFirstVariable(query)).isEqualTo("event"); + } + + @Test + void extractFirstVariableReturnsNullWhenNone() { + var query = "query backendPendingDeployments { x }"; + assertThat(GraphqlContract.extractFirstVariable(query)).isNull(); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java new file mode 100644 index 0000000000..045d15cf3c --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class GraphqlDecoderTest { + + private final ObjectMapper mapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private final GraphqlDecoder decoder = new GraphqlDecoder(mapper); + + public static class User { + public String id; + public String name; + } + + @Test + void decodesDataField() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; + var response = buildResponse(json); + + var user = (User) decoder.decode(response, User.class); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + @Test + void decodesListResponse() throws Exception { + var json = + "{\"data\":{\"listUsers\":[{\"id\":\"1\",\"name\":\"Alice\"},{\"id\":\"2\",\"name\":\"Bob\"}]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var users = + (List) + decoder.decode( + response, mapper.getTypeFactory().constructCollectionType(List.class, User.class)); + + assertThat(users).hasSize(2); + assertThat(users.getFirst().name).isEqualTo("Alice"); + assertThat(users.get(1).name).isEqualTo("Bob"); + } + + @Test + void throwsGraphqlErrorExceptionOnErrors() { + var json = "{\"errors\":[{\"message\":\"Not found\"}],\"data\":{\"getUser\":null}}"; + var response = buildResponse(json); + + assertThatThrownBy(() -> decoder.decode(response, User.class)) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("getUser") + .hasMessageContaining("Not found"); + } + + @Test + void returnsNullForNullData() throws Exception { + var json = "{\"data\":{\"getUser\":null}}"; + var response = buildResponse(json); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void returnsNullForEmptyData() throws Exception { + var json = "{\"data\":{}}"; + var response = buildResponse(json); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void returnsEmptyFor404() throws Exception { + var response = + Response.builder() + .status(404) + .reason("Not Found") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .body(new byte[0]) + .build(); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void delegatesToCustomDecoder() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; + var customDecoder = + new GraphqlDecoder( + mapper, + (resp, type) -> + mapper.readValue(resp.body().asReader(resp.charset()), mapper.constructType(type))); + var response = buildResponse(json); + + var user = (User) customDecoder.decode(response, User.class); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + private Response buildResponse(String body) { + return Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .body(body, StandardCharsets.UTF_8) + .build(); + } + + private Request buildRequest() { + return Request.create( + HttpMethod.POST, + "http://localhost/graphql", + Collections.emptyMap(), + Request.Body.empty(), + null); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java new file mode 100644 index 0000000000..a8b8f0eeb7 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java @@ -0,0 +1,97 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.RequestTemplate; +import feign.jackson.JacksonEncoder; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class GraphqlEncoderTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final GraphqlContract contract = new GraphqlContract(); + private final GraphqlEncoder encoder = new GraphqlEncoder(new JacksonEncoder(mapper), contract); + + interface MutationApi { + @GraphqlQuery( + "mutation createUser($input: CreateUserInput!) { createUser(input: $input) { id } }") + Object createUser(Object input); + } + + interface NoVariableApi { + @GraphqlQuery("query pending { pending { id } }") + Object pending(); + } + + private RequestTemplate templateFor(Class apiClass) { + var metadataList = contract.parseAndValidateMetadata(apiClass); + var md = metadataList.getFirst(); + var template = new RequestTemplate(); + template.methodMetadata(md); + return template; + } + + @Test + void encodesBodyWithVariables() throws Exception { + var template = templateFor(MutationApi.class); + var body = Map.of("name", "John", "email", "john@example.com"); + encoder.encode(body, Map.class, template); + + var result = mapper.readTree(template.body()); + assertThat(result.has("query")).isTrue(); + assertThat(result.get("query").asText()).contains("createUser"); + assertThat(result.has("variables")).isTrue(); + assertThat(result.get("variables").has("input")).isTrue(); + assertThat(result.get("variables").get("input").get("name").asText()).isEqualTo("John"); + } + + @Test + void delegatesToWrappedEncoderForNonGraphql() { + var template = new RequestTemplate(); + encoder.encode("plain body", String.class, template); + assertThat(template.body()).isNotNull(); + } + + @Test + void interceptorSetsBodyForNoVariableQuery() throws Exception { + var template = templateFor(NoVariableApi.class); + encoder.apply(template); + + var result = mapper.readTree(template.body()); + assertThat(result.get("query").asText()).contains("pending"); + assertThat(result.has("variables")).isFalse(); + } + + @Test + void interceptorSkipsWhenBodyAlreadySet() { + var template = templateFor(MutationApi.class); + template.body("already set"); + encoder.apply(template); + assertThat(new String(template.body())).isEqualTo("already set"); + } + + @Test + void interceptorSkipsForNonGraphql() { + var template = new RequestTemplate(); + template.body("some body"); + encoder.apply(template); + assertThat(new String(template.body())).isEqualTo("some body"); + } +} diff --git a/pom.xml b/pom.xml index a110ef4e42..cc3a8c2386 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,8 @@ micrometer mock apt-test-generator + graphql + graphql-apt annotation-error-decoder example-github example-github-with-coroutine @@ -212,9 +214,11 @@ 1.6.0 1.15.0 0.23.0 + 22.4 4.5.0 4.5.14 5.6 + 1.13.0 1.5.18 3.1.0 4.0.0 @@ -411,6 +415,12 @@ test + + ${project.groupId} + feign-graphql + ${project.version} + + ${project.groupId} feign-form diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index d4fa6d0698..bd84375752 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -13,15 +13,20 @@ *** Apache HC5 *** OkHttp *** Vertx +*** Reactive Wrappers ** contracts *** Feign *** JAX-RS *** JAX-RS 2 *** JAX-RS 3 / Jakarta -*** Spring 5 +*** JAX-RS 4 +*** Spring *** SOAP *** SOAP Jakarta *** Spring boot (3rd party) +** language +*** Kotlin +*** GraphQL left side @@ -29,15 +34,18 @@ left side *** GSON *** JAXB *** JAXB Jakarta -*** Jackson 1 -*** Jackson 2 +*** Jackson +*** Jackson 3 *** Jackson JAXB *** Jackson Jr *** Sax *** JSON-java *** Moshi *** Fastjson2 +*** Form +*** Form Spring ** metrics +*** Dropwizard Metrics 4 *** Dropwizard Metrics 5 *** Micrometer ** extras diff --git a/vertx/pom.xml b/vertx/pom.xml index d5be4fd4ed..c3fd09e8c7 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -26,6 +26,7 @@ feign-vertx-parent pom + Feign Vertx (Parent) feign-vertx From eced437cc26c72f0e7ad9f143c05e6053f7b2651 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:01:39 -0300 Subject: [PATCH 09/12] build(deps): Bump tools.jackson:jackson-bom from 3.0.2 to 3.0.4 (#3213) Bumps [tools.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 3.0.2 to 3.0.4. - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-3.0.2...jackson-bom-3.0.4) --- updated-dependencies: - dependency-name: tools.jackson:jackson-bom dependency-version: 3.0.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cc3a8c2386..671951ca2e 100644 --- a/pom.xml +++ b/pom.xml @@ -171,7 +171,7 @@ 6.0.2 2.21.0 - 3.0.2 + 3.0.4 3.27.7 5.21.0 2.0.61.android8 From 8cb8293945b65c2b9c4c80666ff23c74e03becc4 Mon Sep 17 00:00:00 2001 From: NiMv1 <54713626+NiMv1@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:02:33 +0300 Subject: [PATCH 10/12] Add methodKey to RetryableException (#3157) * Add methodKey to RetryableException for better retry identification * Apply code formatting --------- Co-authored-by: NiMv1 --- .../main/java/feign/RetryableException.java | 46 ++++++++++++++++++- .../main/java/feign/codec/ErrorDecoder.java | 3 +- .../java/feign/RetryableExceptionTest.java | 41 +++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java index 2e210fef6d..6c69986316 100644 --- a/core/src/main/java/feign/RetryableException.java +++ b/core/src/main/java/feign/RetryableException.java @@ -26,10 +26,11 @@ */ public class RetryableException extends FeignException { - private static final long serialVersionUID = 2L; + private static final long serialVersionUID = 3L; private final Long retryAfter; private final HttpMethod httpMethod; + private final String methodKey; /** * Represents a non-retryable exception when Retry-After information is explicitly not provided. @@ -46,6 +47,7 @@ public RetryableException(int status, String message, HttpMethod httpMethod, Req super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = null; + this.methodKey = null; } /** @@ -65,6 +67,7 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = null; + this.methodKey = null; } /** @@ -95,6 +98,32 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; + } + + /** + * Represents a retryable exception with methodKey for identifying the method being retried. + * + * @param status the HTTP status code + * @param message the exception message + * @param httpMethod the HTTP method (GET, POST, etc.) + * @param cause the underlying cause of the exception + * @param retryAfter the retry delay in milliseconds + * @param request the original HTTP request + * @param methodKey the method key identifying the Feign method + */ + public RetryableException( + int status, + String message, + HttpMethod httpMethod, + Throwable cause, + Long retryAfter, + Request request, + String methodKey) { + super(status, message, request, cause); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter; + this.methodKey = methodKey; } /** @@ -119,6 +148,7 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -139,6 +169,7 @@ public RetryableException( super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; } /** @@ -156,6 +187,7 @@ public RetryableException( super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -183,6 +215,7 @@ public RetryableException( super(status, message, request, responseBody, responseHeaders); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; } /** @@ -209,6 +242,7 @@ public RetryableException( super(status, message, request, responseBody, responseHeaders); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -222,4 +256,14 @@ public Long retryAfter() { public HttpMethod method() { return this.httpMethod; } + + /** + * Returns the method key identifying the Feign method that was being invoked. This corresponds to + * the methodKey parameter in {@link feign.codec.ErrorDecoder#decode}. + * + * @return the method key, or null if not set + */ + public String methodKey() { + return this.methodKey; + } } diff --git a/core/src/main/java/feign/codec/ErrorDecoder.java b/core/src/main/java/feign/codec/ErrorDecoder.java index 29f07a0b2b..83c72ee887 100644 --- a/core/src/main/java/feign/codec/ErrorDecoder.java +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -109,7 +109,8 @@ public Exception decode(String methodKey, Response response) { response.request().httpMethod(), exception, retryAfter, - response.request()); + response.request(), + methodKey); } return exception; } diff --git a/core/src/test/java/feign/RetryableExceptionTest.java b/core/src/test/java/feign/RetryableExceptionTest.java index 6e60e6482a..0bd9a73127 100644 --- a/core/src/test/java/feign/RetryableExceptionTest.java +++ b/core/src/test/java/feign/RetryableExceptionTest.java @@ -49,4 +49,45 @@ void createRetryableExceptionWithResponseAndResponseHeader() { assertThat(retryableException.responseHeaders()).containsKey("TEST_HEADER"); assertThat(retryableException.responseHeaders().get("TEST_HEADER")).contains("TEST_CONTENT"); } + + @Test + void createRetryableExceptionWithMethodKey() { + // given + Long retryAfter = 5000L; + String methodKey = "TestClient#testMethod()"; + Request request = + Request.create(Request.HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8); + Throwable cause = new RuntimeException("test cause"); + + // when + RetryableException retryableException = + new RetryableException( + 503, + "Service Unavailable", + Request.HttpMethod.GET, + cause, + retryAfter, + request, + methodKey); + + // then + assertThat(retryableException).isNotNull(); + assertThat(retryableException.methodKey()).isEqualTo(methodKey); + assertThat(retryableException.retryAfter()).isEqualTo(retryAfter); + assertThat(retryableException.method()).isEqualTo(Request.HttpMethod.GET); + } + + @Test + void methodKeyIsNullWhenNotProvided() { + // given + Request request = + Request.create(Request.HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8); + + // when + RetryableException retryableException = + new RetryableException(503, "Service Unavailable", Request.HttpMethod.GET, request); + + // then + assertThat(retryableException.methodKey()).isNull(); + } } From 09b64752144d3de021f45e39745b77eb8828ddbd Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 9 Feb 2026 15:18:13 -0300 Subject: [PATCH 11/12] Set graphql module to Java 17, apply var and clean up imports Signed-off-by: Marvin Froeder --- graphql/pom.xml | 4 +++ .../java/feign/graphql/GraphqlContract.java | 13 ++++---- .../java/feign/graphql/GraphqlDecoder.java | 30 +++++++++---------- .../java/feign/graphql/GraphqlEncoder.java | 10 +++---- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/graphql/pom.xml b/graphql/pom.xml index 8fdff507fb..3c446afe1d 100644 --- a/graphql/pom.xml +++ b/graphql/pom.xml @@ -29,6 +29,10 @@ Feign GraphQL Feign GraphQL runtime support for declarative GraphQL clients + + 17 + + ${project.groupId} diff --git a/graphql/src/main/java/feign/graphql/GraphqlContract.java b/graphql/src/main/java/feign/graphql/GraphqlContract.java index 65caffc5ac..6f089e8555 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlContract.java +++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java @@ -19,7 +19,6 @@ import feign.Request.HttpMethod; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Matcher; import java.util.regex.Pattern; public class GraphqlContract extends Contract.Default { @@ -35,14 +34,14 @@ public GraphqlContract() { super.registerMethodAnnotation( GraphqlQuery.class, (annotation, data) -> { - String query = annotation.value(); + var query = annotation.value(); if (data.template().method() == null) { data.template().method(HttpMethod.POST); data.template().uri("/"); } - String variableName = extractFirstVariable(query); + var variableName = extractFirstVariable(query); metadata.put(data.configKey(), new QueryMetadata(query, variableName)); }); } @@ -62,8 +61,8 @@ static String extractOperationField(String query) { if (braceCount == 1) { inOperation = true; } else if (braceCount == 2 && inOperation) { - String prefix = query.substring(0, i).trim(); - Matcher m = OPERATION_FIELD_PATTERN.matcher(prefix + "{"); + var prefix = query.substring(0, i).trim(); + var m = OPERATION_FIELD_PATTERN.matcher(prefix + "{"); if (m.find()) { return m.group(1); } @@ -73,7 +72,7 @@ static String extractOperationField(String query) { } } - Matcher m = OPERATION_FIELD_PATTERN.matcher(query); + var m = OPERATION_FIELD_PATTERN.matcher(query); if (m.find()) { return m.group(1); } @@ -81,7 +80,7 @@ static String extractOperationField(String query) { } static String extractFirstVariable(String query) { - Matcher m = VARIABLE_PATTERN.matcher(query); + var m = VARIABLE_PATTERN.matcher(query); if (m.find()) { return m.group(1); } diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java index 060890cb16..3560341054 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -22,9 +22,7 @@ import feign.codec.Decoder; import java.io.BufferedReader; import java.io.IOException; -import java.io.Reader; import java.lang.reflect.Type; -import java.util.Iterator; public class GraphqlDecoder implements Decoder { @@ -57,39 +55,39 @@ public Object decode(Response response, Type type) throws IOException { return null; } - Reader reader = response.body().asReader(response.charset()); + var reader = response.body().asReader(response.charset()); if (!reader.markSupported()) { reader = new BufferedReader(reader, 1); } - JsonNode root = mapper.readTree(reader); + var root = mapper.readTree(reader); - JsonNode errorsNode = root.path("errors"); + var errorsNode = root.path("errors"); if (!errorsNode.isMissingNode() && errorsNode.isArray() && !errorsNode.isEmpty()) { - String operationField = resolveOperationField(root, response); + var operationField = resolveOperationField(root, response); throw new GraphqlErrorException( response.status(), operationField, errorsNode.toString(), response.request()); } - JsonNode dataNode = root.path("data"); + var dataNode = root.path("data"); if (dataNode.isMissingNode() || dataNode.isNull() || !dataNode.isObject()) { return Util.emptyValueOf(type); } - Iterator fieldNames = dataNode.fieldNames(); + var fieldNames = dataNode.fieldNames(); if (!fieldNames.hasNext()) { return Util.emptyValueOf(type); } - String firstField = fieldNames.next(); - JsonNode operationData = dataNode.get(firstField); + var firstField = fieldNames.next(); + var operationData = dataNode.get(firstField); if (operationData == null || operationData.isNull()) { return Util.emptyValueOf(type); } if (delegate != null) { - byte[] dataBytes = mapper.writeValueAsBytes(operationData); - Response dataResponse = + var dataBytes = mapper.writeValueAsBytes(operationData); + var dataResponse = Response.builder() .status(response.status()) .reason(response.reason()) @@ -104,9 +102,9 @@ public Object decode(Response response, Type type) throws IOException { } private String resolveOperationField(JsonNode root, Response response) { - JsonNode dataNode = root.path("data"); + var dataNode = root.path("data"); if (!dataNode.isMissingNode() && dataNode.isObject()) { - Iterator names = dataNode.fieldNames(); + var names = dataNode.fieldNames(); if (names.hasNext()) { return names.next(); } @@ -114,8 +112,8 @@ private String resolveOperationField(JsonNode root, Response response) { if (response.request() != null && response.request().body() != null) { try { - JsonNode requestBody = mapper.readTree(response.request().body()); - String query = requestBody.path("query").asText(null); + var requestBody = mapper.readTree(response.request().body()); + var query = requestBody.path("query").asText(null); if (query != null) { return GraphqlContract.extractOperationField(query); } diff --git a/graphql/src/main/java/feign/graphql/GraphqlEncoder.java b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java index a3d1aaa30a..391d8a5678 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlEncoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java @@ -36,17 +36,17 @@ public GraphqlEncoder(Encoder delegate, GraphqlContract contract) { @Override public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { - GraphqlContract.QueryMetadata meta = lookupMetadata(template); + var meta = lookupMetadata(template); if (meta == null) { delegate.encode(object, bodyType, template); return; } - Map graphqlBody = new LinkedHashMap<>(); + var graphqlBody = new LinkedHashMap(); graphqlBody.put("query", meta.query); if (object != null && meta.variableName != null) { - Map variables = new LinkedHashMap<>(); + var variables = new LinkedHashMap(); variables.put(meta.variableName, object); graphqlBody.put("variables", variables); } @@ -60,12 +60,12 @@ public void apply(RequestTemplate template) { return; } - GraphqlContract.QueryMetadata meta = lookupMetadata(template); + var meta = lookupMetadata(template); if (meta == null) { return; } - Map graphqlBody = new LinkedHashMap<>(); + var graphqlBody = new LinkedHashMap(); graphqlBody.put("query", meta.query); delegate.encode(graphqlBody, MAP_STRING_WILDCARD, template); From b78e0bc8b9ca9341c2c7e2ddf4199b9dd5c3c49f Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 9 Feb 2026 15:29:57 -0300 Subject: [PATCH 12/12] prepare release 13.8 --- annotation-error-decoder/pom.xml | 2 +- apt-test-generator/pom.xml | 2 +- benchmark/pom.xml | 2 +- core/pom.xml | 2 +- dropwizard-metrics4/pom.xml | 2 +- dropwizard-metrics5/pom.xml | 2 +- example-github-with-coroutine/pom.xml | 2 +- example-github/pom.xml | 2 +- example-wikipedia-with-springboot/pom.xml | 2 +- example-wikipedia/pom.xml | 2 +- fastjson2/pom.xml | 2 +- form-spring/pom.xml | 2 +- form/pom.xml | 2 +- googlehttpclient/pom.xml | 2 +- graphql-apt/pom.xml | 2 +- graphql/pom.xml | 2 +- gson/pom.xml | 2 +- hc5/pom.xml | 2 +- httpclient/pom.xml | 2 +- hystrix/pom.xml | 2 +- jackson-jaxb/pom.xml | 2 +- jackson-jr/pom.xml | 2 +- jackson/pom.xml | 2 +- jackson3/pom.xml | 2 +- jakarta/pom.xml | 2 +- java11/pom.xml | 2 +- jaxb-jakarta/pom.xml | 2 +- jaxb/pom.xml | 2 +- jaxrs/pom.xml | 2 +- jaxrs2/pom.xml | 2 +- jaxrs3/pom.xml | 2 +- jaxrs4/pom.xml | 2 +- json/pom.xml | 2 +- kotlin/pom.xml | 2 +- micrometer/pom.xml | 2 +- mock/pom.xml | 2 +- moshi/pom.xml | 2 +- okhttp/pom.xml | 2 +- pom.xml | 2 +- reactive/pom.xml | 2 +- ribbon/pom.xml | 2 +- sax/pom.xml | 2 +- slf4j/pom.xml | 2 +- soap-jakarta/pom.xml | 2 +- soap/pom.xml | 2 +- spring/pom.xml | 2 +- spring4/pom.xml | 2 +- vertx/feign-vertx/pom.xml | 2 +- vertx/feign-vertx4-test/pom.xml | 2 +- vertx/feign-vertx5-test/pom.xml | 2 +- vertx/pom.xml | 2 +- 51 files changed, 51 insertions(+), 51 deletions(-) diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml index 01dee937ff..b5fc2f41c9 100644 --- a/annotation-error-decoder/pom.xml +++ b/annotation-error-decoder/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-annotation-error-decoder diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml index 082abe0100..b1c86b5e61 100644 --- a/apt-test-generator/pom.xml +++ b/apt-test-generator/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 io.github.openfeign.experimental diff --git a/benchmark/pom.xml b/benchmark/pom.xml index d99666fbce..d1e7b26f10 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-benchmark diff --git a/core/pom.xml b/core/pom.xml index fba385c4df..d5272597eb 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-core diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml index 6bbef5d9f0..7c30f06bab 100644 --- a/dropwizard-metrics4/pom.xml +++ b/dropwizard-metrics4/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-dropwizard-metrics4 Feign Dropwizard Metrics4 diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml index b8f2705626..e2bd6383fc 100644 --- a/dropwizard-metrics5/pom.xml +++ b/dropwizard-metrics5/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-dropwizard-metrics5 Feign Dropwizard Metrics5 diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml index b07493cdca..449b3f448c 100644 --- a/example-github-with-coroutine/pom.xml +++ b/example-github-with-coroutine/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-example-github-with-coroutine diff --git a/example-github/pom.xml b/example-github/pom.xml index c9c4bfc882..af558f357d 100644 --- a/example-github/pom.xml +++ b/example-github/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-example-github diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml index d9b5d2a81a..e42b01d49d 100644 --- a/example-wikipedia-with-springboot/pom.xml +++ b/example-wikipedia-with-springboot/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-example-wikipedia-with-springboot diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml index 01b3be0eed..a7f182b134 100644 --- a/example-wikipedia/pom.xml +++ b/example-wikipedia/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 io.github.openfeign diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml index 62aef4c99b..3f8dd12e0e 100644 --- a/fastjson2/pom.xml +++ b/fastjson2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-fastjson2 diff --git a/form-spring/pom.xml b/form-spring/pom.xml index 85a7e4210d..5250a35d3c 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-form-spring diff --git a/form/pom.xml b/form/pom.xml index fb646c21fa..754f0e428a 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-form diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml index 17b8bd66bd..db18707c02 100644 --- a/googlehttpclient/pom.xml +++ b/googlehttpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-googlehttpclient diff --git a/graphql-apt/pom.xml b/graphql-apt/pom.xml index 4f96d5320d..9ef173bf32 100644 --- a/graphql-apt/pom.xml +++ b/graphql-apt/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 io.github.openfeign.experimental diff --git a/graphql/pom.xml b/graphql/pom.xml index 3c446afe1d..08b14173fa 100644 --- a/graphql/pom.xml +++ b/graphql/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-graphql diff --git a/gson/pom.xml b/gson/pom.xml index b8cf5cada4..ed802d6a26 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-gson diff --git a/hc5/pom.xml b/hc5/pom.xml index d736d9e7b7..38874a707d 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-hc5 diff --git a/httpclient/pom.xml b/httpclient/pom.xml index d2a98601c7..75549b0e39 100644 --- a/httpclient/pom.xml +++ b/httpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-httpclient diff --git a/hystrix/pom.xml b/hystrix/pom.xml index 21f0775b46..7d0428f34a 100644 --- a/hystrix/pom.xml +++ b/hystrix/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-hystrix diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml index c7a5604d84..bb64157d04 100644 --- a/jackson-jaxb/pom.xml +++ b/jackson-jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jackson-jaxb diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml index e3b9e47219..82141f913e 100644 --- a/jackson-jr/pom.xml +++ b/jackson-jr/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jackson-jr diff --git a/jackson/pom.xml b/jackson/pom.xml index 0cfc8976aa..c11a2b91c4 100644 --- a/jackson/pom.xml +++ b/jackson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jackson diff --git a/jackson3/pom.xml b/jackson3/pom.xml index f615df00d0..6834de4737 100644 --- a/jackson3/pom.xml +++ b/jackson3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jackson3 diff --git a/jakarta/pom.xml b/jakarta/pom.xml index 9709dffb7d..d79b6f2849 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jakarta diff --git a/java11/pom.xml b/java11/pom.xml index 475134fc2e..e54b3249dd 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-java11 diff --git a/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml index 3a64c01612..91fa0024a1 100644 --- a/jaxb-jakarta/pom.xml +++ b/jaxb-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxb-jakarta diff --git a/jaxb/pom.xml b/jaxb/pom.xml index 5470fa747b..116a72edf2 100644 --- a/jaxb/pom.xml +++ b/jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxb diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml index 9b1ec21ce1..38299e4377 100644 --- a/jaxrs/pom.xml +++ b/jaxrs/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxrs diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml index dfee5d0a1c..3796e793b3 100644 --- a/jaxrs2/pom.xml +++ b/jaxrs2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxrs2 diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml index ac058a757e..91a10d0845 100644 --- a/jaxrs3/pom.xml +++ b/jaxrs3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxrs3 diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml index f07e902dea..740c196435 100644 --- a/jaxrs4/pom.xml +++ b/jaxrs4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-jaxrs4 diff --git a/json/pom.xml b/json/pom.xml index a5ae81ec1d..1c61fb50b4 100644 --- a/json/pom.xml +++ b/json/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-json diff --git a/kotlin/pom.xml b/kotlin/pom.xml index 3ef76e2a65..53b23930a9 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-kotlin diff --git a/micrometer/pom.xml b/micrometer/pom.xml index c98d50beb6..a776c70a91 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-micrometer Feign Micrometer diff --git a/mock/pom.xml b/mock/pom.xml index 565d9cf80d..caa161af11 100644 --- a/mock/pom.xml +++ b/mock/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-mock diff --git a/moshi/pom.xml b/moshi/pom.xml index 6d160bbb64..0ae80ec6df 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-moshi diff --git a/okhttp/pom.xml b/okhttp/pom.xml index eab4785c97..94d93e1cd1 100644 --- a/okhttp/pom.xml +++ b/okhttp/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-okhttp diff --git a/pom.xml b/pom.xml index 671951ca2e..9045032c7b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 pom Feign (Parent) diff --git a/reactive/pom.xml b/reactive/pom.xml index 2a490e9a57..3c3d7656da 100644 --- a/reactive/pom.xml +++ b/reactive/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-reactive-wrappers diff --git a/ribbon/pom.xml b/ribbon/pom.xml index fc3ca30ec6..6d3b0950b7 100644 --- a/ribbon/pom.xml +++ b/ribbon/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-ribbon diff --git a/sax/pom.xml b/sax/pom.xml index 10b52bb149..6f5895df7a 100644 --- a/sax/pom.xml +++ b/sax/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-sax diff --git a/slf4j/pom.xml b/slf4j/pom.xml index 7f2e335970..4e23d837d3 100644 --- a/slf4j/pom.xml +++ b/slf4j/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-slf4j diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml index 627b33be82..aa0aa4cc1a 100644 --- a/soap-jakarta/pom.xml +++ b/soap-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-soap-jakarta diff --git a/soap/pom.xml b/soap/pom.xml index e25715e0ab..815a6e2fd8 100644 --- a/soap/pom.xml +++ b/soap/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-soap diff --git a/spring/pom.xml b/spring/pom.xml index a441d7130f..b516632787 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-spring diff --git a/spring4/pom.xml b/spring4/pom.xml index 998527191b..3974261e77 100644 --- a/spring4/pom.xml +++ b/spring4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-spring4 diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index 6119cea9ab..cce30bdbfd 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.8-SNAPSHOT + 13.8 feign-vertx diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 4627c60a23..82646fd4f0 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.8-SNAPSHOT + 13.8 feign-vertx4-test diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index f7941a4712..c479f48ffe 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.8-SNAPSHOT + 13.8 feign-vertx5-test diff --git a/vertx/pom.xml b/vertx/pom.xml index c3fd09e8c7..652fd02a37 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.8-SNAPSHOT + 13.8 feign-vertx-parent