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.
2 How to Think About It
Think about how REST maps actions to HTTP, before any code:
/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.
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.
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")
}kotlinc on your own machine instead; the “Run It” section explains exactly how.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.
HttpServer callback, with no separate function to test.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.@Synchronized on every Store method that reads or writes todos prevents two simultaneous requests from corrupting the in-memory map.exchange.sendResponseHeaders with a body length of 0 for an empty response.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.
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.
What it expects
{"task": "Buy milk"}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.
kotlinc restApi.kt -include-runtime -d restApi.jar && java -jar restApi.jarStarts 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.
$ 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}]java.net.BindException: Address already in usestart(...).POST always returns a 400 even with a real text fieldextractTextField looks for a literal "text":"..." substring, not a fully general JSON parse.// Jenkinsfile — 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 — look above.' }
}
}
You have a working rest api. Extend it:
- Add a PUT /todos/:id route. Let an existing todo's
textordonebe updated. (Teaches: parsing a body for an update instead of a create.) - Validate more of the request body. Reject malformed JSON outright instead of treating it as missing text. (Teaches: being stricter about untrusted input.)
- Persist to a file. Save the
Storeto 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.) - Add query-string filtering. Support
GET /todos?done=true. (Teaches: parsing the exchange's raw query string.)
@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.