Skip to content

Commit

Permalink
Docs
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Jul 15, 2024
1 parent 666ab3d commit 16489a9
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 0 deletions.
1 change: 1 addition & 0 deletions generated-doc/out/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ sttp is a family of Scala HTTP-related projects, and currently includes:
tutorials/04_errors
tutorials/05_multiple_inputs_outputs
tutorials/06_error_variants
tutorials/07_cats_effect
.. toctree::
:maxdepth: 2
Expand Down
247 changes: 247 additions & 0 deletions generated-doc/out/tutorials/07_cats_effect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# 7. Integration with cats-effect & http4s

[cats-effect](https://github.com/typelevel/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 [](01_hello_world.md).
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:

```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.13

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:

{emphasize-lines="2, 4, 12-14"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.13
//> using dep com.softwaremill.sttp.tapir::tapir-cats:1.10.13

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](https://http4s.org) 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 [](02_openapi_docs.md). 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!):

{emphasize-lines="2, 5, 7, 18-19"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.13
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.10.13

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:

{emphasize-lines="3, 5, 7, 8, 12, 24-30"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.13
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.10.13
//> 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:

{emphasize-lines="3, 7, 13, 27-32, 37"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.13
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server:1.10.13
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.10.13
//> 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
.use(_ => IO.never)
.as(ExitCode.Success)
```

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.

## Further reading

* [Netty-cats interpreter](../server/netty.md)
* [Armeria-cats interpreter](../server/armeria.md)
* [Integration with cats datatypes](../endpoint/customtypes.md)

0 comments on commit 16489a9

Please sign in to comment.