Generating a Kotlin client using the GraphQL scheme
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 cinemaContextOf
which 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 suspend
so 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.mutation
who 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 receive
declared 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.