This is a demo inspired by @antonarhipov's Top 5 Server-Side Frameworks for Kotlin in 2022 @ Kotlin by JetBrains where, spoiler alert, the author shares this top 5 list:
๐ฅ Spring Boot
๐ฅ Quarkus
๐ฅ Micronaut
๐
Ktor
๐
Http4k
I have a lot of experience in Spring Boot, so I wanted to take a look at the other ones ๐
To do so we will create a simple application with each one of these frameworks, implementing the following scenario:
rogervinas / top-5-server-side-kotlin-frameworks-2022
โญ Top 5 Server-Side Frameworks for Kotlin in 2022
This post will describe the step-by-step Ktor implementation, you can check the other ones in this series too.
To begin with you can follow the Creating Ktor applications guide.
To create a Ktor project we have three alternatives:
- Use IntelliJ Idea plugin
- Use start.ktor.io web interface (similar to Spring Initializr for Spring Boot)
- Create a project manually
For example this project has been created using start.ktor.io and these options:
- Adjust project settings:
- Build system = Gradle Kotlin
- Engine = Netty
- Configuration in = YAML file
- Add sample code โ
- Add plugins:
- Postgres โ
Once created you can run it to check everything is ok:
./gradlew run
And make a request to the sample endpoint:
curl http://localhost:8080
Hello World!
Implementation
YAML configuration
As we generated the project choosing "Configuration in YAML file" all is set, and we can add our custom property in application.yaml
:
greeting:
name: "Bitelchus"
If we had chosen "Configuration in Code" we would need to make these changes:
1) Add io.ktor:ktor-server-config-yaml
dependency.
2) Change Application's main method from:
fun main() {
embeddedServer(
factory = Netty,
port = 8080,
host = "0.0.0.0",
module = Application::module
)
.start(wait = true)
}
To:
fun main() {
EngineMain.main(args)
}
3) Add Application's port and modules in application.yaml
:
ktor:
deployment:
port: 8080
application:
modules:
- org.rogervinas.GreetingApplicationKt.module
GreetingRepository
We will create a GreetingRepository
:
interface GreetingRepository {
fun getGreeting(): String
}
class GreetingJdbcRepository(private val connection: Connection) : GreetingRepository {
init {
createGreetingsTable()
}
override fun getGreeting(): String = connection
.createStatement()
.use { statement ->
statement
.executeQuery("""
SELECT greeting FROM greetings
ORDER BY random() LIMIT 1
""".trimIndent()
)
.use { resultSet ->
return if (resultSet.next()) {
resultSet.getString("greeting")
} else {
throw Exception("No greetings found!")
}
}
}
private fun createGreetingsTable() {
connection.createStatement().use {
it.executeUpdate("""
CREATE TABLE IF NOT EXISTS greetings (
id serial,
greeting varchar(100) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO greetings (greeting) VALUES ('Hello');
INSERT INTO greetings (greeting) VALUES ('Hola');
INSERT INTO greetings (greeting) VALUES ('Hi');
INSERT INTO greetings (greeting) VALUES ('Holi');
INSERT INTO greetings (greeting) VALUES ('Bonjour');
INSERT INTO greetings (greeting) VALUES ('Ni hao');
INSERT INTO greetings (greeting) VALUES ('Bon dia');
""".trimIndent()
)
}
}
}
- As Ktor does not offer any specific database support:
- We just use plain
java.sql
code (instead of any database connection library) - We just create the
greetings
table if it does not exist (instead of any database migration library like flyway)
- We just use plain
- Adding Postgres plugin when creating the project should add two dependencies:
-
org.postgresql:postgresql
for production. -
com.h2database:h2
for testing, that we can remove it as we will use Testcontainers
-
We create this function to create the repository:
private fun greetingRepository(config: ApplicationConfig): GreetingRepository {
val host = config.property("database.host").getString()
val port = config.property("database.port").getString()
val name = config.property("database.name").getString()
val username = config.property("database.username").getString()
val password = config.property("database.password").getString()
val connection = DriverManager.getConnection("jdbc:postgresql://$host:$port/$name", username, password)
return GreetingJdbcRepository(connection)
}
And we add these properties in application.yaml
:
database:
host: "$DB_HOST:localhost"
port: 5432
name: "mydb"
username: "myuser"
password: "mypassword"
Note that we allow to override database.host
with the value of DB_HOST
environment variable, or "localhost" if not set. This is only needed when running locally using docker compose.
GreetingController
We create a GreetingController
to serve the /hello
endpoint:
fun Application.greetingController(
name: String,
secret: String,
repository: GreetingRepository
) {
routing {
get("/hello") {
call.respondText {
"${repository.getGreeting()} my name is $name and my secret is $secret"
}
}
}
}
We just name it GreetingController
to follow the same convention as the other frameworks in this series, mainly SpringBoot.
Complete documentation at Routing guide.
Vault configuration
Ktor does not support Vault, so we will simply use BetterCloud/vault-java-driver:
private fun ApplicationConfig.withVault(): ApplicationConfig {
val vaultProtocol = this.property("vault.protocol").getString()
val vaultHost = this.property("vault.host").getString()
val vaultPort = this.property("vault.port").getString()
val vaultToken = this.property("vault.token").getString()
val vaultPath = this.property("vault.path").getString()
val vaultConfig = VaultConfig()
.address("$vaultProtocol://$vaultHost:$vaultPort")
.token(vaultToken)
.build()
val vaultData = Vault(vaultConfig).logical().read(vaultPath).data
return this.mergeWith(MapApplicationConfig(vaultData.entries.map { Pair(it.key, it.value) }))
}
With these properties in application.yaml
:
vault:
protocol: "http"
host: "$VAULT_HOST:localhost"
port: 8200
token: "mytoken"
path: "secret/myapp"
Note that we allow to override vault.host
with the value of VAULT_HOST
environment variable, or "localhost" if not set. This is only needed when running locally using docker compose, same as with database.host
.
As an alternative, we could also use karlazzampersonal/ktor-vault plugin.
Next section will show how to use this ApplicationConfig.withVault()
extension.
Application
As defined in application.yaml
the only module loaded will be org.rogervinas.GreetingApplicationKt.module
so we need to implement it:
fun Application.module() {
val environmentConfig = environment.config.withVault()
val repository = greetingRepository(environmentConfig)
greetingController(
environmentConfig.property("greeting.name").getString(),
environmentConfig.propertyOrNull("greeting.secret")?.getString() ?: "unknown",
repository
)
}
- It will merge default
ApplicationConfig
and override it with Vault values. - It will create a
GreetingRepository
and aGreetingController
.
Testing the endpoint
We can test the endpoint this way:
class GreetingControllerTest {
private val repository = mockk<GreetingRepository>().apply {
every { getGreeting() } returns "Hello"
}
@Test
fun `should say hello`() = testApplication {
environment {
config = MapApplicationConfig()
}
application {
greetingController("Bitelchus", "apple", repository)
}
client.get("/hello").apply {
assertThat(status).isEqualTo(OK)
assertThat(bodyAsText())
.isEqualTo("Hello my name is Bitelchus and my secret is apple")
}
}
}
- We use testApplication DSL with an empty configuration to just test the controller.
- We mock the repository with
mockk
. - Complete documentation at Testing guide.
Testing the application
We can test the whole application this way:
@Testcontainers
class GreetingApplicationTest {
companion object {
@Container
private val container = DockerComposeContainer(File("../docker-compose.yaml"))
.withServices("db", "vault", "vault-cli")
.withLocalCompose(true)
.waitingFor("db", forLogMessage(".*database system is ready to accept connections.*", 1))
.waitingFor("vault", forLogMessage(".*Development mode.*", 1))
}
@Test
fun `should say hello`() = testApplication {
client.get("/hello").apply {
assertThat(status).isEqualTo(OK)
assertThat(bodyAsText())
.matches(".+ my name is Bitelchus and my secret is watermelon")
}
}
}
- We use Testcontainers to test with Postgres and Vault containers.
- We use pattern matching to check the greeting, as it is random.
- As this test uses Vault, the secret should be
watermelon
. - Complete documentation at Testing guide.
Test
./gradlew test
Run
# Start Vault and Database
docker compose up -d vault vault-cli db
# Start Application
./gradlew run
# Make requests
curl http://localhost:8080/hello
# Stop Application with control-c
# Stop all containers
docker compose down
Note that main class is specified in build.gradle.kts
:
application {
mainClass.set("org.rogervinas.GreetingApplicationKt")
}
Build a fatjar and run it
# Build fatjar
./gradlew buildFatJar
# Start Vault and Database
docker compose up -d vault vault-cli db
# Start Application
java -jar build/libs/ktor-app-all.jar
# Make requests
curl http://localhost:8080/hello
# Stop Application with control-c
# Stop all containers
docker compose down
More documentation at Creating fat JARs guide.
Build a docker image and run it
# Build docker image and publish it to local registry
./gradlew publishImageToLocalRegistry
# Start Vault and Database
docker compose up -d vault vault-cli db
# Start Application
docker compose --profile ktor up -d
# Make requests
curl http://localhost:8080/hello
# Stop all containers
docker compose --profile ktor down
docker compose down
We can configure "Image name", "Image tag" and "JRE version" in build.gradle.kts
:
ktor {
docker {
localImageName.set("${project.name}")
imageTag.set("${project.version}")
jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_17)
}
}
More documentation at Docker guide.
That's it! Happy coding! ๐
Top comments (0)