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/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml
index 8a42cb1fcc..b5fc2f41c9 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
feign-annotation-error-decoder
diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml
index a1c3199c8e..b1c86b5e61 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
io.github.openfeign.experimental
diff --git a/benchmark/pom.xml b/benchmark/pom.xml
index cc8e609cc4..d1e7b26f10 100644
--- a/benchmark/pom.xml
+++ b/benchmark/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-benchmark
diff --git a/core/pom.xml b/core/pom.xml
index 63fbeb8ac0..d5272597eb 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-core
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();
+ }
}
diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml
index 1c2f49dd80..7c30f06bab 100644
--- a/dropwizard-metrics4/pom.xml
+++ b/dropwizard-metrics4/pom.xml
@@ -21,7 +21,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-dropwizard-metrics4
Feign Dropwizard Metrics4
diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml
index 4a0230cd4d..e2bd6383fc 100644
--- a/dropwizard-metrics5/pom.xml
+++ b/dropwizard-metrics5/pom.xml
@@ -21,7 +21,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 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 c43a10293d..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.7
+ 13.8
feign-example-github-with-coroutine
diff --git a/example-github/pom.xml b/example-github/pom.xml
index 5fca1fb298..af558f357d 100644
--- a/example-github/pom.xml
+++ b/example-github/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-example-github
diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml
index 7547cb8c80..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.7
+ 13.8
feign-example-wikipedia-with-springboot
diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml
index 67f5327186..a7f182b134 100644
--- a/example-wikipedia/pom.xml
+++ b/example-wikipedia/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
io.github.openfeign
diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml
index ffbd2880e8..3f8dd12e0e 100644
--- a/fastjson2/pom.xml
+++ b/fastjson2/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-fastjson2
diff --git a/form-spring/pom.xml b/form-spring/pom.xml
index 0f3ad97b40..5250a35d3c 100644
--- a/form-spring/pom.xml
+++ b/form-spring/pom.xml
@@ -23,7 +23,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-form-spring
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/form/pom.xml b/form/pom.xml
index 1b217e7f9d..754f0e428a 100644
--- a/form/pom.xml
+++ b/form/pom.xml
@@ -23,7 +23,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-form
diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml
index fa0c734d92..db18707c02 100644
--- a/googlehttpclient/pom.xml
+++ b/googlehttpclient/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-googlehttpclient
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..9ef173bf32
--- /dev/null
+++ b/graphql-apt/pom.xml
@@ -0,0 +1,89 @@
+
+
+
+ 4.0.0
+
+
+ io.github.openfeign
+ feign-parent
+ 13.8
+
+
+ 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 extends TypeElement> 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..08b14173fa
--- /dev/null
+++ b/graphql/pom.xml
@@ -0,0 +1,67 @@
+
+
+
+ 4.0.0
+
+
+ io.github.openfeign
+ feign-parent
+ 13.8
+
+
+ feign-graphql
+ Feign GraphQL
+ Feign GraphQL runtime support for declarative GraphQL clients
+
+
+ 17
+
+
+
+
+ ${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..6f089e8555
--- /dev/null
+++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java
@@ -0,0 +1,99 @@
+/*
+ * 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.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) -> {
+ var query = annotation.value();
+
+ if (data.template().method() == null) {
+ data.template().method(HttpMethod.POST);
+ data.template().uri("/");
+ }
+
+ var 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) {
+ var prefix = query.substring(0, i).trim();
+ var m = OPERATION_FIELD_PATTERN.matcher(prefix + "{");
+ if (m.find()) {
+ return m.group(1);
+ }
+ }
+ } else if (c == '}') {
+ braceCount--;
+ }
+ }
+
+ var m = OPERATION_FIELD_PATTERN.matcher(query);
+ if (m.find()) {
+ return m.group(1);
+ }
+ return null;
+ }
+
+ static String extractFirstVariable(String query) {
+ var 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..3560341054
--- /dev/null
+++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java
@@ -0,0 +1,127 @@
+/*
+ * 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.lang.reflect.Type;
+
+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;
+ }
+
+ var reader = response.body().asReader(response.charset());
+ if (!reader.markSupported()) {
+ reader = new BufferedReader(reader, 1);
+ }
+
+ var root = mapper.readTree(reader);
+
+ var errorsNode = root.path("errors");
+ if (!errorsNode.isMissingNode() && errorsNode.isArray() && !errorsNode.isEmpty()) {
+ var operationField = resolveOperationField(root, response);
+ throw new GraphqlErrorException(
+ response.status(), operationField, errorsNode.toString(), response.request());
+ }
+
+ var dataNode = root.path("data");
+ if (dataNode.isMissingNode() || dataNode.isNull() || !dataNode.isObject()) {
+ return Util.emptyValueOf(type);
+ }
+
+ var fieldNames = dataNode.fieldNames();
+ if (!fieldNames.hasNext()) {
+ return Util.emptyValueOf(type);
+ }
+
+ var firstField = fieldNames.next();
+ var operationData = dataNode.get(firstField);
+ if (operationData == null || operationData.isNull()) {
+ return Util.emptyValueOf(type);
+ }
+
+ if (delegate != null) {
+ var dataBytes = mapper.writeValueAsBytes(operationData);
+ var 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) {
+ var dataNode = root.path("data");
+ if (!dataNode.isMissingNode() && dataNode.isObject()) {
+ var names = dataNode.fieldNames();
+ if (names.hasNext()) {
+ return names.next();
+ }
+ }
+
+ if (response.request() != null && response.request().body() != null) {
+ try {
+ var requestBody = mapper.readTree(response.request().body());
+ var 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..391d8a5678
--- /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 {
+ var meta = lookupMetadata(template);
+ if (meta == null) {
+ delegate.encode(object, bodyType, template);
+ return;
+ }
+
+ var graphqlBody = new LinkedHashMap();
+ graphqlBody.put("query", meta.query);
+
+ if (object != null && meta.variableName != null) {
+ var 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;
+ }
+
+ var meta = lookupMetadata(template);
+ if (meta == null) {
+ return;
+ }
+
+ var 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/gson/pom.xml b/gson/pom.xml
index e1428f5894..ed802d6a26 100644
--- a/gson/pom.xml
+++ b/gson/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-gson
diff --git a/hc5/pom.xml b/hc5/pom.xml
index a493230e64..38874a707d 100644
--- a/hc5/pom.xml
+++ b/hc5/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-hc5
diff --git a/httpclient/pom.xml b/httpclient/pom.xml
index 4a28235fc4..75549b0e39 100644
--- a/httpclient/pom.xml
+++ b/httpclient/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-httpclient
diff --git a/hystrix/pom.xml b/hystrix/pom.xml
index 76b6059215..7d0428f34a 100644
--- a/hystrix/pom.xml
+++ b/hystrix/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-hystrix
diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml
index b5c0a3c652..bb64157d04 100644
--- a/jackson-jaxb/pom.xml
+++ b/jackson-jaxb/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jackson-jaxb
diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml
index b1efd62542..82141f913e 100644
--- a/jackson-jr/pom.xml
+++ b/jackson-jr/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jackson-jr
diff --git a/jackson/pom.xml b/jackson/pom.xml
index 70550e4787..c11a2b91c4 100644
--- a/jackson/pom.xml
+++ b/jackson/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jackson
diff --git a/jackson3/pom.xml b/jackson3/pom.xml
index 5e64cc6151..6834de4737 100644
--- a/jackson3/pom.xml
+++ b/jackson3/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jackson3
diff --git a/jakarta/pom.xml b/jakarta/pom.xml
index 51f7b28581..d79b6f2849 100644
--- a/jakarta/pom.xml
+++ b/jakarta/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jakarta
diff --git a/java11/pom.xml b/java11/pom.xml
index 26aba291bc..e54b3249dd 100644
--- a/java11/pom.xml
+++ b/java11/pom.xml
@@ -21,7 +21,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-java11
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/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml
index 1ae19936aa..91fa0024a1 100644
--- a/jaxb-jakarta/pom.xml
+++ b/jaxb-jakarta/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxb-jakarta
diff --git a/jaxb/pom.xml b/jaxb/pom.xml
index 91c2762644..116a72edf2 100644
--- a/jaxb/pom.xml
+++ b/jaxb/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxb
diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml
index 1a9cce1ac7..38299e4377 100644
--- a/jaxrs/pom.xml
+++ b/jaxrs/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxrs
diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml
index 6ba219ae14..3796e793b3 100644
--- a/jaxrs2/pom.xml
+++ b/jaxrs2/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxrs2
diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml
index f69b5c54e1..91a10d0845 100644
--- a/jaxrs3/pom.xml
+++ b/jaxrs3/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxrs3
diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml
index 91ef735b8b..740c196435 100644
--- a/jaxrs4/pom.xml
+++ b/jaxrs4/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-jaxrs4
diff --git a/json/pom.xml b/json/pom.xml
index 3243e5a0e4..1c61fb50b4 100644
--- a/json/pom.xml
+++ b/json/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-json
diff --git a/kotlin/pom.xml b/kotlin/pom.xml
index cf2b677593..53b23930a9 100644
--- a/kotlin/pom.xml
+++ b/kotlin/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-kotlin
diff --git a/micrometer/pom.xml b/micrometer/pom.xml
index 47aaaf5c78..a776c70a91 100644
--- a/micrometer/pom.xml
+++ b/micrometer/pom.xml
@@ -21,14 +21,14 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-micrometer
Feign Micrometer
Feign Micrometer Application Metrics
- 1.16.1
+ 1.16.2
diff --git a/mock/pom.xml b/mock/pom.xml
index 0bc1580024..caa161af11 100644
--- a/mock/pom.xml
+++ b/mock/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-mock
diff --git a/moshi/pom.xml b/moshi/pom.xml
index 2186b4a714..0ae80ec6df 100644
--- a/moshi/pom.xml
+++ b/moshi/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-moshi
diff --git a/okhttp/pom.xml b/okhttp/pom.xml
index dc0ca0dd6a..94d93e1cd1 100644
--- a/okhttp/pom.xml
+++ b/okhttp/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-okhttp
diff --git a/pom.xml b/pom.xml
index cf075a3629..9045032c7b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,7 +21,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
pom
Feign (Parent)
@@ -117,6 +117,8 @@
micrometer
mock
apt-test-generator
+ graphql
+ graphql-apt
annotation-error-decoder
example-github
example-github-with-coroutine
@@ -164,21 +166,21 @@
2.13.2
1.15.2
2.0.17
- 20250517
- 3.5.6
+ 20251224
+ 4.0.2
6.0.2
2.21.0
- 3.0.2
+ 3.0.4
3.27.7
5.21.0
- 2.0.60.android8
+ 2.0.61.android8
1.5.3
5.4
3.15.0
3.1.4
- 3.3.1
+ 3.4.0
3.12.0
5.0.0
3.5.0
@@ -212,9 +214,11 @@
1.6.0
1.15.0
0.23.0
+ 22.4
4.5.0
4.5.14
- 5.5.1
+ 5.6
+ 1.13.0
1.5.18
3.1.0
4.0.0
@@ -237,9 +241,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
@@ -411,6 +415,12 @@
test
+
+ ${project.groupId}
+ feign-graphql
+ ${project.version}
+
+
${project.groupId}
feign-form
diff --git a/reactive/pom.xml b/reactive/pom.xml
index fe8e53a327..3c3d7656da 100644
--- a/reactive/pom.xml
+++ b/reactive/pom.xml
@@ -21,7 +21,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-reactive-wrappers
diff --git a/ribbon/pom.xml b/ribbon/pom.xml
index a7e91876b8..6d3b0950b7 100644
--- a/ribbon/pom.xml
+++ b/ribbon/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-ribbon
diff --git a/sax/pom.xml b/sax/pom.xml
index 35fdbf2d4a..6f5895df7a 100644
--- a/sax/pom.xml
+++ b/sax/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-sax
diff --git a/slf4j/pom.xml b/slf4j/pom.xml
index d10acb769e..4e23d837d3 100644
--- a/slf4j/pom.xml
+++ b/slf4j/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-slf4j
diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml
index 458eddd5f2..aa0aa4cc1a 100644
--- a/soap-jakarta/pom.xml
+++ b/soap-jakarta/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-soap-jakarta
diff --git a/soap/pom.xml b/soap/pom.xml
index 5d6509594d..815a6e2fd8 100644
--- a/soap/pom.xml
+++ b/soap/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-soap
diff --git a/spring/pom.xml b/spring/pom.xml
index 9a3c7dc196..b516632787 100644
--- a/spring/pom.xml
+++ b/spring/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-spring
@@ -31,7 +31,7 @@
17
- 6.2.11
+ 7.0.3
diff --git a/spring4/pom.xml b/spring4/pom.xml
index 60fd6ddb1d..3974261e77 100644
--- a/spring4/pom.xml
+++ b/spring4/pom.xml
@@ -22,7 +22,7 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-spring4
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/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml
index 6e862bafb4..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.7
+ 13.8
feign-vertx
diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml
index 5a381fd8e9..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.7
+ 13.8
feign-vertx4-test
diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml
index 8909516d1b..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.7
+ 13.8
feign-vertx5-test
diff --git a/vertx/pom.xml b/vertx/pom.xml
index cf2b82e9e2..652fd02a37 100644
--- a/vertx/pom.xml
+++ b/vertx/pom.xml
@@ -21,11 +21,12 @@
io.github.openfeign
feign-parent
- 13.7
+ 13.8
feign-vertx-parent
pom
+ Feign Vertx (Parent)
feign-vertx