Migrating to Ktor
Reading Time: 6 minutesIntroduction
The next hurdle to cross when considering KMM is networking. This means we will need to ditch retrofit in favour of Ktor, and if you haven’t already RxJava in favour of Coroutines. This post is only focussing on the Ktor aspect of the migration, as there is a plethora of Coroutines info online.
Initial Retrofit implementation
So for a basic example app, I’ve used the free online Json Placeholder and displayed a simple list of posts. To achieve this initially I’ve made a PostsRepo which takes in a retrofit instance.
interface PostsRepo {
suspend fun getPosts(): List<Post>
}
class RetrofitPostsRepo(retrofit: Retrofit) : PostsRepo {
private val postsHttpService = retrofit.create(PostsApi::class.java)
override suspend fun getPosts(): List<Post> {
return postsHttpService.getPosts()
}
}
For the basic retrofit implementation, that’s basically it. I didn’t go into too much detail here as most people will already be familiar with retrofit.
Ktor implementation
The first step to trying any technology for the first time should be to read the documentation. As Ktor can be used for both servers and clients, you need to skip down to the client section.
The first thing we need to do is setup the HttpClient and engine. Selecting the engine wasn’t so straight forward. The docs state that you should use the CIO engine if you’re unsure which to choose, so that’s what we’ll do here. The CIO engine uses out of the box kotlin dependencies, however it only supports HTTP/1.x currently. You could also use the Okhttp engine, which would make migrating an existing project a little bit easier. As well as that, there’s an Android Engine which uses out of the box Android dependencies.
So to use the CIO engine, we need add the gradle dependency and then set up the client like so:
implementation("io.ktor:ktor-client-cio:1.4.0")
private val httpClient = HttpClient(CIO) {
install(JsonFeature) {
serializer = KotlinxSerializer()
}
}
You will also notice I’ve added the Json deserialisation here too. Ktor uses pluggable features to add functionality to the http client. This allows you to add a number of features to your http client such as Auth, Json, Logging, Timeouts and basically everything else you’d expect from a http client. If it doesn’t come with what you need, the docs have instructions to write your own.
With the client ready, we can make our Repo implementation:
class KtorPostsRepo(private val httpClient: HttpClient) : PostsRepo {
override suspend fun getPosts(): List<Post> {
return httpClient.get {
headers { "Accept" to "application/json" }
url("$BASE_URL/posts")
}
}
}
Once the client is setup, we simply use it to create a get request and setup the details of the request inside the lambda. Like with the client, the request builder has all the required functionality.
It’s worth pointing out here, if you don’t want to return a coroutine from the repo, you would have to transform it into a callback or Single (or anything else you want) manually. Unlike retrofit, Ktor doesn’t have any other options.
Another difference you may have noticed between this and the retrofit implementation is how we handle the base URL and different URI’s. With retrofit, you provide a base url and then each request just specifies the URI. In the Ktor example though, we specify the full URL for each request.
You can achieve similar functionality to retrofit by doing something like this:
private val httpClient = HttpClient(CIO) {
defaultRequest {
url.takeFrom(URLBuilder().takeFrom(BASE_URL).apply {
encodedPath += url.encodedPath
})
}
}
return httpClient.get {
url { path("posts") }
}
I didn’t particularly like this work around so I left it out of the sample. More info can be found on this issue here and here.
Testing
The last thing worth talking about is how to unit test the Ktor client.
I was initially planning on comparing an okhttp mockwebserver implementation with the ktor one, however I ran into problems with mockwebserver and coroutines which appears to be an open bug in the coroutines issue tracker. So I’ve just skipped that, to look at the Ktor implementation.
As well as the client engines I mentioned earlier, there is one specifically for testing purposes, MockEngine. The mock engine takes a handler object which allows you to listen for specific requests and return a response. The pluggable nature of this should make it easy to swap out the engine for testing purposes.
I refactored the initial implementation of the HttpClient, now it allows us to pass in the Engine and any additional config, but keep the stuff that we want in both our live and test instances.
fun <T : HttpClientEngineConfig> provideHttpClient(
engineFactory: HttpClientEngineFactory<T>,
block: HttpClientConfig<T>.() -> Unit = {}
): HttpClient {
return HttpClient(engineFactory) {
install(JsonFeature) {
serializer = KotlinxSerializer()
}
block()
}
}
// now our activity calling code looks like this:
private val ktorPostsRepo = KtorPostsRepo(provideHttpClient(CIO))
This allows us to use the Json Serialization and any other common config we add to the clients in both the tests and live environment.
So now, writing the unit test we just have to use the MockEngine and handle the correct path.
class KtorPostsRepoTest : TestCase() {
private val client = provideHttpClient(MockEngine) {
engine {
addHandler { request ->
when (request.url.encodedPath) {
"/posts" -> {
val headers = headersOf("Content-Type" to listOf("application/json"))
respond(json, headers = headers)
}
else -> error("Unhandled ${request.url.fullPath}")
}
}
}
}
private val repo = KtorPostsRepo(client)
fun `test ktor repo posts request`() = runBlockingTest {
val expectedData = listOf(Post(1, 1, "Title 1", "Body 1"), Post(1, 2, "Title 2", "Body 2"))
val posts = repo.getPosts()
assertEquals(expectedData, posts)
}
}
So the testability of this is very good and straight forward. One minor issue I had is similar to the issue around paths I mentioned earlier. In the handler I’m checking just the encoded path, the documentation uses a function to get the full URL and use that, but that function isn’t in the current API (version 1.4.1) at the time of writing this. But that’s only a minor discrepancy, and if you did want that functionality, it would be possible to add an extension function to build it up yourself as all the individual parts of the URL are accessible.
Conclusion
This sample app and migration is probably a bit too simplistic to get a real feel for Ktor. However the basics are there and working with no real issues or concerns. The implementation is pretty clean allowing you to plug in the features you want to use and ignore what you don’t. It’s broken up into different dependencies as well, which helps trim down your app.
Github repo and resources used
Github Repo: https://github.com/danieljonker/ktor-migration
Ktor Docs: https://ktor.io/docs/quickstart-index.html