Kotlin - Ktor

Overview

Ktor is a modern, asynchronous web framework for Kotlin that makes it easy to build web applications and APIs. This tutorial covers Ktor setup, routing, serialization, authentication, testing, and building production-ready applications with coroutines.

🎯 Learning Objectives:
  • Understand Ktor architecture and setup
  • Learn routing and HTTP handling
  • Master serialization with kotlinx.serialization
  • Implement authentication and authorization
  • Build and test Ktor applications effectively

Getting Started with Ktor

Project Setup

// build.gradle.kts
plugins {
    kotlin("jvm") version "1.9.10"
    id("io.ktor.plugin") version "2.3.7"
    kotlin("plugin.serialization") version "1.9.10"
    application
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    // Ktor Server
    implementation("io.ktor:ktor-server-core-jvm")
    implementation("io.ktor:ktor-server-netty-jvm")
    implementation("io.ktor:ktor-server-config-yaml")
    
    // Content Negotiation & Serialization
    implementation("io.ktor:ktor-server-content-negotiation-jvm")
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
    
    // Authentication
    implementation("io.ktor:ktor-server-auth-jvm")
    implementation("io.ktor:ktor-server-auth-jwt-jvm")
    
    // CORS
    implementation("io.ktor:ktor-server-cors-jvm")
    
    // Status Pages
    implementation("io.ktor:ktor-server-status-pages-jvm")
    
    // Call Logging
    implementation("io.ktor:ktor-server-call-logging-jvm")
    
    // Validation
    implementation("io.ktor:ktor-server-request-validation-jvm")
    
    // Database
    implementation("org.jetbrains.exposed:exposed-core:0.44.1")
    implementation("org.jetbrains.exposed:exposed-dao:0.44.1")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.44.1")
    implementation("org.jetbrains.exposed:exposed-java-time:0.44.1")
    implementation("com.h2database:h2:2.2.224")
    implementation("com.zaxxer:HikariCP:5.0.1")
    
    // Logging
    implementation("ch.qos.logback:logback-classic:1.4.14")
    
    // Testing
    testImplementation("io.ktor:ktor-server-tests-jvm")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.10")
    testImplementation("io.ktor:ktor-client-content-negotiation-jvm")
}

application {
    mainClass.set("com.example.ApplicationKt")
}

ktor {
    fatJar {
        archiveFileName.set("ktor-app.jar")
    }
}

Basic Application Setup

// Application.kt
package com.example

import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

fun Application.module() {
    configureHTTP()
    configureSerialization()
    configureMonitoring()
    configureSecurity()
    configureRouting()
    configureDatabase()
}

Configuration

# application.yml
ktor:
  application:
    modules:
      - com.example.ApplicationKt.module

  development: true
  
server:
  port: 8080
  host: 0.0.0.0

database:
  url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1"
  driver: "org.h2.Driver"
  user: "sa"
  password: ""

jwt:
  secret: "your-256-bit-secret-key-here"
  issuer: "ktor-app"
  audience: "ktor-users"
  realm: "ktor-api"

Routing and HTTP Handling

Basic Routing

// plugins/Routing.kt
package com.example.plugins

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

fun Application.configureRouting() {
    routing {
        // Health check
        get("/health") {
            call.respond(mapOf("status" to "OK", "timestamp" to System.currentTimeMillis()))
        }
        
        // API routes
        route("/api/v1") {
            userRoutes()
            postRoutes()
            authRoutes()
        }
        
        // Static content
        staticResources("/static", "static")
    }
}

User Routes

// routes/UserRoutes.kt
package com.example.routes

import com.example.models.*
import com.example.services.UserService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject

fun Route.userRoutes() {
    val userService by inject<UserService>()
    
    route("/users") {
        // Public routes
        post {
            val createUserRequest = call.receive<CreateUserRequest>()
            try {
                val user = userService.createUser(createUserRequest)
                call.respond(HttpStatusCode.Created, ApiResponse.success(user))
            } catch (e: Exception) {
                call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid request"))
            }
        }
        
        get("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid user ID"))
                return@get
            }
            
            val user = userService.getUserById(id)
            if (user != null) {
                call.respond(ApiResponse.success(user))
            } else {
                call.respond(HttpStatusCode.NotFound, ApiResponse.error("User not found"))
            }
        }
        
        // Protected routes
        authenticate("auth-jwt") {
            get {
                val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
                val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 10
                val search = call.request.queryParameters["search"]
                
                val users = userService.getUsers(page, size, search)
                call.respond(ApiResponse.success(users))
            }
            
            put("/{id}") {
                val id = call.parameters["id"]?.toLongOrNull()
                if (id == null) {
                    call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid user ID"))
                    return@put
                }
                
                val updateRequest = call.receive<UpdateUserRequest>()
                try {
                    val user = userService.updateUser(id, updateRequest)
                    if (user != null) {
                        call.respond(ApiResponse.success(user))
                    } else {
                        call.respond(HttpStatusCode.NotFound, ApiResponse.error("User not found"))
                    }
                } catch (e: Exception) {
                    call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Update failed"))
                }
            }
            
            delete("/{id}") {
                val id = call.parameters["id"]?.toLongOrNull()
                if (id == null) {
                    call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid user ID"))
                    return@delete
                }
                
                val deleted = userService.deleteUser(id)
                if (deleted) {
                    call.respond(HttpStatusCode.NoContent)
                } else {
                    call.respond(HttpStatusCode.NotFound, ApiResponse.error("User not found"))
                }
            }
        }
    }
}

Data Models and Serialization

Data Classes with Serialization

// models/User.kt
package com.example.models

import kotlinx.serialization.Serializable
import java.time.LocalDateTime

@Serializable
data class User(
    val id: Long = 0,
    val name: String,
    val email: String,
    @Serializable(with = LocalDateTimeSerializer::class)
    val createdAt: LocalDateTime = LocalDateTime.now(),
    @Serializable(with = LocalDateTimeSerializer::class)
    val updatedAt: LocalDateTime = LocalDateTime.now()
)

@Serializable
data class CreateUserRequest(
    val name: String,
    val email: String
) {
    fun validate(): List<String> {
        val errors = mutableListOf<String>()
        if (name.isBlank()) errors.add("Name is required")
        if (name.length > 100) errors.add("Name must not exceed 100 characters")
        if (email.isBlank()) errors.add("Email is required")
        if (!email.contains("@")) errors.add("Email must be valid")
        return errors
    }
}

@Serializable
data class UpdateUserRequest(
    val name: String,
    val email: String
)

@Serializable
data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
    @Serializable(with = LocalDateTimeSerializer::class)
    val createdAt: LocalDateTime,
    val postCount: Int = 0
)

@Serializable
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val message: String? = null,
    val errors: List<String> = emptyList()
) {
    companion object {
        fun <T> success(data: T, message: String? = null) = ApiResponse(
            success = true,
            data = data,
            message = message
        )
        
        fun error(message: String, errors: List<String> = emptyList()) = ApiResponse<Nothing>(
            success = false,
            message = message,
            errors = errors
        )
    }
}

@Serializable
data class PaginatedResponse<T>(
    val items: List<T>,
    val page: Int,
    val size: Int,
    val total: Long,
    val totalPages: Int
)

// Custom serializer for LocalDateTime
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
    override val descriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
    
    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        encoder.encodeString(value.toString())
    }
    
    override fun deserialize(decoder: Decoder): LocalDateTime {
        return LocalDateTime.parse(decoder.decodeString())
    }
}

Serialization Configuration

// plugins/Serialization.kt
package com.example.plugins

import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.json.Json

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
            encodeDefaults = true
        })
    }
}

Database Integration with Exposed

Database Configuration

// plugins/Database.kt
package com.example.plugins

import com.example.database.DatabaseFactory
import io.ktor.server.application.*

fun Application.configureDatabase() {
    DatabaseFactory.init(environment.config)
}

// database/DatabaseFactory.kt
package com.example.database

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.config.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

object DatabaseFactory {
    fun init(config: ApplicationConfig) {
        val driverClassName = config.property("database.driver").getString()
        val jdbcURL = config.property("database.url").getString()
        val user = config.property("database.user").getString()
        val password = config.property("database.password").getString()
        
        val database = Database.connect(createHikariDataSource(jdbcURL, driverClassName, user, password))
        
        transaction(database) {
            SchemaUtils.create(Users, Posts)
        }
    }
    
    private fun createHikariDataSource(
        url: String,
        driver: String,
        user: String,
        password: String
    ) = HikariDataSource(HikariConfig().apply {
        driverClassName = driver
        jdbcUrl = url
        username = user
        this.password = password
        maximumPoolSize = 3
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })
}

// database/Tables.kt
package com.example.database

import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.javatime.datetime
import java.time.LocalDateTime

object Users : LongIdTable() {
    val name = varchar("name", 100)
    val email = varchar("email", 255).uniqueIndex()
    val createdAt = datetime("created_at").default(LocalDateTime.now())
    val updatedAt = datetime("updated_at").default(LocalDateTime.now())
}

object Posts : LongIdTable() {
    val title = varchar("title", 200)
    val content = text("content")
    val userId = reference("user_id", Users)
    val createdAt = datetime("created_at").default(LocalDateTime.now())
}

Data Access Layer

// services/UserService.kt
package com.example.services

import com.example.database.Users
import com.example.models.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.LocalDateTime

class UserService {
    
    suspend fun createUser(request: CreateUserRequest): User {
        // Validate request
        val errors = request.validate()
        if (errors.isNotEmpty()) {
            throw IllegalArgumentException(errors.joinToString(", "))
        }
        
        return transaction {
            // Check if email already exists
            val existingUser = Users.select { Users.email eq request.email }.singleOrNull()
            if (existingUser != null) {
                throw IllegalArgumentException("User with email ${request.email} already exists")
            }
            
            val id = Users.insertAndGetId {
                it[name] = request.name
                it[email] = request.email
                it[createdAt] = LocalDateTime.now()
                it[updatedAt] = LocalDateTime.now()
            }
            
            User(
                id = id.value,
                name = request.name,
                email = request.email,
                createdAt = LocalDateTime.now(),
                updatedAt = LocalDateTime.now()
            )
        }
    }
    
    suspend fun getUserById(id: Long): User? {
        return transaction {
            Users.select { Users.id eq id }
                .singleOrNull()
                ?.let { row ->
                    User(
                        id = row[Users.id].value,
                        name = row[Users.name],
                        email = row[Users.email],
                        createdAt = row[Users.createdAt],
                        updatedAt = row[Users.updatedAt]
                    )
                }
        }
    }
    
    suspend fun getUsers(page: Int, size: Int, search: String?): PaginatedResponse<UserResponse> {
        return transaction {
            val query = if (search.isNullOrBlank()) {
                Users.selectAll()
            } else {
                Users.select { Users.name like "%$search%" or (Users.email like "%$search%") }
            }
            
            val total = query.count()
            val users = query
                .limit(size, offset = (page * size).toLong())
                .orderBy(Users.createdAt, SortOrder.DESC)
                .map { row ->
                    UserResponse(
                        id = row[Users.id].value,
                        name = row[Users.name],
                        email = row[Users.email],
                        createdAt = row[Users.createdAt]
                    )
                }
            
            PaginatedResponse(
                items = users,
                page = page,
                size = size,
                total = total,
                totalPages = ((total + size - 1) / size).toInt()
            )
        }
    }
    
    suspend fun updateUser(id: Long, request: UpdateUserRequest): User? {
        return transaction {
            val existingUser = Users.select { Users.id eq id }.singleOrNull()
                ?: return@transaction null
            
            // Check if email is being changed and already exists
            if (request.email != existingUser[Users.email]) {
                val emailExists = Users.select { (Users.email eq request.email) and (Users.id neq id) }
                    .singleOrNull()
                if (emailExists != null) {
                    throw IllegalArgumentException("User with email ${request.email} already exists")
                }
            }
            
            Users.update({ Users.id eq id }) {
                it[name] = request.name
                it[email] = request.email
                it[updatedAt] = LocalDateTime.now()
            }
            
            User(
                id = id,
                name = request.name,
                email = request.email,
                createdAt = existingUser[Users.createdAt],
                updatedAt = LocalDateTime.now()
            )
        }
    }
    
    suspend fun deleteUser(id: Long): Boolean {
        return transaction {
            Users.deleteWhere { Users.id eq id } > 0
        }
    }
}

Authentication and Security

JWT Authentication

// plugins/Security.kt
package com.example.plugins

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*

fun Application.configureSecurity() {
    val jwtSecret = environment.config.property("jwt.secret").getString()
    val jwtIssuer = environment.config.property("jwt.issuer").getString()
    val jwtAudience = environment.config.property("jwt.audience").getString()
    val jwtRealm = environment.config.property("jwt.realm").getString()
    
    authentication {
        jwt("auth-jwt") {
            realm = jwtRealm
            verifier(
                JWT
                    .require(Algorithm.HMAC256(jwtSecret))
                    .withAudience(jwtAudience)
                    .withIssuer(jwtIssuer)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != "") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
        }
    }
}

// Auth service
package com.example.services

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.example.models.LoginRequest
import com.example.models.LoginResponse
import java.util.*

class AuthService(
    private val userService: UserService,
    private val jwtSecret: String,
    private val jwtIssuer: String,
    private val jwtAudience: String
) {
    
    suspend fun login(request: LoginRequest): LoginResponse? {
        // In a real app, you'd verify password hash
        val user = userService.getUserByEmail(request.email) ?: return null
        
        val token = JWT.create()
            .withAudience(jwtAudience)
            .withIssuer(jwtIssuer)
            .withClaim("username", user.email)
            .withClaim("userId", user.id)
            .withExpiresAt(Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) // 24 hours
            .sign(Algorithm.HMAC256(jwtSecret))
        
        return LoginResponse(
            token = token,
            user = user,
            expiresIn = 24 * 60 * 60 // 24 hours in seconds
        )
    }
}

Authentication Routes

// routes/AuthRoutes.kt
package com.example.routes

import com.example.models.LoginRequest
import com.example.models.ApiResponse
import com.example.services.AuthService
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 org.koin.ktor.ext.inject

fun Route.authRoutes() {
    val authService by inject<AuthService>()
    
    route("/auth") {
        post("/login") {
            try {
                val loginRequest = call.receive<LoginRequest>()
                val response = authService.login(loginRequest)
                
                if (response != null) {
                    call.respond(ApiResponse.success(response))
                } else {
                    call.respond(
                        HttpStatusCode.Unauthorized,
                        ApiResponse.error("Invalid credentials")
                    )
                }
            } catch (e: Exception) {
                call.respond(
                    HttpStatusCode.BadRequest,
                    ApiResponse.error("Invalid request format")
                )
            }
        }
        
        post("/logout") {
            // In a real app, you might maintain a blacklist of tokens
            call.respond(ApiResponse.success("Logged out successfully"))
        }
    }
}

Error Handling and Status Pages

Global Error Handling

// plugins/HTTP.kt
package com.example.plugins

import com.example.models.ApiResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*

fun Application.configureHTTP() {
    install(CORS) {
        allowMethod(HttpMethod.Options)
        allowMethod(HttpMethod.Put)
        allowMethod(HttpMethod.Delete)
        allowMethod(HttpMethod.Patch)
        allowHeader(HttpHeaders.Authorization)
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        allowNonSimpleContentTypes = true
        anyHost() // In production, specify allowed hosts
    }
    
    install(StatusPages) {
        exception<IllegalArgumentException> { call, cause ->
            call.respond(
                HttpStatusCode.BadRequest,
                ApiResponse.error(cause.message ?: "Bad request")
            )
        }
        
        exception<IllegalAccessException> { call, cause ->
            call.respond(
                HttpStatusCode.Forbidden,
                ApiResponse.error(cause.message ?: "Access denied")
            )
        }
        
        exception<Exception> { call, cause ->
            call.respond(
                HttpStatusCode.InternalServerError,
                ApiResponse.error("Internal server error")
            )
            call.application.log.error("Unhandled exception", cause)
        }
        
        status(HttpStatusCode.NotFound) { call, _ ->
            call.respond(
                HttpStatusCode.NotFound,
                ApiResponse.error("Endpoint not found")
            )
        }
        
        status(HttpStatusCode.Unauthorized) { call, _ ->
            call.respond(
                HttpStatusCode.Unauthorized,
                ApiResponse.error("Authentication required")
            )
        }
    }
}

Monitoring and Logging

Request Logging and Monitoring

// plugins/Monitoring.kt
package com.example.plugins

import io.ktor.server.application.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.request.*
import org.slf4j.event.Level

fun Application.configureMonitoring() {
    install(CallLogging) {
        level = Level.INFO
        filter { call -> call.request.path().startsWith("/api") }
        format { call ->
            val status = call.response.status()
            val httpMethod = call.request.httpMethod.value
            val path = call.request.path()
            val userAgent = call.request.headers["User-Agent"]
            "$httpMethod $path - $status - $userAgent"
        }
    }
}

Testing

API Testing

// test/ApplicationTest.kt
package com.example

import com.example.models.CreateUserRequest
import com.example.models.ApiResponse
import com.example.plugins.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import kotlin.test.*

class ApplicationTest {
    
    @Test
    fun testHealthEndpoint() = testApplication {
        application {
            configureRouting()
        }
        
        client.get("/health").apply {
            assertEquals(HttpStatusCode.OK, status)
            val response = body<Map<String, Any>>()
            assertEquals("OK", response["status"])
        }
    }
    
    @Test
    fun testCreateUser() = testApplication {
        application {
            module()
        }
        
        val client = createClient {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
        
        val createRequest = CreateUserRequest("John Doe", "[email protected]")
        
        client.post("/api/v1/users") {
            contentType(ContentType.Application.Json)
            setBody(createRequest)
        }.apply {
            assertEquals(HttpStatusCode.Created, status)
            val response = body<ApiResponse<Any>>()
            assertTrue(response.success)
        }
    }
    
    @Test
    fun testUserNotFound() = testApplication {
        application {
            module()
        }
        
        client.get("/api/v1/users/999").apply {
            assertEquals(HttpStatusCode.NotFound, status)
        }
    }
    
    @Test
    fun testInvalidUserCreation() = testApplication {
        application {
            module()
        }
        
        val client = createClient {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
        
        val invalidRequest = CreateUserRequest("", "invalid-email")
        
        client.post("/api/v1/users") {
            contentType(ContentType.Application.Json)
            setBody(invalidRequest)
        }.apply {
            assertEquals(HttpStatusCode.BadRequest, status)
        }
    }
}

Client-Side Usage

Ktor Client

// client/ApiClient.kt
package com.example.client

import com.example.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class ApiClient(
    private val baseUrl: String = "http://localhost:8080",
    private val token: String? = null
) {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                isLenient = true
            })
        }
        
        install(Logging) {
            logger = Logger.DEFAULT
            level = LogLevel.INFO
        }
        
        token?.let { authToken ->
            install(Auth) {
                bearer {
                    loadTokens {
                        BearerTokens(authToken, authToken)
                    }
                }
            }
        }
    }
    
    suspend fun createUser(request: CreateUserRequest): ApiResponse<User> {
        return client.post("$baseUrl/api/v1/users") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.body()
    }
    
    suspend fun getUser(id: Long): ApiResponse<User> {
        return client.get("$baseUrl/api/v1/users/$id").body()
    }
    
    suspend fun getUsers(page: Int = 0, size: Int = 10, search: String? = null): ApiResponse<PaginatedResponse<UserResponse>> {
        return client.get("$baseUrl/api/v1/users") {
            parameter("page", page)
            parameter("size", size)
            search?.let { parameter("search", it) }
        }.body()
    }
    
    suspend fun login(request: LoginRequest): ApiResponse<LoginResponse> {
        return client.post("$baseUrl/api/v1/auth/login") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.body()
    }
    
    fun close() {
        client.close()
    }
}

// Usage example
suspend fun main() {
    val client = ApiClient()
    
    try {
        // Create a user
        val createRequest = CreateUserRequest("Jane Doe", "[email protected]")
        val createResponse = client.createUser(createRequest)
        println("Created user: ${createResponse.data}")
        
        // Get users
        val usersResponse = client.getUsers(page = 0, size = 5)
        println("Users: ${usersResponse.data?.items}")
        
    } finally {
        client.close()
    }
}

Production Deployment

Docker Configuration

# Dockerfile
FROM openjdk:17-jre-slim

WORKDIR /app

COPY build/libs/ktor-app.jar app.jar
COPY src/main/resources/application.yml application.yml

EXPOSE 8080

CMD ["java", "-jar", "app.jar"]
# docker-compose.yml
version: '3.8'

services:
  ktor-app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=jdbc:postgresql://postgres:5432/ktordb
      - DATABASE_USER=postgres
      - DATABASE_PASSWORD=password
      - JWT_SECRET=your-production-secret-key
    depends_on:
      - postgres
    networks:
      - ktor-network

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_DB=ktordb
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - ktor-network

volumes:
  postgres_data:

networks:
  ktor-network:

Best Practices

Performance and Optimization

// Use connection pooling
val database = Database.connect(createHikariDataSource(...))

// Implement caching
class CachedUserService(private val userService: UserService) {
    private val cache = mutableMapOf<Long, User>()
    
    suspend fun getUserById(id: Long): User? {
        return cache[id] ?: userService.getUserById(id)?.also { cache[id] = it }
    }
}

// Use coroutines for concurrent operations
suspend fun getUsersWithPosts(userIds: List<Long>): List<UserWithPosts> = coroutineScope {
    userIds.map { userId ->
        async {
            val user = userService.getUserById(userId)
            val posts = postService.getPostsByUserId(userId)
            UserWithPosts(user, posts)
        }
    }.awaitAll()
}

// Implement request validation
fun Application.configureValidation() {
    install(RequestValidation) {
        validate<CreateUserRequest> { request ->
            val errors = request.validate()
            if (errors.isNotEmpty()) {
                ValidationResult.Invalid(errors)
            } else {
                ValidationResult.Valid
            }
        }
    }
}

Key Takeaways

  • Ktor provides a lightweight, coroutine-based framework for Kotlin web development
  • Use kotlinx.serialization for efficient JSON handling
  • Implement proper error handling with StatusPages plugin
  • Leverage coroutines for asynchronous operations
  • Use Exposed for type-safe database operations
  • Implement comprehensive testing with Ktor's testing utilities
  • Follow REST API conventions and proper HTTP status codes

Practice Exercises

  1. Build a complete blog API with users, posts, comments, and tags
  2. Implement file upload and download functionality
  3. Add WebSocket support for real-time features
  4. Create a microservice architecture with multiple Ktor applications

Quiz

  1. What are the main advantages of Ktor over traditional servlet-based frameworks?
  2. How does Ktor handle asynchronous operations?
  3. What's the recommended way to handle database operations in Ktor?
Show Answers
  1. Ktor is lightweight, uses Kotlin coroutines for async operations, has a modular plugin system, and provides excellent Kotlin integration with type safety.
  2. Ktor uses Kotlin coroutines throughout, allowing non-blocking operations with suspend functions and structured concurrency.
  3. Use Exposed ORM with transaction blocks or implement a repository pattern with proper connection pooling and async database operations.