• Wed. Jan 15th, 2025

Intro to Ktor: The server-side stack

Byadmin

Jan 15, 2025



My previous article introduced Ktor and some of its basic features for building web applications. Now, we’ll expand the example application developed in that article by adding persistent data and HTMX, which will provide more interactive views. This gives us a setup with a lot of power in a relatively simple stack.

Please see the previous article for the example application code and setup. We’ll build on that example here.

Add persistence to the Ktor-HTMX application

The first step toward making our application more powerful is to add persistent data. The most popular way to interact with an SQL database in Kotlin is with the Exposed ORM framework. It gives us a couple of ways to interact with the database, using either a DAO mapping or a DSL. Kotlin’s native syntax means the overall feel of using the ORM mapping layer has less overhead than others you might have encountered.

We’ll need to add a few dependencies to our build.gradle.kt, in addition to those we already have:

dependencies {
// existing deps…
implementation(“org.jetbrains.exposed:exposed-core:0.41.1”)
implementation(“org.jetbrains.exposed:exposed-jdbc:0.41.1”)
implementation(“com.h2database:h2:2.2.224”)
}

You’ll notice we’ve included the exposed core and JDBC libraries, as well as a driver for the in-memory H2 database. We’ll use H2 as a simple persistence mechanism that can easily be switched over to an external SQL database like Postgres later on.

Add services

To start with, we’ll create a couple of simple services that interact with a main service, which talks to the database. Here’s our QuoteSchema.kt file so far, which sets up the database schema and provides service functions for interacting with it:

// src/main/kotlin/com/example/plugins/QuoteSchema.kt
package com.example.plugins

import kotlinx.coroutines.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object Quotes : Table() {
val id: Column = integer(“id”).autoIncrement()
val quote = text(“quote”)
val author = text(“author”)

override val primaryKey = PrimaryKey(id, name = “PK_Quotes_ID”)
}

data class Quote(val id: Int? = null, val quote: String, val author: String)

class QuoteService {
suspend fun create(quote: Quote): Int = withContext(Dispatchers.IO) {
transaction {
Quotes.insert {
it[this.quote] = quote.quote
it[this.author] = quote.author
} get Quotes.id
} ?: throw Exception(“Unable to create quote”)
}
suspend fun list(): List = withContext(Dispatchers.IO) {
transaction {
Quotes.selectAll().map {
Quote(
id = it[Quotes.id],
quote = it[Quotes.quote],
author = it[Quotes.author]
)
}
}
}
}

There’s a lot going on in this file, so let’s take it step-by-step. The first thing we do is declare a Quotes object that extends Table. Table is a part of the Exposed framework and lets us define a table in the database. It does a lot of work for us based on the four variables we define: id, quote, author, and primary key. The id element will be auto-generated for an auto-increment primary key, while the other two will have their appropriate column types (text becomes string, for example, depending on the database’s dialect and driver). 

Exposed is also smart enough to only generate the table if it doesn’t already exist.

Next, we declare a data class called Quote, using the constructor style. Notice id is marked as optional (since it will be auto-generated). 

Then, we create a QuoteService class with two suspendable functions: create and list. These are both interacting with the concurrent support in Kotlin, using the IO dispatcher. These methods are optimized for IO-bound concurrency, which is appropriate for database access. 

Inside each service method, we have a database transaction, which does the work of either inserting a new Quote or returning a List of Quotes.

Routes

Now let’s make a Database.kt file that pulls in the QuoteService and exposes endpoints for interacting with it. We’ll need a POST for creating quotes and a GET for listing them.

//src/main/kotlin/com/example/plugins/Database.kt
package com.example.plugins

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.sql.*
import kotlinx.coroutines.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

fun Application.configureDatabases() {
val database = Database.connect(
url = “jdbc:h2:mem:test;DB_CLOSE_DELAY=-1”,
user = “root”,
driver = “org.h2.Driver”,
password = “”,
)
transaction {
SchemaUtils.create(Quotes)
}
val quoteService = QuoteService()
routing {
post(“/quotes”) {
val parameters = call.receiveParameters()
val quote = parameters[“quote”] ?: “”
val author = parameters[“author”] ?: “”

val newQuote = Quote(quote = quote, author = author)

val id = quoteService.create(newQuote)
call.respond(HttpStatusCode.Created, id)
}
get(“/quotes”) {
val quotes = quoteService.list()
call.respond(HttpStatusCode.OK, quotes)
}
}
}

We begin by using Database.connect from the Exposed framework to create a database connection using standard H2 parameters. Then, inside a transaction we create the Quotes schema, using our Quotes class we defined in QuoteSchema.kt.

Next, we create two routes using the syntax we developed in the first stage of this example and relying on the create and list functions and Quote class from QuoteSchema.

Don’t forget to include the new function in Application.kt:

// src/main/kotlin/com/example/Application.kt
package com.example

import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main(args: Array) {
io.ktor.server.netty.EngineMain.main(args)
}

fun Application.module() {

configureTemplating()
//configureRouting()
install(RequestLoggingPlugin)

configureDatabases()
}

Notice I’ve commented out the old configureRouting() call, so it won’t conflict with our new routes.

To do a quick test of these routes, we can use the curl command-line tool. This line inserts a row:

$ curl -X POST -H “Content-Type: application/x-www-form-urlencoded” -H “Host: localhost:8080” -d “quote=FooBar.&author=William+Shakespeare” http://localhost:8080/quotes

And this one outputs the existing rows:

$ curl http://localhost:8080/quotes

Using HTMX for interactive views

Now let’s jump right into creating a UI to interact with the services using HTMX. We want a page that lists the existing quotes and a form that we can use to submit a new quote. The quote will be dynamically inserted into the list on the page, without a page reload.

To achieve these goals, we’ll need a route that draws everything at the outset and then another route that accepts the form POST and returns the markup for the newly inserted quote. We’ll add these to the Database.kt routes for simplicity.

Here is the /quotes-htmx page that gives us the initial list and form:

get(“/quotes-htmx”) {
val quotes = quoteService.list()
call.respondHtml {
head {
script(src = “https://unpkg.com/htmx.org@1.9.6″) {}
}
body {
h1 { +”Quotes (HTMX)” }
div {
id = “quotes-list”
quotes.forEach { quote ->
div {
p { +quote.quote }
p { +”― ${quote.author}” }
}
}
}
form(method = FormMethod.post, action = “/quotes”, encType = FormEncType.applicationXWwwFormUrlEncoded) {
attributes[“hx-post”] = “/quotes”
attributes[“hx-target”] = “#quotes-list”
attributes[“hx-swap”] = “beforeend”
div {
label { +”Quote:” }
textInput(name = “quote”)
}
div {
label { +”Author:” }
textInput(name = “author”)
}
button(type = ButtonType.submit) { +”Add Quote” }
}
}
}
}

First, we grab the list of quotes from the service. Then we start outputting the HTML, beginning with a head element that includes the HTMX library from a CDN. Next, we open a body tag and render a title (H1) element followed by a div with the id of quotes-list. Notice that id is handled as a call from inside the div block, instead of as an attribute on div. 

Inside quotes-list, we iterate over the quotes collection and output a div with each quote and author. (In the Express version of this application, we used a UL and list items. We could have done the same here.)

After the list comes the form, which sets several non-standard attributes (hx-post, hx-target, and hx-swap) on the attributes collection. These will be set on the output HTML form element.

Now all we need is a /quotes route to accept the incoming quotes from POST and respond with an HTML fragment that represents the new quote to be inserted into the list:

post(“/quotes”) {
val parameters = call.receiveParameters()
val quote = parameters[“quote”] ?: “”
val author = parameters[“author”] ?: “”
val newQuote = Quote(quote = quote, author = author)
val id = quoteService.create(newQuote)
val createdQuote = quoteService.read(id)
call.respondHtml(HttpStatusCode.Created) {
body{
div {
p { +createdQuote.quote }
p { +”― ${createdQuote.author}” }
}
}
}

This is pretty straightforward. One wrinkle is that Kotlin’s HTML DSL doesn’t like to send an HTML fragment, so we have to wrap our quote markup in a body tag, which shouldn’t be there. (There is a simple workaround we are skipping for simplicity, found in this project called respondHtmlFragment). It seems likely that generating HTML fragments will eventually become a standard part of the HTML DSL.

Other than that, we just parse the form and use the service to create a Quote and then use the new Quote to generate the response, which HTMX will use to update the UI dynamically.

Conclusion

We went fast and lean with this example, to explore the essence of Ktor. However, we have all the elements of a highly performant and dynamic stack without much overhead. Because Kotlin is built on top of the JVM it gives you access to everything Java does. That, coupled with its powerful union of object-oriented and functional programming, and DSL capabilities, makes Kotlin a compelling server-side language. You can use it for building applications with traditional RESTful JSON endpoints, or with dynamic HTMX-powered UIs, as we’ve seen here.

See my GitHub repository for the complete source code for the Ktor-HTMX application example.



Source link