Routing HTTP Requests with Scala 3

Noel Welsh
London Scala User Group
18 June 2025

Introduction

  • Request routing
  • Design considerations and implementation techniques
  • Comparisons between different implementations

What is Routing?

  1. Choose the function to handle a HTTP request
  2. Extract values needed by that function
  3. Generate a HTTP response from the result of that function

Routing in Play

GET   /user/:id          controllers.Users.show(id: Int)

External DSL

Routing in http4s

HttpRoutes.of[IO] {
  case GET -> Root / "user" / IntVar(userId) => 
    Ok(s"You asked for $userId")
}

Pattern matching

Routing in Tapir

endpoint
  .in("user" / path[Int])
  .out(stringBody)

Combinator library

Routing in Krop

val handler = 
  Route(
    Request.get(Path / "user" / Param.int), 
    Response.ok(Entity.text)
  ).handle(userId => s"You asked for $userId")

Combinator library

Routes in Krop

Route(
  Request.get(Path / "user" / Param.int), 
  Response.ok(Entity.text)
).handle(userId => s"You asked for $userId")

Request

Request.get(Path / "user" / Param.int), 

Output elements from the HTTP request. (Here, an Int.)

Response

Response.ok(Entity.text)

Generate a HTTP response from input. (Here, a String.)

Handler

userId => s"You asked for $userId"
  • Function from inputs to outputs
  • The "business logic"

Compositional

Request, Response, and Path are values that we can compose.

val api = Path / "api" / "v1"
val users = api / "users"
val products = api / "products"

val notFound = Response.staticFile("/etc/assets/not-found.html")
Response.ok(Entity.html).orElse(notFound)
val viewUser =
  Route(
    Request.get(Path / "user" / Param.int), 
    Response.ok(Entity.text)
  )
  
viewUser.pathTo(1234)
// "/user/1234"

Create links to embed, for example, in HTML

Reverse Routing: Clients

val viewUser =
  Route(
    Request.get(Path / "user" / Param.int), 
    Response.ok(Entity.text)
  )
  
viewUser.client(1234)
// IO[String]

Call routes from another server, or from the browser. Work-in-progress

Implementation

  • Variadic Types
  • Builders

Variadic Types

Path / "user" / Param.int
// Extracts an Int
Path / "order" / Param.uuid / "item" / Param.int
// Extracts a UUID and an Int
  • Variable number of elements, each with a different type
  • How do we handle "variadic types"?

Variadic Types?

Path[A]
Path[A, B]
Path[A, B, C]
// etc...

This approach doesn't work. 😿

Variadic Types

Path[(A)]
Path[(A, B)]
Path[(A, B, C)]

This approach does work. But how?

Scala 3 Tuple Types

Path[P <: Tuple]

There is a common super type for all tuples.

Type-level Tuple Functions

class Path[P <: Tuple] {

  /** Add a segment that extracts a parameter to this `Path`. */
  def /[B](param: Param[B]): Path[Tuple.Append[P, B]] = ???
}
  • Tuple.Append[P, B] is the type of the tuple type P with the type B appended to it. E.g. if P is (Int, String), Tuple.Append[P, Double] is (Int, String, Double).

  • Easy to implement with match types.

  • Already defined in the standard library.

Handlers

  • Request accumulates a tuple of values
  • Handler is a normal function, not a function expecting a tuple.
  • TupleApply converts a call to function with N args to a call to a function with a single N-element Tuple arg.

TupleApply

trait TupleApply[I, O] {
  type Fun

  def tuple(f: Fun): I => O
}
object TupleApply {

  given tuple1Apply[A, C]: TupleApply[Tuple1[A], C] with {
    type Fun = A => C
    def tuple(f: A => C): Tuple1[A] => C = a => f(a(0))
  }
  
  // etc ...
}

Replace with TupledFunction if/when it's no longer experimental.

Request Type Parameters

/** A [[krop.route.Request]] describes a pattern within a [[org.http4s.Request]] ...
  *
  * @tparam P
  *   The type of values extracted from the URI path.
  * @tparam Q
  *   The type of values extracted from the URI query parameters.
  * @tparam I
  *   The type of values extracted from all parts of the request, including path
  *   and query.
  * @tparam O
  *   The type of values to create a [[org.http4s.Request]] that matches this
  *   [[krop.route.Request]].
  */
sealed abstract class Request[P <: Tuple, Q <: Tuple, I <: Tuple, O <: Tuple]

Reverse Routing Requirements

We need to know different groups of parameters for reverse routing:

  • just the path parameters, to generate a link for, e.g., a form
  • path and query parameters, to generate a complete link
  • all the parameters, for a client

Building Requests

val jsonContentType = `Content-Type`(MediaType.application.json)

Request.post(Path / "user")
  .ensureHeader(jsonContentType)
  .withEntity(Entity.jsonOf[User])

Must specify components of a Request in a specific order.

  • Method
  • Path
  • Query parameters (optional)
  • Headers (optional)
  • Entity (optional)

Building Requests

Fixed order:

  • makes reading definitions easier
  • makes updating type parameters simpler

Finite-State Builders

Request is a (not quite) algebraic data type:

  • RequestMethodPath for specifying just a method and path
  • RequestHeaders for adding headers
  • RequestENtity for adding an entity

Methods transition between these types, forming a simple finite-state machine.

Example

final case class RequestMethodPath[P <: Tuple, Q <: Tuple] {
    /** When matching a request, the given header must exist in the request and
      * it will be extracted and made available to the handler.
      *
      * When producing a request, a value of type `A` must be provided.
      */
    def extractHeader[A](using
        h: Header[A, ?],
        s: Header.Select[A]
    ): RequestHeaders[P, Q, Tuple1[s.F[A]], Tuple1[s.F[A]]] =
      RequestHeaders.empty(this).andExtractHeader[A]
}

Evaluation

What is best?

  • Technical considerations, properties of code
  • Process considerations, properties of use of the code

Composition

Technical consideration. Is routing built from reusable pieces?

  • Play: not compositional
  • http4s: not compositional (patterns don't compose)
  • Tapir: compositional
  • Krop: compositional

Reverse Routing

Technical consideration. Can routes be converted into links and clients?

  • Play: links only
  • http4s: no, patterns are not values and hence cannot be inspected.
  • Tapir: client only
  • Krop: links and clients

Process Considerations

Code is (mostly) written by humans. What is it like to use the library? Can a junior developer make sense of it? This is more subjective.

Dot-driven Development

Can I press . and follow the completions?

Play

❌ External DSL. IDE doesn't know about it.

http4s

❌ Pattern matches don't get completions.

Tapir

endpoint
  .in("user" / path[Int])
  .out(stringBody)

❌ Methods like path and stringBody are global imports.

Krop

val handler = 
  Route(
    Request.get(Path / "user" / Param.int), 
    Response.ok(Entity.text)
  ).handle(userId => s"You asked for $userId")

✅ Methods and values are found on companion objects.

Krop vs Tapir

Krop is a bit more verbose than Tapir. Follow the types. Need a Request? Type Request press . and follow completions.

Krop is a bit more structured than Tapir. Allows more flexible reverse routing. Makes routes easier to read.

Errors

What happens when it goes wrong? Many decisions affect debugging.

Not Found Page

In development mode, when no route matches a helpful page is shown.

  • Only Play has equivalent

Avoid Givens

Param.int instead of param[Int]. Simpler error messages with equivalent usability.

ImplicitNotFound

Use @implicitNotFound annotation to give useful error message when given values are used.

Scala 3

Use Scala 3 features, instead of equivalent but more obscure features in, e.g., Shapeless

  • Scala 3 metaprogramming is really good!

Philosophy

Bring joy into the world!

  • Make Krop a joy to use. Make people want to use it.
  • Can be clever, so long as it's usable!

Thanks!