slide-transition: true slidenumbers: true
- Async server/client framework
- Lightweight
- Flexible
- Based on kotlin with full coroutine support
- Kotlin - using both DSL and extension support
- Full tooling support and java interop
- Give the project a name
- Under adjust project settings - choose your preferred build system
- I will be using Gradle Kotlin
- Choose
- latest stable ktor version
- CIO as engine
- Config in code
- Uncheck sample code
Choose to add some plugins - let's start with something basic
- Routing
- Static Content
- Status Pages
- Content Negotiation
- Kotlinx Serialization
- Generate, download, unpack and open
Open the project in Idea.
If using gradle and a late JDK - you might need to update the gradle wrapper:
./gradlew wrapper --gradle-version 8.7
Tidy each of the plugin files (remove any empty routing blocks).
Check if it runs OK (run Application).
Add a new file under plugins for static pages
Add the following code:
fun Application.configureStaticPages() {
routing {
staticFiles("/static", File("content"))
}
}
and add a content directory under the project root - with a simple index.html page inside
Finally - add the plugin to application (call configureStaticPages
)
We can also add static resources that will load from the classpath
Expand the routing block
staticResources("/staticresource", "staticcontent")
Add a directory staticcontent under src/main/resources - and add a simple index.html file there too
https://ktor.io/docs/serving-static-content.html
We've already used routing for the static pages - what else can we do?
Let's create a simple get request.
Add a new plugin file and create the following:
fun Application.configureSimpleRequests() {
routing {
get("/get_1") {
call.respondText("Get 1")
}
}
}
Add it to the list of plugins the application loads
route('path') {}
- groups calls under a given path - can be nested
get {}
orget('path') {}
- creates a get endpoint
- similar for post, put, patch, delete etc
OK - but this isn't much better than static files - how can we accept
- path parameters?
- query parameters?
- post request bodies?
- ...
Add a couple of routes to the simple request routing block:
get("/pathParam/{paramName}") {
call.respondText(call.parameters["paramName"] ?: "No paramName supplied")
}
get("/queryParam") {
call.respondText(call.request.queryParameters["paramName"] ?: "No paramName supplied")
}
Note that we are forced to handle the fact that the params may not be supplied (they return String?)
This is the case for both even though the pathParam route will not match without a param.
https://ktor.io/docs/routing-in-ktor.html https://ktor.io/docs/requests.html
Let's consume a request body to a data class - and echo it back
We'll need a class - for now - just throw it into the same file as the simple request routes.
kotlinx.serialization needs the @Serializable
annotation.
@Serializable
data class SimpleRequestBody(val name: String, val message: String)
Add this to the routing block:
route("/simpleBody") {
get {
call.respond(SimpleRequestBody("Hi", "There"))
}
post {
val body = call.receive<SimpleRequestBody>()
call.respond(body)
}
}
https://ktor.io/docs/serialization.html
Ktor by default provides an slf4j compatible logging using logback classic as the implementation.
Let's add the following:
- call logging
- call ID
- custom loggers for our code
New dependency:
io.ktor:ktor-server-call-logging
Add a new module plugin for logging
fun Application.configureLogging() {
install(CallLogging)
}
Remember to call it from your application.
For larger systems - we may want a unique ID for a call that is used for calls on to other backends. A correlation ID.
New dependency:
io.ktor:ktor-server-call-id
Add this to the logging plugin config
install(CallId) {
generate {
UUID.randomUUID().toString()
}
}
Remember to call it from your application.
So - we can see the ID is being set in the logs:
Setting id for a call ... to 72748957-0bbf-4f4d-8262-8abe5d2bce1c
But - we want to see it for all logs related.
We can add it to the MDC.
Add this to the configuration of the CallLogging block
callIdMdc("call-id")
Logback classic needs the MDC configuration in its encoder pattern.
Edit the pattern in logback.xml - either add %X (whole MDC) or %X{call-id}
https://ktor.io/docs/call-logging.html https://ktor.io/docs/call-id.html
Full support for just using the slf4j API is already present - but I prefer kotlin logging
Dependency:
io.github.oshai:kotlin-logging-jvm:6.0.3
or latest
For any file we can create a static logger at the top:
private val logger = KotlinLogging.logger {}
From the docs - the two most useful forms are:
logger.debug { "Some $expensive message!" }
logger.error(exception) { "a $fancy message about the $exception" }
Let's log the params for query and request param cases.
get("/pathParam/{paramName}") {
val param = call.parameters["paramName"] ?: "No paramName supplied"
logger.debug { "Path param: $param" }
call.respondText(param)
}
get("/queryParam") {
val param = call.request.queryParameters["paramName"] ?: "No paramName supplied"
logger.debug { "Query param: $param" }
call.respondText(param)
}
https://github.com/oshai/kotlin-logging
The StatusPages plugin was installed by the starter code into the Routing file.
At the minute it catches Throwable - and sends a 500.
This can be customized to match any exception and set any different HTTP status.
Remember that routes can also return other codes than 200 - it doesn't have to go via StatusPages and exceptions.
So far - each of our module configuration plugins are independent.
This is not common in a large application.
Let's test out a sample with a route that calls a service that calls a repository
We'll use a mutable list as backend storage in the repository and the service will be a simple repository wrapper just to keep things simple.
Create the following packages
- model
- repository
- service
@Serializable
data class Thingy(
val id: Int,
val name: String
)
class ThingyRepository {
private val thingies = mutableListOf(
Thingy(id = 1, name = "Thingy 1"),
Thingy(id = 2, name = "Thingy 2"),
Thingy(id = 3, name = "Thingy 3"),
Thingy(id = 4, name = "Thingy 4"),
)
fun find(id: Int) = thingies.find { t -> t.id == id }
fun add(thingy: Thingy) = when (find(thingy.id)) {
null -> {
thingies.add(thingy)
thingy
}
else -> {
logger.info { "Thingy ${thingy.id} already exists" }
null
}
}
fun all() = thingies.toList()
fun delete(id: Int) = find(id)?.let { thingy ->
thingies.remove(thingy)
thingy
}
}
class ThingyService(private val repository: ThingyRepository) {
fun find(id: Int) = repository.find(id)
fun add(thingy: Thingy) = repository.add(thingy)
fun all() = repository.all()
fun delete(id: Int) = repository.delete(id)
}
fun Application.configureThingyRequests(service: ThingyService) {
routing {
route("/thingies") {
get("/") {
call.respond(service.all())
}
get("/{id}") {
when (val id = call.parameters["id"]?.toInt()) {
null -> call.respond(HttpStatusCode.BadRequest)
else -> when (val thingy = service.find(id)) {
null -> call.respond(HttpStatusCode.NotFound)
else -> call.respond(thingy)
}
}
}
post("/") {
val thingy = call.receive<Thingy>()
when (val newThingy = service.add(thingy)) {
null -> call.respond(HttpStatusCode.Conflict)
else -> call.respond(newThingy)
}
}
delete("/") {
when (val id = call.request.queryParameters["id"]?.toInt()) {
null -> call.respond(HttpStatusCode.BadRequest)
else -> when (val thingy = service.delete(id)) {
null -> call.respond(HttpStatusCode.NotFound)
else -> call.respond(thingy)
}
}
}
}
}
}
Add to Application.module()
val thingyRepository = ThingyRepository()
val thingyService = ThingyService(thingyRepository)
configureThingyRequests(thingyService)
No - there are libraries out there that provide a similar idea to spring's context.
See Koin for example: https://insert-koin.io/docs/reference/koin-ktor/ktor/
What if we need to call out to other microservices?
Let's add a client.
Dependencies:
io.ktor:ktor-client-core
io.ktor:ktor-client-cio
io.ktor:ktor-client-content-negotiation
There are multiple engines available for different platforms - here we've grabbed CIO
NOTE: There are similar named classes in both server and client (e.g. ContentNegotiation). Make sure you have imported the correct ones for each part of the system.
Create a utility file:
val catClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
}
@Serializable
data class CatFact(
val fact: String,
val length: Int
)
Then add this endpoint to simpleRequest:
get("catFact") {
call.respond(catClient.get("https://catfact.ninja/fact").body<CatFact>())
}
Note that the get call on the client is a suspending function. Route blocks are coroutine scoped so this is fine.
https://ktor.io/docs/client-dependencies.html https://ktor.io/docs/create-client.html https://ktor.io/docs/http-client-engines.html
Ktor supports most of the common authentication mechanisms - but the one we use most often is JWT.
We'll use the simplest form - just an auth token.
Start with adding the following dependencies:
io.ktor:ktor-server-auth-jvm
io.ktor:ktor-server-auth-jwt-jvm
com.password4j:password4j:1.8.1
// or latest
We need to be able to login with username and password, and get back a token.
That token needs to include some claims.
We need to have some model classes for login, token and claims.
In model - create the following:
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class LoginResponse(
val token: String,
)
@Serializable
data class UserClaims(
val username: String,
)
We need to be able to load some configuration.
This is in two parts, and the existing configuration needs to change - the application should load a configuration file.
The configuration file should have sensible (for development) defaults - but read values from the environment (for deployment)
Rebuild the embeddedServer
in Application to look like this:
val env = applicationEngineEnvironment {
connector {
host = "0.0.0.0"
port = 8080
}
config = ApplicationConfig("application.conf")
module {
module()
}
}
embeddedServer(CIO, env).start(wait = true)
This is the same as before with the additional load of application.conf
Create this file in src/main/resources/application.conf
jwt {
secret = "secret"
secret = ${?JWT_SECRET}
issuer = "http://0.0.0.0:8080/"
issuer = ${?JWT_ISSUER}
audience = "http://0.0.0.0:8080/"
audience = ${?JWT_AUDIENCE}
realm = "Workshop"
}
The ${?FOO}
syntax means "overwrite from environment if found"
We need to tell Password4j what encryption we want it to use when creating password hashes.
When reading hashes - the hash itself contains this information.
Create the file src/main/resources/psw4j.properties
global.banner=false
hash.bcrypt.minor=b
hash.bcrypt.rounds=12
We're going to need a login route. That will need to be able to build a token:
Add this to a login routing plugin file:
private const val EXPIRY = 1000 * 60 * 20
fun buildToken(
config: ApplicationConfig,
claims: UserClaims,
): String =
JWT.create()
.withAudience(config.property("jwt.audience").getString())
.withIssuer(config.property("jwt.issuer").getString())
.withClaim("username", claims.username)
.withExpiresAt(Date(System.currentTimeMillis() + EXPIRY))
.sign(Algorithm.HMAC256(config.property("jwt.secret").getString()))
Then add the actual routing
fun Application.configureLoginRouting(
service: UserService,
) {
val config = environment.config
routing {
post("/login") {
val loginRequest = call.receive<LoginRequest>()
if (!service.checkPassword(loginRequest.username, loginRequest.password)) {
logger.debug { "Invalid password for ${loginRequest.username}" }
throw RuntimeException("Invalid password")
}
val claims = service.claims(loginRequest.username) ?: throw RuntimeException("Missing claims")
call.respond(LoginResponse(token = buildToken(config, claims)))
}
}
}
class UserService(private val repository: UserRepository) {
fun checkPassword(username: String, password: String) = repository.hashForUser(username)?.let { dbPassword ->
Password.check(password, dbPassword).withBcrypt()
} ?: false
fun claims(username: String) = repository.claimsForUser(username)
}
Dummy - only username "valid" with a password of "password" is valid :)
class UserRepository {
fun hashForUser(username: String) = when (username) {
// "password" - should be loaded from a database or similar of course
"valid" -> "\$2b\$12\$Tc8n3Lvjuvv5iF4of0Q.Tu.wAJ9n5EMJWntbtw5NgvaSGAN00H4aK"
else -> null
}
fun claimsForUser(username: String) = when (username) {
"valid" -> UserClaims(username)
else -> null
}
}
val userRepository = UserRepository()
val userService = UserService(userRepository)
configureLoginRouting(userService)
Currently an incorrect password is throwing a runtime exception and giving us a 500.
We could just call.respond(HttpStatusCode.Unauthorized)
- but let's use status pages.
Create the following exception somewhere (same file as status pages will do)
class FailedLogin(message: String) : Exception(message)
Then add a clause to the status pages block (will need to add a logger to the file too):
exception<FailedLogin> { call, cause ->
logger.error(cause) { "Login failed due to $cause" }
call.respond(HttpStatusCode.Unauthorized)
}
Now change the exception from RuntimeException to FailedLogin in the login routing block and fail to login again.
It's not a lot of use to have a token - if you can't use the token
This is in two parts - we need a configuration and a route block that is authenticated
Yet another plugin file - Security
The configuration has two parts - a verifier which we can call to verify a token:
fun buildJwtVerifier(config: ApplicationConfig): JWTVerifier =
JWT
.require(Algorithm.HMAC256(config.property("jwt.secret").getString()))
.withAudience(config.property("jwt.audience").getString())
.withIssuer(config.property("jwt.issuer").getString())
.build()
and an actual auth config called auth-jwt
which also validates the audience from the token
fun Application.configureSecurity() {
val config = environment.config
val verifier = buildJwtVerifier(config)
authentication {
jwt("auth-jwt") {
realm = config.property("jwt.realm").getString()
verifier(verifier)
validate { credential ->
if (credential.payload.audience.contains(config.property("jwt.audience").getString())) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
}
And finally - we can add a set of routes that require this auth to be used.
Add this to the configureSecurity block after the authentication block:
routing {
route("/authorized") {
authenticate("auth-jwt") {
get {
when (val principal = call.principal<JWTPrincipal>()) {
null -> throw RuntimeException("Didn't find principal")
else -> call.respondText(principal.get("username") ?: "No username found")
}
}
}
}
}
Remember to add configureSecurity
to application
Basic unit testing is the same as for any other project.
Integration testing via test client however is specific.
io.kotest:kotest-assertions-core:5.8.1
io.kotest:kotest-runner-junit5:5.8.1
// if you want to use junit
io.mockk:mockk-jvm:1.13.10
Use latest versions
Create the following test class under src/test/kotlin (might need creating).
Here I'm using kotlin.test.Test:
class SampleTests {
@Test
fun `test simple GET request`() = testApplication {
val response = client.get("/get_1")
response.status shouldBe HttpStatusCode.OK
response.headers[HttpHeaders.ContentType] shouldContain "text/plain"
response.bodyAsText() shouldBe "Get 1"
}
}
If you run the test - you get a 404 not found - why?
It's because we have not configured any routing (or anything else) in the test application.
Add the following to the testApplication block just above val response
application {
configureSimpleRequests()
}
Re-running the test now should pass
To test these - we need to install the server ContentNegotiation plugin to the test application and the client ContentNegotiation plugin in the test client.
@Test
fun `test with content negotiation`() {
}
Now - let's build it block by block
We want to test thingies - so we'll need a service which will need a repository. We'll mock the repository with mockk
val repository = mockk<ThingyRepository>()
every { repository.all() } returns listOf(Thingy(1, "Thing 1"))
Configure the application - here we'll need configureSerialization too (without it you'll get a 406 Not Acceptable)
testApplication {
application {
configureSerialization()
configureThingyRequests(ThingyService(repository))
}
}
Inside test application - instead of using the default client - we need to configure one with client content negotiation.
val client = createClient {
install(ContentNegotiation) {
json()
}
}
Be sure to import client and not server ClientNegotiation.
This gives a test application that has server content negotiation (from configureSerialization()
) and a client with client content negotiation - so they should be able to negotiate.
val response = client.get("/thingies/")
response.status shouldBe HttpStatusCode.OK
response.headers[HttpHeaders.ContentType] shouldContain "application/json"
val thingies = response.body<List<Thingy>>()
thingies.size shouldBe 1
thingies.first() shouldBe Thingy(1, "Thing 1")