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 rootGraphQLResult
- Entities which can contain
PostCollection
andPost
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 aListView
- We're hooking into the
onItemClickListener
of theListView
in order to get thePost
that has been selected and navigate to theSecondFragment
- We're making use of
lifecycleScope.launchWhenStarted { }
in order to call out to ourPostRepository
, whichonSuccess
, will collect all thePost
's from the collection, and add them to theArrayAdapter
.
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.