Search code examples
scalacirceredoctapir

Redoc documentation for tapir endpoint with sealed heirarchy not rendering as expected


I'm trying to define a tapir endpoint, which will accept two potential different payloads (in the snippet below, two different ways of defining a Thing). I'm broadly following the instructions here: https://circe.github.io/circe/codecs/adt.html, and defining my endpoint:

endpoint
    .post
    .in(jsonBody[ThingSpec].description("Specification of the thing"))
    .out(jsonBody[Thing].description("Thing!"))

ThingSpec is a sealed trait, which both the classes representing possible payloads extend:

import io.circe.{Decoder, Encoder, derivation}
import io.circe.derivation.{deriveDecoder, deriveEncoder}
import sttp.tapir.Schema
import sttp.tapir.Schema.annotations.description
import sttp.tapir.generic.Configuration
import cats.syntax.functor._
import io.circe.syntax.EncoderOps

sealed trait ThingSpec {
  def kind: String
}

object ThingSpec {
  implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames
  implicit val thingConfigDecoder
    : Decoder[ThingSpec] = Decoder[ThingOneSpec].widen or Decoder[ThingTwoSpec].widen
  implicit val thingConfigEncoder: Encoder[ThingSpec] = {
    case one @ ThingOneSpec(_, _) => one.asJson
    case two @ ThingTwoSpec(_, _) => two.asJson
  }
  implicit val thingConfigSchema: Schema[ThingSpec] =
    Schema.oneOfUsingField[ThingSpec, String](_.kind, _.toString)(
      "one" -> ThingOneSpec.thingConfigSchema,
      "two" -> ThingTwoSpec.thingConfigSchema
    )
}

case class ThingOneSpec(
  name: String,
  age: Long               
) extends ThingSpec {
  def kind: String = "one"
}
object ThingOneSpec {
  implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames
  implicit val thingConfigEncoder: Encoder[ThingOneSpec] = deriveEncoder(
    derivation.renaming.snakeCase
  )
  implicit val thingConfigDecoder: Decoder[ThingOneSpec] = deriveDecoder(
    derivation.renaming.snakeCase
  )
  implicit val thingConfigSchema: Schema[ThingOneSpec] = Schema.derived
}

case class ThingTwoSpec(
  height: Long,
  weight: Long,
) extends ThingSpec {
  def kind: String = "two"
}
object ThingTwoSpec {
  implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames
  implicit val thingConfigEncoder: Encoder[ThingTwoSpec] = deriveEncoder(
    derivation.renaming.snakeCase
  )
  implicit val thingConfigDecoder: Decoder[ThingTwoSpec] = deriveDecoder(
    derivation.renaming.snakeCase
  )
  implicit val thingConfigSchema: Schema[ThingTwoSpec] = Schema.derived
}

Which seems to be working OK - except for the redoc docs which are generated. The "request body section" of the redoc, which I believe is generated from

.in(jsonBody[ThingSpec].description("Specification of the thing"))

only includes details of the ThingOneSpec object, there is no mention of ThingTwoSpec. The "payload" example section includes both.

My main question is how to get the request body section of the docs to show both possible payloads.

However - I'm aware that I might not have done this in the best way (from a circe/tapir point of view). Ideally, I'd like not to include an explicit discriminator (kind) in the trait/classes, because I'd rather it not be exposed to the end user in the 'Payload' sections of the docs. Despite reading

I cannot get this working without the explicit discriminator.


Solution

  • You can get rid of the discriminator by defining a one-of schema by hand:

    implicit val thingConfigSchema: Schema[ThingSpec] =
      Schema(
        SchemaType.SCoproduct(List(ThingOneSpec.thingConfigSchema, ThingTwoSpec.thingConfigSchema), None) {
          case one: ThingOneSpec => Some(SchemaWithValue(ThingOneSpec.thingConfigSchema, one))
          case two: ThingTwoSpec => Some(SchemaWithValue(ThingTwoSpec.thingConfigSchema, two))
        },
        Some(Schema.SName(ThingSpec.getClass.getName))
      )
    

    (Yes, it is unnecessarily hard to write; I'll look if this can be possibly generated by a macro or otherwise.)

    When rendered by redoc, I get a "one of" switch, so I think this is the desired outcome:

    redoc one of