Migrating to Ktor 2.0

Alexey Soshin
Engineering at Depop
6 min readAug 16, 2022

--

Photo by Nick Fewings on Unsplash

Recently I migrated several Kotlin services using the Ktor framework from version 1.6 to 2.0. This is the first major version upgrade the Ktor framework had, and being a major version upgrade, it isn’t without its challenges.

IntelliJ IDEA generally suggests migrating it for you. And it does a good job overall. But there are still a couple of areas that I had to rewrite myself.

In this article, I would like to highlight some of those major changes, why they were made, and how to refactor your code in case you may need to.

Reading request bodies

If you’re using the Ktor HTTP Client, you usually parse responses into data classes using one of the JSON serialization libraries. But sometimes you just need the raw response as a simple string.

Up to Ktor 1.6, you would get it like this:

val responseBody = response.body<String>()

Now there’s a better method for that:

val responseBody = response.bodyAsText()

If you continue using body<String> it will compile, but crash at runtime, because now the body() function expects a class that can be unmarshalled as its type.

Writing request bodies

Another common task with HTTP Client is to send POST requests.

Previously body of the request was a field that you would set:

client.post("https://...") {
body = "..."
}

In Ktor 2 the field is made internal, and the body is set using the new setBody() method

client.post("https://...") {
setBody("...")
}

We’ll see later on that this is to make the API more consistent.

JsonFeature is now ContentNegotiation

As I mentioned previously, the Ktor HTTP Client is usually used in conjunction with a JSON serializing library.

For example, previously to parse all client responses as JSON using the Gson library you would write:

HttpClient(CIO) {
install(JsonFeature) {
serializer = GsonSerializer {
serializeNulls()
}
}
}

This functionality is now embedded into the ContentNegotiation plugin:

HttpClient(CIO)
install(ContentNegotiation) {
gson {
serializeNulls()
}
}
}

Again, we’ll see in the next section that this change was made for consistency reasons.

Different ContentNegotiation plugins

Speaking of ContentNegotiation plugins, there are two of them now. One for HTTP Client, and another for HTTP Server.

This single import from Ktor 1.6:

import io.ktor.features.ContentNegotiation

Can now be separated into two imports in Ktor 2.0:

import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation

Note that here I use aliases so the imports wouldn’t clash.

This clash may happen for an instance in your tests, where you may set up both a mock client and a mock server within the same test file. In general, I think that aliasing those plugins is a good practice.

To parse requests using ServerContentNegotiation, we just need to install it, exactly as we did for the client:

install(ServerContentNegotiation) {
gson {
serializeNulls()
}
}

Previously, configuring handling JSONs for Ktor client and Ktor server was done differently, but now those are consistent.

StatusPages plugin and explicit context

StatusPages is another useful Ktor plugin that allows developers to map different exceptions to custom HTTP responses.

In Ktor 1.6 the response context was set implicitly:

install(StatusPages) {
exception<Throwable> { exception ->
call
.respondText(
"ERROR!",
ContentType.Application.Json, HttpStatusCode.InternalServerError
)
}
}

Notice that the call previously wasn’t specified as a lambda argument.

With Ktor 2.0, the block has now two arguments instead of one. The first argument is the explicit context:

install(StatusPages) {
exception<Throwable> { call, exception ->
call.respondText(
"ERROR!",
ContentType.Application.Json, HttpStatusCode.InternalServerError
)
}
}

Apart from adding that extra argument, you don’t need to make any more changes if you’re using the StatusPages plugin.

Testing web servers

Testing your web applications is extremely important. This is probably one of the most significant changes you’ll have to make during your migration to Ktor 2.0. The silver lining is that, unlike other changes I’ve mentioned, this one is not mandatory yet.

Ktor 2.0 will issue deprecation warnings, but the code will continue to compile.

To understand the changes, let’s first look at what a test for a simple health check endpoint looked like in Ktor 1.6:

withTestApplication(testModule()) {
val response = handleRequest(HttpMethod.Get, "/status").response
assertEquals(HttpStatusCode.OK, response.status())
assertEquals("""{"status":"OK"}""", response.content)
}

With Ktor 1.6 we would start our test application using the withTestApplication builder. This builder received a module as an argument. A module is a function that sets up all our test dependencies and mocks.

Inside the builder lambda, we had a handleRequest function that issues HTTP calls to our test application.

Now let’s see what that test would look like with Ktor 2.0. Please ignore the mockApplication() function for now, as I’ll explain it in the next section.

testApplication {
mockApplication()
val response = client.get("/status")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("""{"status":"OK"}""", response.bodyAsText())
}

Instead of the handleRequest() function, which was specific to the Ktor test framework, we now use client.get() method, similarly to what we would use in our production code.

Since the response from the client.get() function is a regular HttpResponse now, and now TestApplicationCall it was previously, we should use the same API we use in our production code. This means that status is now a property, and not a function, while to read the response we use the bodyAsText() function we already discussed earlier in this article.

Setting up dependencies in tests

In the previous section I mentioned that previously, we would set up test dependencies passing the testModule function as an argument, while now we invoke a function that we named mockApplication() directly. Let’s dive a little deeper and see how dependencies were set up previously, and how to mock dependencies in Ktor 2.0.

So, this is our implementation of the testModuleusing Ktor 1.6:

fun testModule(
// Any mocks go here
): Application.() -> Unit {
return {
install(ContentNegotiation) {
gson {
}
}
routing {
status()
}
}
}

Now, let’s compare this to the new implementation:

fun ApplicationTestBuilder.mockApplication(
// Any mocks go here
) {
environment {
config = MapApplicationConfig("key" to "value")
}
application {
this.install(ContentNegotiation) {
gson()
}
routing {
status()
}
}
}

Notice that now the signature of the function is much simpler. Instead of returning Application.() -> Unit we made this an extension of the ApplicationTestBuilder directly.

Inside the function, we have access to the environment, which allows us to set environment parameters for our tests. An important note here is that if you don’t specify the environment at all or leave it empty, Ktor will read the default application.conf file. Something that you probably don’t want to happen in your tests, since this file most probably would point to the production configuration of your application. If you want to avoid that behaviour, you should specify empty MapApplicationConfig as I did in the example.

Next comes the application block, which allows us to set up our mock server.

This allows us to specify the plugins that we need, for example, ContentNegotiation

The routing block allows us to expose only certain routes to our tests. This way each route can be tested by a separate test suite, and suites are kept relatively simple.

Testing external dependencies

Finally, let’s discuss what happens if your service needs to call other services, and you’d like to mock those interactions. With Ktor 2.0 this becomes rather easy. Inside the testApplication block that we mentioned earlier, you have access to the externalServices block that lets you override external services by providing mock responses:

testApplication {
...

externalServices {
hosts("https://mock-remote-service.com") {
routing {
get("/third-party/") {
call.respondText(
"""{"a":1}""",
contentType = ContentType.Application.Json
)
}
}
}
}

val response = client.get("/api/v1/internal/")
assertEquals(response.status, HttpStatusCode.OK)
assertEquals(response.bodyAsText(), """{"a":1,"b":2}""")
}

There are a couple of caveats to this, though. First, even though there is no network involved, you must specify the protocol (HTTPS in our case). And, make sure that in your mocks you set the same Content Type that is returned by the real third-party service (we do that with the contentType parameter in our example).

Also, take note that the JSON returned by the mock third-party service is not the same as our test expects. This is not a mistake. Most services don’t simply proxy responses from third-parties, but process and transform them. This is what happens here as well. Remember, your goal is to test the logic of your service, not the third-party. Mocking third-party responses is just a means to test your code better.

Conclusions

Between automatic migration that IntelliJ IDEA provides and this collection of tips, migration of your Ktor applications to Ktor 2.0 shouldn’t take too long.
Take a look at the official Ktor 2.0 migration guide: https://ktor.io/docs/migrating-2.html

Did you migrate to Ktor 2.0 as well and encountered issues that weren’t described in this article? Make sure to leave a comment!

--

--

Solutions Architect @Depop, author of “Kotlin Design Patterns and Best Practices” book and “Pragmatic System Design” course