Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alignment of tapir and circe with respect of coproduct support with discriminator #315

Closed
froth opened this issue Nov 19, 2019 · 6 comments

Comments

@froth
Copy link

froth commented Nov 19, 2019

Circe and tapir both support coproduct derivation for sealed trait types with a discriminator field.
However currently the features are not very good aligned to work together.
Issue is a result of a gitter discussion with @ghostbuster91

Circe coproduct derivation:

https://circe.github.io/circe/codecs/adt.html#the-future
circe allows for automatical derivation of a sealed trait with a configurable field as dicriminator

sealed trait SealedTrait
case class TypeA(i: Int) extends SealedTrait
decode[SealedTrait]("""{ "i": 1000, "what_am_i": "TypeA" }""")
// res6: Either[io.circe.Error,Event] = Right(TypeA(1000))

tapir coproduct support

https://tapir-scala.readthedocs.io/en/latest/endpoint/customtypes.html#sealed-traits-coproducts

tapir supports something similar by deriving Schema for TypeA and then using Schema.oneOf to define a Schema for SealedTrait.
However this only generates a valid openapi-spec if the defined discriminator field is also a field of TypeA like case class TypeA(i: Int, what_am_i: String = "TypeA") as the autogenerated Schema for TypeA only then contains the field "what_am_i" in the OpenApi Spec. I believe this is not a satisfactory solution as the field should not be changeable and also the autogenerated circe codec has redundant fields as it has a Configuration for the discriminating field but also sees the field in the case classes.

I believe the Documentation is misleading as the example code does not lead to a valid openapi spec. More serious the only clean solution for this in my opinion is modifying the autogenerated Schema for TypeA and add the "what_am_i" field.

suggested solution

I would like a possibility to get a Schema and a corresponding circe Codec with discriminator for a sealed trait that is easy to generate and in the best case guarantees that what is in the openapi-spec is also what circe understands.
Maybe the oneOf makro could alter the autogenerated Schema of the case classes?
In this case the oneOf code would also not need an extractor as the field does not really exist in the case classes when using the circe autoderivation as stated above. It could accept a circe Configuration object or something similar.

@froth
Copy link
Author

froth commented Nov 20, 2019

For clarity I wrote code to produce a valid openapi spec and use the circe codec:

sealed trait TestType
case class CaseClass(a: String) extends TestType
case object CaseObject extends TestType

object TestType {
  private[this] val typeDiscriminator = "typeDiscrimininator"
  private[this] implicit val configuration: Configuration =
    Configuration.default.withDiscriminator(typeDiscriminator)
  implicit val codec: Codec.AsObject[TestType] =
    io.circe.generic.extras.semiauto.deriveConfiguredCodec[TestType]
  private[this] val caseClassSchema: Schema[CaseClass] = {
    val tempSchema = implicitly[Schema[CaseClass]]
    val schemaType = tempSchema.schemaType.asInstanceOf[SProduct]
    tempSchema.copy(
      schemaType = schemaType.copy(fields = (typeDiscriminator -> Schema(
        SString,
        isOptional = false) :: schemaType.fields.toList)))
  }
  private[this] val caseObjectSchema: Schema[CaseObject.type] = {
    val tempSchema = implicitly[Schema[CaseObject.type]]
    val schemaType = tempSchema.schemaType.asInstanceOf[SProduct]
    tempSchema.copy(
      schemaType = schemaType.copy(fields = (typeDiscriminator -> Schema(
        SString,
        isOptional = false) :: schemaType.fields.toList)))
  }
  implicit val conditionSchema: Schema[TestType] = {
    val tempSchema = Schema
      .oneOf[TestType, String](_.toString, identity)(
        "CaseClass" -> caseClassSchema,
        "CaseObject" -> caseObjectSchema)
    val schemaType = tempSchema.schemaType.asInstanceOf[SCoproduct]
    tempSchema.copy(
      schemaType = schemaType.copy(
        discriminator = schemaType.discriminator.map(discriminator => {
          discriminator.copy(propertyName = typeDiscriminator)
        })))
  }

this produces:

components:
  schemas:
    TestType:
      oneOf:
      - $ref: '#/components/schemas/CaseClass'
      - $ref: '#/components/schemas/CaseObject'
      discriminator:
        propertyName: typeDiscrimininator
        mapping:
          CaseClass: '#/components/schemas/CaseClass'
          CaseObject: '#/components/schemas/CaseObject'
    CaseClass:
      required:
      - typeDiscrimininator
      - a
      type: object
      properties:
        typeDiscrimininator:
          type: string
        a:
          type: string
    CaseObject:
      required:
      - typeDiscrimininator
      type: object
      properties:
        typeDiscrimininator:
          type: string

I know that the code could be simplified by reducing redundant code and using lenses but I think it would be great to get this into tapir somehow :)

I would appreciate input on how it could be achieved. If we can agree on a possible solution I would be willing to invest some to help implementing it, however currently I don't see the best path towards it.

@Krever
Copy link

Krever commented Jan 19, 2020

To have a full typesafety it would be nice to also have enum as type of typeDiscrimininator field. Or literal type if thats supported by openapi.

@yeryomenkom
Copy link

Any progress on this?

@oscar-broman
Copy link

I ran into this exact problem and ended up writing a function to auto-generate coproduct schemas with discriminator keys.

This will also inject the discriminator field into each coproduct schema, that's optional and can be removed. It's there because, for example, Loopback's validation against OpenAPI requires that the field exists.

I'm posting it here in case it helps someone else finding their way to this issue, like I did previously.

// Usage example
import my.package.TapirCoproductSchema.deriveCoproductSchema

object MyTrait {
  implicit val schema: Schema[MyTrait] = deriveCoproductSchema[MyTrait]()
}
sealed trait MyTrait
case object Foo extends MyTrait
case class Bar(x: Int) extends MyTrait
package my.package

import scala.annotation.nowarn
import scala.reflect.runtime.universe.WeakTypeTag

import shapeless.{Coproduct, Generic, HList, LabelledGeneric, ops}
import sttp.tapir.{FieldName, Schema}
import sttp.tapir.SchemaType.{Discriminator, SCoproduct, SObject, SObjectInfo, SProduct, SRef, SString}

class TapirCoproductSchema[A] {
  val discriminatorKey = "kind"
  
  def apply[C <: Coproduct, C0 <: Coproduct, L <: HList, K <: HList]()(implicit
    // FIXME: make sure the ordering of gen and lgen is the same
    @nowarn gen: Generic.Aux[A, C],
    // Used to get all the symbol names
    @nowarn lgen: LabelledGeneric.Aux[A, C0],
    // Lift all schemas for the coproducts
    schemas: ops.coproduct.LiftAll.Aux[Schema, C, L],
    schemasToSeq: ops.hlist.ToTraversable.Aux[L, Seq, Schema[_]],
    // Get the keys for the coproducts
    keys: ops.union.Keys.Aux[C0, K],
    keysToSeq: ops.hlist.ToTraversable.Aux[K, Seq, Symbol],
    typeTag: WeakTypeTag[A]
  ): Schema[A] = {
    val typeName = typeTag.tpe.typeSymbol.fullName
    // "CoproductName" -> Schema[CoproductName]
    val mapping = keysToSeq(keys()).map(_.name).zip(schemasToSeq(schemas.instances)).map {
      // Add discriminator key to all product schemas
      case (key, schema @ Schema(schemaType: SProduct, _, _, _, _)) =>
        key -> schema.copy(
          schemaType = schemaType.copy(
            fields = schemaType.fields.toSeq :+ (FieldName(discriminatorKey) -> Schema(SString))
          )
        )
      // Convert object schemas to product schemas containing only the discriminator key
      case (key, schema @ Schema(schemaType: SObject, _, _, _, _)) =>
        key -> schema.copy(
          schemaType = SProduct(schemaType.info, Seq(FieldName(discriminatorKey) -> Schema(SString)))
        )
      case (key, _) =>
        throw new Exception(s"Expected an object or product schema for $key in $typeName")
    }.toMap
    val discriminator = Discriminator(discriminatorKey, mapping.map {
      case (k, sf) =>
        // asInstanceOf will never fail as an exception would be thrown above first
        k -> SRef(sf.schemaType.asInstanceOf[SObject].info)
    })
    Schema(
      SCoproduct(
        SObjectInfo(
          typeName,
          Nil
        ),
        mapping.values.toList,
        Some(discriminator)
      )
    )
  }
}

object TapirCoproductSchema {
  def deriveCoproductSchema[A] = new TapirCoproductSchema[A]
}

@adamw
Copy link
Member

adamw commented Oct 22, 2020

I've added a Configuration.withDiscriminator option for generic derivation, see: #806

Let me know if that would solve the problems described above. Also, the docs are changed, hopefully they are clear now, but if not - also let me know :)

adamw added a commit that referenced this issue Oct 22, 2020
#315: configurable coproducts schema derivation
@adamw
Copy link
Member

adamw commented Oct 22, 2020

Fixed in 0.17.0-M4

@adamw adamw closed this as completed Oct 22, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants