← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Kotlin Project

REST API

Build a JSON REST API with proper routes and methods — create, read, update, delete records over HTTP. The backbone of modern apps.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.

Where this shows up: every mobile app backend, every single-page web app, every microservice, every public API (Stripe, Twitter, GitHub). REST is the lingua franca of the internet. Understanding it is essential to backend work.

2 How to Think About It

Think about how REST maps actions to HTTP, before any code:

The plan — in plain English
1. Each record lives at a URL like /todos/3. → 2. The HTTP method says what to do: GET reads, POST creates, DELETE removes. → 3. The body carries JSON data. → 4. The server routes each request to the right handler and returns JSON. This method-plus-URL convention is REST.

GET

POST

PUT

DELETE

Request arrives

Which method?

Return records as JSON

Create a record

Update a record

Remove a record

Send JSON response

3 The Build — explained part by part

Here is the complete API. Read each part’s note below — you should understand the whole thing from the notes alone.

KotlinrestApi.kt
import com.sun.net.httpserver.HttpServer
import java.io.IOException
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicInteger

/** One to-do item. */
data class Todo(val id: Int, val text: String, val done: Boolean)

/** A pure, framework-free routing result: status, content type, and body. */
data class Response(val status: Int, val contentType: String, val body: String)

/** An in-memory to-do store, safe to call from the server's request threads. */
class Store {
    private val todos = LinkedHashMap<Int, Todo>()
    private val nextId = AtomicInteger(1)

    @Synchronized
    fun add(text: String): Todo {
        val id = nextId.getAndIncrement()
        val todo = Todo(id, text, false)
        todos[id] = todo
        return todo
    }

    @Synchronized
    fun all(): List<Todo> = todos.values.toList()

    @Synchronized
    fun delete(id: Int): Boolean = todos.remove(id) != null
}

private fun quote(s: String): String =
    "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""

fun todoToJson(t: Todo): String =
    "{\"id\":${t.id},\"text\":${quote(t.text)},\"done\":${t.done}}"

/** Pulls "text" out of a tiny, single-field JSON body without a JSON library. */
fun extractTextField(jsonBody: String): String {
    val i = jsonBody.indexOf("\"text\"")
    if (i < 0) return ""
    val colon = jsonBody.indexOf(':', i)
    val firstQuote = jsonBody.indexOf('"', colon + 1)
    val secondQuote = jsonBody.indexOf('"', firstQuote + 1)
    if (firstQuote < 0 || secondQuote < 0) return ""
    return jsonBody.substring(firstQuote + 1, secondQuote)
}

/** The whole routing table, as a pure function of method+path+body — no I/O, fully testable. */
fun handle(store: Store, method: String, path: String, body: String): Response = when {
    method == "GET" && path == "/todos" -> {
        val json = store.all().joinToString(",", prefix = "[", postfix = "]") { todoToJson(it) }
        Response(200, "application/json", json)
    }
    method == "POST" && path == "/todos" -> {
        val text = extractTextField(body)
        if (text.isBlank()) {
            Response(400, "application/json", "{\"error\":\"text is required\"}")
        } else {
            Response(201, "application/json", todoToJson(store.add(text)))
        }
    }
    method == "DELETE" && path.startsWith("/todos/") -> {
        val id = path.removePrefix("/todos/").toIntOrNull()
        when {
            id == null -> Response(400, "application/json", "{\"error\":\"invalid id\"}")
            store.delete(id) -> Response(204, "application/json", "")
            else -> Response(404, "application/json", "{\"error\":\"not found\"}")
        }
    }
    else -> Response(404, "application/json", "{\"error\":\"no such route\"}")
}

@Throws(IOException::class)
fun start(port: Int): HttpServer {
    val store = Store()
    val server = HttpServer.create(InetSocketAddress(port), 0)
    server.createContext("/todos") { exchange ->
        val rawBody = exchange.requestBody.readAllBytes()
        val body = String(rawBody, StandardCharsets.UTF_8)
        val resp = handle(store, exchange.requestMethod, exchange.requestURI.path, body)
        val bytes = resp.body.toByteArray(StandardCharsets.UTF_8)
        exchange.responseHeaders.add("Content-Type", resp.contentType)
        exchange.sendResponseHeaders(resp.status, if (bytes.isEmpty()) -1 else bytes.size.toLong())
        if (bytes.isNotEmpty()) exchange.responseBody.write(bytes)
        exchange.close()
    }
    server.executor = null
    server.start()
    return server
}

fun main() {
    val port = 8080
    start(port)
    println("Listening on http://localhost:$port/todos")
}
⚠ No in-browser playground here
Kotlin compiles to real JVM bytecode, not something a browser can run directly — running it live would need either a server-side compiler or a third-party embed, the same kind of external dependency this site avoids relying on for a core teaching example. Copy the code below and run it with a real kotlinc on your own machine instead; the “Run It” section explains exactly how.
What each part does — in plain words
data class Todo(val id: Int, val text: String, val done: Boolean) / data class Response(...) — two small, strongly-typed records: one for a stored to-do, one for a pure routing result (status, content type, body) with no HTTP objects involved at all.

class Store with @Synchronized — the JDK's own com.sun.net.httpserver.HttpServer can call a handler from multiple threads at once, so every method that touches the shared todos map is marked @Synchronized — Kotlin's direct equivalent of Java's synchronized keyword.

fun handle(store: Store, method: String, path: String, body: String): Response — the entire routing table as one pure function: method, path, and body in, a Response out, with no real HTTP request or socket anywhere nearby — which is exactly what makes it directly testable.

HttpServer.create(...).createContext("/todos") { exchange -> ... } — the thin, real-I/O layer: read the request, call the pure handle function above, write the response back. All the actual logic already lives in a function the tests exercise directly.
Common mistakes — and how to avoid them
✗ Mixing request-handling logic directly into the HttpServer callback, with no separate function to test.
✓ Pulling the whole routing table into a pure handle(store, method, path, body) function means the tests below call it directly — no server, no socket, no curl required for most of the coverage.
✗ Forgetting to guard shared mutable state when a server can be hit by concurrent requests.
✓ @Synchronized on every Store method that reads or writes todos prevents two simultaneous requests from corrupting the in-memory map.
✗ Calling exchange.sendResponseHeaders with a body length of 0 for an empty response.
✓ The JDK's HTTP server treats a length of 0 differently from “no body”; pass -1 when there is nothing to write, as this program does for a 204 response.

4 Test & Prove Each Part

How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.

Listing an empty store returns an empty JSON array
Posting creates a stored todo and returns it
Posting without text is rejected with a 400
Deleting an existing id succeeds with a 204
Deleting a missing id returns a 404
An unknown route returns a 404
KotlinrestApiTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class RestApiTest {
    @Test
    fun listingAnEmptyStoreReturnsAnEmptyArray() {
        val resp = handle(Store(), "GET", "/todos", "")
        assertEquals(200, resp.status)
        assertEquals("[]", resp.body)
    }

    @Test
    fun postingCreatesATodo() {
        val store = Store()
        val resp = handle(store, "POST", "/todos", "{\"text\":\"Buy milk\"}")
        assertEquals(201, resp.status)
        assertTrue(resp.body.contains("Buy milk"))
        assertEquals(1, store.all().size)
    }

    @Test
    fun postingWithoutTextIsRejected() {
        val resp = handle(Store(), "POST", "/todos", "{}")
        assertEquals(400, resp.status)
    }

    @Test
    fun deletingAnExistingIdSucceeds() {
        val store = Store()
        val created = store.add("Walk dog")
        val resp = handle(store, "DELETE", "/todos/${created.id}", "")
        assertEquals(204, resp.status)
        assertEquals(0, store.all().size)
    }

    @Test
    fun deletingAMissingIdIsNotFound() {
        val resp = handle(Store(), "DELETE", "/todos/999", "")
        assertEquals(404, resp.status)
    }

    @Test
    fun unknownRouteIsNotFound() {
        val resp = handle(Store(), "GET", "/nope", "")
        assertEquals(404, resp.status)
    }
}

Compile with kotlinc restApi.kt restApiTest.kt -include-runtime -d restApi.jar and run with JUnit's own runner. handle takes plain strings and a Store, so every test below calls it directly — the real server (started by start(port)) is verified separately, by hand, with curl (see below).

5 The Interface

The API’s endpoints, documented like any professional REST service.

INPUTPOST /todoscreate a todo (JSON body)
What it expects
{"task": "Buy milk"}
OUTPUTResponse (201)the created todo
What it returns
{"id":1,"task":"Buy milk","done":false}

6 Run It & Automate It

Save the code as restApi.kt and compile it with kotlinc restApi.kt -include-runtime -d restApi.jar. It listens on localhost:8080.

Run it locally
kotlinc restApi.kt -include-runtime -d restApi.jar && java -jar restApi.jar
Starts the server; leave it running in one terminal and talk to it with curl from another.

A CI tool like Jenkins compiles and tests automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ curl http://localhost:8080/todos
[]
$ curl -X POST http://localhost:8080/todos -d '{"text":"Buy milk"}'
{"id":1,"text":"Buy milk","done":false}
$ curl http://localhost:8080/todos
[{"id":1,"text":"Buy milk","done":false}]
If it breaks — how to fix it
🚨 java.net.BindException: Address already in use
Another process (often a previous run you forgot to stop) is already listening on port 8080. Stop it, or change the port passed to start(...).
🚨 POST always returns a 400 even with a real text field
Check the request body is actually being sent as raw text (not URL-encoded or JSON-escaped twice); extractTextField looks for a literal "text":"..." substring, not a fully general JSON parse.
GroovyJenkinsfile
// Jenkinsfile &mdash; compiles and tests automatically every time the code changes.
pipeline {
    agent any                                  // run on any available machine

    stages {
        stage('Get the code') {
            steps { checkout scm }             // download the latest code
        }
        stage('Set up Kotlin') {
            steps {
                sh 'kotlinc -version'                             // confirm the compiler is installed
            }
        }
        stage('Compile and test') {
            steps {
                sh 'kotlinc restApi.kt restApiTest.kt -include-runtime -d build.jar'  // one real JVM jar, no build tool required
                sh 'java -cp build.jar:kotlin-test-junit.jar:junit.jar org.junit.runner.JUnitCore RestApiTest'
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed &mdash; look above.' }
    }
}
🎯 Try this next — make it yours

You have a working rest api. Extend it:

  1. Add a PUT /todos/:id route. Let an existing todo's text or done be updated. (Teaches: parsing a body for an update instead of a create.)
  2. Validate more of the request body. Reject malformed JSON outright instead of treating it as missing text. (Teaches: being stricter about untrusted input.)
  3. Persist to a file. Save the Store to a text file on every change, as the to-do list and CLI task manager projects do. (Teaches: combining this project with another on this page.)
  4. Add query-string filtering. Support GET /todos?done=true. (Teaches: parsing the exchange's raw query string.)
What you learned
You learned to keep HTTP routing logic in a pure, directly-testable function with real I/O pushed to the edges, @Synchronized for shared mutable state under concurrent requests, and the JDK's zero-dependency com.sun.net.httpserver.HttpServer. Related reference: Data Classes, Kotlin & Java Interop.