Creating your reactive REST API with Kotlin and Ktor part III

José Luis González Sánchez
Hyperskill
Published in
21 min readJun 27, 2023

Introduction

Now it is time for this tutorial’s third and final part on creating a reactive REST service.

In the first part, we discussed the benefits of applying reactivity in our services and created our first endpoint using Ktor. In the second part, we implement some services: WebSockets, cache, storage, and use railway-oriented programming to manage our errors and test using mocks.

In the final part, we will focus on improving dependency injection, providing authentication and authorization mechanisms, documenting, and deploying our service in a container. Please visit previous tutorials first to understand some of the steps taken here and taken for granted or see how the code has been organized.

Like in other tutorials, the objective is to show you everything you will learn through the tracks on Hyperskill. Hyperskill is the perfect place to delve deeper, expand your knowledge, and learn more about what is presented in this tutorial. It is the ideal platform for learning Kotlin technologies and using Ktor. Feel free to join and continue your learning journey. Remember that this code is pedagogical and shows many of the contents you will learn in a didactic and easy-to-read way. It is not intended to create the best production code in real environments. We know that many things can be done better, but they are exaggerated in the code so that, as a student, you can analyze the possibilities. Take it easy and check Hyperskill for more cool features for your Kotlin and Ktor services. Please feel free to experiment and modify what can improve your self-coding with different examples and reach your goals.

Dependency Injection with Koin

Dependency injection is a design pattern used in software development to achieve loose coupling and promote modular, reusable code. In this pattern, the dependencies (external objects or services) required by a class are provided rather than the class creating or managing those dependencies themselves. There are several reasons why you should use dependency injection:

  • Decoupling: By injecting dependencies, a class doesn’t need to know how to create or manage them. It decouples the class from the specific implementations of its dependencies, allowing for easier maintenance, testing, and future modifications.
  • Reusability: With dependency injection, dependencies can be reused across multiple classes or modules. It promotes code reuse, as the same dependency can be injected into different classes without duplicating code or creating tight dependencies.
  • Testability: Dependency injection facilitates unit testing by allowing dependencies to be easily mocked or replaced with test doubles. This enables isolated testing of individual components without complex setups or reliance on external resources.
  • Flexibility: Dependency injection makes it easier to swap out or change dependencies without modifying the classes that depend on them. This flexibility allows for easier adaptation to changing requirements or integrating new functionalities.

In our service, we use Koin. Koin is a multiplatform lightweight dependency injection framework for Kotlin. It provides a simple and concise way to handle dependencies in your Kotlin projects. With Koin, you can easily define and inject dependencies without the need for complex configuration or boilerplate code. It offers a DSL (Domain Specific Language) that allows you to declare and resolve dependencies readably and intuitively. We can use annotations or a special version for Ktor. I love Koin because it adapts to what you need in any situation.

Koin logo by Kotzilla

We add the following dependencies in our build.gradle.kts file and sync the project. We’ll use annotations because if you’re new to Koin, the end goal is to make dependency injection easier and not learn all the DSL or other options that Koin offers, so we need to add the ksp plugin and options with Koin.

plugins {
kotlin("jvm") version "1.8.21"
id("io.ktor.plugin") version "2.3.1"
id("org.jetbrains.kotlin.plugin.serialization") version "1.8.21"
// KSP for Koin Annotations
id("com.google.devtools.ksp") version "1.8.21-1.0.11"
}

//...
dependencies {
// ...
// Koin for Dependency Injection
implementation("io.insert-koin:koin-ktor:$koin_ktor_version") // Koin for Ktor
implementation("io.insert-koin:koin-logger-slf4j:$koin_ktor_version") // Koin Logger
implementation("io.insert-koin:koin-annotations:$koin_ksp_version") // Koin Annotations for KSP
ksp("io.insert-koin:koin-ksp-compiler:$koin_ksp_version") // Koin KSP Compiler for KSP
}

Set up Koin

Before we proceed, we need to configure Koin in Ktor. Thanks to its functionality for Ktor, this is an easy task. Once again, we’ll create a plugin called “Koin.” Since we’ll be using Koin with annotations, we don’t have to define anything else explicitly. All the dependencies will be defined using annotations, automatically creating a default module with all the pre-calculated dependencies. It’s straightforward and to the point. Finally, as we have done before, we’ll load this plugin into our application. I recommend you load it as the first plugin because these dependencies will be needed from the beginning.

fun Application.configureKoin() {
install(Koin) {
slf4jLogger() // Logger
defaultModule() // Default module with Annotations
}
fun Application.module() {
configureKoin() // Configure the Koin plugin to inject dependencies
configureWebSockets() // Configure the websockets plugin
// ...
}

Configuration injection

Let’s create a configuration class to encapsulate our service’s settings. This way, we can use different configuration files if needed. By doing this, we can easily inject this configuration. We will use the @Singleton annotation to ensure only one instance of it.

@Singleton
class AppConfig {
val applicationConfiguration: ApplicationConfig = ApplicationConfig("application.conf")
// We can set here all the configuration we want from application.conf or from other sources
// val applicationName: String = applicationConfiguration.property("ktor.application.name").getString()
// val applicationPort: Int = applicationConfiguration.property("ktor.application.port").getString().toInt()
}

Preparing Repository and Services

We use @Singleton in our repository and services

@Singleton
class RacketsRepositoryImpl(
private val dataBaseService: DataBaseService
) : RacketsRepository {
//...
}

@Singleton
class DataBaseService(
private val myConfig: AppConfig,
) {
//...
}

@Singleton
class CacheService(
private val myConfig: AppConfig,
) {
//...
}

@Singleton
class StorageServiceImpl(
private val myConfig: AppConfig
) : StorageService {
//...
}

@Singleton
class RacketsServiceImpl(
private val racketsRepository: RacketsRepository,
private val cacheService: CacheService
) : RacketsService {
//...
}

Injecting dependencies in routes

Now, it’s time to inject dependencies into the routes. To do this, we can use get, which provides us with an immediate injection, or lazy, which provides us with a lazy injection, meaning the dependencies will be injected when we want to use them. In our case, the repository will be retrieved using get to have it immediately available. However, the storage will be lazy because it’s necessary once an image is uploaded. It’s important to note that this can vary depending on your specific problem or needs. As we can easily see, Koin will inject the dependencies we need wherever we need them, almost like magic. Isn’t it wonderful!

// Dependency injection by Koin
val racketsService: RacketsService = get()
val storageService: StorageService by inject()

Authentication and Authorization with JWT

Authentication is verifying a user’s or entity’s identity, while authorization involves granting or denying access based on the user’s privileges. JWT (JSON Web Token) is a token-based authentication and authorization mechanism in web applications. JWT is a compact, self-contained token that consists of three parts: a header, a payload, and a signature. It securely carries information about the user’s identity and permissions.

JWT structure

The token is generated by the server upon successful authentication and is included in subsequent requests for authorization. When a request with a JWT is received, the server verifies the token’s authenticity using a secret key. It extracts the necessary information from the payload and determines whether the user has the required permissions to access the requested resources. By utilizing JWT, developers can implement a secure and scalable authentication and authorization system for services without needing a session state on the server side. It simplifies managing user sessions and enables stateless communication between client and server.

JWT auth

Added dependencies

The first step is to add the dependencies we need to handle JWT. Additionally, we will use Bcrypt to encrypt user passwords. Add the following dependencies to our build.gradle.kts file and sync the project.

// Auth JWT
implementation("io.ktor:ktor-server-auth-jvm:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktor_version")
implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version")
// BCrypt
implementation("com.ToxicBakery.library.bcrypt:bcrypt:$bcrypt_version")

Token configuration

Let’s add our token configuration to our application.conf file.

# JWT
jwt {
secret = "IL0v3L34rn1ngKt0rWithJ0s3Lu1sGS4ndHyp3r$k1ll"
realm = "rackets-ktor"
## Expiration time: 3600s (1 hour)
expiration = "3600"
issuer = "rackets-ktor"
audience = "rackets-ktor-auth"
}

Token Service

The next step is to create a service that generates the tokens and allows us to verify them.

sealed class TokenException(message: String) : RuntimeException(message) {
class InvalidTokenException(message: String) : TokenException(message)
}

@Single
class TokensService(
private val myConfig: AppConfig
) {

val audience by lazy {
myConfig.applicationConfiguration.propertyOrNull("jwt.audience")?.getString() ?: "jwt-audience"
}
val realm by lazy {
myConfig.applicationConfiguration.propertyOrNull("jwt.realm")?.getString() ?: "jwt-realm"
}
private val issuer by lazy {
myConfig.applicationConfiguration.propertyOrNull("jwt.issuer")?.getString() ?: "jwt-issuer"
}
private val expiresIn by lazy {
myConfig.applicationConfiguration.propertyOrNull("jwt.tiempo")?.getString()?.toLong() ?: 3600
}
private val secret by lazy {
myConfig.applicationConfiguration.propertyOrNull("jwt.secret")?.getString() ?: "jwt-secret"
}

init {
logger.debug { "Init tokens service with audience: $audience" }
}

fun generateJWT(user: User): String {
return JWT.create()
.withAudience(audience)
.withIssuer(issuer)
.withSubject("Authentication")
// user claims and other data to store
.withClaim("username", user.username)
.withClaim("usermail", user.email)
.withClaim("userId", user.id.toString())
// expiration time from currentTimeMillis + (tiempo times in seconds) * 1000 (to millis)
.withExpiresAt(Date(System.currentTimeMillis() + expiresIn * 1000L))
// sign with secret
.sign(
Algorithm.HMAC512(secret)
)
}

fun verifyJWT(): JWTVerifier {

return try {
JWT.require(Algorithm.HMAC512(secret))
.withAudience(audience)
.withIssuer(issuer)
.build()
} catch (e: Exception) {
throw TokenException.InvalidTokenException("Invalid token")
}
}
}

Users Repository

Now we need a user repository, so we need our user model.

data class User(
val id: Long = NEW_USER,
val name: String,
val email: String,
val username: String,
val password: String,
val avatar: String = DEFAULT_IMAGE,
val role: Role = USER,
val createdAt: LocalDateTime = LocalDateTime.now(),
val updatedAt: LocalDateTime = LocalDateTime.now(),
val deleted: Boolean = false
) {

companion object {
const val NEW_USER = -1L
const val DEFAULT_IMAGE = "https://i.imgur.com/fIgch2x.png"
}

enum class Role {
USER, ADMIN
}
}

We also need our tables and entities and their mappers to store them in the database.

object UserTable : H2Table<UserEntity>("users") {
// Autoincrement and primary key
val id = autoIncrementBigInt(UserEntity::id).primaryKey()

// Other fields
val name = varchar(UserEntity::name)
val email = varchar(UserEntity::email)
val username = varchar(UserEntity::username)
val password = varchar(UserEntity::password)
val avatar = varchar(UserEntity::avatar)
val role = varchar(UserEntity::role)

// metadata
val createdAt = timestamp(UserEntity::createdAt, "created_at")
val updatedAt = timestamp(UserEntity::updatedAt, "updated_at")
val deleted = boolean(UserEntity::deleted)
}

data class UserEntity(
// Id
val id: Long?, //

// data
val name: String,
val email: String,
val username: String,
val password: String,
val avatar: String = User.DEFAULT_IMAGE,
val role: String = User.Role.USER.name,
val createdAt: LocalDateTime = LocalDateTime.now(),
val updatedAt: LocalDateTime = LocalDateTime.now(),
val deleted: Boolean = false
)

Finally, we will add some sample data using BCrypt to make some users available. In our database service, we will instruct it to create the user tables and load this data, as in previous tutorials. We need Bcrypt to hash passwords because it provides a secure and computationally expensive hashing algorithm that helps protect user passwords from being easily compromised or decrypted by potential attackers, and we store hashed passwords in our database.

It’s time to code our repository to handle users and implement the CRUD operations for them.

@Singleton
class UsersRepositoryImpl(
private val dataBaseService: DataBaseService
) : UsersRepository {


override suspend fun findAll(): Flow<User> = withContext(Dispatchers.IO) {
logger.debug { "findAll" }

return@withContext (dataBaseService.client selectFrom UserTable).fetchAll()
.map { it.toModel() }
}

// . . .


override suspend fun checkUserNameAndPassword(username: String, password: String): User? =
withContext(Dispatchers.IO) {
val user = findByUsername(username)
return@withContext user?.let {
if (Bcrypt.verify(password, user.password.encodeToByteArray())) {
return@withContext user
}
return@withContext null
}
}
// . . .
override suspend fun findById(id: Long): User? = withContext(Dispatchers.IO) {
logger.debug { "findById: Buscando usuario con id: $id" }

return@withContext (dataBaseService.client selectFrom UserTable
where UserTable.id eq id
).fetchFirstOrNull()?.toModel()
}
// . . .
}

Users Service

Now it’s time for our service, where we can also implement caching for users and follow a similar approach as we did in the previous tutorial with rackets with Railway-oriented Programming (UserErrors).

@Singleton
class UsersServiceImpl(
private val usersRepository: UsersRepository,
private val cacheService: CacheService
) : UsersService {

override suspend fun findAll(): Flow<User> {
logger.debug { "findAll: search all users" }
return usersRepository.findAll()
}

override suspend fun findById(id: Long): Result<User, UserError> {
logger.debug { "findById: search user by id" }

// find in cache if not found in repository
return cacheService.users.get(id)?.let {
logger.debug { "findById: found in cache" }
Ok(it)
} ?: run {
usersRepository.findById(id)?.let { user ->
logger.debug { "findById: found in repository" }
cacheService.users.put(id, user)
Ok(user)
} ?: Err(UserError.NotFound("User with id $id not found"))
}
}

override suspend fun findByUsername(username: String): Result<User, UserError> {
logger.debug { "findById: search user by username" }
// find in cache if not found in repository
return usersRepository.findByUsername(username)?.let { user ->
logger.debug { "findById: found in repository" }
cacheService.users.put(user.id, user)
Ok(user)
} ?: Err(UserError.NotFound("User with username: $username not found"))
}
// . . .
}

Configure JWT Plugin

Before we proceed, we need to configure the JWT plugin. This way, we can intercept JWT tokens using our token service to analyze their validity. In Security.kt Plugin.

fun Application.configureSecurity() {
// Inject the token service
val jwtService: TokensService by inject()

authentication {
jwt {
// Load the token verification config
verifier(jwtService.verifyJWT())
// With realm we can get the token from the request
realm = jwtService.realm
validate { credential ->
// If the token is valid, it also has the indicated audience,
// and has the user's field to compare it with the one we want
// return the JWTPrincipal, otherwise return null
if (credential.payload.audience.contains(jwtService.audience) &&
credential.payload.getClaim("username").asString().isNotEmpty()
)
JWTPrincipal(credential.payload)
else null
}

challenge { defaultScheme, realm ->
throw TokenException.InvalidTokenException("Invalid or expired token")
}
}
}

}

We can also use StatusPages to automatically return error responses when encountering invalid or expired JWT tokens.

// Token is not valid or expired
exception<TokenException.InvalidTokenException> { call, cause ->
call.respond(HttpStatusCode.Unauthorized, cause.message.toString())
}

Users Routes

Now it’s time to create the routes to allow users to register, log in or perform specific operations based on their roles. We are going to inject the dependencies you need.

We can use authenticate to indicate that those routes or requests should be authenticated. Additionally, we can obtain the token data using JWTPrincipal.

authenticate {
// Get the user info --> GET /api/users/me (with token)
get("/me") {
logger.debug { "GET Me /$ENDPOINT/me" }

// Token came with principal (authenticated) user in its claims
// Be careful, it comes with quotes!!!
val userId = call.principal<JWTPrincipal>()
?.payload?.getClaim("userId")
.toString().replace("\"", "").toLong()

usersService.findById(userId)
.mapBoth(
success = { call.respond(HttpStatusCode.OK, it.toDto()) },
failure = { handleUserError(it) }
)
}
// Get all users --> GET /api/users/list (with token and only if you are admin)
get("/list") {
logger.debug { "GET Users /$ENDPOINT/list" }

val userId = call.principal<JWTPrincipal>()
?.payload?.getClaim("userId")
.toString().replace("\"", "").toLong()

usersService.isAdmin(userId)
.onSuccess {
usersService.findAll().toList()
.map { it.toDto() }
.let { call.respond(HttpStatusCode.OK, it) }
}.onFailure {
handleUserError(it)
}
}
//..
}

Now, with Postman, we can use our tokens to make requests. You can obtain 401 if you are unauthorized or 403 Forbidden if you are not admin.

SSL/TSL

We have a problem, and you can see that our password when logging in needs to be end-to-end encrypted. SSL/TLS (Secure Sockets Layer/Transport Layer Security) is crucial in service for the following reasons:

  • Encryption: SSL/TLS ensures secure communication by encrypting data transmission and preventing unauthorized access to sensitive information.
  • Data Integrity: SSL/TLS verifies that data remains unchanged during transit, preventing tampering or modification by malicious actors.
  • Authentication: SSL/TLS enables server authentication, validating the server’s identity to establish trust with clients and prevent impersonation.
  • Trust and Credibility: Implementing SSL/TLS builds trust and credibility with users, assuring them that their data is protected and fostering a positive user experience.
  • Compliance: SSL/TLS is often required for regulatory compliance, ensuring the protection of sensitive data and adherence to industry standards and regulations.

In this example, we will use self-signed certificates but do not use them in production. We are only doing this to demonstrate how to secure our communications.

Let’s create a folder called cert, and with this script, we will generate our keystore containing the server’s private and public keys and their certificate for advertising to clients.

#!/usr/bin/env bash
## Server KeyStore: Private Key + Public Certificate (PKCS12)
keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype PKCS12 -keystore server_keystore.p12 -storepass 1234567

Added dependencies

We add the following dependencies in our build.gradle.kts file and sync the project.

// SSL/TLS
implementation("io.ktor:ktor-network-tls-certificates:$ktor_version")

Configure SSL/TS

Now it is time to configure the service. We will add this option in our configuration file application.conf with the secure port and SSL options.

ktor {
deployment {
port = 8080
port = ${?PORT}
## SSL, you need to enable it
sslPort = 8083
sslPort = ${?SSL_PORT}
}

# Configure the main module
application {
modules = [ joseluisgs.dev.ApplicationKt.module ]
}

## Development mode
# Enable development mode. Recommended to set it via -Dktor.deployment.environment=development
# development = true
deployment {
## Watch for changes in this directory and automatically reload the application if any file changes.
watch = [ classes, resources ]
}

## Modo de ejecución
environment = dev
environment = ${?KTOR_ENV}

## To enable SSL, you need to generate a certificate and configure it here
security {
ssl {
keyStore = cert/server_keystore.p12
keyAlias = "serverKeyPair"
keyStorePassword = "1234567"
privateKeyPassword = "1234567"
}
}
}

From now on, all requests to the secure port with Postman will be encrypted.

Document our service

It’s time to document our service. We will use Dokka to generate Kotlin code documentation and Swagger for API documentation.

Dokka is a documentation engine for Kotlin. It allows developers to generate comprehensive documentation for their code, including classes, functions, properties, and more. Dokka parses the source code and generates HTML or other documentation formats that developers can easily browse and access.

Swagger is an open-source framework and a powerful tool for designing, building, documenting, and consuming RESTful APIs. It provides a suite of tools that enable developers to define API specifications using the OpenAPI Specification (OAS), formerly known as Swagger Specification. With Swagger, developers can create interactive API documentation that includes details about endpoints, request/response formats, parameters, authentication requirements, and more. It also allows for testing and exploring APIs directly from the documentation.

Added dependencies

We added the following dependencies in our build.gradle.kts file and sync the project. For Dokka, add the plugin.

// Dokka for documentation
id("org.jetbrains.dokka") version "1.8.10"

We will use the following library for Swagger: Ktor Swagger, which offers an extension function to document our routes.

repositories {
mavenCentral()
maven("https://jitpack.io") // For Swagger UI
}

We will need CORS options

// CORS
implementation("io.ktor:ktor-server-cors:$ktor_version")
// To generate Swagger UI
implementation("io.github.smiley4:ktor-swagger-ui:$ktor_swagger_ui_version")

Configure Plugins

We need to configure the CORS plugin. CORS (Cross-Origin Resource Sharing) is a security mechanism implemented in web browsers that control access to resources from different origins. It allows or restricts cross-origin requests, helping to prevent unauthorized access and protect user data. Here is an example.

fun Application.configureCors() {
install(CORS) {
anyHost() // Allow from any host
allowHeader(HttpHeaders.ContentType) // Allow Content-Type header
allowHeader(HttpHeaders.Authorization)
allowHost("client-host") // Allow requests from client-host
}
}

Now we configure the Swagger plugin to configure the global options and detect the endpoint.

fun Application.configureSwagger() {
// https://github.com/SMILEY4/ktor-swagger-ui/wiki/Configuration
// http://xxx/swagger/
install(SwaggerUI) {
swagger {
swaggerUrl = "swagger"
forwardRoot = false
}
info {
title = "Ktor Hyperskill Reactive API REST"
version = "latest"
description = "Example of a Ktor API REST using Kotlin and Ktor"
contact {
name = "José Luis González Sánchez"
url = "https://github.com/joseluisgs"
}
license {
name = "Creative Commons Attribution-ShareAlike 4.0 International License"
url = "https://joseluisgs.dev/docs/license/"
}
}

schemasInComponentSection = true
examplesInComponentSection = true
automaticTagGenerator = { url -> url.firstOrNull() }
// We can filter paths and methods
pathFilter = { method, url ->
url.contains("rackets")
//(method == HttpMethod.Get && url.firstOrNull() == "api")
// || url.contains("test")
}

// We can add security
securityScheme("JWT-Auth") {
type = AuthType.HTTP
scheme = AuthScheme.BEARER
bearerFormat = "jwt"
}
}
}

We add these plugins to our Application module

fun Application.module() {
configureKoin() // Configure the Koin plugin to inject dependencies
configureSecurity() // Configure the security plugin with JWT
configureWebSockets() // Configure the websockets plugin
configureSerialization() // Configure the serialization plugin
configureRouting() // Configure the routing plugin
configureValidation() // Configure the validation plugin
configureStatusPages() // Configure the status pages plugin
configureCompression() // Configure the compression plugin
configureCors() // Configure the CORS plugin
configureSwagger() // Configure the Swagger plugin
}

Document with Dokka

To document with Dokka, we can make use of your annotations.

/**
* Find by ID, if not exists return null
* @param id Long ID
* @return Racket? Racket or null
*/
override suspend fun findById(id: Long): Racket? = withContext(Dispatchers.IO) {
logger.debug { "findById: $id" }

return@withContext (dataBaseService.client selectFrom RacketTable
where RacketTable.id eq id)
.fetchFirstOrNull()?.toModel()
}

Now with Gradle → Documentation, you can generate Dokka docs.

Document with Swagger

To document with Swagger, we can complete our routes and petitions with docs options.

routing {
route("/$ENDPOINT") {

// Get all racket --> GET /api/rackets
get({
description = "Get All Rackets"
request {
queryParameter<Int>("page") {
description = "page number"
required = false // Optional
}
queryParameter<Int>("perPage") {
description = "number of elements per page"
required = false // Optional
}
}
response {
default {
description = "List of Rackets"
}
HttpStatusCode.OK to {
description = "List of Rackets"
body<List<RacketResponse>> { description = "List of Rackets" }
}
}
}) {
// QueryParams: rackets?page=1&perPage=10
call.request.queryParameters["page"]?.toIntOrNull()?.let {
val page = if (it > 0) it else 0
val perPage = call.request.queryParameters["perPage"]?.toIntOrNull() ?: 10

logger.debug { "GET ALL /$ENDPOINT?page=$page&perPage=$perPage" }

racketsService.findAllPageable(page, perPage)
.toList()
.run {
call.respond(HttpStatusCode.OK, RacketPage(page, perPage, this.toResponse()))
}

} ?: run {
logger.debug { "GET ALL /$ENDPOINT" }

racketsService.findAll()
.toList()
.run { call.respond(HttpStatusCode.OK, this.toResponse()) }
}
}

// Get one racket by id --> GET /api/rackets/{id}
get("{id}", {
description = "Get Racket by ID"
request {
pathParameter<Long>("id") {
description = "Racket ID"
}
}
response {
HttpStatusCode.OK to {
description = "Racket"
body<RacketResponse> { description = "Racket" }
}
HttpStatusCode.NotFound to {
description = "Racket not found"
body<RacketError.NotFound> { description = "Racket not found" }
}
}
}) {
logger.debug { "GET BY ID /$ENDPOINT/{id}" }

call.parameters["id"]?.toLong()?.let { id ->
racketsService.findById(id).mapBoth(
success = { call.respond(HttpStatusCode.OK, it.toResponse()) },
failure = { handleRacketErrors(it) }
)
}
}
//...
}

Now we can watch the Swagger doc at http://0.0.0.0:8080/swagger

Testing routes

In previous tutorials, we have learned how to test our repositories or services, either through unit tests or tests with doubles using mocks. Now it’s time to test our endpoints. We have used Postman, which is excellent for these scenarios and can be optimized for efficient testing of endpoints. But Ktor provides a particular testing engine that doesn’t create a web server, doesn’t bind to sockets, and doesn’t make any real HTTP requests. Instead, it hooks directly into internal mechanisms and processes an application call. This results in quicker test execution compared to running a complete web server for testing. Knowing how to use such tools to improve our deployments and optimize tests is great.

Added dependencies

Add the following dependencies in our build.gradle.kts file and sync the project.

// Ktor Test
testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") // For testing with Ktor Client JSON
implementation("io.ktor:ktor-client-auth:$ktor_version") // For testing with Ktor Client Auth JWT

Testing

Now we can test the desired routes quickly and efficiently using this method. You can pass tokens as a header or process multipart requests to upload images.

An example with rackets

private val json = Json { ignoreUnknownKeys = true }

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class RacketsRoutesKtTest {
// Load configuration from application.conf
private val config = ApplicationConfig("application.conf")

val racket = RacketRequest(
brand = "Test",
model = "Test",
price = 10.0,
numberTenisPlayers = 1,
)

// New we can user it to test routes with Ktor
@Test
@Order(1)
fun testGetAll() = testApplication {
// Set up the test environment
environment { config }

// Launch the test
val response = client.get("/api/rackets")

// Check the response and the content
assertEquals(HttpStatusCode.OK, response.status)
// Check the content if we want
// val result = response.bodyAsText()
// val list = json.decodeFromString<List<RacketResponse>>(result)
// ....

}
//...
@Test
@Order(4)
fun testPut() = testApplication {
environment { config }

val client = createClient {
install(ContentNegotiation) {
json()
}
}

// Create
var response = client.post("/api/rackets") {
contentType(ContentType.Application.Json)
setBody(racket)
}

// Take the id of the result
var dto = json.decodeFromString<RacketResponse>(response.bodyAsText())

// Update
response = client.put("/api/rackets/${dto.id}") {
contentType(ContentType.Application.Json)
setBody(racket.copy(brand = "TestBrand2", model = "TestModel2"))
}

// Check that the response and the content is correct
assertEquals(HttpStatusCode.OK, response.status)
val result = response.bodyAsText()
dto = json.decodeFromString<RacketResponse>(result)
assertAll(
{ assertEquals("TestBrand2", dto.brand) },
{ assertEquals("TestModel2", dto.model) },
{ assertEquals(racket.price, dto.price) },
{ assertEquals(racket.numberTenisPlayers, dto.numberTenisPlayers) },
)
}

@Test
@Order(5)
fun testPutNotFound() = testApplication {
environment { config }

val client = createClient {
install(ContentNegotiation) {
json()
}
}

val response = client.put("/api/rackets/-1") {
contentType(ContentType.Application.Json)
setBody(racket.copy(brand = "TestBrand2", model = "TestModel2"))
}

assertEquals(HttpStatusCode.NotFound, response.status)
}
// ...
}

Or using JWT with users

private val json = Json { ignoreUnknownKeys = true }

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class UsersRoutesKtTest {
private val config = ApplicationConfig("application.conf")

val userDto = UserCreateDto(
name = "Test",
email = "test@test.com",
username = "test",
password = "test12345",
avatar = User.DEFAULT_IMAGE,
role = User.Role.USER
)

val userLoginDto = UserLoginDto(
username = "test",
password = "test12345"
)

val userLoginAdminDto = UserLoginDto(
username = "pepe",
password = "pepe1234"
)

@Test
@Order(1)
fun registerUserTest() = testApplication {
// Set up the test environment
environment { config }
val client = createClient {
install(ContentNegotiation) {
json()
}
}

// Launch the test
val response = client.post("/api/users/register") {
contentType(ContentType.Application.Json)
setBody(userDto)
}

// Check the response and the content
assertEquals(response.status, HttpStatusCode.Created)
val res = json.decodeFromString<UserDto>(response.bodyAsText())
assertAll(
{ assertEquals(res.name, userDto.name) },
{ assertEquals(res.email, userDto.email) },
{ assertEquals(res.username, userDto.username) },
{ assertEquals(res.avatar, userDto.avatar) },
{ assertEquals(res.role, userDto.role) },
)
}


@Test
@Order(2)
fun login() = testApplication {
environment { config }
val client = createClient {
install(ContentNegotiation) {
json()
}
}

client.post("/api/users/register") {
contentType(ContentType.Application.Json)
setBody(userDto)
}

val responseLogin = client.post("/api/users/login") {
contentType(ContentType.Application.Json)
setBody(userLoginDto)
}

assertEquals(responseLogin.status, HttpStatusCode.OK)
val res = json.decodeFromString<UserWithTokenDto>(responseLogin.bodyAsText())
assertAll(
{ assertEquals(res.user.name, userDto.name) },
{ assertEquals(res.user.email, userDto.email) },
{ assertEquals(res.user.username, userDto.username) },
{ assertEquals(res.user.avatar, userDto.avatar) },
{ assertEquals(res.user.role, userDto.role) },
{ assertNotNull(res.token) },
)
}

@Test
@Order(3)
fun meInfoTest() = testApplication {
environment { config }

var client = createClient {
install(ContentNegotiation) {
json()
}
}

var response = client.post("/api/users/register") {
contentType(ContentType.Application.Json)
setBody(userDto)
}

response = client.post("/api/users/login") {
contentType(ContentType.Application.Json)
setBody(userLoginDto)
}

assertEquals(response.status, HttpStatusCode.OK)

val res = json.decodeFromString<UserWithTokenDto>(response.bodyAsText())
// token
client = createClient {
install(ContentNegotiation) {
json()
}
install(Auth) {
bearer {
loadTokens {
// Load tokens from a local storage and return them as the 'BearerTokens' instance
BearerTokens(res.token, res.token)
}
}
}
}

response = client.get("/api/users/me") {
contentType(ContentType.Application.Json)
}

assertEquals(response.status, HttpStatusCode.OK)
val resUser = json.decodeFromString<UserDto>(response.bodyAsText())
assertAll(
{ assertEquals(resUser.name, userDto.name) },
{ assertEquals(resUser.email, userDto.email) },
{ assertEquals(resUser.username, userDto.username) },
{ assertEquals(resUser.avatar, userDto.avatar) },
{ assertEquals(resUser.role, userDto.role) },
)
}
// ...
}

Deploy with Docker

The next is to deploy or service. We will use Docker to deploy it. Docker is an open-source platform that allows developers to automate the deployment and management of applications within isolated containers. Containers are lightweight, portable, and self-sufficient environments that package all the necessary dependencies and components to run an application. Advantages of deploying a service with Docker include:

  • Portability: Docker containers can run consistently across different environments, such as development, testing, and production, ensuring that the application behaves the same way everywhere.
  • Scalability: Docker enables easy scaling of services by deploying multiple containers across multiple machines, efficiently utilizing resources.
  • Isolation: Containers provide process-level isolation, preventing conflicts between dependencies and allowing for better resource allocation.
  • Versioning and Rollback: Docker images can be versioned, allowing easy rollbacks to previous versions in case of issues or bugs.
  • DevOps Integration: Docker integrates well with DevOps practices, facilitating continuous integration, continuous delivery (CI/CD), and infrastructure automation.

Docker simplifies the deployment process, enhances application portability, and improves resource utilization, making it a popular choice for deploying and managing services.

We can use the Ktor plugin to create our container and configure it.

// To generate Docker Image with JRE 17
ktor {
docker {
localImageName.set("hyperskill-reactive-api-kotlin-ktor")
imageTag.set("0.0.1-preview")
jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_17)
portMappings.set(
listOf(
io.ktor.plugin.features.DockerPortMapping(
8080,
8080,
io.ktor.plugin.features.DockerPortMappingProtocol.TCP
),
io.ktor.plugin.features.DockerPortMapping(
8083,
8083,
io.ktor.plugin.features.DockerPortMappingProtocol.TCP
)
)
)
}
}

But in this example, we have certificates and will show how to do it manually. For this purpose, we have a Dockerfile that creates a container solely to generate a fat JAR and subsequently create our container. Additionally, we can summarize this process in a Docker Compose file.

# With this file we create a Docker image that contains the application
FROM gradle:7-jdk17 AS build
# We create a directory for the application and copy the build.gradle file
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon

# We create a new image with the application
FROM openjdk:17-jdk-slim-buster
EXPOSE 8080:8080
EXPOSE 8083:8082
# Directory to store the application
RUN mkdir /app
# Copy the certificate to the container (if it is necessary)
RUN mkdir /cert
COPY --from=build /home/gradle/src/cert/* /cert/
# Copy the jar file to the container
COPY --from=build /home/gradle/src/build/libs/ktor-reactive-rest-hyperskill-all.jar /app/ktor-reactive-rest-hyperskill.jar
# Run the application
ENTRYPOINT ["java","-jar","/app/ktor-reactive-rest-hyperskill.jar"]

And now, what’s next?

I hope this series of tutorials has been interesting and has opened up options for you to develop as a developer, creating your services. But this is just the tip of the iceberg of the many things you can do. The only limit you will have is yourself, so I invite you to keep improving and coding your services.

You have the code of this project at https://github.com/joseluisgs/ktor-reactive-rest-hyperskill. The code of this part is this link: https://github.com/joseluisgs/ktor-reactive-rest-hyperskill/releases. Please don’t forget to give a star or follow me to be aware of new tutorials and news. You can follow it commit-by-commit and use the Postman backup file to test it. Remember, this is not a code to use in a natural or production environment. It is a didactic project for you to experiment, analyze and improve or adapt to your way of programming. It is about presenting concepts and seeing how they work. If you have any questions, please do not hesitate to contact

You can continue learning and mastering spectacular things on Hyperskill through different topics and tasks that will help you improve as a developer in Kotlin technologies. The following tracks offered by JetBrains Academy on Hyperskill can be a perfect starting point. These articles show all the information and explanation of concepts and techniques. Don’t miss them!

These tracks will provide you with hands-on experience using cutting-edge tools and teach you how to build server-side applications, ensure persistent data storage in databases, and effectively test the functionality of your applications using modern tools.

Please leave any questions or feedback in the comments section below this blog post. You can also follow us on social media platforms like Reddit, LinkedIn, Twitter, and Facebook to stay informed about our latest articles and projects.

--

--

José Luis González Sánchez
Hyperskill

PhD. Software Development. Loving the art of teaching how to develop software. Kotlin Trainer Certified by JetBrains and Member of Hyperskill Kotlin team.