Your First Real API: CRUD, End to End


This is the milestone post. Everything from the last four — routing, path parameters, JSON, status codes — assembles into a working REST API for tasks. By the end you’ll have all five CRUD operations, the right status code on each, and a structure you’ll keep building on.

REST in one paragraph

REST maps HTTP verbs onto operations against a resource (here, a task):

VerbPathDoesSuccess code
GET/taskslist all200
GET/tasks/{id}fetch one200 (or 404)
POST/taskscreate201
PUT/tasks/{id}update200 (or 404)
DELETE/tasks/{id}delete204 (or 404)

The verb says what, the path says which, and the status code reports how it went. Stick to that and your API is predictable to anyone who’s used one before.

The models

Two data classes — one stored, one for incoming data — as we settled on last post:

import kotlinx.serialization.Serializable

@Serializable
data class Task(val id: Int, val title: String, val done: Boolean = false)

@Serializable
data class NewTask(val title: String, val done: Boolean = false)

A repository

Routes shouldn’t manage storage directly. Put that behind a small repository class — today it’s an in-memory list; in a few posts we’ll swap it for a database without touching the routes.

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.ConcurrentHashMap

class TaskRepository {
    private val tasks = ConcurrentHashMap<Int, Task>()
    private val nextId = AtomicInteger(1)

    fun all(): List<Task> = tasks.values.sortedBy { it.id }

    fun find(id: Int): Task? = tasks[id]

    fun create(draft: NewTask): Task {
        val task = Task(nextId.getAndIncrement(), draft.title, draft.done)
        tasks[task.id] = task
        return task
    }

    fun update(id: Int, draft: NewTask): Task? {
        if (!tasks.containsKey(id)) return null
        val updated = Task(id, draft.title, draft.done)
        tasks[id] = updated
        return updated
    }

    fun delete(id: Int): Boolean = tasks.remove(id) != null
}

A web server handles requests concurrently across threads, so the shared store has to be thread-safe: hence ConcurrentHashMap and AtomicInteger rather than a plain MutableList and an Int. update and delete return null/false when the id is missing, so the route can map that to a 404.

The routes

Now the route extension from the routing post, filled in against the repository:

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*

fun Route.taskRoutes(repo: TaskRepository) {
    route("/tasks") {
        get {
            call.respond(repo.all())
        }

        post {
            val draft = call.receive<NewTask>()
            val created = repo.create(draft)
            call.response.headers.append(HttpHeaders.Location, "/tasks/${created.id}")
            call.respond(HttpStatusCode.Created, created)
        }

        route("/{id}") {
            get {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@get call.respond(HttpStatusCode.BadRequest)
                val task = repo.find(id)
                    ?: return@get call.respond(HttpStatusCode.NotFound)
                call.respond(task)
            }

            put {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@put call.respond(HttpStatusCode.BadRequest)
                val draft = call.receive<NewTask>()
                val updated = repo.update(id, draft)
                    ?: return@put call.respond(HttpStatusCode.NotFound)
                call.respond(updated)
            }

            delete {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@delete call.respond(HttpStatusCode.BadRequest)
                if (repo.delete(id)) {
                    call.respond(HttpStatusCode.NoContent)
                } else {
                    call.respond(HttpStatusCode.NotFound)
                }
            }
        }
    }
}

A few things to note:

  • ?: return@get call.respond(...) is the Kotlin Elvis-plus-early-return idiom doing double duty: bail out and send the right error in one line. Coming from the routing post’s if-block version, this is the tighter form you’ll settle on.
  • HttpHeaders.Location — typed header names, same spirit as typed status codes.
  • The repository’s null/false returns become 404s. The route’s only job is translating HTTP to repository calls and back.

Wiring it up

Create one repository for the app’s lifetime and hand it to the routes:

fun Application.module() {
    install(ContentNegotiation) { json() }

    val repo = TaskRepository()

    routing {
        taskRoutes(repo)
    }
}

That’s a complete API. Run it and exercise it with curl:

curl localhost:8080/tasks
curl -X POST localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship the API"}'
curl localhost:8080/tasks/1
curl -X DELETE localhost:8080/tasks/1

Create returns 201 with a Location header; fetch a missing id returns 404; delete returns 204. A real, conventional REST API in well under a hundred lines.

Final thoughts

The shape you just built — models, a repository, a Route extension, wired in module() — is the backbone of essentially every Ktor service. Passing the repository in as a parameter is the move that pays off most: the routes never know whether storage is a hash map or Postgres, so we’ll swap the implementation later and these handlers won’t change a line.

You’ve probably also noticed how repetitive that id parsing and 404 handling is. Next: error handling and structuring the app — but first, organizing a growing application so it doesn’t all live in one module function.

Comments