Endpoint that can accept multiple different json bodies

Hey there. I’m building an endpoint that takes in a json request that has multiple subclasses. I’m struggling to get this to work. I am using scala 3.3.1, Tapir 1.7.3 with HTTP4S, Circe and Cats IO.

class example:

  case class CreateResponse(uuid: UUID)

  enum DragonType:
    case Ice,Fire

  sealed trait Dragon:
    val name: String
    val dragonType: DragonType
  case class IceDragon(name: String, dragonType: DragonType= DragonType.Ice) extends Dragon
  case class FireDragon(name: String, wingspan: Int, dragonType: DragonType = DragonType.Fire) extends Dragon

  val createAnimal: ServerEndpoint[Any, IO] = endpoint.post
    .in("foo/animal")
    .in(jsonBody[Dragon])
    .out(jsonBody[CreateResponse])
    .serverLogic((dragon: Dragon) =>
      dragon.dragonType match
        case DragonType.Ice =>
          //Ice business logic here
          IO.pure(Right(CreateResponse(UUID.randomUUID())))
        case DragonType.Fire =>
          //Fire business logic here
          IO.pure(Right(CreateResponse(UUID.randomUUID())))
    )

This does compile, but rejects a JSON message of both dragon types with a 400. I am using this in a test environment where I’m invoking the endpoints through sttp.client3.

There are a few issues here.

  1. I’m not sure how to specify how to map a IceDragon from a json object such that it uses the dragonType in the request to build the right case class in Circe.
  2. Making the json body be optional and accept both as input gets messy. For example the below:
val createAnimal: ServerEndpoint[Any, IO] = endpoint.post
    .in("foo/animal")
    .in(jsonBody[[Optional[IceDragon])
    .in(jsonBody[Optional[FireDragon[])
    .out(jsonBody[CreateResponse])
    .serverLogic( (dragonA: Optional[IceDragon], dragonB: Optional[FireDragon]) =>
       if(Some(dragonA)) 
           //Do ice Dragon stuff
       else if (Some(dragonb)) 
          // Do fire dragon stuff
       else
         IO.pure(Left("error"))
    )

However this has the result of making it so that when I implement the remaining 20 other Dragon types, this gets long and messy quick as I also have to handle authentication and authorization along with other path inputs. So I’m not sure if it’s feasible to have multiple endpoints that have the same path but a different input jsonBody object.

What is the recommended way to accomplish this?

You need a few tweaks, here’s a full working example (using Netty with Futures for simplicity):

//> using scala 3.3.1
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server:1.8.0
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.8.0
//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.8.0

package com.softwaremill

import sttp.tapir.*
import DragonApi.*
import ResponseApi.*
import sttp.tapir.generic.auto.*
import io.circe.*
import io.circe.derivation.*
import sttp.tapir.generic.{Configuration => SchemaConfiguration}
import sttp.tapir.json.circe.*
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import sttp.tapir.Schema
import sttp.tapir.Schema.SName
import sttp.tapir.server.netty.NettyFutureServer

import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext, Future}
import ExecutionContext.Implicits.global
import scala.io.StdIn

object Endpoints:
  val createAnimal: ServerEndpoint[Any, Future] = endpoint.post
    .in("dragons")
    .in(jsonBody[Dragon])
    .out(jsonBody[CreateResponse])
    .serverLogic((dragon: Dragon) =>
      dragon match
        case IceDragon(name) =>
          //Ice business logic here
          Future.successful(Right(CreateResponse(s"Hello, Ice Dragon $name")))
        case FireDragon(name) =>
          //Fire business logic here
          Future.successful(Right(CreateResponse(s"Hello, Fire Dragon $name")))
    )

  val apiEndpoints: List[ServerEndpoint[Any, Future]] = List(createAnimal)

  val docEndpoints: List[ServerEndpoint[Any, Future]] = SwaggerInterpreter()
    .fromServerEndpoints[Future](apiEndpoints, "growing-harrier", "1.0.0")

  val all: List[ServerEndpoint[Any, Future]] = apiEndpoints ++ docEndpoints

object ResponseApi:
  given Configuration = Configuration.default
  case class CreateResponse(msg: String) derives ConfiguredCodec

object DragonApi:
  given Configuration = Configuration.default.withDiscriminator("dragonType")
  given SchemaConfiguration = SchemaConfiguration.default.withDiscriminator("dragonType")
  given Schema[Dragon] = Schema.derived

  sealed trait Dragon derives ConfiguredCodec:
    val name: String

  case class IceDragon(name: String) extends Dragon
  case class FireDragon(name: String) extends Dragon

    
@main def run(): Unit =

  val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080)
  val program: Future[Unit] =
    for
      binding <- NettyFutureServer().port(port).addEndpoints(Endpoints.all).start()
      _ <- Future {
        println(s"Go to http://localhost:${binding.port}/docs to open SwaggerUI. Press ENTER key to exit.")
        StdIn.readLine()
      }
      stop <- binding.stop()
    yield ()

  Await.result(program, Duration.Inf)

Put it in a .scala file in an empty directory, and run scala-cli --power run .
Test with

curl -v -X POST -H "Content-Type: application/json" -d '{"dragonType": "FireDragon", "name":"Bob"}' http://localhost:8080/dragons

Notes:

  1. You may remove the DragonType enum entirely, as it is redundant to type information already present in the case class type.
  2. Use Circe’s configured codec derivation. Note that configured codec derivation for Scala 3 in latest Circe versions is unfortunately not described in Circe documentation. There’s a blogpost on this feature on our company blog How to serialize case class to Json in Scala 3 & Scala 2 using Circe
    Make sure you don’t use generic automatic derivation, and specify:
  • An implicit Configuration object
  • Your type (case class like CreateResponse or sealed trait like Dragon) extended with derives CirceCodec. Make sure that implicit Configuration is in proper scope, together with types it configures. You can use objects to define this scope, like DragonApi and ResponseApi in the showcase example.
  1. If API documentation (schema) is relevant, you also need to create a custom configured Schema[Dragon] which will be reflected in OpenAPI schema for your endpoint. By the way, just for the record, keeping custom JSON codecs and tapir Schema in sync is something we’d like to simplify with our new expewrimental Pickler feature.

Great so that seems to work for creates. However getting a dragron seems to throw a 500 error. Is there anything that I need to do for the reverse? Something such as:

val get: ServerEndpoint[Any, IO] = endpoint.get
  .in("foo/animal"/path[Int](name = "id"))
  .out(jsonBody[Dragon])
  .serverLogic( (id: Int) =>
    database.getByID(id)
  )

And for the Configuration.defaut.withDiscrimination(“dragonType”). rather than it be the case class name (as it’s long and not user friendly). Is there a way to map the discrimination to an DragonType enum?

Your path definition should be

    .in("foo" / "animal" / path[Int](name = "id"))

with slash outside of quotes. With that, it all works well for me:

curl http://localhost:8080/foo/animal/3
{"name":"Alice","dragonType":"FireDragon"}

As for long class name - you mean that the name without package (“FireDragon” in our example, but probably something else in your code) is too long and not suitable for the API?
There are a few ways to use other values than class name for the discriminator in Circe, as well as Tapir Schema configuration.
One is a simple (String => String) mapper which you can add to your codec configuration. This function would transform “FireDragon” and “IceDragon” to more preferable strings. It’s a simple approach, but a bit unsafe:

given Configuration = Configuration.default.withDiscriminator("dragonType")
  .withTransformConstructorNames(_.toLowerCase)

Tapir Schema discriminator value can be altered in a quite similar manner, for example:

given SchemaConfiguration = SchemaConfiguration.default.withDiscriminator("dragonType").copy(toDiscriminatorValue = (s: SName) => s.fullName.toLowerCase)

(Notice that schema full name is qualified with package. Your transformation has to handle that).

If you want to use an enum instead, you may get into a pretty complex subject of enum encoding in Circe, which I’m not familar with very well, and there’s no documentation. Additionally, you would make the class model prone to inconsistencies, allowing creation of illegal states like FireDragon(dragonType = DragonType.Ice), which may need extra handling etc. Consider staying with using simple class names as discriminator, possibly with some simple transformations to tweak the strings if you really need that.