Skip to content

02 · Building APIs

Real Scala services usually expose HTTP endpoints, typically with a library like http4s or akka-http built on top of Future/effect types. This module builds the same request/response mental model with the JDK's own com.sun.net.httpserver — no extra dependencies, fully runnable with plain scala Main.scala — so you can see exactly what a routing library automates before you reach for one.

A minimal HTTP server

import com.sun.net.httpserver.{HttpServer, HttpExchange, HttpHandler}
import java.net.InetSocketAddress

class HelloHandler extends HttpHandler:
  def handle(exchange: HttpExchange): Unit =
    val query = Option(exchange.getRequestURI.getQuery).getOrElse("")
    val name = query.split("=").lift(1).getOrElse("World")
    val body = s"""{"message":"Hello, $name!"}"""
    exchange.getResponseHeaders.add("Content-Type", "application/json")
    exchange.sendResponseHeaders(200, body.length)
    val os = exchange.getResponseBody
    os.write(body.getBytes)
    os.close()

val server = HttpServer.create(new InetSocketAddress(8181), 0)
server.createContext("/hello", new HelloHandler)
server.setExecutor(null)
server.start()

Every handler follows the same shape: read something off the request (exchange.getRequestURI, headers, body), decide a status code, write a response body, and — critically — close the response stream. Forgetting os.close() hangs the client, because it's still waiting for the server to signal "no more bytes coming."

Calling it back

import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.net.URI

val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("http://localhost:8181/hello?name=Ada")).build()
val resp = client.send(req, HttpResponse.BodyHandlers.ofString())
println(s"status: ${resp.statusCode()}")   // status: 200
println(s"body: ${resp.body()}")           // body: {"message":"Hello, Ada!"}

HttpClient.send is synchronous and blocks the calling thread until the response arrives — fine here since it's just a demo hitting localhost. client.sendAsync(...) returns a Future-like CompletableFuture for non-blocking use in real code.

The trap: one handler per path, matched by prefix

createContext("/hello", handler) matches /hello and every path under it (/hello/world, /hello/anything) unless the handler itself checks exchange.getRequestURI.getPath and rejects what it doesn't expect. Real routing libraries like http4s give you declarative, exhaustive route matching (GET -> Root / "hello" / name); rolling your own dispatch on HttpServer means remembering to validate the path and method yourself:

class StrictHandler extends HttpHandler:
  def handle(exchange: HttpExchange): Unit =
    if exchange.getRequestMethod != "GET" then
      exchange.sendResponseHeaders(405, -1)
    else
      // ... handle GET
      exchange.sendResponseHeaders(200, 0)

Routing by method and path

A tiny router is just a pattern match over method and path segments:

class Router extends HttpHandler:
  def handle(exchange: HttpExchange): Unit =
    val path = exchange.getRequestURI.getPath
    val method = exchange.getRequestMethod
    (method, path) match
      case ("GET", "/users") =>
        respond(exchange, 200, """[{"id":1,"name":"Ada"}]""")
      case ("GET", p) if p.startsWith("/users/") =>
        val id = p.stripPrefix("/users/")
        respond(exchange, 200, s"""{"id":$id,"name":"User $id"}""")
      case _ =>
        respond(exchange, 404, """{"error":"not found"}""")

  private def respond(exchange: HttpExchange, status: Int, body: String): Unit =
    exchange.getResponseHeaders.add("Content-Type", "application/json")
    exchange.sendResponseHeaders(status, body.length)
    val os = exchange.getResponseBody
    os.write(body.getBytes)
    os.close()

This is the same idea http4s expresses as HttpRoutes.of[IO] { case GET -> Root / "users" / IntVar(id) => ... } — a pure function from request to response, matched declaratively — just without the parser combinators or the effect type wrapping it.

Status codes as data

Model your API's outcomes as a small enum instead of scattering raw integers through handler code — it documents every response your endpoint can give and keeps the mapping to an HTTP status in one place:

enum ApiResult:
  case Ok(body: String)
  case NotFound(resource: String)
  case BadRequest(reason: String)

def toResponse(result: ApiResult): (Int, String) = result match
  case ApiResult.Ok(body)          => (200, body)
  case ApiResult.NotFound(res)     => (404, s"""{"error":"$res not found"}""")
  case ApiResult.BadRequest(why)   => (400, s"""{"error":"$why"}""")

println(toResponse(ApiResult.Ok("""{"id":1}""")))          // (200,{"id":1})
println(toResponse(ApiResult.NotFound("user")))            // (404,{"error":"user not found"})

How It Actually Works

Underneath any Scala HTTP library sits the same primitive: a java.net.ServerSocket (or, in async servers, Java NIO's ServerSocketChannel) bound to a port, accept()-ing incoming TCP connections in a loop. Each accepted connection hands you a raw byte stream that the library parses according to the HTTP/1.1 text protocol — request line, headers, optional body — into the request object your handler function receives. "Routing by method and path" is nothing more than pattern matching (see Level 1's pattern-matching module) over the parsed (method, path) pair extracted from that request line; there's no special HTTP-routing bytecode, just ordinary match dispatch layered on top of string parsing.

A "minimal HTTP server" handling one connection per accepted socket, if built the naive blocking way, spends one JVM thread per open connection — that thread sits blocked in a socket read until the client sends its next byte, which is why production frameworks either use a thread pool sized to expected concurrency or, more commonly today, an event-loop / non-blocking I/O model (Java NIO's selectors, or Akka HTTP's actor-and-Future-based pipeline built on the concurrency primitives from Module 1) so thousands of idle connections don't each tie up a full OS thread.

Returning a status code "as data" (an Int or enum you compute, rather than calling an imperative .setStatus(200) and returning void) works because the response is ultimately just bytes written to the same output stream in a specific text format (HTTP/1.1 200 OK\r\n...) — treating the status as a return value your handler produces, rather than a side effect it performs, is a design choice the library makes; the JVM-level mechanism underneath is the same either way: bytes serialized onto the socket's output stream in HTTP's specified order.

Cheat sheet

Need to... Use
Start a server on a port HttpServer.create(new InetSocketAddress(port), 0)
Attach a handler to a path prefix server.createContext(path, handler)
Read query params / path exchange.getRequestURI.getQuery / .getPath
Send a response sendResponseHeaders(status, len) then write + close getResponseBody
Make an HTTP call HttpClient.newHttpClient().send(request, BodyHandlers.ofString())
Route by method + path declaratively pattern match on (method, path), or use http4s/akka-http in production
Model API outcomes as data an enum mapped to (status, body) in one place

Exercise

Extend the Router above with a POST /users route: read the request body with new String(exchange.getRequestBody.readAllBytes()), treat it as a plain-text name, "store" it in a mutable scala.collection.mutable.ListBuffer[String] declared outside the handler, and respond 201 with the new user's assigned id. Add a GET /users route that lists everyone currently stored as a JSON array. Start the server, use HttpClient to POST two names and then GET /users, and print the final response body to confirm both are present.