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

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