1. Overview

Ktor is a Kotlin-based framework well suited to handle various tasks, ranging from microservices to multiplatform applications. It seamlessly integrates Kotlin features such as coroutines and proves powerful in today’s landscape.

In today’s article, we’ll look at the Ktor Client and how to leverage it for our applications. It’s a robust tool, and its standout feature is the ability to be used in a multiplatform environment. This allows us to reuse code between different environments.

For simplicity, we’ll be looking at the approach for Microservices, as the multiplatform version is more difficult to set up. The client is not supported by every Operating System, so make sure to check here that it fits the requirements. Furthermore, advanced features such as Response Interceptors, Cookies Support, and Caching will be left for a follow-up article.

2. Performing Requests

2.1. Getting Started

We’ll start by adding our client dependency using Gradle:

implementation("io.ktor", "ktor-client-core", ktorVersion)

Usually, with clients, you also have to choose an engine that performs the requests. The default one can work fine, but, especially for multiplatform, a different engine may be needed.

2.2. Testing Our Implementation

The last thing we need now is an actual target for our requests. It can be that we have a server on which to make our requests. Sometimes, however, client implementations are done in parallel with server-side ones. To this end, the Ktor Client provides great testing support through the use of a mock engine. For simplicity, we’ll also be using it to mock our server.

First, we add the Gradle dependency:

testImplementation("io.ktor", "ktor-client-mock", ktorVersion)

Then, let’s create a mock for a simple application for managing cars and their drivers:

val mockEngine = MockEngine {
    request -> when {
        request.url.fullPath == "/cars" && request.method == HttpMethod.Get -> respond(
            content = ByteReadChannel(CARS),
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
        request.url.fullPath.startsWith("/driver") && request.method == HttpMethod.Get -> {
            val driverId = request.url.parameters["id"]?.toIntOrNull() ?: 0
            respond(
                content = ByteReadChannel(DRIVERS[driverId]),
                status = HttpStatusCode.OK,
                headers = headersOf(HttpHeaders.ContentType, "application/json")
            )
        }
        request.url.fullPath == "/car" && request.method == HttpMethod.Put -> {
            // Some business logic
        }
        request.url.fullPath == "/driver" && request.method == HttpMethod.Put -> {
            // Some business logic
        }
        else -> respond(
            content = ByteReadChannel("Unknown Request!"),
            status = HttpStatusCode.NotFound
        )
    }
}

We can mock any sort of response and even have complex logic, such as in-memory data storage.

2.3. Requests

We can now finally make our requests. Let’s start by creating our client:

private val client = HttpClient(mockEngine)

And let’s try to fetch our car list:

with(client.get("/cars")) {
    assertEquals(HttpStatusCode.OK, status)
    assertEquals(CARS, this.bodyAsText())
}

Since the client runs the code asynchronously, we need to handle that. Depending on the use case, using the suspend keyword usually makes for better performance, but runBlocking can also work.

The request works fine, and we get our data, but even though we return a JSON, it’s not deserialized. To do that, we’ll start by adding a serializer in our dependencies as well as support for Content Negotiation:

implementation("io.ktor", "ktor-serialization-jackson", ktorVersion)
implementation("io.ktor", "ktor-client-content-negotiation", ktorVersion)

We’ll also change the client configuration to use the serializer:

private val client = HttpClient(mockEngine) {
    install(ContentNegotiation) {
        jackson()
    }
}

Let’s try that again:

with(client.get("/cars")) {
    assertEquals(HttpStatusCode.OK, status)
    val cars: List<Car> = body()
    assertEquals(2, cars.size)
    assertTrue { cars.any { car -> car.name == "Car 1" } }
}

Let’s also try out a Put request:

with(client.put("/driver") {
    contentType(ContentType.Application.Json)
    setBody(Driver(id = 2, name = "Jack"))
}) {
    assertEquals(HttpStatusCode.OK, status)
    assertEquals("Created!", bodyAsText())
}

2.4. Authorization

Most of the time, we’ll need to authenticate to the server when we make a request. The client, by default, expects the server to request authentication first by returning a 401 and a WWW-Authenticate header. You can override this behavior to always send credentials by using the sendWithoutRequest function. We’ll first add the dependency for the Auth plugin:

implementation("io.ktor", "ktor-client-auth", ktorVersion)

And then, we’ll configure the plugin next to our serialization plugin:

private val client = HttpClient(mockEngine) {
    // ...
    install(Auth) {
        basic {
            credentials {
                BasicAuthCredentials(username = "baeldung", password = "baeldung")
            }
            sendWithoutRequest { _ -> true }
        }
    }
}

Finally, let’s also extend our mock to check for this:

val mockEngine = MockEngine {
    request -> when {
        request.headers["Authorization"] != "Basic YmFlbGR1bmc6YmFlbGR1bmc=" -> respond(
            content = "Wrong credentials!",
            status = HttpStatusCode.Unauthorized
        )
    // ...
}

Our requests will now send the authorization header.

2.5. Response Validation

A difficult part when making requests to a server is response validation. For example, in case our credentials are wrong, expired, or missing, the response won’t be usable. In those cases, we probably want to throw an exception.

The client trivializes this by configuring to throw an exception on any non-successful request:

private val client = HttpClient(mockEngine) {
    expectSuccess = true
    // Plugin configuration...
}

Let’s try that out:

try {
    client.get("/this-does-not-exist")
} catch (exception: ClientRequestException) {
    return@runBlocking
}
fail("Did not throw an exception!")

We can also easily write our custom validator:

HttpResponseValidator {
    validateResponse { response ->
        // some validation code
    }
}

3. WebSockets

The Ktor Client provides great support for WebSockets through asynchronous handling. However, the mock engine cannot handle WebSockets, so we have to start a server for this. We’ll follow the Ktor article for a basic setup and add our WebSocket dependency:

implementation("io.ktor", "ktor-server-websockets", ktorVersion)

Now, let’s create a Ktor module where the server awaits a message Bye! and then closes the session:

fun Application.websocketsModule() {
    install(WebSockets)
    routing {
        webSocket("/messages") {
            send("Hi!")
            for (frame in incoming) {
                frame as? Frame.Text ?: continue
                val receivedText = frame.readText()
                if (receivedText.equals("Bye!", ignoreCase = true)) {
                    close(CloseReason(CloseReason.Codes.NORMAL, "Client said bye!"))
                }
            }
        }
    }
}

Back to the client implementation, we can then add the dependency:

implementation("io.ktor", "ktor-client-websockets", ktorVersion)

Let’s create a different client, as we need one that doesn’t use the mock engine:

// Different to the regular client, as that one is using a mock engine
private val websocketsClient = HttpClient {
    install(WebSockets)
}

We can then start our session:

websocketsClient.webSocket(method = HttpMethod.Get, host = "127.0.0.1", port = 8080, path = "/messages") {
    var message = incoming.receive() as? Frame.Text
    while (message == null) {
        message = incoming.receive() as? Frame.Text
    }
    assertEquals("Hi!", String(message.data))
    send("Bye!")
}

3.1. Serialization

We’ve sent and received content through WebSockets but without serialization. We need to configure the client to use serialization:

private val websocketsClient = HttpClient {
    install(WebSockets) {
        contentConverter = JacksonWebsocketContentConverter()
    }
}

Let’s add a new endpoint that retrieves a driver based on a received id:

webSocket("/driver") {
    send("Which driver would you like to see?")
    for (frame in incoming) {
        frame as? Frame.Text ?: continue
        val receivedText = frame.readText()
        if (receivedText.equals("Bye!", ignoreCase = true)) {
            close(CloseReason(CloseReason.Codes.NORMAL, "Client said bye!"))
        }
        val receivedDriverId = receivedText.toIntOrNull() ?: continue
        send(DRIVERS[receivedDriverId])
    }
}

Now let’s see if we can deserialize our driver:

websocketsClient.webSocket(method = HttpMethod.Get, host = "127.0.0.1", port = 8080, path = "/driver") {
    var message = incoming.receive() as? Frame.Text
    while (message == null) {
        message = incoming.receive() as? Frame.Text
    }
    assertEquals("Which driver would you like to see?", String(message.data))
    send("0")
    val driver = receiveDeserialized<Driver>()
    assertEquals(0, driver.id)
    send("Bye!")
}

4. Conclusion

In this article, we’ve talked about various solutions offered by the Ktor Client. It’s straightforward to set up and provides seamless integration with Kotlin features such as coroutines. The Ktor Client is a powerful and stable tool tackling issues from requests to WebSockets. While the testing tools lack WebSockets support, they cover most other cases and save the developer time.

As always, the code can be found over on GitHub.

Comments are closed on this article!