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!
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:
- Add
io.ktor:ktor-server-config-yaml
dependency. - 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)
}
- Add Application's port and modules in
application.yaml
:
ktor:
deployment:
port: 8080
application:
modules:
- org.rogervinas.GreetingApplicationKt.module
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.
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.
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.
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
.
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.
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.
./gradlew test
# 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 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 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! 💙