Skip to content

Overview of generated GraphQL DSL

dermakov edited this page Jun 21, 2023 · 17 revisions

Let's take a look at a DSL generated from a simple GraphQL schema (file example.graphqls):

type Query {
    filmCount: Int!
}

Context

By our schema file (example.graphqls) Kobby will generate Kotlin file - example.kt:

fun exampleContextOf(adapter: ExampleAdapter): ExampleContext = ExampleContextImpl(adapter)

interface ExampleContext {
    suspend fun query(__projection: QueryProjection.() -> Unit): Query
    suspend fun mutation(__projection: MutationProjection.() -> Unit): Mutation
    fun subscription(__projection: SubscriptionProjection.() -> Unit): ExampleSubscriber<Subscription>
}

// ... skipped

The ExampleContext interface is an entry point to generated client DSL (more about entry point configuration see here). It contains three functions - query , mutation and subscription - which provide the ability to perform the corresponding GraphQL operations. Our schema example.graphqls only defines a Query type, so the generated mutations and subscriptions are dummy. But the query function allows us to create and execute real GraphQL queries according to our schema. Let's try to execute a simple query and get a response.

Projection

First, we have to build our GraphQL query:

query {
    filmCount
}

The query function argument __projection is responsible for building the query. It has a Kotlin lambda type with QueryProjection receiver:

suspend fun query(__projection: QueryProjection.() -> Unit): Query

The QueryProjection is an interface, defined in entity/Query.kt file:

@ExampleDSL
interface QueryProjection {
    fun filmCount(): Unit
}

This "projection" interface allows us to write a query with syntax very similar to GraphQL's native syntax:

fun main() = runBlocking {
    val context: ExampleContext = exampleContextOf(createMyAdapter())
    val response: Query = context.query {
        filmCount()
    }
}

fun createMyAdapter(): ExampleAdapter =
    TODO("Let's look at adapters later")

We have used the exampleContextOf function, defined in example.kt file, to instantiate the ExampleContext interface. And then we have called the query function to build the GraphQL query and get the response to the query.

Entity

The response to our query is JSON, that looks like:

{
  "data": {
    "filmCount": 25
  }
}

To represent the response, Kobby generates an "entity" interface, that holds the response data. For our GraphQL Query type, defined in the schema, the corresponding "entity" interface is the Query interface defined in entity/Query.kt file (just before the QueryProjection interface):

interface Query {
    fun __context(): ExampleContext

    val filmCount: Int
}

The Query interface has the filmCount property that contains the value of the filmCount attribute in our JSON response:

val response: Query = context.query {
    filmCount()
}
println("Film count: ${response.filmCount}")

Note, that the Query interface contains the __context() function that returns instance of the ExampleContext interface. So, every "entity" interface that Kobby generates contains an entry point for new GraphQL queries, mutations, and subscriptions. This enables us to use Kotlin extension functions for smart customization of the generated DSL.

Adapter

In the example above, we need an adapter instance to create the context. Let's take a closer look at this topic. Adapter interface - ExampleAdapter - is defined in example.kt file:

interface ExampleAdapter {
    suspend fun executeQuery(query: String, variables: Map<String, Any?>): QueryDto
    suspend fun executeMutation(query: String, variables: Map<String, Any?>): MutationDto
    suspend fun executeSubscription(
        query: String,
        variables: Map<String, Any?>,
        block: suspend ExampleReceiver<SubscriptionDto>.() -> Unit
    ): Unit
}

As you can see, adapter contains three functions - executeQuery, executeMutation and executeSubscription, which correspond to three main GraphQL operations - query, mutation and subscription. As you remember, mutations and subscriptions are dummy operations in our example, so we will only consider the query operation:

suspend fun executeQuery(query: String, variables: Map<String, Any?>): QueryDto

The ExampleContext, generated by Kobby, knows nothing about the transport layer and GraphQL communication protocol. The context implementation just build query string and variables map, and pass it to executeQuery function of the adapter. And the adapter has to do all the dirty work - send query and variables to the server side, and receive the response.

By default, Kobby does not generate any adapter implementations. There are many libraries that can be used to communicate between client and server. Kobby doesn't want to get attached to any of them. But to lower the entry threshold, Kobby is able to generate adapter implementations for the Ktor library. To ask Kobby to generate the Ktor adapter implementation, just add io.ktor:ktor-client-cio dependency to you project.

DTO

As you can see, the adapter is returning QueryDto object from executeQuery function. What's this?

GraphQL server replies to a query in JSON format. The ExampleContext, generated by Kobby, cannot parse JSON. To extract data from the server reply, Kobby generates data transfer objects (DTO) for all GraphQL types, defined in the schema. Adapter should deserialize JSON into these objects.

For type Query, defined in our GraphQL schema, Kobby generates QueryDto class defined in dto/QueryDto.kt file:

data class QueryDto(
    val filmCount: Int? = null
)

As you can see, the filmCount property is of Int type. Kobby uses configurable scalar mapping to map Kotlin data types to GraphQL scalars. Note that the implementation of the Query "entity" interface is just a wrapper over QueryDto object returned by the adapter.

To help the adapter deserialize JSON into a DTO objects, Kobby supports Jackson annotation generation. To switch on Jackson support, just add com.fasterxml.jackson.core:jackson-annotations dependency to you project, and Kobby will generate appropriate annotations for DTO classes:

@JsonTypeName(value = "Query")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = QueryDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class QueryDto @JsonCreator constructor(
    val filmCount: Int? = null
)

Kobby also supports Kotlinx Serialization since release 3.0.0.

Complex graph query

GraphQL is commonly used to query complex graphs of related objects. Let's complicate our GraphQL schema:

type Query {
    films(offset: Int!, limit: Int!): [Film!]!
}

type Film {
    title: String!
    actors(offset: Int!, limit: Int!): [Actor!]!
}

type Actor {
    firstName: String!
    lastName: String
}

By this complex schema, Kobby will generate a graphs of projections, entities and data transfer objects.

Projection graph

@ExampleDSL
interface QueryProjection {
    fun films(offset: Int, limit: Int, __projection: FilmProjection.() -> Unit): Unit
}

@ExampleDSL
interface FilmProjection {
    fun title(): Unit
    fun actors(offset: Int, limit: Int, __projection: ActorProjection.() -> Unit): Unit
}

@ExampleDSL
interface ActorProjection {
    fun firstName(): Unit
    fun lastName(): Unit
}

With the help of such a projection graph, we can build complex queries.

GraphQL query:

query {
    films(offset: 0, limit: 100) {
        title
        actors(offset: 0, limit: 100) {
            firstName
            lastName
        }
    }
}

Kotlin query:

fun main() = runBlocking {
    val context: ExampleContext = exampleContextOf(createMyAdapter())
    val response: Query = context.query {
        films(offset = 0, limit = 100) {
            title()
            actors(offset = 0, limit = 100) {
                firstName()
                lastName()
            }
        }
    }
}

Entity graph

interface Query {
    fun __context(): ExampleContext

    val films: List<Film>
}

interface Film {
    fun __context(): ExampleContext

    val title: String
    val actors: List<Actor>
}

interface Actor {
    fun __context(): ExampleContext

    val firstName: String
    val lastName: String?
}

Such an entity graph allows us to work with a complex query result:

val response: Query = context.query {
    films(offset = 0, limit = 100) {
        title()
        actors(offset = 0, limit = 100) {
            firstName()
            lastName()
        }
    }
}

response.films.forEach { film: Film ->
    println()
    println(film.title)
    println("Actors:")
    film.actors.forEach { actor: Actor ->
        println("  ${actor.firstName} ${actor.lastName}")
    }
}

DTO graph

Jackson's annotations skipped

data class QueryDto(
    val films: List<FilmDto>? = null
)

data class FilmDto(
    val title: String? = null,
    val actors: List<ActorDto>? = null
)

data class ActorDto(
    val firstName: String? = null,
    val lastName: String? = null
)

Such an DTO graph helps us to deserialize a complex JSON result.