Working cross-platform with Kotlin, GraphQL, and Grafbase

Working cross-platform with Kotlin, GraphQL, and Grafbase

The Kotlin Programming Language is a cross-platform layer for building native applications for the web, mobile and desktop. Kotlin prides itself on developer happiness which makes it the perfect fit when building a backend with Grafbase.

In this guide we'll explore adding a GraphQL backend (Grafbase) to an existing Kotlin application using kotr and kotlinx.serialization to serialize objects.

Let's begin by creating a GraphQL backend using the Grafbase CLI.

In the root of your existing Swift application run the following:

npx grafbase init --template https://github.com/grafbase/grafbase/tree/main/examples/kotlin

Then open the generated grafbase/schema.graphql file. You should see the following schema that defines your backend:

type Post @model {
  title: String!
  body: String!
  comments: [Comment]
}

type Comment @model {
  message: String!
  post: Post
}

We can use the Grafbase CLI to run the GraphQL backend locally:

npx grafbase dev

You should see a success message that your GraphQL Playground and API is running at http://localhost:4000.

In this guide we'll only explore querying data from our backend, so you will need to populate the backend using a GraphQL mutation inside the playground.

mutation {
  postCreate(
    input: {
      title: "Kotlin + GraphQL!"
      body: "Hello from Grafbase."
      comments: [
        { create: { message: "GraphQL is awesome!" }
        { create: { message: "Another comment from Grafbase" } }
      ]
    }
  ) {
    post {
      id
    }
  }
}

We're going to make things a little easier from a developer experience point of view. We'll use the libraries kotr and kotlinx to make things easier.

Make sure to add the following dependencies to using gradle:

implementation 'io.ktor:ktor-client-android:2.1.3'
implementation 'io.ktor:ktor-client-serialization:2.1.3'
implementation 'io.ktor:ktor-client-logging-jvm:2.1.3'
implementation 'io.ktor:ktor-client-content-negotiation:2.1.3'
implementation 'io.ktor:ktor-serialization-kotlinx-json:2.1.3'

Also make sure to add the following plugins:

id 'kotlinx-serialization'
id 'kotlin-parcelize'

Finish by updating build.gradle to include the following:

id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.20'

We'll need to do some work before we can request data from our Grafbase backend. Let's begin by creating the API layer itself, a Repositories layer to interact with it, and some Entities to store our information.

package com.example.grafbaseandroid.API

import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.json.Json

private const val TIME_OUT = 60_000

val ktorHttpClient = HttpClient(Android) {
  install(ContentNegotiation) {
    json(Json {
      prettyPrint = true
      isLenient = true
      ignoreUnknownKeys = true
    })

    engine {
      connectTimeout = TIME_OUT
      socketTimeout = TIME_OUT
    }
  }

  install(Logging) {
    logger = object : Logger {
      override fun log(message: String) {
        Log.v("Logger Ktor =>", message)
      }
    }

    level = LogLevel.ALL
  }

  defaultRequest {
    header(HttpHeaders.ContentType, ContentType.Application.Json)
    url {
      protocol = URLProtocol.HTTP
      host = "10.0.2.2"
      port = 4000
      path("graphql")
    }
  }
}

Here we've set up our ktor client with some basic defaults. We use JSON for content negotiation, as well as set up our defaultRequest with some standard headers and a base url.

Replace the host parameter with your server URL if you're not using localhost, and you can also add your x-api-key as a header if required here.

Now we're going to create a GraphQLOperation file in order to serialize our queries for use with our API:

package com.example.grafbaseandroid.API

import kotlinx.serialization.Serializable;
import kotlinx.serialization.SerialName

@Serializable
data class GraphQLOperation (
  @SerialName("query")
  val operationString: String
)

Finally, we'll add a basic query using our GraphQLOperation and our ktoHttpClient in a file called PostAPI:

package com.example.grafbaseandroid.API

import com.example.grafbaseandroid.Entities.GraphQLResult
import com.example.grafbaseandroid.Entities.Post
import com.example.grafbaseandroid.Entities.PostCollection
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*

class PostAPIImpl(
    private val client: HttpClient
) : PostAPI {
    override suspend fun getPosts(): GraphQLResult<PostCollection> {
      return client.post {
        setBody(GraphQLOperation("""
          {
            postCollection(first:10) {
              edges {
                node {
                  id
                  title
                  body
                  comments(first: 10) {
                    edges {
                      node {
                        id
                        message
                      }
                    }
                  }
                }
              }
            }
          }
      """.trimIndent()))
    }.body()
  }
}

interface PostAPI {
  suspend fun getPosts(): GraphQLResult<PostCollection>

  companion object {
    fun create(): PostAPI {
      return PostAPIImpl(
        ktorHttpClient
      )
    }
  }
}

This API interface and class helps us easily use the PostAPI in order to list posts, and return the data as GraphQLResult<PostCollection>. It'll allow us to call PostAPI.create().getPosts() in order to make the API call, and serialize that data back down into an Entity of GraphQLResult.

However, we don't yet have those entities, so let's now create them.

There are three main areas we need:

  • Entities to represent Edge's, Node's and our root GraphQLResult
  • Entities which can contain PostCollection and Post data
  • An entity which can contain Comment data.

Let's create a new module for Entities, and create a file within that module name GraphQLResult.

package com.example.grafbaseandroid.Entities

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName

@Serializable
@Parcelize
data class Edge<T: Parcelable> (
  @SerialName("edges")
  val edges: Array<Node<T>>
): Parcelable {
  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (javaClass != other?.javaClass) return false

    other as Edge<*>

    if (!edges.contentEquals(other.edges)) return false

    return true
  }

  override fun hashCode(): Int {
    return edges.contentHashCode()
  }
}

@Serializable
@Parcelize
data class Node<T: Parcelable> (
  @SerialName("node")
  val node: T,
): Parcelable

@Serializable
@Parcelize
data class GraphQLResult<T: Parcelable>(
  val data: T?,
) : Parcelable

Our system will make use of @Serializable in order to convert data between our entities and JSON.

We'll also mark our data classes with @Parcelize and Parcelable in order to make sure we can pass them between fragments later on.

Let's now create a Post file:

package com.example.grafbaseandroid.Entities

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class PostCollection (
  val postCollection: Edge<Post>,
): Parcelable

@Serializable
@Parcelize
data class Post(
    @SerialName("id")
    val id: String,
    @SerialName("title")
    val title: String,
    @SerialName("body")
    val body: String,
    @SerialName("comments")
    val comments: Edge<Comment>
): Parcelable {
  override fun toString(): String {
    return this.title;
  }
}

This file contains data classes for our PostCollection and Post. We're also overriding our Post's toString method to allow for simple use of ArrayAdapper down the line.

Let's also fill in our Comment file, as we have a reference to it but have not yet created it:

package com.example.grafbaseandroid.Entities

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class Comment(
  @SerialName("id")
  val id: String,
  @SerialName("message")
  val message: String,
): Parcelable {
  override fun toString(): String {
    return this.message;
  }
}

Finally, let's add a repositories layer which can make a request to the API and return us a PostCollection.

Let's create a new module named Repositories, and create a PostRepository class:

package com.example.grafbaseandroid.Repositories

import com.example.grafbaseandroid.API.PostAPI
import com.example.grafbaseandroid.Entities.PostCollection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class PostRepository {
  suspend fun fetchPosts(): Result<PostCollection> {
    return withContext(Dispatchers.IO) {
      try {
        val result = PostAPI.create().getPosts().data
        Result.success(result)
      } catch (exception: Exception) {
        Result.failure(exception)
      } as Result<PostCollection>
    }
  }
}

This repository is able to call our PostAPI on a background thread, taking the data it returns in order to return a Result of PostCollection.

This simplifies our views by allowing them not to worry about the internal mechanisms of how data is transformed from our API back down to our views.

With all this in place, we can now start working with our views in the form of Fragment's.

The basic application template created us two Fragment's, and their associated layouts.

We're going to modify these fragments in order to pull our data down and display it.

In the FirstFragment file, replace the content with:

package com.example.grafbaseandroid

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStarted
import androidx.navigation.fragment.findNavController
import com.example.grafbaseandroid.Entities.Post
import com.example.grafbaseandroid.Repositories.PostRepository
import com.example.grafbaseandroid.databinding.FragmentFirstBinding
import kotlinx.coroutines.launch

/**
 * A simple [Fragment] subclass as the default destination in the navigation.
 */
class FirstFragment : Fragment() {
  private var _binding: FragmentFirstBinding? = null

  // This property is only valid between onCreateView and
  // onDestroyView.
  private val binding get() = _binding!!

  private var posts: List<Post> = mutableListOf()

  override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View? {
    _binding = FragmentFirstBinding.inflate(inflater, container, false)
    return binding.root
  }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val arrayAdapter: ArrayAdapter<Post>
    val context = context as MainActivity

    var postsListView: ListView = binding.postsList
    arrayAdapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, this.posts);
    postsListView.adapter = arrayAdapter

    postsListView.onItemClickListener = AdapterView.OnItemClickListener {
      parent, view, position, id ->
      val bundle = Bundle()
      val post = this.posts.get(position)
      bundle.putParcelable("post", post)
      findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment, bundle);
    }

    lifecycleScope.launchWhenStarted {
      PostRepository().fetchPosts().onSuccess { result ->
        val posts = result.postCollection.edges.map { edge -> edge.node }
        arrayAdapter.clear()
        arrayAdapter.addAll(posts)
        arrayAdapter.notifyDataSetChanged()
      }.onFailure { exception ->
        exception.printStackTrace()
      }
    }
  }

  override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
  }
}

The onViewCreated here has been modified in order to do several things:

  • We've created an ArrayAdapter which can use a simple layout in order to display some information in a ListView
  • We're hooking into the onItemClickListener of the ListView in order to get the Post that has been selected and navigate to the SecondFragment
  • We're making use of lifecycleScope.launchWhenStarted { } in order to call out to our PostRepository, which onSuccess, will collect all the Post's from the collection, and add them to the ArrayAdapter.

We'll also need to update our fragment_first.xml to complement this new code:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".FirstFragment">

  <ListView
    android:id="@+id/posts_list"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

With this, Post's should be showing in your fragment in a basic list view.

Next, let's add the details of the Post in the SecondFragment.

In the SecondFragment file, replace the content of that file with:

package com.example.grafbaseandroid

import android.R
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import com.example.grafbaseandroid.Entities.Comment
import com.example.grafbaseandroid.Entities.Post
import com.example.grafbaseandroid.databinding.FragmentSecondBinding

/**
 * A simple [Fragment] subclass as the second destination in the navigation.
 */
class SecondFragment : Fragment() {
  private var _binding: FragmentSecondBinding? = null

  // This property is only valid between onCreateView and
  // onDestroyView.
  private val binding get() = _binding!!

  override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View {
    _binding = FragmentSecondBinding.inflate(inflater, container, false)
    return binding.root
  }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val arrayAdapter: ArrayAdapter<Comment>
    val context = context as MainActivity

    var commentsListView: ListView = binding.comments
    arrayAdapter = ArrayAdapter(context, R.layout.simple_list_item_1, mutableListOf());
    commentsListView.adapter = arrayAdapter

    arguments?.getParcelable<Post>("post").let { post ->
      binding.title.text = post?.title
      binding.content.text = post?.body

      val comments = post?.comments?.edges?.map { edge -> edge.node }.orEmpty()
      if (comments.isEmpty()) {
        binding.commentsTitle.visibility = View.INVISIBLE;
        binding.comments.visibility = View.INVISIBLE;
      } else {
        arrayAdapter.clear()
        arrayAdapter.addAll(comments)
        arrayAdapter.notifyDataSetChanged();
      }
    }
  }

  override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
  }
}

This new code does some simple view manipulation:

  • It gets the Parcelable Post which has been passed through the bundle arguments to the fragment
  • It creates an ArrayAdapter in order to display comments if they're available
  • It sets the text content of several binded text views to display the Post data.

We'll need to tweak the second_fragment.xml to complement this:

<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".SecondFragment">

  <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
      android:id="@+id/title"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      style="@style/TextAppearance.AppCompat.Title"
      android:padding="10dp" />

    <TextView
      android:id="@+id/content"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:padding="10dp" />

    <TextView
      android:id="@+id/comments_title"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:text="@string/comments_title"
      android:padding="10dp"
      style="@style/TextAppearance.AppCompat.Title" />

    <ListView
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:id="@+id/comments" />
  </LinearLayout>
</androidx.appcompat.widget.LinearLayoutCompat>

We use @string formats in the android:text directives here, so we'll need to also add those to the strings.xml file.

<resources>
  <string name="app_name">Grafbase Android</string>
  <string name="first_fragment_label">Posts</string>
  <string name="second_fragment_label">Post Detail</string>
  <string name="comments_title">Comments</string>
</resources>

That's it! We now have a basic Kotlin application which can pull the content from a GraphQL backend and show it in the UI.

Bg

Get Started

Deploy your Kotlin backend in minutes with Grafbase.