1

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.

user3468054
  • 610
  • 4
  • 11
  • 1
    Running your example, I can choose either the first or second subtype using a dropdown (next to `kind`), so this seems to work? – adamw Feb 06 '23 at 17:12
  • Regarding getting rid of the discriminator, did you take a look at `oneOf` inputs & related examples? https://tapir.softwaremill.com/en/latest/endpoint/oneof.html – adamw Feb 06 '23 at 17:13
  • @adamw Thanks for the quick reply! In the "request body schema" sectino, `kind` doesn't appear at all - and definitely no dropdown. (There is a dropdown labelled "Example" in the Payload section on the RHS.) It may be that I am using quite an old version of tapir. It's all enmeshed in a very large project, and I haven't worked out how to run my above example standalone yet :( – user3468054 Feb 06 '23 at 17:22
  • @adamw I did look at `oneOf` - but that page seems to cover using `oneOf` for *outputs*, not inputs (or `oneOfBody`, for inputs which differ by content type). So I concluded it wasn't applicable... – user3468054 Feb 06 '23 at 17:25
  • 1
    You are right of course, `oneOf` is for outputs only, sorry :) – adamw Feb 06 '23 at 18:24

1 Answers1

1

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

adamw
  • 8,038
  • 4
  • 28
  • 32
  • 1
    Here's the GH issue: https://github.com/softwaremill/tapir/issues/2714 – adamw Feb 06 '23 at 20:46
  • Thanks - I really appreciate your being so responsive to SO posts! Sadly, I can't actually test this out because my tapir is too elderly to have `SchemaWithValue`. I need to get on with upgrading it, but that is a whole new bag of spanners :-) – user3468054 Feb 07 '23 at 11:32
  • 1
    In earlier versions you should be able to create a coproduct `Schema` by hand as well, the parameters might slightly differ. You can also simply provide `_ => None` at the last parameter, if you are not doing tapir-validation of the incoming data. – adamw Feb 07 '23 at 12:36