Table of Contents
- Prerequisites
- Setting Up the Project
- Understanding Ktor Basics
- Building Core REST Endpoints
- Handling Requests and Responses
- Essential Plugins and Middleware
- Data Persistence with Exposed ORM
- Testing the API
- Deployment
- Conclusion
- 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
- Open IntelliJ IDEA → New Project.
- Select “Ktor” from the left pane.
- Choose a project name (e.g.,
ktor-rest-api), location, and JDK. - 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).
- 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.,ContentNegotiationfor JSON handling).routing: Defines API endpoints using HTTP methods likeget,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 forcall.respond).201 Createdfor successful POST.204 No Contentfor successful DELETE (no body).400 Bad Requestfor invalid input.404 Not Foundfor 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.