Generating a Kotlin client using the GraphQL scheme

Remember, if you don’t give up REST, you will go broke very soon … The word “Kotlin” and the word “GraphQL” mean the same thing to you!

On the one hand, the GraphQL schema uniquely defines the data model and the available operations of the service that implements it. On the other hand, Kotlin provides amazing opportunities for creating domain-specific languages ​​(DSLs). Thus, it is possible to write a domain-specific language to interact with a GraphQL service according to the published schema. But writing such a code by hand is a Sisyphean labor. Better to just generate it. And this will help us Kobby plugin… It parses the GraphQL schema and generates a client-side DSL. Let’s try it out!

What will we get in the end?

GraphQL:

query {
  film(id: 0) {
    id
    title
    actors {
      id
      firstName
      lastName
    }
  }
}

Kotlin:

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

GraphQL:

mutation {
  createFilm(title: "My Film") {
    id
    title
  }
}

Kotlin:

val result = context.mutation {
    createFilm(title = "My Film") {
        id()
        title()
    }
}

GraphQL:

subscription {
  filmCreated {
    id
    title
  }
}

Kotlin:

launch(Dispatchers.Default) {
    context.subscription {
        filmCreated {
            id()
            title()
        }
    }.subscribe {
        while (true) {
            val result = receive()
        }
    }
}

The source code of all examples is available on GitHub in projects Kobby Gradle Tutorial and Kobby Maven Tutorial


Plugin config

Let’s start with a diagram of our service. Default Kobby is looking for a GraphQL schema in files with extension graphqls in the project resources. For simplicity, we will place our circuit in one file. cinema.graphqls:

type Query {
    film(id: ID!): Film
    films: [Film!]!
}

type Mutation {
    createFilm(title: String!): Film!
}

type Subscription {
    filmCreated: Film!
}

type Film {
    id: ID!
    title: String!
    actors: [Actor!]!
}

type Actor {
    id: ID!
    firstName: String!
    lastName: String
}

This simple schema will allow us to try out all kinds of GraphQL operations – queries, mutations, and subscriptions.

Next, we need to configure the plugin itself. For Gradle it’s simple:

plugins {
    kotlin("jvm")
    id("io.github.ermadmi78.kobby") version "1.3.0"
}

dependencies {
    // Add this dependency to enable 
    // Jackson annotation generation in DTO classes
    compileOnly("com.fasterxml.jackson.core:jackson-annotations:2.12.2")

    // Add this dependency to enable 
    // default Ktor adapters generation
    compileOnly("io.ktor:ktor-client-cio:1.5.4")
}

Plugin configuration for Maven not so elegant:

<project>
    <build>
        <plugins>
            <plugin>
                <groupId>io.github.ermadmi78</groupId>
                <artifactId>kobby-maven-plugin</artifactId>
                <version>${kobby.version}</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate-kotlin</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <!--Add this dependency to enable-->
        <!--Jackson annotation generation in DTO classes-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>${jackson.version}</version>
            <scope>compile</scope>
        </dependency>

        <!--Add this dependency to enable-->
        <!--default Ktor adapters generation-->
        <dependency>
            <groupId>io.ktor</groupId>
            <artifactId>ktor-client-cio-jvm</artifactId>
            <version>${ktor.version}</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

Kobby supports two ways to configure a plugin – explicit configuration in code and implicitly based on conventions. We took advantage of a convention-based configuration by adding library dependencies to the project Jackson and Ktor… The fact is that in the process of building a project, Kobby analyzes its dependencies. And, if it finds a dependency on Jackson, then it generates Jackson annotations for DTO classes to simplify their deserialization from JSON. And if the plugin finds a dependency on Ktor, then it generates a default DSL adapter. We’ll talk about adapters in the next section.


Create DSL context

We have configured our plugin. Execute the command gradle build for Gradle or mvn compile for Maven and the plugin will find the file cinema.graphqls and will create a DSL based on it:

Plugin created file cinema.kt with function cinemaContextOfwhich allows you to instantiate the interface CinemaContext… This interface is the entry point for our DSL:

fun cinemaContextOf(adapter: CinemaAdapter): CinemaContext =
    CinemaContextImpl(adapter)

As an argument, the function cinemaContextOf accepts a reference to the adapter – CinemaAdapter… What is an adapter? The fact is that the context we created does not know anything about the transport layer and about the GraphQL interaction protocol. It simply collects the query string and passes it to the adapter. And the adapter, in turn, has to do all the dirty work – send the request to the server, receive and deserialize the response. You can write your own implementation of the adapter, or you can use the default adapter generated by the plugin.

We’ll use the default adapter. He uses Ktor to interact with the server. GraphQL queries and mutations are done over HTTP, and subscription sessions are established over WebSocket:

fun createKtorAdapter(): CinemaAdapter {
    // Create Ktor http client
    val client = HttpClient {
        install(WebSockets)
    }

    // Create Jackson object mapper
    val mapper = jacksonObjectMapper().registerModule(
        ParameterNamesModule(JsonCreator.Mode.PROPERTIES)
    )

    // Create default implementation of CinemaAdapter
    return CinemaCompositeKtorAdapter(
        client = client,
        httpUrl = "http://localhost:8080/graphql",
        webSocketUrl = "ws://localhost:8080/subscriptions",
        mapper = object : CinemaMapper {
            override fun serialize(value: Any): String =
                mapper.writeValueAsString(value)

            override fun <T : Any> deserialize(
                content: String,
                contentType: KClass<T>
            ): T = mapper.readValue(content, contentType.java)
        }
    )
}

Executing requests

We are ready to fulfill our first request. Let’s try to find a movie with actors by its ID. In GraphQL, this query looks like this:

query {
    film(id: 0) {
        id
        title
        actors {
            id
            firstName
            lastName
        }
    }
}

For Kotlin, our request looks almost exactly the same:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

Function context.query declared with modifier suspendso it does not block the current thread. And what do we get as the result of the query? In GraphQL, the result is JSON, which looks like this:

{
  "data": {
    "film": {
      "id": "0",
      "title": "Amelie",
      "actors": [
        {
          "id": "0",
          "firstName": "Audrey",
          "lastName": "Tautou"
        },
        {
          "id": "1",
          "firstName": "Mathieu",
          "lastName": "Kassovitz"
        }
      ]
    }
  }
}

To navigate the query results, the plugin generates “entity” interfaces based on GraphQL types from the schema:

interface Query {
    val film: Film?
    val films: List<Film>
}

interface Mutation {
    val createFilm: Film
}

interface Subscription {
    val filmCreated: Film
}

interface Film {
    val id: Long
    val title: String
    val actors: List<Actor>
}

interface Actor {
    val id: Long
    val firstName: String
    val lastName: String?
}

Function context.query returns an instance of an entity Query, so navigation through the result looks like this:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

result.film?.also { film ->
    println(film.title)
    film.actors.forEach { actor ->
        println("  ${actor.firstName} ${actor.lastName}")
    }
}

Performing mutations

Let’s create a new movie. The GraphQL mutation for making a movie looks like this:

mutation {
    createFilm(title: "My Film") {
        id
        title
    }
}

And, as a result, we get the following JSON:

{
  "data": {
    "createFilm": {
      "id": "4",
      "title": "My Film"
    }
  }
}

I think you have already guessed how our mutation will look like in Kotlin:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.mutation {
    createFilm(title = "My Film") {
        id()
        title()
    }
}

result.createFilm.also { film ->
    println(film.title)
}

Function context.mutation returns an instance of an entity Mutation, and, like the function context.query, declared with the modifier suspend… Thus, the current thread does not block our mutation.


Create subscriptions

Let’s sign up for new movie notifications in GraphQL:

subscription {
    filmCreated {
        id
        title
    }
}

For this subscription, we will receive notifications in JSON format:

{
  "data": {
    "filmCreated": {
      "id": "4",
      "title": "My Film"
    }
  }
}

The semantics of the subscription operation in Kotlin is different from the semantics of the request and mutation operations. Unlike functions context.query and context.mutationwho simply send a request and receive a response, the subscription creates a long-running session to listen to incoming messages. We need an asynchronous listener:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

launch(Dispatchers.Default) {
    context.subscription {
        filmCreated {
            id()
            title()
        }
    }.subscribe {
        while (true) {
            val result = receive()
            result.filmCreated.also { film ->
                println(film.title)
            }
        }
    }
}

Don’t worry, we won’t block the current thread in an infinite loop, as the function subscribe and function receivedeclared with modifier suspend

The lifetime of the subscription session is the same as the execution time of the function subscribe… When we enter a function, a session is created, and when we exit it, the session is destroyed.

Function receive returns an instance of an entity Subscription for each incoming message.


What have I not covered in this article?

And, most importantly, I did not talk about how using a file and Kotlin extension functions turn generated DSL into rich domain model on steroids. Perhaps I will cover this in future articles.

Similar Posts

Leave a Reply