1. Introduction

Tapir is an HTTP API definition library. With Tapir, we can define HTTP endpoints and share them with potential clients and servers. As a concept, this is very similar to the OpenAPI specification.

A Tapir endpoint can be used in a number of ways:

  • generating web clients that communicate to that endpoint
  • generating web servers that serve the endpoint
  • full documentation with up-to-date type information

In this tutorial, we’ll define Tapir endpoints and we’ll use them to generate clients, servers, and documentation.

2. Dependencies

To demonstrate that the endpoint definition, server, and the client aren’t coupled, we’ll create a project with three sbt modules: client, endpoint, and server.

2.1. Endpoint Dependencies

For the endpoint module, the tapir-core dependency is mandatory. In addition, we’ll add the circe JSON library for object marshaling and unmarshalling:

libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-core" % "1.0.3"
libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "1.0.3"

2.2. Server Dependencies

For the server module, we add the Akka HTTP server dependency and the endpoint module:

lazy val server = (project in file("server"))
  .dependsOn(endpoint)
  .settings(libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-akka-http-server" % "1.0.3")

2.3. Client Dependencies

Finally, for the client module we add the sttp client dependency and the endpoint module:

lazy val client = (project in file("client"))
  .dependsOn(endpoint)
  .settings(libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % "1.0.3")

3. Endpoint

We’ll create endpoints for a simple resource CRUD API that we’ll use in this article. Our main resource will be the Kitten case class:

case class Kitten(id: Long, name: String, gender: String, ageInDays: Int)

Also, for simplicity, a List will be our database:

object Database {
  var kittens: List[Kitten] = List(
    Kitten(1L, "mew", "male", 20),
    Kitten(2L, "mews", "female", 25),
    Kitten(3L, "smews", "female", 29)
  )
}

Let’s take a look at the GET endpoint code:

val kittens: Endpoint[Unit, Unit, String, List[Kitten], Any] = endpoint
  .get
  .in("kitten")
  .errorOut(stringBody)
  .out(jsonBody[List[Kitten]])

To clarify, the above endpoint describes a GET endpoint “/kitten” that returns a list of kittens or a string error message.

Similarly, we can describe POST, PUT, and DELETE endpoints:

val kittensPost: Endpoint[Unit, Kitten, (StatusCode, ErrorResponse), (StatusCode, Kitten), Any] = endpoint
  .post
  .in("kitten")
  .in(jsonBody[Kitten])
  .errorOut(statusCode)
  .errorOut(jsonBody[ErrorResponse])
  .out(statusCode)
  .out(jsonBody[Kitten])

val kittensPut: Endpoint[Unit, Kitten, (StatusCode, ErrorResponse), (StatusCode, Kitten), Any] = endpoint
  .put
  .in("kitten")
  .in(jsonBody[Kitten])
  .errorOut(statusCode)
  .errorOut(jsonBody[ErrorResponse])
  .out(statusCode)
  .out(jsonBody[Kitten])

val kittensDelete: Endpoint[Unit, Long, (StatusCode, ErrorResponse), (StatusCode, Kitten), Any] = endpoint
  .delete
  .in("kitten")
  .in(path[Long]("id"))
  .errorOut(statusCode)
  .errorOut(jsonBody[ErrorResponse])
  .out(statusCode)
  .out(jsonBody[Kitten])

It’s important to note that the POST and PUT endpoints read JSON body from the request and the DELETE reads the path parameter id. Also, all endpoints except the GET endpoint return different HTTP status codes based on the request outcome.

4. Server

A number of different servers can be used with Tapir, including Akka HTTPNetty, Play, and http4s.

4.1. Akka Server

For this example, we’ll use Akka HTTP as a server for our endpoints but there are many other servers that are supported.

Let’s look at the server code:

trait BaseAkkaServer {

  val serverPort = 11223
  implicit val system: ActorSystem = ActorSystem("my-system")

  def start(routes: Iterable[server.Route]): Unit = {
    val server = Http()
      .newServerAt("localhost", serverPort)
      .bindFlow(
        routes.reduce((r1, r2) => r1 ~ r2)
      )

    println(s"Server now online.)
    println("Press RETURN to stop...")
    StdIn.readLine() // let it run until user presses return
    server
      .flatMap(_.unbind()) // trigger unbinding from the port
      .onComplete(_ => system.terminate()) // and shutdown when done
  }
}

The above code snippet lets us deploy an Iterable of server routes that we generate from the Tapir endpoints.

4.2. Server Logic

With the server code out of the way, we can now proceed and implement the server logic for our endpoints.

Here’s the server logic for the GET kittens endpoint:

val getKittens = AkkaHttpServerInterpreter().toRoute(
  AnimalEndpoints.kittens
    .serverLogic(_ => {
      Future.successful[Either[String, List[Kitten]]](Right(DB.kittens))
    })
)

As expected, POST and PUT operations implementation are almost identical.  Here’s the implementation for the POST operation:

val postKittens = AkkaHttpServerInterpreter().toRoute(
  AnimalEndpoints.kittensPost
    .serverLogic(kitten => {
      if (kitten.id <= 0) {
        Future.successful(Left(StatusCode.BadRequest -> ErrorResponse("negative ids are not accepted")))
      } else {
        if (DB.kittens.exists(_.id == kitten.id)) {
          Future.successful(Left(StatusCode.BadRequest -> ErrorResponse(s"kitten with id ${kitten.id} already exists")))
        } else {
          DB.kittens = DB.kittens :+ kitten
          Future.successful(Right(StatusCode.Ok -> kitten))
        }
      }
    })
)

And here’s the implementation of the PUT operation:

val putKittens = AkkaHttpServerInterpreter().toRoute(
  AnimalEndpoints.kittensPut
    .serverLogic(kitten => {
      val updatedKittenOpt = DB.kittens.find(_.id == kitten.id).map(_.copy(name = kitten.name, gender = kitten.gender, ageInDays = kitten.ageInDays))
      updatedKittenOpt.map(updatedKitten => {
        DB.kittens = DB.kittens.filterNot(_.id == kitten.id) :+ updatedKitten
        Future.successful(Right(StatusCode.Ok -> updatedKitten))
      }).getOrElse(
        Future.successful(Left(StatusCode.NotFound -> ErrorResponse(s"kitten with id ${kitten.id} was not found")))
      )
    })
)

Finally, let’s see the implementation of the DELETE operation:

val deleteKittens = AkkaHttpServerInterpreter().toRoute(
  AnimalEndpoints.kittensDelete
    .serverLogic(kittenId => {
      val deletedKittenOpt = DB.kittens.find(_.id == kittenId)
      deletedKittenOpt.map(deletedKitten => {
        DB.kittens = DB.kittens.filterNot(_.id == kittenId)
        Future.successful(Right(StatusCode.Ok -> deletedKitten))
      }).getOrElse(
        Future.successful(Left(StatusCode.NotFound -> ErrorResponse(s"kitten with id $kittenId was not found")))
      )
    })
)

As a result of Tapir’s endpoint declaration and AkkaHttpServerInterpreter, writing the server logic becomes as simple as writing a lambda function.

5. Client

We are finally ready to create a client for the kitten API and make some HTTP calls. In our examples, we’ll to use the sttp client, but Play and htt4s clients are also supported.

Let’s create a GET request to the kitten endpoint:

object GetClient extends App {

  val kittenRequest: Unit => Request[DecodeResult[Either[(StatusCode, ErrorResponse), (StatusCode, List[Kitten])]], Any] =
    SttpClientInterpreter()
      .toRequest(AnimalEndpoints.kittens, Some(uri"http://localhost:11223"))

  val backend = HttpClientSyncBackend()
  val kittensR: RequestT[Identity, String, Any] = kittenRequest().response(asStringAlways)
  val kittensResponse = kittensR.send(backend)
  println(s"kittens: ${kittensResponse.body}")
}

This is the output of GetClient above:

kittens: [{"id":1,"name":"mew","gender":"male","ageInDays":20},{"id":2,"name":"mews","gender":"female","ageInDays":25},{"id":3,"name":"smews","gender":"female","ageInDays":29}]

To understand an endpoint with JSON body, let’s look at a POST request:

object ClientPost extends App {

  val kittenPostRequest: Kitten => Request[DecodeResult[Either[(StatusCode, ErrorResponse), (StatusCode, Kitten)]], Any] =
    SttpClientInterpreter()
    .toRequest(AnimalEndpoints.kittensPost, Some(uri"http://localhost:11223"))
  
  val backend = HttpClientSyncBackend()
  val chaseR: RequestT[Identity, String, Any] = kittenPostRequest(Kitten(12L, "chase", "male", 14)).response(asStringAlways)
  val chaseResponse = chaseR.send(backend)
  println(s"chase: ${chaseResponse.body}")
}

6. Documentation

Additionally, we can generate OpenApi documentation and expose it with Swagger UI with just a few lines of code.

First, we need to add the Swagger UI bundle dependency:

libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirV

Next, we simply generate the documentation server route with the SwaggerInterprepter:

def withSwaggerDocs(endpoints: List[AnyEndpoint]): server.Route = {
  AkkaHttpServerInterpreter().toRoute(SwaggerInterpreter().fromEndpoints[Future](endpoints, "My App", "1.0"))
}

Afterward, we add the docs route we generated to our server and the documentation is under the “/docs” path.

7. Conclusion

In this article, we got a high-level overview of Tapir and saw some of its practical features in action.

As always, the code of the above examples is available over on GitHub.