Complete Guide to Becoming a Fullstack Kotlin Developer - Mobile and Web HTTP Client with KTOR
In this blog post, you will gain a comprehensive understanding of how to create a unified HTTP client for Android, iOS, and web applications within a Compose Multiplatform project. We will be utilizing the powerful KTOR library as our client. This guide will walk you through the entire process, from setting up your project to implementing the HTTP client, and will include detailed code examples and explanations to ensure you can follow along easily. By the end of this post, you will have the knowledge and skills to efficiently manage network requests across different platforms using a single codebase.
The complete project is avaiable on GitHub
Initial setup
In the previous post, I covered common backend in which we decided that some objects can be shared across the app. Shared repository is a contract between a server and a client. We know exactly what methods are available and what data are served. Common objects also can save us a bit of time, there is no need to map them from one to another. It is time to use those objects in the frontend apps. First, we need to add the dependencies to the previously created modules in our shared module.
shared/build.gradle.kts
sourceSets {
commonMain.dependencies {
implementation(projects.domain)
implementation(projects.repository)
}
}
Now we can start from we end of the last blog post by creating the implementation of the GamesRepository
as the HttpGamesRepository
in the common frontends code.
HttpGamesRepository.kt
internal class HttpGamesRepository(private val client: HttpClient) : GamesRepository {
override suspend fun getGames(): List<Game> {
return client.get(“/games”).body()
}
}
If we take a good look at the previous backend code, it is almost the same as the RealDatabaseRepository
. The only difference is that we need an HttpClient for the apps.
The most common multiplatform client is KTOR. It can be easily built into the KMM project without any additional code for each platform. It only needs a separate engine that can be provided via the dependencies. Following the documentation, we can create something like this.
Please note that I am using the latest KTOR version with support for WASM, which is “3.0.0-wasm2”.
libs.versions.toml
[versions]
ktor-wasm = "3.0.0-wasm2"
[libraries]
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-wasm" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-wasm" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-wasm" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-wasm" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor-wasm" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-wasm" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-wasm" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor-wasm" }
shared/build.gradle.kts
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.serialization)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
val wasmJsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
}
With the core dependencies added to the commonMain and the proper engines for the platforms, we can implement the client. The client can take the desired engine as a parameter to the HttpClient
function, but if we leave it blank, the plugin will try to automatically provide a valid client based on the added dependencies – so for Android, it will be okHttp, and so on.
fun create(): HttpClient {
return HttpClient {
install(ContentNegotiation) {
json()
}
install(DefaultRequest) {
url("http://localhost:3000")
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
}
install(Auth) {
bearer {
loadTokens {
TODO()
}
refreshTokens {
TODO()
}
sendWithoutRequest { request ->
when (request.url.pathSegments.last()) {
"login" -> false
else -> true
}
}
}
}
}
}
ContentNegotiation is a crucial component that handles the serialization and deserialization of requests and responses, ensuring that data is correctly formatted when being sent to and received from the server. This is especially important when dealing with JSON data, as it allows for seamless conversion between Kotlin objects and JSON strings.
With the DefaultRequest plugin, we can set up a default configuration for all outgoing requests. This includes specifying the base URL, which serves as the starting point for all relative URLs used in the client. Additionally, we can define common headers such as Content-Type
and Accept
, which inform the server about the type of data being sent and expected in return. This setup ensures that every request adheres to a consistent format, reducing the need for repetitive code.
The Auth plugin, combined with the bearer lambda, is responsible for managing authentication tokens. This plugin handles the acquisition and refreshing of tokens, which are essential for maintaining secure communication with the server. Within the bearer lambda, we define how to load the initial tokens and how to refresh them when they expire. This involves specifying functions that will be called to retrieve new tokens, ensuring that the client always has valid credentials.
Moreover, we can configure the Auth plugin to automatically add tokens to certain requests. The sendWithoutRequest
function allows us to specify which endpoints should bypass token addition. For instance, in our setup, the /login
endpoint is publicly accessible and does not require tokens. By returning false
for this endpoint, we ensure that the client does not attempt to add tokens or handle 401 Unauthorized responses for login requests.
In scenarios where the server responds with an HTTP 401 Unauthorized status, the client is designed to automatically attempt to refresh the token. If successful, the client will retry the original request with the new token, thereby maintaining a seamless user experience without requiring manual intervention.
In our specific case, the /login
endpoint is the only publicly available endpoint. This endpoint is responsible for providing a valid access token and user data upon successful authentication.
Handling Authentication
The lambda requires a BearerTokens
object, which consists of two string values accessToken
and refreshToken
. If we want to be flexible, we need to store a token after every successful login and add it to every subsequent requests.
internal interface TokenStorage {
fun putTokens(
accessToken: String,
refreshToken: String,
)
fun getToken(): BearerTokens
}
The implementation is as simple as it can be the values are stored in the mutableList
.
internal class RealTokenStorage : TokenStorage {
private val tokens = mutableSetOf<BearerTokens>()
override fun putTokens(
accessToken: String,
refreshToken: String,
) {
tokens.add(BearerTokens(accessToken, refreshToken))
}
override fun getToken(): BearerTokens {
return tokens.last()
}
}
The updated HttpClient
looks like this:
internal class HttpClientFactory(
private val tokenStorage: TokenStorage,
) {
fun create(): HttpClient {
return HttpClient {
...
install(Auth) {
bearer {
loadTokens {
tokenStorage.getToken()
}
refreshTokens {
TODO(“Not implemented uet”)
}
...
}
}
}
}
}
Please notice that we don’t implement the refresh token mechanism as this is not important for the matter of the post.
At the beginning, let's delve into the implementation details of the HttpGamesRepository
. This repository is responsible for fetching game data from a remote server. However, before we can retrieve any game information, we need to authenticate the user and obtain a valid token. To handle this, I have introduced a LoginRepository
along with its implementation.
interface LoginRepository {
suspend fun login(username: String, password: String): LoginResponse?
}
internal class HttpLoginRepository(
private val client: HttpClient,
private val tokenStorage: TokenStorage,
) : LoginRepository {
override suspend fun login(username: String, password: String ): LoginResponse? {
val request = LoginRequest(username, password)
return client.post("/login") { setBody(request) }.body<LoginResponse?>()
.also {
if (it != null) {
tokenStorage.putTokens(it.token, "NOT IMPLEMENTED")
}
}
}
}
Making requests with KTOR is simple. Using the provided login and password, we create a LoginRequest
, and then we use the injected client (which has a base URL and headers configured by default).
We need to specify the method and path for our request. Then, in the lambda builder block, we add the body of the request and that's it - the request is ready.
To obtain the body of the response, we use a typed function that tries to receive the JSON and deserialize it to a given type.
To wrap things up and make everything work, it is very helpful to use a DI framework like KOIN. Unfortunately, at the time of writing this post, KOIN does not support the wasm target. In such cases, we need to create our own way of injection.
When the blog post was published the KOIN added support for the wasm*. Feel free to use it. I will do my best to update the project and add some explanations as soon as possible.*
object DI {
private val tokenStorage: TokenStorage = RealTokenStorage()
private val httpClientFactory: HttpClientFactory = HttpClientFactory(tokenStorage)
val remoteRepository: RemoteRepository = RealRemoteRepository(httpClientFactory.create(), tokenStorage)
}
I’ve added a factory for the remote repositories which will aggregate all the HttpRepositories
in one class for simplicity.
interface RemoteRepository {
fun loginRepository(): LoginRepository
fun gamesRepository(): GamesRepository
}
internal class RealRemoteRepository(
private val client: HttpClient,
private val tokenStorage: TokenStorage,
) : RemoteRepository {
override fun loginRepository(): LoginRepository = HttpLoginRepository(client, tokenStorage)
override fun gamesRepository(): GamesRepository = HttpGamesRepository(client)
}
With all the work done, we can use our DI and write a quick test to see if logging and fetching the games works. We can use the jvmTest
module to hold tests for the standard code. To do so, we need some dependencies that will allow us to manage tests – kotest
, JUnit
, coroutines
, and a valid client for the JVM target (we can use KTOR
CIO
or OkHttp
).
libs.versions.toml
[versions]
junit = "4.13.2"
kotest = "5.8.0"
coroutines = "1.8.0-RC2" // wasm support
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotest-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
shared/build.gradle.kts
jvmTest.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.kotest.core)
implementation(libs.kotest.property)
implementation(libs.junit)
implementation(libs.coroutines.test)
}
With the dependencies added we can write a simple repositories test.
class HttpRepositoriesTest {
private val loginRepository = DI.remoteRepository.loginRepository()
private val gamesRepository = DI.remoteRepository.gamesRepository()
@Test
fun `login with valid credentials`() = runTest {
val result = loginRepository.login("admin", "pass")
with(result.shouldNotBeNull()) {
token.shouldNotBeNull()
user.shouldNotBeNull()
}
}
@Test
fun `should return all games`() = runTest {
// login first to get the token and store it in the token storage
loginRepository.login("admin", "pass")
val result = gamesRepository.getGames()
result.shouldNotBeEmpty()
}
}
To sum things up, we have successfully set up a Kotlin multiplatform project that includes a fully functional KTOR backend and a common KTOR client capable of communicating with the running server. This setup allows us to send and fetch data seamlessly. The next step in our development process is to present this data to the application users in an intuitive and efficient manner.
Achieving this requires addressing several architectural tasks. First, we need to create a presentation layer that will display our data and manage user interactions. Additionally, we must implement a navigation system to guide users through different sections of the app, ensuring a smooth and coherent user experience.
In the upcoming blog post, we will delve deeper into the Mobile and Web Application Architecture using Decompose. We will explore how to structure the application, manage state, and handle navigation effectively. Stay tuned for a comprehensive guide that will help you build robust and scalable applications.
This blog post provides a detailed guide on creating a unified HTTP client for Android, iOS, and web applications using Compose Multiplatform and the KTOR library. You'll learn how to set up your project, implement the HTTP client, and handle authentication tokens. The post includes step-by-step instructions, code examples, and explanations to help you manage network requests efficiently across multiple platforms using a single codebase.
Subscribe to my newsletter
Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by