Generate right schema and documentation with Tapir and sealed traits

207 Views Asked by At

I have the following endpoint:

  val myEndpoint: PublicEndpoint[Unit, ErrorResponse, Entity, Any] = endpoint.get
    .in("test")
    .out(jsonBody[Entity])
    .errorOut(jsonBody[ErrorResponse])

Where Entity and ErrorResponse are:

sealed trait ErrorResponse
object ErrorResponse {
  final case class NotFound(id: String) extends ErrorResponse
  final case class UnknownError(error: String) extends ErrorResponse

  implicit val notFoundSchema: Schema[NotFound] = Schema.derived
  implicit val unknownErrorSchema: Schema[UnknownError] = Schema.derived
  implicit val errorResponseSchema: Schema[ErrorResponse] = Schema.derived
}

Then I convert the endpoints to akka routes:

  val myEndpointRoute: Route = AkkaHttpServerInterpreter().toRoute(myEndpoint.serverLogic { _ =>
    val result: Either[ErrorResponse, Entity] = Right(Entity("some data of the entity"))
    Future.successful(result)
  })

  val swaggerEndpoint = SwaggerInterpreter().fromEndpoints[Future](List(myEndpoint), "Tapir Demo", "1.0")
  val swaggerRoutes: Route = AkkaHttpServerInterpreter().toRoute(swaggerEndpoint)

And run the server:

  Http()
    .newServerAt("localhost", 8080)
    .bind(myEndpointRoute ~ swaggerRoutes)
    .onComplete {
      case Success(_) =>
        println(s"Started on port 8080")
      case Failure(e) =>
        println("Failed to start ... ", e)
    }

The issue I have is when browsing the schema for ErrorResponse I see an hierarchy of objects (#0 and #1) instead of subtypes like NotFound, UnknownError.

enter image description here

How should I define the schema for ErrorResponse?

PS: dependencies:

  "com.softwaremill.sttp.tapir" %% "tapir-core" % "1.9.6",
  "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "1.9.6"
  "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle"  % "1.9.6",
  "com.softwaremill.sttp.tapir" %% "tapir-akka-http-server" % "1.9.6"

1

There are 1 best solutions below

4
Gastón Schabas On BEST ANSWER

That is how swagger UI renders oneOf in OpenAPI Spec 3.1.0, which is the one that tapir uses as the default version. If you change it to OpenAPI Spec 3.0.3 or any 3.x.x you will get the result you expect. Here you have a POC that reproduce your case (I used pekko-http instead of akka-http, but it's the same)

  • build.sbt
lazy val root = (project in file("."))
  .settings(
    name := "tapir-swagger-ui-poc",
    libraryDependencies ++= Seq(
      "ch.qos.logback"                 % "logback-classic"         % "1.4.14",
      "com.typesafe.scala-logging"    %% "scala-logging"           % "3.9.5",
      "org.apache.pekko"              %% "pekko-actor-typed"       % "1.0.2",
      "com.softwaremill.sttp.tapir"   %% "tapir-pekko-http-server" % "1.9.6",
      "com.softwaremill.sttp.tapir"   %% "tapir-sttp-stub-server"  % "1.9.6",
      "com.softwaremill.sttp.tapir"   %% "tapir-openapi-docs"      % "1.9.6",
      "com.softwaremill.sttp.tapir"   %% "tapir-json-circe"        % "1.9.6",
      "com.softwaremill.sttp.tapir"   %% "tapir-swagger-ui-bundle" % "1.9.6",
      "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml"      % "0.7.3"
    ),
    run / fork := true
  )
  • Main.scala
import com.typesafe.scalalogging.Logger
import io.circe.generic.auto._
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.server.Route
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter
import sttp.tapir.swagger.SwaggerUIOptions
import sttp.tapir.swagger.bundle.SwaggerInterpreter

import scala.concurrent.{ExecutionContextExecutor, Future}

object Main {

  sealed trait ErrorResponse

  object ErrorResponse {
    final case class NotFound(id: String) extends ErrorResponse

    final case class UnknownError(error: String) extends ErrorResponse

    implicit val notFoundSchema: Schema[NotFound] = Schema.derived
    implicit val unknownErrorSchema: Schema[UnknownError] = Schema.derived
    implicit val errorResponseSchema: Schema[ErrorResponse] = Schema.derived
  }

  private val myEndpoint = endpoint.get
    .in("test")
    .out(stringBody)
    .errorOut(jsonBody[ErrorResponse])

  def main(args: Array[String]): Unit = {

    val logger = Logger(getClass)

    implicit val system: ActorSystem[Nothing] =
      ActorSystem(Behaviors.empty, "money-maniacs-http")

    implicit val executionContext: ExecutionContextExecutor =
      system.executionContext

    /**
     * swagger UI endpoints for OpenAPI Spec 3.0.3
     */
    val swaggerEndpoints_3_0_3: List[ServerEndpoint[Any, Future]] =
      SwaggerInterpreter(
        customiseDocsModel = openAPI => openAPI.openapi("3.0.3"), // set OpenAPI spec version
        swaggerUIOptions = SwaggerUIOptions.default.pathPrefix(List("docs-3.0.3")) // set path prefix for docs
      )
      .fromEndpoints[Future](
        List(myEndpoint), // list of endpoints 
        "My App", 
        "1.0"
      )

    /**
     * swagger UI endpoints for OpenAPI Spec 3.1.0
     */
    val swaggerEndpoints_3_1_0: List[ServerEndpoint[Any, Future]] =
      SwaggerInterpreter(
        swaggerUIOptions = SwaggerUIOptions.default.pathPrefix(List("docs-3.1.0")) // set path prefix for docs
      )
      .fromEndpoints[Future](
        List(myEndpoint), // list of endpoints
        "My App", 
        "1.0"
      )

    val routes: Route =
      PekkoHttpServerInterpreter()
        .toRoute(swaggerEndpoints_3_0_3 ::: swaggerEndpoints_3_1_0)

    val interface = "0.0.0.0"
    val port = 9000

    Http()
      .newServerAt(interface = interface, port = port)
      .bind(routes)
      .foreach { _ =>
        logger.info(s"Server started at $interface:$port")
        logger.info(s"Press enter to stop the server")
      }
  }

}

Once you start the server with sbt run you can check the endpoints for OpenAPI Spec 3.1.0 and 3.0.3

open http://localhost:9000/docs-3.0.3
open http://localhost:9000/docs-3.1.0

A similar issue was reported in Swagger. The suggestion was add the property title with the same name of the object.

For tapir 1.9.6 which is the version you are using and the latest one released up to now, you can get the same result changing the schemas defined to something like

import scala.reflect.runtime.universe.typeOf

implicit val notFoundSchema: Schema[NotFound] =
  Schema.derived.title(typeOf[NotFound].typeSymbol.name.toString)
implicit val unknownErrorSchema: Schema[UnknownError] =
  Schema.derived.title(typeOf[UnknownError].typeSymbol.name.toString)
implicit val errorResponseSchema: Schema[ErrorResponse] =
  Schema.derived.title(typeOf[UnknownError].typeSymbol.name.toString)

doing that, will produce the result you are looking for (the hash with the number stills there but now it also adds the description you want)

OpenAPI Spec oneOf 3.1.0 with title