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.
Add both modules to use schema-driven type generation:
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-graphql</artifactId>
<version>${feign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign.experimental</groupId>
<artifactId>feign-graphql-apt</artifactId>
<version>${feign.version}</version>
<scope>provided</scope>
</dependency>Define a GraphQL schema in src/main/resources:
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:
@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:
public record User(String id, String name, String email) {}Build the client using GraphqlCapability, which wires the contract, encoder, decoder, and request interceptor automatically. It takes any JsonCodec:
// Using Jackson
UserApi api = Feign.builder()
.addCapability(new GraphqlCapability(new JacksonCodec()))
.target(UserApi.class, "https://api.example.com/graphql");
User user = api.getUser("123");Any JSON codec works the same way:
// Gson
new GraphqlCapability(new GsonCodec())
// Jackson 3
new GraphqlCapability(new Jackson3Codec())
// Fastjson2
new GraphqlCapability(new Fastjson2Codec())Methods with parameters are sent as GraphQL variables:
@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:
public record CreateUserInput(String name, String email) {}subscription operations are detected from the query text and executed over the
graphql-transport-ws WebSocket
protocol instead of HTTP. The endpoint is the target URL with its scheme swapped to ws/wss, and
one connection is opened per call.
Queries, mutations and subscriptions can live on the same interface: only subscriptions are routed
to a WebSocket, everything else goes over the regular Feign client — including whichever one you
configured with .client(...) — with its own timeouts, retryer and interceptors unchanged.
The return type decides how many events you get and whether the call blocks:
| Return type | Events | Behaviour |
|---|---|---|
T |
first only | blocks until the first event, then unsubscribes |
Optional<T> |
first only | as above, empty if the server completes without one |
CompletableFuture<T> |
first only | returns immediately, completes with the first event |
Stream<T> |
all | returns once subscribed, then blocks on each element |
Flow.Publisher<T> |
all | returns immediately, elements are pushed to the subscriber |
@GraphqlSchema("my-schema.graphql")
interface StockApi {
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
Price nextPrice(@Param("symbol") String symbol);
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
Stream<Price> onPrice(@Param("symbol") String symbol);
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
Flow.Publisher<Price> publishPrice(@Param("symbol") String symbol);
}The single-event forms close the subscription as soon as they have their event. The multi-event
forms hand you the lifecycle: closing the Stream — or cancelling the Flow.Subscription — sends
complete and closes the WebSocket, so consume a Stream with try-with-resources:
try (var prices = api.onPrice("ACME")) {
prices.forEach(System.out::println);
}Stream here is the ordinary java.util.stream.Stream: synchronous and pull-based, with no timeout
facilities of its own. So the blocking forms — T, Optional<T> and Stream<T> — are bounded by
an event timeout, which defaults to 60 seconds and applies to each event rather than to the
subscription as a whole. Override it when creating the capability:
Feign.builder()
// wait at most 5s for each event; Duration.ZERO waits indefinitely
.addCapability(new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5)))
.target(StockApi.class, "https://example.com/graphql");Exceeding it raises SocketTimeoutException from the blocking call or the stream element. A
subscription that can legitimately sit idle for longer needs Duration.ZERO.
Flow.Publisher<T> and CompletableFuture<T> are deliberately not bounded by it — their caller
already owns the deadline, via cancelling the subscription or
get(timeout, unit)/orTimeout(...). Cancelling either one closes the underlying WebSocket.
Those two asynchronous forms each need a worker for as long as the subscription is open. They run on a bounded daemon pool by default; pass your own to own the lifecycle:
new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5), myExecutor)Reads are demand-driven — the client asks the socket for another frame only once the consumer has taken the previous event — so a slow consumer applies backpressure to the server instead of growing a queue in memory.
Flow.Publisher is java.util.concurrent.Flow.Publisher, so it plugs into Reactor
(JdkFlowAdapter.flowPublisherToFlux) or RxJava (Flowable.fromPublisher) without extra
dependencies here.
A server error message, or errors inside a payload, is raised as GraphqlErrorException.
Request headers (for example Authorization) are forwarded to the WebSocket handshake.
When your schema defines custom scalars, map them to Java types using @Scalar on default methods:
scalar DateTime
type Event {
id: ID!
name: String!
startTime: DateTime!
}@GraphqlSchema("event-schema.graphql")
interface EventApi {
@Scalar("DateTime")
default Instant dateTime() { return null; }
@GraphqlQuery("query { events { id name startTime } }")
List<Event> getEvents();
}The processor maps DateTime fields to java.time.Instant in the generated record:
public record Event(String id, String name, Instant startTime) {}When a GraphQL query returns an array type (e.g. [User!]) but the Java method declares a single return type, the decoder automatically unwraps the first element:
@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }")
User topUser(int limit);This is useful when using limit: 1 to fetch a single result from a list query. If the array is empty, null is returned.
Methods can return Optional<T> to safely handle nullable results:
@GraphqlQuery("query getUser($id: String!) { getUser(id: $id) { id name email } }")
Optional<User> findUser(String id);Returns Optional.empty() when the data is null or missing, and Optional.of(value) otherwise. This also works with array unwrapping:
@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }")
Optional<User> findTopUser(int limit);When using Optional<> fields in records, your JSON codec must support java.util.Optional. For Jackson, add jackson-datatype-jdk8 and register it:
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
UserApi api = Feign.builder()
.addCapability(new GraphqlCapability(new JacksonCodec(mapper)))
.target(UserApi.class, "https://api.example.com/graphql");By default, nullable GraphQL fields (without !) are wrapped in Optional<> in generated records:
type User {
id: ID! # non-null
name: String! # non-null
email: String # nullable
}public record User(String id, String name, Optional<String> email) {}This is controlled by useOptional on @GraphqlSchema (defaults to true):
@GraphqlSchema(value = "schema.graphql", useOptional = false)Override per method with Toggle:
@GraphqlQuery(value = "...", useOptional = Toggle.FALSE)Add annotations to all generated records using typeAnnotations (no-arg) and rawTypeAnnotations (with args):
@GraphqlSchema(
value = "schema.graphql",
typeAnnotations = {Builder.class, Jacksonized.class},
rawTypeAnnotations = {"@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)"}
)Generates:
@Builder
@Jacksonized
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record User(String id, String name) {}Collision rule: when the same annotation simple name appears in both typeAnnotations and rawTypeAnnotations, the class provides only the import and the raw string is used:
typeAnnotations = {Builder.class},
rawTypeAnnotations = {"@Builder(toBuilder = true)"}
// Result: import lombok.Builder; + @Builder(toBuilder = true)Override per method on @GraphqlQuery — non-empty arrays replace class-level values.
When raw annotations reference classes not in typeAnnotations, use uses to add their imports:
@GraphqlSchema(
value = "schema.graphql",
uses = {Min.class, Max.class, Pattern.class}
)These classes are added as imports to all generated files but no annotations are generated from them.
Automatically annotate all non-null (!) fields with nonNullTypeAnnotations:
@GraphqlSchema(
value = "schema.graphql",
nonNullTypeAnnotations = {NotNull.class}
)For name: String! and email: String, generates:
public record User(@NotNull String name, Optional<String> email) {}Same collision rule applies with nonNullRawTypeAnnotations. Overridable per method on @GraphqlQuery.
Apply annotations or override types on specific fields. Repeatable, works on both the interface (class-level default) and individual methods:
@GraphqlSchema(value = "schema.graphql", useOptional = false)
@GraphqlField(name = "email", typeAnnotations = {Email.class})
interface UserApi {
@GraphqlQuery("{ user(id: \"1\") { id name email } }")
@GraphqlField(name = "name", typeAnnotations = {NotBlank.class})
UserResult getUser();
}Generates:
public record UserResult(String id, @NotBlank String name, @Email String email) {}Use dot notation to target fields in nested records:
@GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {NotNull.class})
@GraphqlField(name = "location.planet", typeAnnotations = {NotBlank.class})Override the Java type for a field — useful when APIs return strings for dates without declaring custom scalars:
@GraphqlField(name = "createdAt", type = ZonedDateTime.class)
@GraphqlField(name = "amount", type = BigDecimal.class)Combines with annotations:
@GraphqlField(name = "createdAt", type = ZonedDateTime.class, typeAnnotations = {NotNull.class})Class-level @GraphqlField applies to all methods; method-level overrides for the same field name.
If you provide your own model classes, disable automatic generation:
@GraphqlSchema(value = "my-schema.graphql", generateTypes = false)
interface UserApi {
// ...
}Queries are still validated against the schema at compile time.
GraphQL errors in the response throw GraphqlErrorException:
try {
User user = api.getUser("invalid-id");
} catch (GraphqlErrorException e) {
String operation = e.operation();
String errors = e.errors();
}