7. Integration with cats-effect & http4s
Note
The tutorial is also available as a video.
cats-effect is one of the most popular functional effect systems for Scala (probably also one of the top ones when it comes to pure functional programming in general). So far we’ve used Tapir in combination with so-called “direct style”, where the server logic is expressed using synchronous, blocking code.
However, one of Tapir’s main strengths is that it integrates with virtually every Scala stack out there. This includes, first and foremost, cats-effect. Let’s see how we can combine the two libraries together.
Describing an endpoint
We’ll assume that you are familiar with what’s described in the previous tutorials, especially 1. Hello, world!. The good news is that most of what’s described there applies 1-1 to our scenario, when we want to use cats-effect. The process of describing endpoints is identical, regardless of what programming style, Scala stack of effect library you use.
Hence, we’ll start with the same basic endpoint description. We’ll be editing the cats-effect.scala
file:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.22
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
Note
As a side note, while our previous examples required Java 21+, as they leveraged virtual threads under the hood, the cats-effect version will work with Java 11+.
Server-side logic
The crucial difference when using Tapir+cats-effect, as compared to the “direct” version is in the way the server
logic is provided. The server logic does, most probably, involve side effects. Typically, this might be querying the
database, writing to Kafka, or invoking other endpoints (though in our example, here we’ll constrain ourselves to good
old println
s). Hence, any operations that the server logic performs should be captured using the IO
monad.
That is, given an endpoint with inputs of type I
and error/success outputs of type E
and O
, the shape of the
server logic function should be I => IO[Either[E, O]]
. In other words: given the input parameters I
, extracted from
the request, the server logic should return a description of a computation, yielding either error E
or success O
outputs, which will then be mapped to the HTTP response.
To combine an endpoint description with the server logic, we need to use the .serverLogic
method. While not always
required, as the type parameter is usually inferred correctly, it’s nevertheless beneficial to provide the effect type
parameter explicitly, using .serverLogic[IO]
in our case:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.22
//> using dep com.softwaremill.sttp.tapir::tapir-cats:1.11.22
import cats.effect.IO
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.serverLogic[IO](name => IO
.println(s"Saying hello to: $name")
.flatMap(_ => IO.pure(Right(s"Hello, $name!"))))
The server-side logic consists of printing a message to the server’s logs (IO.println(...)
), followed by returning
a pure value - successful result. The s"Hello, $name"
string that will be mapped to the response needs to be first
wrapped with a Right
(as we want to use the successful outputs), and then lifted to an IO
computation description
using IO.pure
. That way, we obtain a value of type IO[Either[Unit, String]]
, as required by the endpoint
description.
http4s integration
So far we’ve described the shape of the endpoint, and coupled it with a function implementing the server logic, with a matching signature. What we still need to do is to expose the endpoint using a server.
The server must be “cats-effect-aware” - that is, it must need to know how to deal with server-side logic, which is
expressed in terms of IO
computation descriptions. So far we’ve been using the NettySyncServer
, however here it
won’t be useful. Attempting to use it with our endpoint description won’t compile, as there would be a mismatch on the
type used to represent effects (Identity
vs IO
).
Instead, we need to use a different server. Tapir provides a couple of choices (which might be useful depending on what you’re already using in your project), but the most popular option in case of cats-effect is the http4s server. That’s what we’re going to do as well: through a Tapir-http4s integration, called a server interpreter.
We’ve already introduced interpreters in the tutorial 2. Auto-generating OpenAPI docs. In this particular case, the http4s
server interpreter will convert our endpoint description+server logic into a HttpRoutes[IO]
type. This type is
the representation of HTTP routes that is understandable by the http4s API, and which can be used to expose a server
to the outside world.
The conversion process is an almost-one-liner (if it wasn’t for line length limit!):
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.22
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.11.22
import cats.effect.IO
import org.http4s.HttpRoutes
import sttp.tapir.*
import sttp.tapir.server.http4s.Http4sServerInterpreter
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.serverLogic[IO](name => IO
.println(s"Saying hello to: $name")
.flatMap(_ => IO.pure(Right(s"Hello, $name!"))))
val helloWorldRoutes: HttpRoutes[IO] = Http4sServerInterpreter[IO]()
.toRoutes(helloWorldEndpoint)
Exposing the server
As a final step, we need to expose the routes to the outside world. If you’ve ever used http4s, the following is fairly standard code to start a server and handle requests until the application is interrupted or killed:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.22
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.11.22
//> using dep org.http4s::http4s-blaze-server:0.23.16
import cats.effect.{ExitCode, IO, IOApp}
import org.http4s.HttpRoutes
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.server.Router
import sttp.tapir.*
import sttp.tapir.server.http4s.Http4sServerInterpreter
object HelloWorldTapir extends IOApp:
val helloWorldEndpoint = endpoint.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.serverLogic[IO](name => IO
.println(s"Saying hello to: $name")
.flatMap(_ => IO.pure(Right(s"Hello, $name!"))))
val helloWorldRoutes: HttpRoutes[IO] = Http4sServerInterpreter[IO]()
.toRoutes(helloWorldEndpoint)
override def run(args: List[String]): IO[ExitCode] =
BlazeServerBuilder[IO]
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> helloWorldRoutes).orNotFound)
.resource
.use(_ => IO.never)
.as(ExitCode.Success)
First of all, you might notice that instead of the @main
method, we are extending the IOApp
trait. This is needed,
because not only our endpoint’s server logic is expressed using IO
, but the entire process of starting the server
and handling requests is described as an IO
computation. Hence, we need to start the application in an IO
-aware way:
the IOApp
will handle evaluating the IO
description and actually running the code.
Secondly, with http4s we need to use a specific server implementation (http4s itself is only an API to define endpoints -
kind of a middle-man between Tapir and low-level networking code). We can choose from blaze
and ember
servers, here
we’re using the blaze
one, which is reflected in the additional dependency and the server configuration constructor:
BlazeServerBuilder
.
Finally, we’ve got the run
method implementation, which attaches our interpreted route to the root context /
and
exposes the server on localhost:8080
.
Note
Note that you could also attach other, non-Tapir-managed routes to the same http4s application. Tapir-interpreted
HttpRoutes[IO]
can co-exist with routes defined in any other way.
Adding documentation
As a final touch, let’s expose documentation using the Swagger UI, just as we did before using the Netty server.
The base process is the same: we first need to call the SwaggerInterpreter
providing the list of endpoints, for which
documentation should be generated. However, this time we’ll provide the IO
type constructor as the type parameter.
That way, the server logic implementing the behavior of the swagger endpoints (such as reading the .js/.css/.html
resources) will be expressed in terms of IO
, and we’ll be able to convert them later to http4s routes. And that’s
the second step that we need to perform:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.22
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.11.22
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.11.22
//> using dep org.http4s::http4s-blaze-server:0.23.16
import cats.effect.{ExitCode, IO, IOApp}
import cats.syntax.all.*
import org.http4s.HttpRoutes
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.server.Router
import sttp.tapir.*
import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter
object HelloWorldTapir extends IOApp:
val helloWorldEndpoint = endpoint.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.serverLogic[IO](name => IO
.println(s"Saying hello to: $name")
.flatMap(_ => IO.pure(Right(s"Hello, $name!"))))
val helloWorldRoutes: HttpRoutes[IO] = Http4sServerInterpreter[IO]()
.toRoutes(helloWorldEndpoint)
val swaggerEndpoints = SwaggerInterpreter()
.fromServerEndpoints[IO](List(helloWorldEndpoint), "My App", "1.0")
val swaggerRoutes: HttpRoutes[IO] = Http4sServerInterpreter[IO]().toRoutes(swaggerEndpoints)
val allRoutes: HttpRoutes[IO] = helloWorldRoutes <+> swaggerRoutes
override def run(args: List[String]): IO[ExitCode] =
BlazeServerBuilder[IO]
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> allRoutes).orNotFound)
.resource
.useForever
Hence, we first generate endpoint descriptions, which correspond to exposing the Swagger UI (containing the generated
OpenAPI yaml for our /hello/world
endpoint), which use IO
to express their server logic. Then, we interpret those
endpoints as HttpRoutes[IO]
, which we can expose using http4’s blaze server.
Other concepts covered so far
We can use JSON integration, error outputs, status codes, and any other Tapir features in the same way as we did so far with the “synchronous” server! The endpoints are described in the same way, the only thing that changes is how the server logic is provided.