Skip to the content.

🤩 Which benefits?

If you are reading this, you probably already know what Tapir does and why it’s a great library for describing endpoints in a Scala application. But what does it bring specifically to Play Framework applications?

Types

In a Play Action the input type(s) can be defined and known with the controller method signature (parameters and return type) but the output type(s) doesn’t appear anywhere.

def update(bookId: Long): Action[BookDetails] = Action.async(parse.json[BookDetails]) {
  val book: Book = ???
  Future.successful(Ok(Json.toJson(book)))
}

In the example above, nothing in the types tells you that the endpoint will always return succesfully with a Book instance. And we’re not even asking about the content type of the response.

Best practices

Split the business logic from the HTTP logic. Even though nothing prevents you to apply this pattern in a Play application, nothing forces you to do it either.

With Tapir, the only place where HTTP logic (status code, content type…) can be defined is in the Endpoint definition.

Documentation

If you want to provide OpenAPI definition from your code, you can either use:

All these options share the same issues: your documentation will drift from your code as nothing checks that both are consistent and you have to partially duplicate some piece of information.

Documentation was my first motive to get into Tapir. Types then come naturally with the documentation. And the encouraged best practices are the icing on the cake!

🚀 Exposing an endpoint

Let’s see how to transform a “plain old” Play Framework endpoint to Tapir.

A classic Play endpoint

Consider the following endpoint defined by an entry in the routes file and an Action in a controller class:

# routes
GET   /api/books/:id      MyController.getBook(id: Long)
// MyController.scala

import play.api.libs.json.Json
import play.api.mvc.{Action, AnyContent, InjectedController}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

class MyController @Inject() (myService: MyService)(implicit ec: ExecutionContext) extends InjectedController {

  def getBook(id: Long): Action[AnyContent] = Action.async {
    myService.findBook(id) // Calling the business logic
      .map { // Mapping business result to HTTP response and code
        case Right(book) =>
          Ok(Json.toJson(book))
        case Left(BookNotFound) =>
          NotFound
        case Left(error: DatabaseError) =>
          InternalServerError(Json.toJson(error))
      }
  }

}
// MyService.scala

import scala.concurrent.Future

class MyService {

  def findBook(id: Long): Future[Either[GetBookError, Book]] = ???

}
// Book.scala

import play.api.libs.json.{Format, Json}

// A business model
case class Book(id: Long, title: String, description: String)

object Book {
  implicit val format: Format[Book] = Json.format[Book]
}
// GetBookError.scala

import play.api.libs.json.{Format, Json}

// A model to represent possible error cases (business and/or technical)
sealed trait GetBookError

case object BookNotFound extends GetBookError

case class DatabaseError(error: String) extends GetBookError

object DatabaseError {
  implicit val format: Format[DatabaseError] = Json.format[DatabaseError]
}

Now, let’s transform this with Tapir!

Note that the example uses Play JSON as the JSON library but any other one supported by Tapir (Circe, ZIO Json, json4s…) would work.

You can also notice that we already split the business logic from the HTTP one: the Action only calls the business logic which returns a business value (a Right[Book]) or an error (Left[GetBookError]) and maps it to the corresponding HTTP code and response.

Describe an endpoint

With Tapir, we need to define a Endpoint[SEC, IN, ERR, OUT, R] which is the complete description of our endpoint. It will describe the input needed for security (type SEC), the regular input(s) (type IN), the success output(s) (type OUT) and the error output(s) (type ERR). The type R is used for additional capabilities like streaming or websocket which we won’t use here.

In our example, we have:

This translates to the following definition with Tapir:

import sttp.model.StatusCode._
import sttp.tapir.json.play._
import sttp.tapir.generic.auto._
import sttp.tapir._

object MyEndpoint {

  val getBookEndpoint: PublicEndpoint[Long, GetBookError, Book, Any] =
    endpoint
      // GET method
      .get
      // Path and input
      .in("api" / "books" / path[Long]("id"))
      // Success output
      .out(jsonBody[Book])
      // Error output
      .errorOut(
        oneOf[GetBookError](
          oneOfVariant[BookNotFound.type](NotFound, emptyOutputAs(BookNotFound)),
          oneOfVariant[DatabaseError](InternalServerError, jsonBody[DatabaseError])
        )
      )

}

See how the types are already helping us? Just by looking at the type of getBookEndpoint we know that we are dealing with an endpoint that will take a Long as input and give us back either a Book or an error of which we know the possible cases as they are defined in a sealed trait.

We can also see with PublicEndpoint that this endpoint doesn’t require any authentication. PublicEndpoint[IN, ERR, OUT, R] is just a type alias for Endpoint[Unit, IN, ERR, OUT, R].

Note that we defined the endpoint as a “monolithic val” here but another strength of Tapir lies in its composition capabilities. We could have written the same endpoint with reusable pieces like below in order to reuse the base path of the endpoint as well as the error output mapping.

val basePath: EndpointInput[Unit] = "api" / "books"

val getBookErrorOutput: EndpointOutput[GetBookError] = oneOf[GetBookError](
  oneOfVariant[BookNotFound.type](NotFound, emptyOutputAs(BookNotFound)),
  oneOfVariant[DatabaseError](InternalServerError, jsonBody[DatabaseError])
)

val getBookEndpoint: PublicEndpoint[Long, GetBookError, Book, Any] =
  endpoint
    .get
    .in(basePath / path[Long]("id"))
    .out(jsonBody[Book])
    .errorOut(getBookErrorOutput)

At this stage, our endpoint is well defined but it’s not documented and it doesn’t do anything. Let’s first see how we can bind our endpoint to some actual implementation and expose it as a HTTP resource.

Keep the logic

The business logic doesn’t have to change: we can keep the exact same findBook method we used with “classic Play” and we don’t even need to move it elsewhere, it can stay in the MyService class.

This is especially true because we already had a method defined as IN => Future[Either[ERR, OUT]] which is the “default type” expected by Tapir to bind a Endpoint[_, IN, ERR, OUT, _] to some implementation.

Let’s move to the binding!

Binding the endpoint

Now we are going to bind our endpoint to some implementation. It’s as easy as using the method serverLogic(...) (or one of its variants) on the Endpoint defined previously.

import sttp.tapir.server.ServerEndpoint

import javax.inject.{Inject, Singleton}
import scala.concurrent.Future

@Singleton
class TapirRouter @Inject() (myService: MyService) {

  // Binding
  private val getBookServerEndpoint: ServerEndpoint[Any, Future] =
    MyEndpoint.getBookEndpoint.serverLogic(myService.findBook)

}

Here we inject the MyService class and declare that our getBookEndpoint is actually implemented with the findBook method.

This gives us a ServerEndpoint which has nothing related to Play. If we were in a Akka HTTP, http4s or ZIO-HTTP application, we would have written pretty much the same code.

Note that there are several variants depending on how the business method is defined like serverLogicPure which accepts a IN => Either[ERR, OUT] business method, that is without a Future.

Letting Play know about the Routes

It’s now time to get back to Play and somehow integrate what we did with Tapir so far.

With Play, we are used to defined routes using the routes file. But did you know there’s another way around? It’s called the SIRD Router (“String Interpolation Routing DSL”). It’s a Scala DSL that allows to define routes with Scala code. You might have already used it in some tests when you bring up a mock server. Learn more about it here.

This SIRD Router is the way to bridge the gap between Play and Tapir!

Let’s complete the TapirRouter class we wrote in previous step and make it extends play.api.routing.SimpleRouter. Then, let’s ask Tapir to generate a Play Routes for us:

import akka.stream.Materializer
import play.api.routing.Router.Routes
import play.api.routing.SimpleRouter
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.play.{PlayServerInterpreter, PlayServerOptions}

import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class TapirRouter @Inject() (myService: MyService)(implicit ec: ExecutionContext, mat: Materializer) extends SimpleRouter {

  private val getBookServerEndpoint: ServerEndpoint[Any, Future] =
    MyEndpoint.getBookEndpoint.serverLogic(myService.findBook)

  // Tapir interpreter to generate Play Routes
  private val interpreter = PlayServerInterpreter(PlayServerOptions.default)

  val getBookRoute: Routes = interpreter.toRoutes(getBookServerEndpoint)

  override def routes: Routes = getBookRoute // .orElse(anotherRoute)

}

Notice how we instantiated a PlayServerInterpreter which is the Tapir class responsible for transforming a ServerEndpoint into something that Play can work with: a Routes.

The last piece of the puzzle is to “register” our TapirRouter. This can be done either with a configuration key play.http.router in application.conf or within the good old routes file itself. The second option has the advantage that you can still write some plain old Play Actions and declare them from the routes file.

# Classic Play route
#GET   /api/books/:id      MyController.getBook(id: Long)

# Tapir router
->     /                   TapirRouter

Don’t forget to remove the old route declaration!

You can combine Routes by using firstRoute.orElse(secondRoute) method when you have several endpoints. This means you can also combine several Routers classes by using router.routes.orElse(anotherRouter.routes). The order of declaration is the order of precedence if some routes conflict with each other.

🎉 And that’s it! We have a Play application where the routes are defined using Tapir.

It’s important to note that Play (actually Akka HTTP or Netty) is still the runtime HTTP server used. Tapir only brings a way to define the routes and some encoders/decoders.

📝 What about the documentation?

But wait… we said one of the benefits of Tapir was documentation. There’s no documentation for now!

Enrich the endpoint

Let’s enrich our endpoint definition with some descriptions for the input and output:

val getBookEndpoint: PublicEndpoint[Long, GetBookError, Book, Any] =
  endpoint.get
    .in("api" / "books" / path[Long]("id").description("Book identifier"))
    .out(jsonBody[Book].description("The book if found"))
    .errorOut(
      oneOf[GetBookError](
        oneOfVariant[BookNotFound.type](NotFound, emptyOutputAs(BookNotFound).description("If no book for the identifier")),
        oneOfVariant[DatabaseError](InternalServerError, jsonBody[DatabaseError])
      )
    )

Note the use of .description(...) method on both the inputs and outputs. There are other methods for defining everything that we can define with OpenAPI like .example(...) or .default(...). That is not mandatory to generate OpenAPI documentation though: our endpoint as it was before could still have generated a nice documentation.

Generating and exposing the OpenAPI documentation

The next step is to generate the OpenAPI documentation for a list of Endpoints that we want to document.

Tapir comes with a built-in support for SwaggerUI (and Redoc) but you can also generate the OpenAPI YAML only and use it in a custom way. Let’s look at the most straightforward way to do it:

import sttp.apispec.openapi.Info
import sttp.tapir.swagger.SwaggerUIOptions
import sttp.tapir.swagger.bundle.SwaggerInterpreter

import scala.concurrent.Future

object OpenApi {

  val swaggerEndpoints = SwaggerInterpreter(swaggerUIOptions = SwaggerUIOptions.default)
    .fromEndpoints[Future](
      // All the endpoints to be documented
      List(MyEndpoint.getBookEndpoint),
      // OpenAPI additional info
      Info("My awesome application", "1.0.0")
    )

}

This gives us a list of ServerEndpoint which corresponds to the SwaggerUI bundle (HTML/CSS/JS) and the OpenAPI specification YAML for our endpoints.

By default (see SwaggerUIOptions) the documentation is exposed under /docs.

Note that you can hide some endpoints from the documentation by not declaring them here. Or you can generate multiple documentations with different subsets of endpoints at different paths.

Then, it’s just a matter of exposing these documentation endpoints as any other endpoint in the router:

import akka.stream.Materializer
import play.api.routing.Router.Routes
import play.api.routing.SimpleRouter
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.play.{PlayServerInterpreter, PlayServerOptions}

import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class TapirRouter @Inject() (myService: MyService)(implicit ec: ExecutionContext, mat: Materializer) extends SimpleRouter {

  private val getBookServerEndpoint: ServerEndpoint[Any, Future] =
    MyEndpoint.getBookEndpoint.serverLogic(myService.findBook)

  // Tapir interpreter to generate Play Routes
  private val interpreter = PlayServerInterpreter(PlayServerOptions.default)

  val getBookRoute: Routes = interpreter.toRoutes(getBookServerEndpoint)

  val documentationRoute: Routes = interpreter.toRoutes(OpenApi.swaggerEndpoints)

  override def routes: Routes = getBookRoute.orElse(documentationRoute)

}

🏁 Conclusion

Even though Tapir is a new way of writing APIs in Scala, we’ve seen that it integrates nicely with “good old” frameworks like Play.

The vast majority of what you can do with Play, you can do it with Tapir as well.

And what you cannot do today might be solved in the next release of Tapir! The contributors behind Tapir are very reactive if you open an issue on Github.

ℹ️ You can find a sample project with more examples of integration between Play Framework and Tapir at https://github.com/gaeljw/tapir-play-sample.

Note: the code presented here has been written with Play Framework 2.8.16 and Tapir 1.0.0.