cyberangles guide

Building REST APIs with Kotlin and Ktor: A Comprehensive Guide

In today’s software landscape, REST APIs are the backbone of modern applications, enabling communication between clients and servers. When it comes to building these APIs efficiently, Kotlin has emerged as a top choice due to its conciseness, interoperability, and robust support for modern development practices. Paired with **Ktor**—JetBrains’ lightweight, asynchronous web framework—Kotlin becomes a powerful tool for crafting high-performance, scalable REST APIs. Ktor is designed from the ground up for Kotlin, leveraging coroutines for asynchronous operations, and offers a modular architecture that lets you pick only the features you need (routing, serialization, authentication, etc.). This flexibility makes it ideal for projects of all sizes, from small microservices to large-scale backends. In this guide, we’ll walk through building a fully functional REST API with Kotlin and Ktor. We’ll cover project setup, routing, handling requests/responses, data persistence, testing, and deployment. By the end, you’ll have a production-ready API for managing tasks (a "To-Do List" API) with CRUD operations, database integration, and essential middleware.

Table of Contents

  1. Prerequisites
  2. Setting Up the Project
  3. Understanding Ktor Basics
  4. Building Core REST Endpoints
  5. Handling Requests and Responses
  6. Essential Plugins and Middleware
  7. Data Persistence with Exposed ORM
  8. Testing the API
  9. Deployment
  10. Conclusion
  11. References

Prerequisites

Before diving in, ensure you have the following tools installed:

  • JDK 11+: Ktor requires Java Development Kit 11 or higher.
  • IntelliJ IDEA (Community or Ultimate): Recommended for Kotlin development (includes Ktor plugin support).
  • Gradle (or Maven): We’ll use Gradle for dependency management (Ktor supports both, but Gradle is more idiomatic with Kotlin).
  • Basic knowledge of:
    • Kotlin syntax and concepts (data classes, coroutines).
    • REST API fundamentals (endpoints, HTTP methods, status codes).

Setting Up the Project

Let’s create a new Ktor project. We’ll use IntelliJ IDEA’s Ktor plugin for simplicity, but you can also set it up manually with Gradle.

Step 1: Create a New Ktor Project

  1. Open IntelliJ IDEA → New Project.
  2. Select “Ktor” from the left pane.
  3. Choose a project name (e.g., ktor-rest-api), location, and JDK.
  4. Click “Next” and select the following plugins (we’ll add more later):
    • Routing: For defining API endpoints.
    • Content Negotiation: For parsing/serializing JSON.
    • Netty: The HTTP server engine (Ktor supports others like Jetty, but Netty is default).
  5. Click “Finish” to generate the project.

Step 2: Project Structure

The generated project will have this structure:

ktor-rest-api/
├── src/
│   ├── main/
│   │   ├── kotlin/
│   │   │   └── Application.kt  # Main entry point
│   │   └── resources/
│   └── test/
│       └── kotlin/
├── build.gradle.kts  # Dependencies and build config
└── settings.gradle.kts

Step 3: Configure Dependencies

Update build.gradle.kts to include essential dependencies. Here’s the full config:

plugins {
    application
    kotlin("jvm") version "1.9.0"
    kotlin("plugin.serialization") version "1.9.0"  // For JSON serialization
}

application {
    mainClass.set("ApplicationKt")
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-server-core-jvm:2.3.3")
    implementation("io.ktor:ktor-server-netty-jvm:2.3.3")  // Netty engine
    implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.3")  // Content negotiation
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.3")  // JSON serialization
    implementation("io.ktor:ktor-server-routing-jvm:2.3.3")  // Routing
    implementation("io.ktor:ktor-server-logging-jvm:2.3.3")  // Request logging
    implementation("ch.qos.logback:logback-classic:1.4.8")  // Logback for logging
    implementation("io.ktor:ktor-server-cors-jvm:2.3.3")  // CORS support

    // Testing
    testImplementation("io.ktor:ktor-server-tests-jvm:2.3.3")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.0")
}

Sync the project to download dependencies.

Understanding Ktor Basics

Ktor applications are built around plugins (features) and routes. Let’s break down the core concepts.

The Application Module

The entry point is Application.kt, where you configure your Ktor application:

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.routing.*

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

fun Application.module() {
    // Install plugins here
    install(ContentNegotiation) {
        json()  // Enable JSON serialization/deserialization
    }

    // Define routes here
    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
    }
}
  • embeddedServer: Starts the Netty server on port 8080.
  • module: A function where you configure plugins and routes.
  • install: Adds plugins (e.g., ContentNegotiation for JSON handling).
  • routing: Defines API endpoints using HTTP methods like get, post, etc.

Running the Application

Click the run button in IntelliJ (or run ./gradlew run in the terminal). Visit http://localhost:8080 in your browser—you’ll see “Hello, Ktor!“.

Building Core REST Endpoints

Let’s build a “Task Manager” API with CRUD operations. We’ll start with an in-memory list for simplicity, then add a database later.

Step 1: Define the Task Model

Create a Task data class to represent our resource. Add a new file src/main/kotlin/Task.kt:

import kotlinx.serialization.Serializable  // From kotlinx.serialization

@Serializable  // Required for JSON serialization
data class Task(
    val id: Int,
    val title: String,
    val description: String? = null,
    val isCompleted: Boolean = false
)

// Data class for creating/updating tasks (no id, since id is auto-generated)
@Serializable
data class TaskRequest(
    val title: String,
    val description: String? = null,
    val isCompleted: Boolean = false
)

@Serializable enables automatic JSON parsing with kotlinx.serialization.

Step 2: In-Memory Storage

Create an in-memory list to store tasks. Add this to Application.kt (temporarily):

// In-memory "database"
private var tasks = mutableListOf<Task>(
    Task(1, "Learn Ktor", "Build a REST API", false),
    Task(2, "Write blog", "Explain Ktor basics", true)
)
private var nextId = 3  // For auto-generating IDs

Step 3: Implement CRUD Endpoints

Update the routing block in Application.kt to add CRUD routes:

routing {
    route("/api/tasks") {
        // GET all tasks
        get {
            call.respond(tasks)  // Automatically serializes to JSON
        }

        // GET task by ID
        get("{id}") {
            val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respondText(
                "Invalid ID",
                status = HttpStatusCode.BadRequest
            )
            val task = tasks.find { it.id == id } ?: return@get call.respondText(
                "Task not found",
                status = HttpStatusCode.NotFound
            )
            call.respond(task)
        }

        // POST: Create a new task
        post {
            val request = call.receive<TaskRequest>()  // Deserialize request body
            val newTask = Task(
                id = nextId++,
                title = request.title,
                description = request.description,
                isCompleted = request.isCompleted
            )
            tasks.add(newTask)
            call.respond(newTask, HttpStatusCode.Created)  // 201 Created
        }

        // PUT: Update an existing task
        put("{id}") {
            val id = call.parameters["id"]?.toIntOrNull() ?: return@put call.respondText(
                "Invalid ID",
                status = HttpStatusCode.BadRequest
            )
            val request = call.receive<TaskRequest>()
            val index = tasks.indexOfFirst { it.id == id }
            if (index == -1) {
                call.respondText("Task not found", status = HttpStatusCode.NotFound)
            } else {
                tasks[index] = Task(id, request.title, request.description, request.isCompleted)
                call.respond(tasks[index])
            }
        }

        // DELETE: Remove a task
        delete("{id}") {
            val id = call.parameters["id"]?.toIntOrNull() ?: return@delete call.respondText(
                "Invalid ID",
                status = HttpStatusCode.BadRequest
            )
            val removed = tasks.removeIf { it.id == id }
            if (removed) {
                call.respondText("Task deleted", status = HttpStatusCode.NoContent)  // 204 No Content
            } else {
                call.respondText("Task not found", status = HttpStatusCode.NotFound)
            }
        }
    }
}

Testing Endpoints with curl

Test the API using curl or tools like Postman:

  • GET all tasks:
    curl http://localhost:8080/api/tasks

  • GET task by ID:
    curl http://localhost:8080/api/tasks/1

  • POST a task:
    curl -X POST -H "Content-Type: application/json" -d '{"title":"New Task","description":"Learn Exposed"}' http://localhost:8080/api/tasks

  • PUT (update) a task:
    curl -X PUT -H "Content-Type: application/json" -d '{"title":"Updated Task","isCompleted":true}' http://localhost:8080/api/tasks/3

  • DELETE a task:
    curl -X DELETE http://localhost:8080/api/tasks/3

Handling Requests and Responses

Ktor provides utilities to extract data from requests and format responses.

Path Parameters

Access path parameters with call.parameters["name"], as shown in the GET {id} route. Always validate parameters (e.g., check if id is an integer).

Query Parameters

Add query parameters for filtering, pagination, etc. For example, filter tasks by completion status:

// GET tasks with query parameter: /api/tasks?completed=true
get {
    val completed = call.request.queryParameters["completed"]?.toBooleanOrNull()
    val filtered = if (completed != null) {
        tasks.filter { it.isCompleted == completed }
    } else {
        tasks
    }
    call.respond(filtered)
}

Request Body

Use call.receive<T>() to deserialize the request body into a Kotlin object (requires @Serializable on the data class).

Response Status Codes

Set status codes with HttpStatusCode:

  • 200 OK (default for call.respond).
  • 201 Created for successful POST.
  • 204 No Content for successful DELETE (no body).
  • 400 Bad Request for invalid input.
  • 404 Not Found for missing resources.

Middleware and Plugins

Plugins extend Ktor’s functionality. Let’s add essential ones.

Content Negotiation (JSON)

We already installed ContentNegotiation with JSON support. This plugin automatically serializes/deserializes Kotlin objects to/from JSON using kotlinx.serialization.

Logging

Install the Logging plugin to log requests:

install(Logging) {
    level = LogLevel.INFO  // Log info-level events
}

Add a logback.xml file in src/main/resources to configure logging:

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="info">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

CORS

If your API is accessed from a frontend (e.g., React), enable CORS:

install(CORS) {
    allowHost("localhost:3000")  // Allow your frontend origin
    allowMethods(HttpMethod.Get, HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete)
    allowHeaders { true }
}

Data Persistence with Exposed

To make the API persistent, we’ll use Exposed—JetBrains’ SQL framework. We’ll use H2 (in-memory database) for simplicity.

Step 1: Add Exposed Dependencies

Update build.gradle.kts with Exposed and H2:

dependencies {
    // ... existing dependencies ...
    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("com.h2database:h2:2.2.224")  // H2 database
}

Step 2: Set Up Exposed

Create a DatabaseFactory.kt to handle database connections:

import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object Tasks : IntIdTable() {
    val title = varchar("title", 255)
    val description = varchar("description", 1000).nullable()
    val isCompleted = bool("is_completed").default(false)
}

class DatabaseFactory {
    fun init() {
        val driverClass = "org.h2.Driver"
        val jdbcURL = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"  // In-memory DB
        Database.connect(jdbcURL, driverClass)

        transaction {
            SchemaUtils.create(Tasks)  // Create table if not exists
            // Insert sample data
            if (Tasks.selectAll().count() == 0L) {
                Tasks.insert {
                    it[title] = "Learn Ktor"
                    it[description] = "Build a REST API"
                }
                Tasks.insert {
                    it[title] = "Write blog"
                    it[description] = "Explain Ktor basics"
                    it[isCompleted] = true
                }
            }
        }
    }
}

Step 3: Update Routes to Use Exposed

Replace the in-memory list with Exposed DAO operations. For example, the GET all tasks route becomes:

get {
    val tasks = transaction {
        Tasks.selectAll().map { rowToTask(it) }
    }
    call.respond(tasks)
}

// Helper function to map Exposed row to Task object
private fun rowToTask(row: ResultRow): Task {
    return Task(
        id = row[Tasks.id].value,
        title = row[Tasks.title],
        description = row[Tasks.description],
        isCompleted = row[Tasks.isCompleted]
    )
}

Update other routes similarly (see the Exposed docs for details on insert, update, delete).

Testing the API

Ktor provides a test framework to write unit tests for endpoints. Create src/test/kotlin/TaskApiTest.kt:

import io.ktor.server.testing.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

class TaskApiTest {
    @Test
    fun `GET all tasks returns 200 OK`() = testApplication {
        application { module() }
        client.get("/api/tasks").apply {
            assertEquals(HttpStatusCode.OK, status)
            val tasks = Json.decodeFromString<List<Task>>(bodyAsText())
            assert(tasks.isNotEmpty())
        }
    }

    @Test
    fun `GET non-existent task returns 404`() = testApplication {
        application { module() }
        client.get("/api/tasks/999").apply {
            assertEquals(HttpStatusCode.NotFound, status)
        }
    }
}

Run tests with ./gradlew test.

Deployment

Build a Fat JAR

Package the application into a JAR with all dependencies:

./gradlew shadowJar  # Or ./gradlew build (if using the application plugin)

The JAR will be in build/libs/. Run it with:

java -jar ktor-rest-api-1.0-SNAPSHOT-all.jar

Docker Deployment

Create a Dockerfile:

FROM eclipse-temurin:11-jre-alpine
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Build and run the Docker image:

docker build -t ktor-rest-api .
docker run -p 8080:8080 ktor-rest-api

Conclusion

In this guide, we built a REST API with Kotlin and Ktor, covering project setup, CRUD operations, request/response handling, plugins, database integration, testing, and deployment. Ktor’s modular design and Kotlin’s conciseness make it a joy to work with for building scalable, maintainable APIs.

Next steps: Add authentication (JWT), rate limiting, input validation, or deploy to a cloud provider like AWS or Heroku.

References