IntelliJ IDEA
IntelliJ IDEA – the Leading Java and Kotlin IDE, by JetBrains
Ktor 101: Efficient JVM HTTP Toolkit
Introduction
As an experienced Java developer, you’re likely familiar with robust, full-featured web frameworks, like Spring Boot. Today, we’ll explore Ktor, a toolkit for building server applications on JVM with Kotlin. Ktor was designed from the ground up to take advantage of Kotlin’s features, including coroutines, to build efficient and flexible web applications.
As a web application toolkit, Ktor provides the essential components for building the application, like routing, authentication, and utilities for working with various protocols, including HTTP and WebSockets. For other use cases, such as working with databases, developers are free to pick any libraries that suit their needs.
In this article, we provide a quick overview of Ktor’s features to help you get started with this tool.
The “Hello, World!” app
Starting a new project with Ktor is easy. You can use the project generator available at start.ktor.io or the New Project wizard in IntelliJ IDEA.
The equivalent of a Hello World application with Ktor would be:
fun main() { embeddedServer(Netty, port = 8080, host = "0.0.0.0") { routing { get("/hello") { call.respondText("Hello, World!") } } }.start(wait = true) }
To begin, we’re instantiating a server that utilizes Netty as its underlying engine and operates on port 8080.
Next, we define a specific route to handle incoming requests. In this instance, we’re instructing the server to respond with the plain text message Hello, World!
when a request is made to the /hello
path.
Last, we initiate the server and instruct it to wait, thereby preventing our application from terminating immediately.
That’s as simple as it gets when it comes to Ktor. To implement additional functionality, we would need to define additional HTTP verbs and their corresponding URLs within the routing
function. For instance, if we’d like to respond to POST, we’d simply add another function:
routing { get("/hello") { call.respondText("Hello, World!") } post("/hello") { // … } }
Or we can regroup the endpoints by declaring a route
and specify the HTTP verbs for that route as follows:
routing { route("/hello") { get { call.respondText("Hello, World!") } post { // … } } }
Grouping endpoints into routes is useful for enabling Ktor plugins, such as content negotiation, within the scope of each route.
To demonstrate how Ktor leverages Kotlin’s features, let’s extract the individual endpoints into extension functions for the Route type:
routing { route("/hello") { getEndpoint() postEndpoint() } } private fun Route.getEndpoint() { get { … } } private fun Route.postEndpoint() { post { … } }
The new functions, getEndpoint
and postEndpoint
, are defined as extensions of Ktor’s Route class, meaning they can only be used inside the route { … }
DSL block. Ktor relies heavily on type-safe builders for its DSLs, giving the code a more declarative style.
Content negotiation
The purpose of content negotiation is to handle the conversion between different data formats when communicating between the client and the server. In Ktor, content negotiation also enables automatic serialization and deserialization of data formats such as JSON, XML, or other types, based on the Content-Type
headers in HTTP requests and responses.
The following example demonstrates the use of content negotiation with JSON serialization for the Message data class:
@Serializable data class Message(val id: String, val message: String) embeddedServer(Netty, port = 8080, host = "0.0.0.0") { install(ContentNegotiation) { json() } routing { route("/hello") { get { call.respond(Message("123ABC", "Hello, World!")) } post { val message = call.receive<Message>() // do something with the message } } } }.start(wait = true)
First, even if the dependency that implements the corresponding feature is present in the classpath of the project, in Ktor, we have to explicitly enable this functionality. This is done using the install
function, with the feature name following a configuration block.
install(ContentNegotiation) { json() }
This approach avoids the implicit behavior that could be imposed by the accidental presence of a dependency artifact in the classpath.
As soon as the content negotiation is configured, the endpoints are ready to interact with the intended format. In our example, the Message
data class will be serialized to JSON in the GET endpoint on the call.respond(...)
invocation, and deserialized in the POST endpoint on call.receive<Message>()
invocation.
Plugins
We’ve already mentioned the plugins in the previous part about content negotiation. Ktor provides a number of plugins out of the box, as well as a simple API to implement your own plugins. However, Ktor doesn’t prevent you from using any of your favorite libraries without a plugin. A plugin is just a convenience that enables common configuration and a coherent code style.
install(ContentNegotiation) { json() } install(WebSockets) { pingPeriod = Duration.ofSeconds(15) timeout = Duration.ofSeconds(15) maxFrameSize = Long.MAX_VALUE masking = false } install(Compression) { gzip { minimumSize(1024) } } install(CORS) { allowMethod(HttpMethod.Put) allowHeader("MyCustomHeader") }
The plugins can be enabled for the application instance or for the selected routes. The full list of plugins can be found in the project generator or in the GitHub repository for the plugins registry, where plugin developers can contribute by registering their plugins.
Extensibility through interceptors
Ktor provides a powerful interception mechanism that allows you to add custom behavior to your application. This offers flexibility similar to aspect-oriented programming, but in a more Kotlin-idiomatic way.
To create an interceptor, we only need to create an instance of an application plugin using the createApplicationPlugin
function. In the plugin, we can implement custom logic for handling requests and responses using a set of handlers that provide access to different stages of a call.
val UserAgentValidation = createApplicationPlugin("UserAgent") { onCall { call -> val userAgent = call.request.headers["User-Agent"] ?: throw UnsupportedUserAgentException() if (userAgent.isNotBrowser()) call.respond(HttpStatusCode.Forbidden) } } install(UserAgentValidation)
In our example, the onCall
function implements the logic of checking the user agent header and rejecting any clients that aren’t web browsers. Keep in mind, we still need to use the install
function to activate the plugin.
Kotlin Coroutines
The Ktor project started as a testbed for Kotlin coroutines. This is why it provides native support for asynchronous programming. This allows for more intuitive, sequential-looking code that’s actually non-blocking.
import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* fun Application.module() { routing { get("/api/users") { selectAllUsers().let { call.respond(it) } } } } suspend fun selectAllUsers(): List<User> = newSuspendedTransaction(Dispatchers.IO) { UserTable.selectAll() .orderBy(UserTable.id, SortOrder.ASC) .map { row: ResultRow -> User( userId = row[UserTable.id], name = row[UserTable.name], // map more columns ) } } object UserTable : Table("users") { val id = long("id").autoIncrement() val name = varchar("name", 50).uniqueIndex() val email = text("email").uniqueIndex() val link = text("link").nullable() val userType = enumeration<UserType>("user_type") override val primaryKey: Table.PrimaryKey = PrimaryKey(id) }
In this example, selectAllUsers
is marked with the suspend
keyword, meaning that it can pause its execution without blocking the thread. In this example, we use the Exposed library for accessing the database and then mapping the results of the query to a User
object.
You will notice that the editor shows the special icons in the gutter, indicating that there is a suspending function invocation on the corresponding line:
On lines 17, 19, and 21, the icons in the gutter indicate invocations of the asynchronous functions.
When working with Ktor, you will notice that even though the application code looks sequential, there are a lot of functions highlighted in the gutter with these icons. This is a hint for you that in those places, the execution is actually asynchronous.
WebSockets
Thanks to coroutines, Ktor makes it easy to add real-time, bidirectional communication to your application using WebSockets. Here is an example of a very simple echo server with WebSockets:
fun main() { embeddedServer(Netty, port = 8080) { install(WebSockets) routing { webSocket("/chat") { send("You are connected!") for (frame in incoming) { frame as? Frame.Text ?: continue val receivedText = frame.readText() send("You said: $receivedText") } } } }.start(wait = true) }
The server sets up a WebSocket endpoint at /chat
that echoes back messages prefixed with “You said: “.
Testability
Ktor was designed with testability in mind. It provides a test engine that allows you to test your application without starting a real server:
import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication { application { module() } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello, World!", bodyAsText()) } } }
The provided test code demonstrates how Ktor allows you to write tests for your application using its TestEngine. The test simulates an HTTP request to the Ktor server without needing to start an actual server, which is both faster and easier to manage during development. This makes writing comprehensive tests for your Ktor applications a breeze, including full integration tests.
Authentication
Ktor provides a flexible authentication system that supports various authentication methods. Below is an example of basic authentication:
embeddedServer(Netty, port = 8080, host = "0.0.0.0") { install(Authentication) { basic("auth-basic") { realm = "Access to the '/' path" validate { credentials -> if (credentials.name == "username" && credentials.password == "password") { UserIdPrincipal(credentials.name) } else { null } } } } routing { authenticate("auth-basic") { get("/") { call.respondText("Hello, ${call.principal<UserIdPrincipal>()?.name}!") } } } }.start(wait = true)
The example demonstrates how to implement basic authentication in Ktor. When a client, such as a browser, requests the root path (‘/’) without providing an Authorization
header, the server responds with a 401
(Unauthorized) status code. It also includes a WWW-Authenticate
response header, signaling that the route is protected by basic authentication.
Upon receiving this response, the client typically displays a login dialog for the user to enter their credentials. The client then makes a new request, this time including an Authorization
header containing the Base64-encoded username and password pair.
Once the server receives a properly authenticated request, it validates the credentials and allows access to the root endpoint (‘/’). In this example, the endpoint responds with a personalized message: “Hello, username”.
Ktor also supports other authentication methods, including form-based, JWT, and OAuth.
Serving static content
Serving static content is an integral part of the routing functionality. Thus, an extra plugin is not required and the functionality is enabled by default. To configure the resource mapping, we can use Ktor’s API functions, such as staticResources
in the example below:
The root path (‘/’) is mapped to the `static` folder in the project.
Status Pages
By using the status pages plugin, we can configure how the application responds to exceptions and status codes.
install(StatusPages) { exception<AuthorizationException> { call, cause -> call.respond(HttpStatusCode.Forbidden) } exception<Throwable> { call, cause -> call.respondText(text = "500: ${cause.localizedMessage}", status = HttpStatusCode.InternalServerError) } status(HttpStatusCode.NotFound) { call, _ -> call.respondRedirect("/404.html", permanent = true) // 301 } }
This setup provides custom responses for unauthorized access and any other unhandled exception that might occur during the execution. For the 404 status code, the request is redirected to a static resource, assuming the static resources are configured accordingly.
Conclusion
Ktor offers a fresh, Kotlin-first approach to building server-side applications. Its coroutine-based architecture, modular design, and type-safe API provide a powerful toolkit for modern application development. Ktor’s flexibility and performance make it an excellent choice for microservices, API backends, and other modern application architectures.
For Java developers looking to leverage Kotlin’s strengths in their server-side applications, Ktor provides a compelling toolkit that’s worth exploring. Its learning curve is relatively gentle for those already familiar with Kotlin, and the benefits in terms of code conciseness and performance can be significant.
This article provided a brief overview of Ktor’s features. For more examples, we recommend the samples repository on GitHub where we showcase various features provided out of the box.
You can start working with Ktor right away by creating your project using the Ktor Generator.