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.

Get Started

Deploy your Kotlin backend in minutes with Grafbase.