Using GraphQL with Knot.x

Filip Kowalski
2.0.0

Overview

In this tutorial, we'll implement a sample GraphQL endpoint that makes use of Knot.x Configurable Integrations. It's advised to complete the Getting Started tutorials first. You should also be familiar with fragments. Familiarity with GraphQL is not obligatory (but always welcome).

You'll learn how to:

  • use Java and Vert.x GraphQL implementations
  • implement them to work seamlessly with Knot.x
  • create and use custom Fragments and reuse HTTP Action to fetch data from Google API

GraphQL

GraphQL is a query language, a standard for serving and retrieving data. It's commonly used for communication via HTTP. Unlike REST, GraphQL uses a single endpoint that is serving all data. Clients specify what data they need with a dedicated query language. The server then responds with JSON including only data the client asked for and in the structure he or she wanted.

For example, client could ask for data like this:

{
    books(cover_type: "hard") {
        available
        price
    }
}

And get a JSON response:

{
    "data": {
        "books": [
            {
                "available": true,
                "price": "$12"
            },
            {
                "available": false,
                "price": "€15"
            }
        ]
    }
}

Maybe we could ask the server for different properties of books or for other products - it doesn't matter. We get what we asked for.

Another advantage of GraphQL is that it's statically typed. The server defines a schema which is a file defining all operations and types the client can request. It's like a model layer.

You can read more at graphql.org.

Java implementation library

Because GraphQL is a specification, it has many implementations for different languages. The most popular Java one is GraphQL Java.

The main idea of this library is a data fetcher. It is an object implementing the DataFetcher interface with only one method:

T get(DataFetchingEnvironment env)

Let's ignore the env argument for now. As you might suspect, the role of a data fetcher is to provide some data (return an object, to be precise).

At runtime, you assign data fetchers to types defined in GraphQL schema. Such a fetcher needs to return an object that can be automatically mapped to the schema type. For example, if the schema defines an operation book returning a type Book that has a property title, then we would implement it like that:

  1. Define a POJO Book object
class Book {
    private String title;

    public Book(String title) {
        this.title = title;
    }
}
  1. Define a fetcher
class BookFetcher implements DataFetcher<Book> {
    @Override
    Book get(DataFetchingEnvironment env) {
        return new Book("Reindeers World");
    }
}

Note that the example above contains a hardcoded Book instance to keep things simple. Normally the data could be fetched e.g. from the database or 3rd party web service. But as far as GraphQL is concerned, it doesn't matter as long as the fetcher returns a Book object.

  1. Assign the fetcher to operation
//...
RuntimeWiring runtimeWiring = newRuntimeWiring()
    .type("QueryType", builder -> builder.dataFetcher("book", new BookFetcher()))
    .build()
//then load the schema file and construct GraphQL object

QueryType is the name of the query we define in a schema.

You'll end up with a graphQL object (of class GraphQL). You can then ask this object to process a query, e.g:

{
    book {
        title
    }
}

And you should get the following output:

{
    "data": {
        "book": {
            "title": "Reindeers World"
        }
    }
}

Quite simple, right?

Vert.x Web GraphQL module

Note that the implementation above focuses on schema and fetchers only. So far it has nothing to do with HTTP. This is where the Vert.x implementation comes in handy. Vert.x provides an out of the box HTTP handler for GraphQL. We just need to provide it with a configured GraphQL object and it's ready to serve requests through HTTP.

Get the best out of GraphQL and Knot.x

There are two steps to integrate GraphQL and Knot.x. The first is to define a GraphQL handler factory that will return a GraphQLHandler (the one from the Vert.x implementation). It will allow us to define and configure GraphQL endpoint in the Knot.x configuration.

We could stop at implementing the factory but we wouldn't be using the full power of Knot.x. Knot.x has some great mechanisms for fetching data from 3rd parties, called Configurable Integrations. Therefore, the second step is to create data fetchers that make use of Knot.x integration functionalities.

It's assumed you have the project set up and ready for development. If you have troubles with that check the Getting Started tutorials.

Step 0 - Google Books API, schema and model

Let's stick with books. We'll implement a sample service serving books from the Google Books API. Google Books is a typical REST API. It returns a lot of information. In this tutorial, we'll extract just a title, publisher and a list of authors (Google API returns authors as a list of strings). The response from the Google API looks like this:

{
    "items": [
        {
            //...
            "volumeInfo": {
                "title": "...",
                "publisher": "...",
                "authors": ["Author1", "Author2"],
                //...
            }
        }
    ]
}

Example calls on endpoints that we'll use:

curl https://www.googleapis.com/books/v1/volumes\?q\=Java
curl https://www.googleapis.com/books/v1/volumes/UEdjAgAAQBAJ

Our GraphQL schema should look like this:

schema {
    query: QueryType
}

type QueryType {
    books(match: String): [Book]
    book(id: String): Book
}

type Book {
    title: String!
    publisher: String!
    authors: [String]
}

It defines two operations:

  • books: takes a string parameter (search keyword) and returns a list of books
  • book: takes a string parameter (google id) and returns a single book

The schema also defines what a book is. The exclamation point means a field is mandatory (can't be null).

Save it as a books.graphqls file in your app resources, that is: \modules\books\src\main\resources\ (assuming your module is called "books").

We'll also need to implement a Java model for every type our operations can return. It needs to exactly match the schema. In this case, it's just one class, Book:

class Book implements GraphQLDataObject {
    private String title;
    private String publisher;
    private List<String> authors;

    @Override
    public void fromJson(JsonObject json, DataFetchingEnvironment environment) {
        JsonObject volumeInfo = json.getJsonObject("volumeInfo");

        title = volumeInfo.getString("title");
        publisher = volumeInfo.getString("publisher");
        authors = new LinkedList<>();

        volumeInfo.getJsonArray("authors", new JsonArray()).forEach(object -> authors.add((String) object));
    }
}

Note that it's implementing GraphQLDataObject interface with a fromJson method. It will come in handy later. It's not a standard interface, you have to define it:

public interface GraphQLDataObject extends Serializable {
  void fromJson(JsonObject json, DataFetchingEnvironment environment);
}

Step 1 - The factory

Now, it's time to implement the Handler factory that will setup a GraphQL object and produce a Vert.x GraphQLHandler.

public class GraphQLHandlerFactory implements RoutingHandlerFactory {
    @Override
    public String getName() {
        return "graphqlHandler";
    }

    @Override
    public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
        return routingContext -> {
            GraphQL graphQL = setupGraphQL(vertx, config, routingContext);
            GraphQLHandler
                .create(graphQL)
                .handle(routingContext);
        }
    }

    //setupGraphQL method implementation
}

It's a standard Knot.x handler factory. It returns a handler that creates a GraphQL object, uses it to create a Vert.x GraphQLHandler and lets it handle the request.

GraphQL object is created the standard way. See graphql-java.com for more explanation on Java GraphQL specific issues.

private GraphQL setupGraphQL(Vertx vertx, JsonObject config, RoutingContext routingContext) {
    // Schema file path will be read from knot.x configuration
    Reader schema = loadResource(config.getString("schema")); 

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

    RuntimeWiring runtimeWiring = newRuntimeWiring()
        .type("QueryType", builder -> builder
            // Here we can assign data fetchers to operations from schema
            // We'll implement them in Step 2. Normaly it'd look something like:
            .dataFetcher("books", new BooksFetcher())
            .dataFetcher("book", new SingleBookFetcher())
        )
        .build();
    SchemaGenerator schemaGenerator = new SchemaGenerator();
    GraphQLSchema graphQLSchema = schemaGenerator
        .makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);

    return GraphQL.newGraphQL(graphQLSchema).build();
  }

private Reader loadResource(String path) {
    return new InputStreamReader(GraphQLHandlerFactory.class.getResourceAsStream("/" + path));
}

Remember about updating services in the META-INF. With a handler factory, you can now configure a GraphQL endpoint. Add this path to openapi.yml:

/api/graphql:
    post:
        operationId: books-gql-operation
        responses:
            default:
                description: Books Graphql API

Note that it needs to be POST because GraphQL reads queries from the requests' bodies. Now add the operation to operations.conf:

{
    operationId = books-gql-operation
    handlers = ${config.server.handlers.common.request} [
        {
            name = graphqlHandler
            config = {
                schema = "books.graphqls"
            }
        }
    ]
}

Note the ${config.server.handlers.common.request} part. We import some standard configuration because we'll need it later. Now the only thing left are data fetchers and you'll have a working GraphQL endpoint.

Step 2 - Data fetchers and FragmentsEngine

We would like to fetch data from 3rd parties using existing Knot.x mechanisms, not to reinvent the wheel. Ideally we would have a generic data fetcher that can process Knot.x Tasks defined in configuration.

Our fetcher will parse configuration to create Tasks and run them using FragmentsEngine. It will output a Fragment that will contain data fetched from Google in its payload. Fragment's payload is just a JsonObject where actions can store data.

Execute tasks from configuration

Let's start with:

public abstract class TaskDataFetcher<T> implements DataFetcher<CompletableFuture<T>> {

    private final Vertx vertx;
    private final JsonObject config;
    private final RoutingContext routingContext;
    private final String taskName;
    private final FragmentsEngine engine;

    TaskDataFetcher(VertX, JsonObject config, RoutingContext routingContext, String taskName) {
        this.vertx = vertx;
        this.config = config;
        this.routingContext = routingContext;
        this.taskName = taskName;
        engine = new FragmentsEngine(vertx);
    }

    @Override
    public CompletableFuture<T> get(DataFetchingEnvironment environment) {
        FragmentEventContextTaskAware eventContextTaskAware = setupTask(vertx, config, routingContext, environment);
        CompletableFuture<T> future = new CompletableFuture<>();

        engine
            .execute(Collections.singletonList(eventContextTaskAware))
            .subscribe(events -> {
                // Here we'll get the outcome fragment's payload, parse it to model and complete the future with it
            });

        return future;
    }

    //other methods that we'll implement in a moment
}

There's a couple of things to explain here. Firstly, our fetcher is abstract - it will have one abstract method for transforming payload into objects from our model (Book). Also, it's generic. That's because it doesn't matter for it what type will it return - it may be Book, it may be an array Book[] or it may be anything else.

TaskDataFetcher implements DataFetcher (every data fetcher needs to) of type CompletableFuture<T>. Java implementation of GraphQL allows us to return futures instead of plain objects. If a data fetcher returns a future, GraphQL will asynchronously wait for it to complete. Only when all futures returned by data fetchers complete, the GraphQL will construct a response.

Our abstract fetcher creates an instance of FragmentsEngine. The engine can execute tasks. Task is a graph of executable nodes. Also, in the get method we create FragmentEventContextTaskAware. It's basicaly a task ready to be executed by the engine. Let's define the setupTask method that will take a configuration and parse it into a task. We'll also need one constant.

private static final String FRAGMENT_TYPE = "graphql-data";

//...

private FragmentEventContextTaskAware setupTask(Vertx vertx, JsonObject config, RoutingContext routingContext, DataFetchingEnvironment env)  {
    JsonObject fragmentConfig = new JsonObject();
    fragmentConfig.put(FRAGMENT_TYPE, taskName);
    fragmentConfig.put("gql", new JsonObject(env.getArguments()));

    Fragment fragment = new Fragment(FRAGMENT_TYPE, fragmentConfig, "");

    RequestContext requestContext = routingContext.get(RequestContext.KEY);
    ClientRequest clientRequest = requestContext.getRequestEvent().getClientRequest();

    FragmentEvent event = new FragmentEvent(fragment);
    FragmentEventContext eventContext = new FragmentEventContext(event, clientRequest);

    FragmentsHandlerOptions options = new FragmentsHandlerOptions(config);
    ActionProvider proxyProvider = new ActionProvider(options.getAction(), supplyFactories(), vertx.getDelegate());
    TaskBuilder taskBuilder = new TaskBuilder(FRAGMENT_TYPE, options.getTasks(), proxyProvider);

    Task task = taskBuilder
        .build(fragment)
        .orElseThrow(() -> new IllegalStateException("No task built"));

    return new FragmentEventContextTaskAware(task, eventContext);
}

Let's take a look at it piece by piece:

JsonObject fragmentConfig = new JsonObject();
fragmentConfig.put(FRAGMENT_TYPE, taskName);
fragmentConfig.put("gql", new JsonObject(env.getArguments()));

Fragment fragment = new Fragment(FRAGMENT_TYPE, fragmentConfig, "");

We create a new Fragment that will specify Task to be processed. Fragment's type is used as a Task name. We also put GraphQL environment arguments to the configuration under "gql" key. Environment arguments are just values passed as parameters of an operation. In our case "match" in books(match: String) and "id" in book(id: String). By putting it into a Fragment configuration, we allow Task's nodes (in this case HTTP Actions) to access it. Note that the "gql" key is arbitrary and you can call it anything you want.

For the third argument in the fragment's constructor, we pass an empty string. It's our fragment's body which we're not interested in this tutorial.

RequestContext requestContext = routingContext.get(RequestContext.KEY);
ClientRequest clientRequest = requestContext.getRequestEvent().getClientRequest();

We need to extract ClientRequest from routingContext. Remember the ${config.server.handlers.common.request} part in the operation configuration in operations.conf? Without it there wouldn't be anything in routingContext under RequestContext.KEY.

FragmentEvent event = new FragmentEvent(fragment);
FragmentEventContext eventContext = new FragmentEventContext(event, clientRequest);

Our final Task will need two things: Task's name and FragmentEventContext. Here we create the latter. It keeps the client request and our Fragment in the form of FragmentEvent.

FragmentsHandlerOptions options = new FragmentsHandlerOptions(config);
ActionProvider proxyProvider = new ActionProvider(options.getAction(), supplyFactories(), vertx.getDelegate());
TaskBuilder taskBuilder = new TaskBuilder(FRAGMENT_TYPE, options.getTasks(), proxyProvider);

In order to create a task we need to use a TaskBuilder. It needs to be supplied with a few things from the configuration. We use FragmentsHandlerOptions to parse the configuration. We also need a supplier of action factories. We can retrieve it using the standard java ServiceLoader. We'll take a look at supplyFactories method in a moment.

Task task = taskBuilder
        .build(fragment)
        .orElseThrow(() -> new IllegalStateException("No task built"));

return new FragmentEventContextTaskAware(task, eventContext);

Finally we use the task builder to construct a task and return FragmentEventContextTaskAware that is passed to the fragments engine in get method. Note that in a real application you would probably want to define a custom exception for when task builder fails. If that happens, it's most likely due to wrong or incomplete configuration.

Now let's quickly implement supplyFactories method. It simply uses a service loader to retrieve action factories:

private Supplier<Iterator<ActionFactory>> supplyFactories() {
    return () -> {
        ServiceLoader<ActionFactory> factories = ServiceLoader.load(ActionFactory.class);
        return factories.iterator();
    };
}

Retrieving payload

Now that we can make our fetcher execute Knot.x tasks from the configuration, we need to retrieve the outcome. As noted before, the data retrieved from the 3rd party (google books api in our case) is stored in the outcome fragment's payload. Therefore our final get method should look like this:

@Override
public CompletableFuture<T> get(DataFetchingEnvironment environment) {
    FragmentEventContextTaskAware eventContextTaskAware = setupTask(vertx, config, routingContext, environment);
    CompletableFuture<T> future = new CompletableFuture<>();

    engine
        .execute(Collections.singletonList(eventContextTaskAware))
        .subscribe(events -> {
            JsonObject payload = events.get(0).getFragment().getPayload();
            JsonObject fetchedData = payload.getJsonObject("fetchedData");
            T model = getDataObjectFromJson(fetchedData, environment);
            future.complete(model);
        });

    return future;
}

After the engine is done executing the task we:

  • retrieve the fragment and its payload
  • get data from payload under "fetchedData" key
  • transform this data into our model (in our case it would be Book)
  • complete the future with our model so that GraphQL gets the data

Two things require some explanation. Firstly, we assume that the data we need is under "fetchedData" key. It is not exactly the case but we'll deal with it later. Secondly, we use getDataObjectFromJson method to transform json into our model. This method is abstract because our TaskDataFetcher doesn't know how to do it.

abstract T getDataObjectFromJson(JsonObject json, DataFetchingEnvironment environment) throws IllegalAccessException, InstantiationException;

Transforming json into model

Now we need to extend our TaskDataFetcher with something that can implement getDataObjectFromJson method. We'll create TaskSingleDataFetcher that'll transform json into a single model object, for example into Book.

public class TaskSingleDataFetcher<T extends GraphQLDataObject> extends TaskDataFetcher<T> {
  private final Class<T> clazz;

  public TaskSingleDataFetcher(String task, Class<T> clazz, Vertx vertx, JsonObject config, RoutingContext routingContext) {
    super(vertx, config, routingContext, task);
    this.clazz = clazz;
  }

  @Override
  T getDataObjectFromJson(JsonObject json, DataFetchingEnvironment environment) throws IllegalAccessException, InstantiationException {
    T dataObject = clazz.newInstance();
    dataObject.fromJson(json, environment);
    return dataObject;
  }
}

It's very simple. It takes a class of some object implementing GraphQLDataObject to be able to instantiate it. Please note that it must provide also the default constructor. Remember that Book implements this interface and it has a single method: fromJson that takes a json and populates the object.

The overriden getDataObjectFromJson method just creates a new instance of the given class, calls fromJson on it in order to populate it with data, and then returns it.

The only problem with this fetcher is that it returns single objects only. In our schema we have an operation books(match: String): [Book] that needs an array of objects. Therefore, we need a second fetcher extending our TaskDataFetcher that will return an array of objects:

public class TaskArrayDataFetcher<T extends GraphQLDataObject> extends TaskDataFetcher<T[]> {
  private Class<T> clazz;
  private Function<JsonObject, JsonArray> toArray;

  public TaskArrayDataFetcher(String task, Class<T> clazz, Vertx vertx, JsonObject config, RoutingContext routingContext, Function<JsonObject, JsonArray> toArray) {
    super(vertx, config, routingContext, task);
    this.clazz = clazz;
    this.toArray = toArray;
  }

  @Override
  T[] getDataObjectFromJson(JsonObject json, DataFetchingEnvironment environment) throws IllegalAccessException, InstantiationException {
    JsonArray jsonArray = toArray.apply(json);

    T[] dataArray = (T[]) Array.newInstance(clazz, jsonArray.size());

    for (int i = 0; i < jsonArray.size(); i ++) {
      T dataObject = clazz.newInstance();
      dataObject.fromJson(jsonArray.getJsonObject(i), environment);
      dataArray[i] = dataObject;
    }

    return dataArray;
  }
}

It's just a little bit more complicated. It extends TaskDataFetcher<T[]> instead of TaskDataFetcher<T> so it can return arrays. In the getDataObjectFromJson method it creates an array to return and populates it with individual model objects (for example, Book instances) just the way our previous fetcher did.

Also, there's the toArray parameter passed to our fetcher. It's a Function used to transform JsonObject from payload into a JsonArray. In our case, google api returns something like:

{
    "items": [
        {/* book 1 */},
        {/* book 2 */ },
        //...
    ]
}

Therefore, we would pass the following function as toArray:

json -> json.getJsonArray("items")

Using our fetchers

We can now assign our fetchers to operations in runtime wiring builder in GraphQLHandlerFactory:

RuntimeWiring runtimeWiring = newRuntimeWiring()
        .type("QueryType", builder -> builder
            .dataFetcher("books", new TaskArrayDataFetcher<>("get-books", Book.class, vertx, config, routingContext, json -> json.getJsonArray("items")))
            .dataFetcher("book", new TaskSingleDataFetcher<>("get-book", Book.class, vertx, config, routingContext))
        )
        .build();

We just assign operation books to a TaskArrayDataFetcher that uses task get-books from configuration and returns an array of Book objects. Also, we assign operation book to a TaskSingleDataFetcher that uses task get-book and returns a Book.

Let's set it up in Knot.x configuration. It's best to extract our GraphQL configuration to a separate file. The GraphQL operation configuration in operations.conf should now look like this:

{
    operationId = books-gql-operation
    handlers = ${config.server.handlers.common.request} [
        {
            name = graphqlHandler
            config = {include required(classpath("routes/handlers/graphqlHandler.conf"))}
        }
    ]
}

Now we can define GraphQL configuration in a new file, routes/handlers/graphqlHandler.conf:

schema = "books.graphqls"

tasks {
  get-books {
    action = getBooks
  }

  get-book {
    action = getBook
  }
}

actions {
  getBooks {
    factory = http
    config {
      endpointOptions {
        path = "/books/v1/volumes?q={config.gql.match}"
        domain = www.googleapis.com
        port = 443
        allowedRequestHeaders = ["Content-Type"]
      }
      webClientOptions {
        ssl = true
      }
    }
  }

  getBook {
    factory = http
    config {
      endpointOptions {
        path = "/books/v1/volumes/{config.gql.id}"
        domain = www.googleapis.com
        port = 443
        allowedRequestHeaders = ["Content-Type"]
      }
      webClientOptions {
        ssl = true
      }
    }
  }

} 

We simply define two tasks (one per each GraphQL operation, we reference their names in fetcher constructors) each with one HTTP action. Those actions call appropriate endpoints:

  • /books/v1/volumes?q={config.gql.match} to get books by a given keyword
  • /books/v1/volumes/{config.gql.id} to get a single book with a given id

Note the {config.gql.xxx} parts. At runtime its swapped for values from the fragment configuration (remember, we put environment arguments in fragment's config under "gql" key). For example, if we request data like this:

{
    book(id: "cool_id") {
        title
    }
}

Then the http action will swap {config.gql.id} for "cool_id" and call /books/v1/volumes/cool_id.

Exposing the data under the right payload key

There is just one last thing we need to do. Every http action saves retrieved data in payload under the action's name and a "_result" subkey. getBook action will save the data under "getBook._result" key and getBooks action will save the data under "getBooks._result" key. Remember that our fetcher assumes the data is under "fetchedData" key. Therefore, we need an action that will transfer data in payload from one key to the other. We'll call it "expose-payload-data".

Let's create a simple action factory:

public class ExposePayloadActionFactory implements ActionFactory {
    @Override
    public String getName() {
        return "expose-payload-data";
    }

    @Override
    public Action create(String alias, JsonObject config, Vertx vertx, Action doAction) {
        String key = config.getString("key");
        String exposeAs = config.getString("exposeAs");

        return (fragmentContext, resultHandler) ->
            Single.just(fragmentContext.getFragment())
                .map(fragment -> {
                    JsonObject exposedData = fragment.getPayload().getJsonObject(key).getJsonObject("_result");
                    fragment.appendPayload(exposeAs, exposedData);
                    return new FragmentResult(fragment, FragmentResult.SUCCESS_TRANSITION)
                })
                .subscribe(onSuccess -> {
                    Future<FragmentResult> resultFuture = Future.succeededFuture(onSuccess);
                    resultFuture.setHandler(resultHandler);
                }, onError -> {
                    Future<FragmentResult> resultFuture = Future.failedFuture(onError);
                    resultFuture.setHandler(resultHandler);
                });
    }
}

The action simply reads data from key key and puts in the exposeAs key. Remember about updating services in META-INF.

Now we can update our configured tasks to call expose-payload-data action after succesfully finishing http action. In case of get-book task we will expose getBook as fetchedData and in case of get-books task we will expose getBooks as fetchedData.

Add two new actions to graphqlHandler.conf:

exposeInPayload-getBooks {
    factory = expose-payload-data
    config {
        key = getBooks
        exposeAs = fetchedData
    }
}

exposeInPayload-getBook {
    factory = expose-payload-data
    config {
        key = getBook
        exposeAs = fetchedData
    }
}

And then setup transitions in our tasks. They should now look like this:

tasks {
  get-books {
    action = getBooks
    onTransitions {
      _success {
        action = exposeInPayload-getBooks
      }
    }
  }

  get-book {
    action = getBook
    onTransitions {
      _success {
        action = exposeInPayload-getBook
      }
    }
  }
}

We're done

That's it. You can now run the application. Easiest way of seeing it in action is using postman, but you can use curl or anything else you want.

Let's construct a sample query:

{
    book(id: "q5NoDwAAQBAJ") {
        title
        authors
    }
    books(match: "java") {
        publisher
    }
}

We're asking for the title and authors of the book with id "q5NoDwAAQBAJ" and a list of books about java with only their publishers listed. We'll send our request to http://localhost:8092/api/graphql

If you're using postman you can just choose predefined GraphQL body type and paste the query there. If you prefer curl:

curl -i -H 'Content-Type: application/json' -X POST -d '{"query": "{book(id: \"q5NoDwAAQBAJ\") {title authors} books(match: \"java\") {publisher}}"}' http://localhost:8092/api/graphql

You should get the following response:

{
    "data": {
        "book": {
            "title": "Learning GraphQL",
            "authors": [
                "Eve Porcello",
                "Alex Banks"
            ]
        },
        "books": [
            {
                "publisher": "Helion"
            },
            {
                "publisher": "Helion"
            },
            {
                "publisher": "Helion"
            },
            {
                "publisher": "\"O'Reilly Media, Inc.\""
            },
            {
                "publisher": "Morgan Kaufmann"
            },
            //...
        ]
    }
}

Few notes at the end

As you can see Knot.x is highly customizable. Data fetchers that we implemented are quite generic. You can use them to easily create more advanced GraphQL APIs without the need for writing more fetchers. For clarity purposes the code in this tutorial doesn't have advanced error handling and is quite error-prone. It's left as an exercise for you to implement better error handling.

Also, you might have noted that our fromJson method in GraphQLDataObject interface has a DataFetchingEnvironment argument that we didn't use. This is because our example is very simple, but the argument is included in code to show the flexibility of our solution. You can imagine a more complex case where we need to parse JSON to model differently, depending on query parameters. This is where we could use our DataFetchingEnvironment argument.

Summary of what we actually did

GraphQL and Knot.x represent two seperate layers in our application. GraphQL takes care of gathering the data and outputing it in the desired form. What's important is that it's data-source agnostic. We delegate the responsibility of providing the data to Knot.x. With it comes the idea of Configurable Integrations. We can focus on writing business logic, while error handling is defined in the configuration and therefore can be easily changed. Advanced error-handling systems (e.g. fallbacks, partial failures, timeouts, circuit breakers and other stability patterns) can be quickly set up without any custom logic.

Hopefully, you now have a better understanding of Knot.x, Configurable Integrations and GraphQL.

You can find the complete code of this tutorial in our github repository.