Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

README.md

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:

<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>

Basic Usage

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())

Mutations with Variables

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) {}

Subscriptions

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.

Custom Scalars

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) {}

Single Result from Array Queries

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.

Optional Return Types

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);

Optional Fields

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)

Type Annotations on Generated Records

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.

Import-Only Classes with uses

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.

Non-Null Field Annotations

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.

Field-Level Annotations with @GraphqlField

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) {}

Dot Notation for Nested Fields

Use dot notation to target fields in nested records:

@GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {NotNull.class})
@GraphqlField(name = "location.planet", typeAnnotations = {NotBlank.class})

Field Type Override

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.

Disabling Type Generation

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.

Error Handling

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();
}