Search code examples
scalaopenapicircetapir

Generate right schema and documentation with Tapir and sealed traits


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"


Solution

  • 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