Skip to the content.

Building GraphQL APIs with Eclipse Vertx

We have discussed GraphQL in a former Quarkus GraphQL post. In this post, we will explore the GraphQL support in Eclipse Vertx.

Quarkus also includes an alternative GraphQL extension which use the Eclipse Vertx GraphQL feature.

Follow the steps in the Building RESTful APIs with Eclipse Vertx and create a new Eclipse Vertx project, do not forget to add GraphQL into Dependencies.

Or add the following dependency into the existing pom.xml file directly.

 <dependency>
     <groupId>io.vertx</groupId>
     <artifactId>vertx-web-graphql</artifactId>
</dependency>

Checkout the complete sample codes from my Github.

Vertx provides a specific GraphQLHandler to handle GraphQL request from client.

Fill the MainVerticle with the following content.

@Slf4j
public class MainVerticle extends AbstractVerticle {

    static {
        log.info("Customizing the built-in jackson ObjectMapper...");
        var objectMapper = DatabindCodec.mapper();
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
        objectMapper.disable(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS);

        JavaTimeModule module = new JavaTimeModule();
        objectMapper.registerModule(module);
    }

    @Override
    public void start(Promise<Void> startPromise) throws Exception {
        log.info("Starting HTTP server...");
        //setupLogging();

        //Create a PgPool instance
        var pgPool = pgPool();

        // instantiate repos
        var postRepository = new PostRepository(pgPool);
        var commentRepository = new CommentRepository(pgPool);
        var authorRepository = new AuthorRepository(pgPool);

        // Initializing the sample data
        var initializer = new DataInitializer(postRepository, commentRepository, authorRepository);
        initializer.run();

        //assemble PostService
        var postService = new PostService(postRepository, commentRepository, authorRepository);
        var authorService = new AuthorService(authorRepository);

        // assemble DataLoaders
        var dataLoaders = new DataLoaders(authorService, postService);

        //assemble DataFetcher
        var dataFetchers = new DataFetchers(postService);

        // setup GraphQL
        GraphQL graphQL = setupGraphQLJava(dataFetchers);

        // Configure routes
        var router = setupRoutes(graphQL, dataLoaders);

        // enable GraphQL Websocket Protocol
        HttpServerOptions httpServerOptions = new HttpServerOptions()
            .addWebSocketSubProtocol("graphql-ws");
        // Create the HTTP server
        vertx.createHttpServer(httpServerOptions)
            // Handle every request using the router
            .requestHandler(router)
            // Start listening
            .listen(8080)
            // Print the port
            .onSuccess(server -> {
                startPromise.complete();
                log.info("HTTP server started on port " + server.actualPort());
            })
            .onFailure(event -> {
                startPromise.fail(event);
                log.info("Failed to start HTTP server:" + event.getMessage());
            })
        ;
    }

    //create routes
    private Router setupRoutes(GraphQL graphQL, DataLoaders dataLoaders) {

        // Create a Router
        Router router = Router.router(vertx);

        // register BodyHandler globally.
        router.route().handler(BodyHandler.create());

        // register GraphQL Subscription websocket handler.
        ApolloWSOptions apolloWSOptions = new ApolloWSOptions();
        router.route("/graphql").handler(
            ApolloWSHandler.create(graphQL, apolloWSOptions)
                .connectionInitHandler(connectionInitEvent -> {
                    JsonObject payload = connectionInitEvent.message().content().getJsonObject("payload");
                    log.info("connection init event: {}", payload);
                    if (payload != null && payload.containsKey("rejectMessage")) {
                        connectionInitEvent.fail(payload.getString("rejectMessage"));
                        return;
                    }
                    connectionInitEvent.complete(payload);
                })
                //.connectionHandler(event -> log.info("connection event: {}", event))
                //.messageHandler(msg -> log.info("websocket message: {}", msg.content().toString()))
                //.endHandler(event -> log.info("end event: {}", event))
        );

        GraphQLHandlerOptions options = new GraphQLHandlerOptions()
            // enable multipart for file upload.
            .setRequestMultipartEnabled(true)
            .setRequestBatchingEnabled(true);
        // register `/graphql` for GraphQL handler
        // alternatively, use `router.route()` to enable GET and POST http methods
        router.post("/graphql")
            .handler(
                GraphQLHandler.create(graphQL, options)
                    .dataLoaderRegistry(buildDataLoaderRegistry(dataLoaders))
                //.locale()
                //.queryContext()
            );

        // register `/graphiql` endpoint for the GraphiQL UI
        GraphiQLHandlerOptions graphiqlOptions = new GraphiQLHandlerOptions()
            .setEnabled(true);
        router.get("/graphiql/*").handler(GraphiQLHandler.create(graphiqlOptions));

        router.get("/hello").handler(rc -> rc.response().end("Hello from my route"));

        return router;
    }

    private Function<RoutingContext, DataLoaderRegistry> buildDataLoaderRegistry(DataLoaders dataLoaders) {
        DataLoaderRegistry registry = new DataLoaderRegistry();
        registry.register("commentsLoader", dataLoaders.commentsLoader());
        registry.register("authorsLoader", dataLoaders.authorsLoader());
        return rc -> registry;
    }

    @SneakyThrows
    private GraphQL setupGraphQLJava(DataFetchers dataFetchers) {
        TypeDefinitionRegistry typeDefinitionRegistry = buildTypeDefinitionRegistry();
        RuntimeWiring runtimeWiring = buildRuntimeWiring(dataFetchers);
        GraphQLSchema graphQLSchema = buildGraphQLSchema(typeDefinitionRegistry, runtimeWiring);
        return buildGraphQL(graphQLSchema);
    }

    private GraphQL buildGraphQL(GraphQLSchema graphQLSchema) {
        return GraphQL.newGraphQL(graphQLSchema)
            .defaultDataFetcherExceptionHandler(new CustomDataFetchingExceptionHandler())
            //.queryExecutionStrategy()
            //.mutationExecutionStrategy()
            //.subscriptionExecutionStrategy()
            //.instrumentation()
            .build();
    }

    private GraphQLSchema buildGraphQLSchema(TypeDefinitionRegistry typeDefinitionRegistry, RuntimeWiring runtimeWiring) {
        SchemaGenerator schemaGenerator = new SchemaGenerator();
        GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
        return graphQLSchema;
    }

    private TypeDefinitionRegistry buildTypeDefinitionRegistry() throws IOException, URISyntaxException {
        var schema = Files.readString(Paths.get(getClass().getResource("/schema/schema.graphql").toURI()));
        //String schema = vertx.fileSystem().readFileBlocking("/schema/schema.graphql").toString();

        SchemaParser schemaParser = new SchemaParser();
        TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema);
        return typeDefinitionRegistry;
    }

    private RuntimeWiring buildRuntimeWiring(DataFetchers dataFetchers) {
        return newRuntimeWiring()
            // the following codes are moved to CodeRegistry, the central place to configure the execution codes.
            /*
            .wiringFactory(new WiringFactory() {
                @Override
                public DataFetcher<Object> getDefaultDataFetcher(FieldWiringEnvironment environment) {
                    return VertxPropertyDataFetcher.create(environment.getFieldDefinition().getName());
                }
            })
            .type("Query", builder -> builder
                .dataFetcher("postById", dataFetchers.getPostById())
                .dataFetcher("allPosts", dataFetchers.getAllPosts())
            )
            .type("Mutation", builder -> builder.dataFetcher("createPost", dataFetchers.createPost()))
            .type("Post", builder -> builder
                .dataFetcher("author", dataFetchers.authorOfPost())
                .dataFetcher("comments", dataFetchers.commentsOfPost())
            )
            */
            .codeRegistry(buildCodeRegistry(dataFetchers))
            .scalar(Scalars.localDateTimeType())
            .scalar(Scalars.uuidType())
            .scalar(UploadScalar.build())// handling `Upload` scalar
            .directive("uppercase", new UpperCaseDirectiveWiring())
            .build();
    }

    private GraphQLCodeRegistry buildCodeRegistry(DataFetchers dataFetchers) {
        return GraphQLCodeRegistry.newCodeRegistry()
            .dataFetchers("Query", Map.of(
                "postById", dataFetchers.getPostById(),
                "allPosts", dataFetchers.getAllPosts()
            ))
            .dataFetchers("Mutation", Map.of(
                "createPost", dataFetchers.createPost(),
                "upload", dataFetchers.upload(),
                "addComment", dataFetchers.addComment()
            ))
            .dataFetchers("Subscription", Map.of(
                "commentAdded", dataFetchers.commentAdded()
            ))
            .dataFetchers("Post", Map.of(
                "author", dataFetchers.authorOfPost(),
                "comments", dataFetchers.commentsOfPost()
            ))
            //.typeResolver()
            //.fieldVisibility()
            .defaultDataFetcher(environment -> VertxPropertyDataFetcher.create(environment.getFieldDefinition().getName()))
            .build();
    }

    private PgPool pgPool() {
        PgConnectOptions connectOptions = new PgConnectOptions()
            .setPort(5432)
            .setHost("localhost")
            .setDatabase("blogdb")
            .setUser("user")
            .setPassword("password");

        // Pool Options
        PoolOptions poolOptions = new PoolOptions().setMaxSize(5);

        // Create the pool from the data object
        PgPool pool = PgPool.pool(vertx, connectOptions, poolOptions);

        return pool;
    }

}

The start method is similar to the one in the previous posts, but here it enabled graphql-ws WebSocket sub protocol to activate GraphQL Subscription support.

In the setupRoutes method, it adds the route /graphql to use GraphQLHanlder to handle the HTTP request and enable WebSocket support via /graphql endpoint, also adds route /graphiql to activate GraphiQL interactive Web UI.

As you see, the following is used to create a GraphQLHandler instance.

GraphQLHandler.create(graphQL, options)
                    .dataLoaderRegistry(buildDataLoaderRegistry(dataLoaders))

It requires a GraphQL instance and optional options to initialize a GraphQL instance.

To build a GraphQL instance, it requires a GraphQLSchema which depends on the following two essential objects:

In the GraphQLHandler, register the global data loaders which will be instantiated in every request. It is used to decrease the frequency of executing queries in a N+1 query and improve the application performance.

Let’s have a look at the graphql schema file under the main/resources/schema/schema.graphql folder.

directive @uppercase on FIELD_DEFINITION

scalar LocalDateTime
scalar UUID
scalar Upload

type Post {
    id: ID!
    title: String! @uppercase
    content: String
    comments: [Comment]
    status: PostStatus
    createdAt: LocalDateTime
    authorId: String
    author: Author
}

type Author {
    id: ID!
    name: String!
    email: String!
    createdAt: LocalDateTime
    posts: [Post]
}
type Comment {
    id: ID!
    content: String!
    createdAt: LocalDateTime
    postId: String!
}

input CreatePostInput {
    title: String!
    content: String!
}

input CommentInput {
    postId: String!
    content: String!
}

type Query {
    allPosts: [Post!]!
    postById(postId: String!): Post
}

type Mutation {
    createPost(createPostInput: CreatePostInput!): UUID!
    upload(file: Upload!): Boolean
    addComment(commentInput: CommentInput!): UUID!
}

type Subscription{
    commentAdded: Comment!
}

enum PostStatus {
    DRAFT
    PENDING_MODERATION
    PUBLISHED
}

In this schema file, we declare 3 top level types: Query, Mutation and Subscription which presents the basic operations defined in our application. The Post and Comment are generic types to present the types used in the returned response. The CreatePostInput and CommentInput are input arguments presents the payload of the graphql request. The scalar keyword defines custom scalar types. The directive defines custom directive @uppercase applied on field.

In the MainVerticle.buildCodeRegistry method, it assembles data fetchers according to the coordinates of the types defined in the schema definitions.

For example, when performing a Query: postById, it will execute the dataFetchers.postById defined in the buildCodeRegistry method.

.dataFetchers("Query", Map.of(
                "postById", dataFetchers.getPostById(),
    ...

Looking into the buildRuntimeWiring, it also declars the Scalar, Directive, etc.

The following is an example of custom Scalar type.

// LocalDateTimeScalar
public class LocalDateTimeScalar implements Coercing<LocalDateTime, String> {
    @Override
    public String serialize(Object dataFetcherResult) throws CoercingSerializeException {
        if (dataFetcherResult instanceof LocalDateTime) {
            return ((LocalDateTime) dataFetcherResult).format(DateTimeFormatter.ISO_DATE_TIME);
        } else {
            throw new CoercingSerializeException("Not a valid DateTime");
        }
    }

    @Override
    public LocalDateTime parseValue(Object input) throws CoercingParseValueException {
        return LocalDateTime.parse(input.toString(), DateTimeFormatter.ISO_DATE_TIME);
    }

    @Override
    public LocalDateTime parseLiteral(Object input) throws CoercingParseLiteralException {
        if (input instanceof StringValue) {
            return LocalDateTime.parse(((StringValue) input).getValue(), DateTimeFormatter.ISO_DATE_TIME);
        }

        throw new CoercingParseLiteralException("Value is not a valid ISO date time");
    }
}
//Scalars
public class Scalars {

    public static GraphQLScalarType localDateTimeType() {
        return GraphQLScalarType.newScalar()
                .name("LocalDateTime")
                .description("LocalDateTime type")
                .coercing(new LocalDateTimeScalar())
                .build();
    }
}

//register custom scalar type in the MainVertcle buildRuntimeWiring
newRuntimeWiring()
    ...
    .scalar(Scalars.localDateTimeType())

Vertx GraphQL provide a UploadScalar for uploading files. Check out the source codes and explore the UUIDScalar implementation yourself.

Similarly register a custom Directive in the buildRuntimeWiring, eg. the @uppercase directive.

//UpperCaseDirectiveWiring
public class UpperCaseDirectiveWiring implements SchemaDirectiveWiring {
    @Override
    public GraphQLFieldDefinition onField(SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env) {

        var field = env.getElement();
        var parentType = env.getFieldsContainer();

        var originalDataFetcher = env.getCodeRegistry().getDataFetcher(parentType, field);
        var dataFetcher = DataFetcherFactories.wrapDataFetcher(originalDataFetcher,
                (dataFetchingEnvironment, value) -> {
                    if (value instanceof String s) {
                        return s.toUpperCase();
                    }
                    return value;
                }
        );

        env.getCodeRegistry().dataFetcher(parentType, field, dataFetcher);
        return field;
    }
}
//register custom scalar directive in the MainVertcle buildRuntimeWiring
newRuntimeWiring()
    ...
    .directive("uppercase", new UpperCaseDirectiveWiring())

Let’s move on to the data fetchers which are responsible for resolving the type values at runtime.

For example, in the GraphiQL UI page, try to send a predefined query like this.

query {
    allPosts{
        id
        title
        content
        author{ name }
        comments{ content }
    }
}

It means, it will perform an allPosts Query, and returns a Post array, each item includes fields id, title and content and an author object with an exact name field, and a comments array each item includes a single content field.

When the GraphQL request is sent, GraphQLHandler will handle it.

The Mutation handling is similar to Query, but it is designed for performing some mutations. For example, use Mutation createPost to create a new post, it accepts a CreatePostInput input argument, then delegate to dataFecthers.createPost to handle it.

The file uploading is defined by GraphQL multipart request specification, not part of the standard GraphQL spec.

The following is the data fetcher to handle the file uploading.

public DataFetcher<Boolean> upload() {
    return (DataFetchingEnvironment dfe) -> {

        FileUpload upload = dfe.getArgument("file");
        log.info("name: {}", upload.name());
        log.info("file name: {}", upload.fileName());
        log.info("uploaded file name: {}", upload.uploadedFileName());
        log.info("content type: {}", upload.contentType());
        log.info("charset: {}", upload.charSet());
        log.info("size: {}", upload.size());
        //            String fileContent = Vertx.vertx().fileSystem().readFileBlocking(upload.uploadedFileName()).toString();
        //            log.info("file content: {}", fileContent);
        return true;
    };
}

Vertx creates a temporary file for the uploaded file, it is easy to read the files from local file system.

For the Subscription, it is used for tracking the updates from the backend, such as stock trade news, notification, etc. GraphQL Java requires that it has to return a ReactiveStreams Publisher type.

The following is an example of sending notification when a comment is added.

public VertxDataFetcher<UUID> addComment() {
    return VertxDataFetcher.create((DataFetchingEnvironment dfe) -> {
        var commentInputArg = dfe.getArgument("commentInput");
        var jacksonMapper = DatabindCodec.mapper();
        var input = jacksonMapper.convertValue(commentInputArg, CommentInput.class);
        return this.posts.addComment(input)
            .onSuccess(id -> this.posts.getCommentById(id.toString())
                       .onSuccess(c -> subject.onNext(c)));
    });
}

private ReplaySubject<Comment> subject = ReplaySubject.create(1);

public DataFetcher<Publisher<Comment>> commentAdded() {
    return (DataFetchingEnvironment dfe) -> {
        ApolloWSMessage message = dfe.getContext();
        log.info("msg: {}, connectionParams: {}", message.content(), message.connectionParams());
        ConnectableObservable<Comment> connectableObservable = subject.share().publish();
        connectableObservable.connect();
        log.info("connect to `commentAdded`");
        return connectableObservable.toFlowable(BackpressureStrategy.BUFFER);
    };
}

The above example uses RxJava 3’s ReplaySubject as a processor to emit the message to the connected clients. We have configured in MainVerticle to use WebSocket protocol to handle Subscription. In next post, we will create a WebSocket client to consume this message endpoints.

Here we skip other codes, which are similar to the former Quarkus GraphQL post, check out the complete sample codes from my Github and explore it yourself.

Not like Quarkus, Eclipse Vertx does not provides a specific GraphQL client to simplify the GraphQL request in Java.

To write tests for the GraphQL APIs, you have to switch to use the generic Vertx Http Client. And you have to know well about GraphQL over HTTP specification.

The following is an example of testing allPosts query and createPost mutation using Vertx HttpClient.

@ExtendWith(VertxExtension.class)
@Slf4j
public class TestMainVerticle {

    HttpClient client;

    @BeforeEach
    void setup(Vertx vertx, VertxTestContext testContext) {
        vertx.deployVerticle(new MainVerticle(), testContext.succeeding(id -> testContext.completeNow()));
        var options = new HttpClientOptions()
            .setDefaultHost("localhost")
            .setDefaultPort(8080);
        client = vertx.createHttpClient(options);
    }

    @Test
    void getAllPosts(Vertx vertx, VertxTestContext testContext) throws Throwable {
        var query = """
            query {
                allPosts{
                    id
                    title
                    content
                    author{ name }
                    comments{ content }
                }
            }""";
        client.request(HttpMethod.POST, "/graphql")
            .flatMap(req -> req.putHeader("Content-Type", "application/json")
                .putHeader("Accept", "application/json")
                .send(Json.encode(Map.of("query", query)))//have to use Json.encode to convert objects to json string.
                .flatMap(HttpClientResponse::body)
            )
            .onComplete(
                testContext.succeeding(
                    buffer -> testContext.verify(
                        () -> {
                            log.info("buf: {}", buffer.toString());
                            JsonArray array = buffer.toJsonObject()
                                .getJsonObject("data")
                                .getJsonArray("allPosts");
                            assertThat(array.size()).isGreaterThan(0);

                            var titles = array.getList().stream().map(o -> ((Map<String, Object>) o).get("title")).toList();
                            assertThat(titles).allMatch(s -> ((String) s).startsWith("DGS POST"));
                            testContext.completeNow();
                        }
                    )
                )
            );
    }

    @Test
    void createPost(Vertx vertx, VertxTestContext testContext) throws Throwable {
        String TITLE = "My post created by Vertx HttpClient";
        //var creatPostQuery = "mutation newPost($input:CreatePostInput!){ createPost(createPostInput:$input)}";
        var creatPostQuery = """
            mutation newPost($input:CreatePostInput!){
                createPost(createPostInput:$input)
            }""";
        client.request(HttpMethod.POST, "/graphql")
            .flatMap(req -> {
                    String encodedJson = Json.encode(Map.of(
                        "query", creatPostQuery,
                        "variables", Map.of(
                            "input", Map.of(
                                "title", TITLE,
                                "content", "content of my post"
                            )
                        )
                    ));
                    log.info("sending encoded json: {}", encodedJson);
                    return req.putHeader("Content-Type", "application/json")
                        .putHeader("Accept", "application/json")
                        .send(encodedJson)//have to use Json.encode to convert objects to json string.
                        .flatMap(HttpClientResponse::body);
                }
            )
            .flatMap(buf -> {
                Object id = buf.toJsonObject().getJsonObject("data").getValue("createPost");

                log.info("created post: {}", id);
                assertThat(id).isNotNull();

                var postById = """
                    query post($id:String!) {
                        postById(postId:$id){id title content}
                    }""";

                return client.request(HttpMethod.POST, "/graphql")
                    .flatMap(req -> req.putHeader("Content-Type", "application/json")
                        .putHeader("Accept", "application/json")
                        .send(Json.encode(Map.of(
                            "query", postById,
                            "variables", Map.of("id", id.toString())
                        )))//have to use Json.encode to convert objects to json string.
                        .flatMap(HttpClientResponse::body)
                    );
            })
            .onComplete(
                testContext.succeeding(
                    buffer -> testContext.verify(
                        () -> {
                            log.info("buf: {}", buffer.toString());
                            String title = buffer.toJsonObject()
                                .getJsonObject("data")
                                .getJsonObject("postById")
                                .getString("title");
                            assertThat(title).isEqualTo(TITLE.toUpperCase());
                            testContext.completeNow();
                        }
                    )
                )
            );
    }
}

Get the sample codes from my Github.