In the previous tutorial, you built a CLI tool. Now let’s build a REST API. Ktor is Kotlin’s official framework for building asynchronous servers and clients. It is lightweight, flexible, and uses Kotlin coroutines.
In this tutorial, you will learn:
- Setting up Ktor
- Routing (GET, POST, PUT, DELETE)
- JSON serialization with Content Negotiation
- In-memory storage
- Path parameters and query parameters
- Error handling with StatusPages
- Testing with ktor-server-test-host
What We’re Building
A Notes API with CRUD operations:
| Method | Endpoint | Description |
|---|---|---|
| GET | / | API info |
| GET | /notes | List all notes |
| GET | /notes/{id} | Get a note |
| POST | /notes | Create a note |
| PUT | /notes/{id} | Update a note |
| DELETE | /notes/{id} | Delete a note |
| GET | /notes?search=kotlin | Search notes |
| GET | /notes?tag=kotlin | Filter by tag |
Setting Up
Add Ktor dependencies to your build.gradle.kts:
val ktorVersion = "3.1.3"
dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
}
You also need the serialization plugin (from the previous tutorial):
plugins {
kotlin("jvm") version "2.3.0"
kotlin("plugin.serialization") version "2.3.0"
}
Step 1: Data Models
Define the data classes for our API. All classes use @Serializable for automatic JSON conversion.
@Serializable
data class Note(
val id: Int,
val title: String,
val content: String,
val tags: List<String> = emptyList()
)
@Serializable
data class CreateNoteRequest(
val title: String,
val content: String,
val tags: List<String> = emptyList()
)
@Serializable
data class UpdateNoteRequest(
val title: String? = null,
val content: String? = null,
val tags: List<String>? = null
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val message: String? = null
)
@Serializable
data class ErrorResponse(
val success: Boolean = false,
val error: String
)
We separate the request models from the Note model. CreateNoteRequest does not have an id because the server assigns it. UpdateNoteRequest has all nullable fields so you can update only the fields you want.
Step 2: In-Memory Storage
A simple storage class for our notes:
class NoteStore {
private val notes = mutableMapOf<Int, Note>()
private val nextId = AtomicInteger(1)
fun getAll(): List<Note> = notes.values.toList()
fun getById(id: Int): Note? = notes[id]
fun create(request: CreateNoteRequest): Note {
val id = nextId.getAndIncrement()
val note = Note(
id = id,
title = request.title,
content = request.content,
tags = request.tags
)
notes[id] = note
return note
}
fun update(id: Int, request: UpdateNoteRequest): Note? {
val existing = notes[id] ?: return null
val updated = existing.copy(
title = request.title ?: existing.title,
content = request.content ?: existing.content,
tags = request.tags ?: existing.tags
)
notes[id] = updated
return updated
}
fun delete(id: Int): Boolean {
return notes.remove(id) != null
}
fun search(query: String): List<Note> {
val lower = query.lowercase()
return notes.values.filter { note ->
note.title.lowercase().contains(lower) ||
note.content.lowercase().contains(lower) ||
note.tags.any { it.lowercase().contains(lower) }
}
}
}
Key patterns:
AtomicIntegerfor thread-safe ID generationcopy()for partial updates (only change non-null fields)- Return
nullfor not-found cases
In a real app, you would use a database. But this pattern shows the same API design.
Step 3: Configure Ktor
The Application.configureApi() function sets up everything:
fun Application.configureApi(store: NoteStore = NoteStore()) {
// Install JSON serialization
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
// Install error handling
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(error = cause.message ?: "Bad request")
)
}
exception<Throwable> { call, cause ->
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse(error = cause.message ?: "Internal server error")
)
}
}
// Configure routes
routing {
get("/") {
call.respond(ApiResponse(success = true, data = "Notes API v1.0"))
}
route("/notes") {
// All note routes go here (see Step 4)
}
}
}
Content Negotiation
The ContentNegotiation plugin automatically converts Kotlin objects to JSON and back. When you call call.respond(note), Ktor converts the note to JSON. When you call call.receive<CreateNoteRequest>(), Ktor parses the JSON body into a Kotlin object.
StatusPages
The StatusPages plugin handles exceptions. When a route throws an IllegalArgumentException, it returns a 400 response with an error message. Any other exception returns a 500.
Step 4: Routing
All note routes go inside a route("/notes") block. This groups them under the /notes path:
routing {
get("/") {
call.respond(ApiResponse(success = true, data = "Notes API v1.0"))
}
route("/notes") {
// All note routes here...
}
}
GET /notes — List All Notes
route("/notes") {
get {
val tag = call.request.queryParameters["tag"]
val search = call.request.queryParameters["search"]
val notes = when {
search != null -> store.search(search)
tag != null -> store.getAll().filter { tag in it.tags }
else -> store.getAll()
}
call.respond(ApiResponse(success = true, data = notes))
}
}
This single endpoint handles three cases:
- No parameters: return all notes
?search=kotlin: search by keyword?tag=kotlin: filter by tag
The bare get { } inside route("/notes") handles GET /notes. If you wrote get outside a route block, it would register at the root / instead.
GET /notes/{id} — Get a Note
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid note ID")
val note = store.getById(id)
if (note != null) {
call.respond(ApiResponse(success = true, data = note))
} else {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(error = "Note not found: $id")
)
}
}
call.parameters["id"] gets the path parameter. We convert it to Int and throw if it is invalid. This get("/{id}") is inside the route("/notes") block, so it handles GET /notes/{id}.
POST /notes — Create a Note
post {
val request = call.receive<CreateNoteRequest>()
if (request.title.isBlank()) {
throw IllegalArgumentException("Title cannot be empty")
}
val note = store.create(request)
call.respond(HttpStatusCode.Created, ApiResponse(success = true, data = note))
}
call.receive<CreateNoteRequest>() parses the JSON body. We validate the title and return 201 Created on success.
PUT /notes/{id} — Update a Note
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid note ID")
val request = call.receive<UpdateNoteRequest>()
val updated = store.update(id, request)
if (updated != null) {
call.respond(ApiResponse(success = true, data = updated))
} else {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(error = "Note not found: $id")
)
}
}
DELETE /notes/{id} — Delete a Note
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid note ID")
if (store.delete(id)) {
call.respond(ApiResponse(success = true, data = "Note deleted"))
} else {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(error = "Note not found: $id")
)
}
}
The get("/{id}"), put("/{id}"), and delete("/{id}") handlers are all inside the same route("/notes") block, so they handle /notes/{id}.
Step 5: Testing with Ktor Test Host
Ktor provides testApplication for testing without starting a real server:
@Test
fun `GET root returns API info`() = testApplication {
application { configureApi() }
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("Notes API"))
}
Testing POST with JSON Body
@Test
fun `POST notes creates a note`() = testApplication {
application { configureApi() }
val jsonClient = createClient {
install(ContentNegotiation) { json() }
}
val response = jsonClient.post("/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Test Note", "Content", listOf("test")))
}
assertEquals(HttpStatusCode.Created, response.status)
assertTrue(response.bodyAsText().contains("Test Note"))
}
For POST/PUT requests, create a client with ContentNegotiation installed. Set the content type and body.
Testing with Shared Store
Pass a shared NoteStore to control the test data:
@Test
fun `GET notes id returns specific note`() = testApplication {
val store = NoteStore()
application { configureApi(store) }
// Pre-populate the store
store.create(CreateNoteRequest("My Note", "Content"))
val response = client.get("/notes/1")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("My Note"))
}
Testing Error Cases
@Test
fun `GET notes id returns 404 for non-existent`() = testApplication {
application { configureApi() }
val response = client.get("/notes/999")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `POST notes with empty title returns 400`() = testApplication {
application { configureApi() }
val jsonClient = createClient {
install(ContentNegotiation) { json() }
}
val response = jsonClient.post("/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("", "Content"))
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
Running the Server
To run the API as a real server, create a main function:
fun main() {
embeddedServer(Netty, port = 8080) {
configureApi()
}.start(wait = true)
}
Then test with curl:
# Create a note
curl -X POST http://localhost:8080/notes \
-H "Content-Type: application/json" \
-d '{"title":"My Note","content":"Hello World","tags":["kotlin"]}'
# List all notes
curl http://localhost:8080/notes
# Get a specific note
curl http://localhost:8080/notes/1
# Search notes
curl http://localhost:8080/notes?search=kotlin
# Update a note
curl -X PUT http://localhost:8080/notes/1 \
-H "Content-Type: application/json" \
-d '{"title":"Updated Title"}'
# Delete a note
curl -X DELETE http://localhost:8080/notes/1
Key Ktor Concepts
Application Module
In Ktor, an application module is an extension function on Application that configures the server. This is the standard pattern:
fun Application.myModule() {
install(ContentNegotiation) { json() }
install(StatusPages) { /* ... */ }
routing { /* ... */ }
}
You can split your configuration into multiple modules:
fun Application.configureRouting() { routing { /* ... */ } }
fun Application.configureSerialization() { install(ContentNegotiation) { json() } }
fun Application.configureErrorHandling() { install(StatusPages) { /* ... */ } }
Plugins
Ktor uses a plugin system. You install plugins to add features:
| Plugin | Purpose |
|---|---|
ContentNegotiation | JSON serialization |
StatusPages | Error handling |
CORS | Cross-origin requests |
Authentication | Auth (JWT, OAuth) |
Logging | Request/response logging |
Compression | Gzip responses |
Route Organization
For larger APIs, organize routes in separate files:
// NotesRoutes.kt
fun Route.notesRoutes(store: NoteStore) {
route("/notes") {
get { /* ... */ }
post { /* ... */ }
get("/{id}") { /* ... */ }
put("/{id}") { /* ... */ }
delete("/{id}") { /* ... */ }
}
}
// Application module
routing {
notesRoutes(store)
usersRoutes(userStore)
}
HTTP Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Success | GET, PUT, DELETE |
| 201 Created | Resource created | POST |
| 400 Bad Request | Invalid input | Validation errors |
| 404 Not Found | Resource not found | Invalid ID |
| 500 Internal Error | Server error | Unexpected exceptions |
Common Mistakes
Mistake 1: Not Validating Input
// BAD — no validation
post {
val request = call.receive<CreateNoteRequest>()
val note = store.create(request)
call.respond(note)
}
// GOOD — validate before creating
post {
val request = call.receive<CreateNoteRequest>()
if (request.title.isBlank()) {
throw IllegalArgumentException("Title cannot be empty")
}
val note = store.create(request)
call.respond(HttpStatusCode.Created, note)
}
Mistake 2: Wrong Status Codes
// BAD — always returning 200
call.respond(note) // Returns 200 even for creation
// GOOD — use correct status codes
call.respond(HttpStatusCode.Created, note) // 201 for creation
call.respond(HttpStatusCode.NotFound, error) // 404 for not found
Mistake 3: Not Handling Errors
// BAD — crashes on invalid ID
val id = call.parameters["id"]!!.toInt()
// GOOD — handle invalid input
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid note ID")
Summary
| Concept | Description |
|---|---|
routing { } | Define API routes |
get, post, put, delete | HTTP method handlers |
call.respond() | Send a response |
call.receive<T>() | Parse request body |
call.parameters["id"] | Get path parameter |
call.request.queryParameters | Get query parameters |
ContentNegotiation | Auto JSON conversion |
StatusPages | Global error handling |
testApplication | Test without real server |
Source Code
You can find the source code for this tutorial on GitHub: tutorial-24-ktor-api
What’s Next?
In the next tutorial, you will learn about testing in Kotlin with JUnit 5, MockK, and best practices.