Lesson 22 / 25
Building HTTP Services
Choose a web stack and build a small JSON API.
Scala web stacks
Scala offers several web stacks for different styles. Play Framework is a full-stack MVC framework, popular for traditional web apps. http4s (with Cats Effect) and ZIO HTTP are functional, typed HTTP libraries used for high-throughput APIs. Tapir describes endpoints as values, then interprets them into servers (http4s, Pekko HTTP, Netty, ZIO HTTP), clients and OpenAPI documentation from the same definition. Pekko HTTP (or Akka HTTP) suits actor-based systems, and Cask from the Li Haoyi ecosystem is a lightweight, Flask-like option for simple services. For JSON, common libraries are circe, jsoniter-scala (very fast) and upickle; for databases, Doobie and Skunk (functional), Slick and ScalikeJDBC, or Quill and Magnum. Whatever the stack, keep the domain logic in pure functions and ADTs, keep HTTP and database code at the edges, validate inputs into domain types, and map domain errors to HTTP status codes in one place.
Layers of a Scala service
HTTP routes call pure domain logic, and repositories handle persistence at the edge.
A small JSON API with Cask and upickle
Routes, validation into domain types and status codes.
//> using dep com.lihaoyi::cask:0.10.2
//> using dep com.lihaoyi::upickle:4.1.0
import upickle.default.*
case class NewOrder(customerId: String, totalPaise: Long) derives ReadWriter
case class Order(id: String, customerId: String, totalPaise: Long) derives ReadWriter
object OrderApi extends cask.MainRoutes:
private var orders = Map.empty[String, Order] // in-memory store for the example
@cask.get("/orders/:id")
def getOrder(id: String) =
orders.get(id) match
case Some(o) => cask.Response(write(o), headers = Seq("Content-Type" -> "application/json"))
case None => cask.Response(s"order $id not found", statusCode = 404)
@cask.post("/orders")
def create(request: cask.Request) =
val input = read[NewOrder](request.text())
if input.totalPaise <= 0 then cask.Response("total must be positive", statusCode = 400)
else
val order = Order(s"o-${orders.size + 1}", input.customerId, input.totalPaise)
orders = orders.updated(order.id, order)
cask.Response(write(order), statusCode = 201, headers = Seq("Content-Type" -> "application/json"))
initialize()
// run with: scala run . then POST /orders on port 8080One definition, many uses
With Tapir, the same endpoint value generates the server route, a typed client and OpenAPI docs, so documentation never drifts from the implementation.
Quick check: What is a key benefit of Tapir?
- It is a database driver
- It replaces the JVM
- Endpoints are defined once as values and interpreted into servers, clients and OpenAPI docs
- It formats code
Answer
Endpoints are defined once as values and interpreted into servers, clients and OpenAPI docs — Tapir separates endpoint descriptions from their interpreters.