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:

MethodEndpointDescription
GET/API info
GET/notesList all notes
GET/notes/{id}Get a note
POST/notesCreate a note
PUT/notes/{id}Update a note
DELETE/notes/{id}Delete a note
GET/notes?search=kotlinSearch notes
GET/notes?tag=kotlinFilter 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:

  • AtomicInteger for thread-safe ID generation
  • copy() for partial updates (only change non-null fields)
  • Return null for 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:

PluginPurpose
ContentNegotiationJSON serialization
StatusPagesError handling
CORSCross-origin requests
AuthenticationAuth (JWT, OAuth)
LoggingRequest/response logging
CompressionGzip 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

CodeMeaningWhen to Use
200 OKSuccessGET, PUT, DELETE
201 CreatedResource createdPOST
400 Bad RequestInvalid inputValidation errors
404 Not FoundResource not foundInvalid ID
500 Internal ErrorServer errorUnexpected 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

ConceptDescription
routing { }Define API routes
get, post, put, deleteHTTP method handlers
call.respond()Send a response
call.receive<T>()Parse request body
call.parameters["id"]Get path parameter
call.request.queryParametersGet query parameters
ContentNegotiationAuto JSON conversion
StatusPagesGlobal error handling
testApplicationTest 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.