Java code-first type-safe GraphQL Client API
A Java code-first type-safe GraphQL Client API suggestion for Microprofile GraphQL Issue #185.
Basic Usage
Creating the client-side counterpart of the GraphQL API:
package examples.typesafeclient;
import io.smallrye.graphql.client.typesafe.api.GraphQLClientApi;
import java.util.List;
@GraphQLClientApi
public interface SuperHeroesApi {
List<SuperHero> allHeroesIn(String location);
}
A model class:
package examples.typesafeclient;
import java.util.List;
public class SuperHero {
private String name;
private List<String> superPowers;
// plus getters and setters
}
Injecting the client using CDI and using it:
package examples.typesafeclient;
import jakarta.inject.Inject;
import java.util.List;
public class MyClientUsage {
@Inject
SuperHeroesApi superHeroesApi;
public void execute() {
List<SuperHero> allHeroes = superHeroesApi.allHeroesIn("Outer Space");
// ...
}
}
- The default request type is
query
. To make it a mutation, annotate it@Mutation
. The parameter name is only available if you compile the source with the-parameters
option. Otherwise, you’ll have to annotate all parameters with@Name
.
The example above uses CDI, e.g. when you are in a MicroProfile or Jakarta EE environment. If you are in an environment without CDI support, you need to instantiate the API interface by using the builder:
SuperHeroesApi api = TypesafeGraphQLClientBuilder.newBuilder().build(SuperHeroesApi.class);
The basic idea of the Java code-first approach is to start by writing the DTOs and query/mutation methods as you need them in your client. This ensures that you don’t request fields that you don’t need; the thinking is inspired by Consumer Driven Contracts.
If the server uses names different from yours, you can use annotations to do a mapping:
Name Mapping / Aliases
If the server defines a different field or parameter name, annotate it
with @Name
. If the server defines a different query name, annotate the
method as, e.g., @Query("findHeroesCurrentlyLocatedIn")
.
By renaming methods, you can also define several variations of the same request but using different return types or parameters. E.g.:
public interface SuperHeroesApi {
SuperHero findHeroByName(String name);
@Query("findHeroByName")
SuperHeroWithTeams findHeroWithTeamsByName(String name);
}
-
The
SuperHero
class has no team affiliations (for this example). -
The
SuperHeroWithTeams
class has aList<Team> teamAffiliations
field. The actual query name is stillfindHeroByName
. TheTeam
class doesn’t contain the members to break recursion.
If you rename a field or method, the real field or method name will be
used as an alias, so you can select the same data twice (see and
below).
Configuration
If the endpoint is always the same, e.g. a public API of a cloud service, you can add the URL to your API annotation, e.g.:
@GraphQLClientApi(endpoint = "https://superheroes.org/graphql")
interface SuperHeroesApi {
}
When instantiating the API with the builder, you can set (or overwrite) the endpoint there:
SuperHeroesApi api = TypesafeGraphQLClientBuilder.newBuilder()
.endpoint("https://superheroes.org/graphql")
.build(SuperHeroesApi.class);
Commonly you’ll need different endpoints, e.g. when you need one
endpoint for your production system, but a different endpoint for your
test system. Simply use MicroProfile
Config
to set the endpoint; similar to the MicroProfile Rest
Client,
the key for the endpoint is the fully qualified name of the API
interface, plus /mp-graphql/url
, e.g.:
org.superheroes.SuperHeroesApi/mp-graphql/url=https://superheroes.org/graphql
If you want to use a different key, set the base config key on the
annotation @GraphQLClientApi(configKey = "superheroes")
; then use this
key for the endpoint superheroes/mp-graphql/url
.
When using the builder, you can override the config key as well:
TypesafeGraphQLClientBuilder.newBuilder().configKey("superheroes")
.
NestedParameter
Some APIs require parameters beyond the root level, e.g. for filtering or paginating nested lists. Say you have a schema like this:
type Query {
team(name: String!): Team!
}
type Team {
members(first: Int!): [SuperHero!]!
}
To pass the parameter to the nested field/method, annotate it as
@NestedParameter
, e.g.:
@GraphQLClientApi
interface TeamsApi {
Team team(String name, @NestedParameter("members") int first);
}
The value of the @NestedParameter
annotation is the dot-delimited path
to the nested field/method that the value should be added to.
Example of server code
@GraphQLApi
public class RoleApi {
@Query
public List<Role> findAllRolesByUserId(@NonNull UUID userId) {
// return roles
}
public List<Permission> permission(@Source Roles role, @DefaultValue("5") int limit) {
// return permissions, based on roles
}
public List<PermissionType> permissionType(@Source Permission permission, @DefaultValue("5") int limit) {
// return permissionType, based on permission
}
}
Query looks like
query {
findAllRolesByUserId(userId: ...) {
id
permission(limit: 2) {
id
permissionType(limit: 3) {
id
}
}
}
}
On the client side, you can declare the following code:
public record PermissionType(Long id) {
}
public record Permission(Long id, List<PermissionType> permissionType) {
}
public record Role(UUID id, List<Permission> permission) {
}
@GraphQLClientApi
public interface ApiClient {
List<Role> findAllRolesByUserId(
UUID userId,
@NestedParameter("permission") @Name("limit") int permissionLimit,
@NestedParameter("permission.permissionType") @Name("limit") int permissionTypeLimit
);
}
Namespaces
There are several ways to work with namespaces in a type-safe client:
1. Using @Namespace
2. Using @Name
(deprecated)
[NOTE] You can only use one of the annotations -
@Name
or@Namespace
on aGraphClientQLApi
interface.
Using @Namespace annotation
The @Namespace
annotation accepts an array of strings specifying the nesting levels of the namespace.
This flexible approach allows you to create namespaces with any desired level of nesting, combining different levels as needed.
If the remote GraphQL API has the following schema:
"Query root"
type Query {
admin: AdminQuery
}
type AdminQuery {
users: AdminUsersQuery
}
type AdminUsersQuery {
findAll: User
}
type User {
id: BigInteger
[...]
}
While declaring the following interface:
@Namespace({"admin", "users"})
@GraphQLClientApi
public interface UsersClient {
List<User> findAll();
}
Its outcome will be the following GraphQL query:
query AminUsersFindAll {
admin {
users {
findAll {
id
}
}
}
}
Using @Name (deprecated)
[NOTE] This feature may be removed in the future.
The @Name
annotation functions similarly to @Namespace
, but it is limited to a single nesting level.
query {
users {
findAll {
...
}
}
}
@Name("users")
@GraphQLClientApi
public interface ApiClient {
List<User> findAll();
}