softwaremill / tapir

Rapid development of self-documenting APIs
https://tapir.softwaremill.com
Apache License 2.0
1.36k stars 411 forks source link

Support for error ADT and circe Codec in output #1043

Open FunFunFine opened 3 years ago

FunFunFine commented 3 years ago

Tapir version: 0.17.12

Scala version: 2.13.5

Prerequisites

So let's say I have the following error ADT:

sealed trait ErrorInfo

case class NotFound(what: String) extends ErrorInfo
case class Unauthorized(realm: String) extends ErrorInfo
case class Unknown(code: Int, msg: String) extends ErrorInfo
case object NoContent extends ErrorInfo

object ErrorInfo {
   implicit val circeCodec: circe.Codec[ErrorInfo] = ???
}

And defined circe codec produces and expects the discriminator object:

NotFound(what = "something").asJson.spaces2 == 
"""
{   
  "not_found" : {
      "what" : "something"
   }
}
"""

Problem

I want to use ErrorInfo in the error output of my endpoint, so I write this code:

val baseEndpoint = endpoint.errorOut(
  oneOf[ErrorInfo](
    statusMapping(StatusCode.NotFound, jsonBody[NotFound].description("not found")),
    statusMapping(StatusCode.Unauthorized, jsonBody[Unauthorized].description("unauthorized")),
    statusMapping(StatusCode.NoContent, emptyOutput.map(_ => NoContent)(_ => ())),
    statusDefaultMapping(jsonBody[Unknown].description("unknown"))
  )
)

And it does not compile, because the compiler can't find circe codec for each case of the ADT:

[error]  could not find implicit value for evidence parameter of type io.circe.Encoder[NotFound]
[error]           statusMapping(StatusCode.NotFound, jsonBody[NotFound].description("not found")),

which is understendable actually because of jsonBody signature: def jsonBody[T: Encoder : Decoder: Schema] — T here and in circe's typeclasses is invariant.

I could use import io.circe.generic.auto._ but then the output differs from what I expect: it's just plain JSON without discriminator: { "what" : "something" }.

Workaround

For now, I am using this custom output:

 def jsonBodyADT[A: Encoder: Decoder, B <: A: Schema: ClassTag]: EndpointIO.Body[String, B] = {
    implicit val bEncoder: Encoder[B] = Encoder.instance[B](Encoder[A].apply(_))
    implicit val bDecoder: Decoder[B] = Decoder.instance[B](
      json =>
        Decoder[A].apply(json) match {
          case Left(value) => Left(value)
          case Right(value) if value.getClass == implicitly[ClassTag[B]].runtimeClass =>
            Right(value.asInstanceOf[B]) //scalafix:ok
          case _ => Left(DecodingFailure.fromThrowable(new Throwable("wrong subtype"), Nil))
        }
    )

 //and then in oneOf(...)
    statusMapping(StatusCode.NotFound, jsonBodyADT[ErrorInfo, NotFound].description("not found"))

and this works but feels very wrong.

Question

So what can be done to fix this behaviour? Is there a built-in way to work with that in Tapir? If there is none, can I contribute it?

adamw commented 3 years ago

Hm interesting problem :)

Having a Decoder[ErrorInfo], you don't really know that you are able to decode a json into NotFound - the decoder may always return NoContent, for example.

So other than defining decoders for NotFound etc which verify that the discriminator is present, I don't think I have a good solution.

One direction to explore, might be in jsonBody[T] requiring an Encoder[U], where U <: T, as if we need to encode a T, a U-encoder will work just fine. But there might be reasons why Encoder is not covariant in the first place. This would solve the encoding part, but the decoder would still be problematic.

Zhen-hao commented 2 years ago

Another note. I can't find statusDefaultMapping in the latest code. But it is still in the documentation. Anyone who tries the example code will get a complier error.

adamw commented 2 years ago

@Zhen-hao This is now renamed to oneOfDefaultVariant. Where in the docs did you encounter statusDefaultMapping?

Zhen-hao commented 2 years ago

https://github.com/softwaremill/tapir/blob/master/doc/adr/0003-shape-of-ios.md#L44 and https://github.com/softwaremill/tapir/blob/master/generated-doc/out/adr/0003-shape-of-ios.md#L44

adamw commented 2 years ago

@Zhen-hao ah in the ADR, fixed :)

soujiro32167 commented 2 years ago

My workaround (with zio-json):

  def jsonBody2[T: JsonEncoder: JsonDecoder, U <: T: Schema]: EndpointIO.Body[String, U] = {
    implicit val enc = JsonEncoder[T].narrow[U]
    implicit val dec = JsonDecoder[T].asInstanceOf[JsonDecoder[U]]
    // this is safe only in the case where T is a sealed trait with an auto-generated decoder for all subtypes
    stringBodyUtf8AnyFormat(zioCodec[U])
  }

usage (same as above):

statusMapping(StatusCode.NotFound, jsonBody2[ErrorInfo, NotFound].description("not found"))