Mobile and Web HTTP Client with KTOR
In the previous post, I covered common backend for mobile and web applications in which we decided that some objects can be shared across the app – the domain objects and the repositories. A 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 them in 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 the HttpGamesRepository.
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, it is almost the same as the RealDatabaseRepository from the backend code. The only difference is that we need a HttpClient for frontend apps. The most common multiplatform client is KTOR. It can be easily built into the KMM project without 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 that. Please note that I am using the latest KTOR version with the 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 proper engines of 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 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 will be responsible for the serialization/deserialization of requests. With the DefaultRequest, we can configure a basic set of properties such as base URL, and headers. The auth plugin with the bearer lambda are responsible for handling tokens and refreshing tokens necessary for secure communication. We can specify from where and how to obtain the access and refresh tokens and which endpoints should be automatically enhanced with the mechanism. In case the server responds with HTTP 401 Unauthorized, the client will attempt to refresh the token and then repeat the request with a new token.
In our case, the only publicly available endpoint is the “/login”, which is responsible for providing a valid access token and User data. For this URL we don’t want to add tokens or handle the 401 responses.
The next problem to solve is how to handle tokens in the Auth block.
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 request.
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, please notice that we don’t implement the refresh token mechanism as this is not important for the matter of the post.
internal class HttpClientFactory(
private val tokenStorage: TokenStorage,
) {
fun create(): HttpClient {
return HttpClient {
...
install(Auth) {
bearer {
loadTokens {
tokenStorage.getToken()
}
refreshTokens {
TODO(“Not implemented uet”)
}
...
}
}
}
}
}
At the beginning, we can check how the details of the HttpGamesRepository are implemented. Since we need to log in first to obtain the token and after that, we can fetch the games, I’ve added a LoginRepository and 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")
}
}
}
}
KTOR client is simple. From the provided login and password, we create the LoginRequest, and then we use the provided client (since it 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 can add the body of the request – that’s all, the request is ready. When we want to obtain the body of the response, there is a typed function that will try to receive the JSON and deserialize it to a given type.
Now to wrap things up and make them work it would be very helpful to use a DI framework like KOIN. Unfortunately, when I am writing this post KOIN does not support wasm target. In such cases, we need to create our own pattern.
NOTE: 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 a Kotlin multiplatform project with a working KTOR backend and a common KTOR client able to communicate with the running server. If we can communicate, send, and fetch data. The next thing to do is to present them to the application users. This cannot be done without some architecture tasks – we should create a layer that will present our data and handle interactions and also a way to navigate users through the app.
The following blog post will discuss the Mobile and Web Application Architecture with Decompose. Stay tuned!