tapir icon indicating copy to clipboard operation
tapir copied to clipboard

Support Codec derivation for opaque types

Open kubinio123 opened this issue 3 years ago • 4 comments

Create new macro in scala3 sources that will try to derive codec for "value classes" implemented as opaque types.

kubinio123 avatar Feb 14 '22 08:02 kubinio123

Hi,

I played with opaque types and manual codecs and stumbled upon the issue that tapir will silently ignore a provided codec and use the underlying type (e.g. String).

Some code:

final class HelloWorld[F[_]: Async] extends Http4sDsl[F] {
  final val message: GreetingMessage = GreetingMessage("This is a fancy message directly from http4s! :-)")

  implicit def decodeGreetings: EntityDecoder[F, Greetings] = jsonOf
  implicit def encodeGreetings: EntityEncoder[F, Greetings] = jsonEncoderOf

  private val sayHello: HttpRoutes[F] =
    Http4sServerInterpreter[F]().toRoutes(HelloWorld.greetings.serverLogic { nameParameter =>
      // FIXME We wrap into an extra layer because of the tapir validation issue with opaque types (see below).
      val greetings = HelloWorld.NameParameter
        .from(nameParameter.toString)
        .flatMap(name =>
          (
            GreetingTitle.from(s"Hello $name!"),
            GreetingHeader.from(s"Hello $name, live long and prosper!")
          ).mapN { case (title, headings) =>
            Greetings(
              title = title,
              headings = headings,
              message = message
            )
          }
        )
      Sync[F].delay(greetings.fold(StatusCode.BadRequest.asLeft[Greetings])(_.asRight[StatusCode]))
    })

  val routes: HttpRoutes[F] = sayHello

}

object HelloWorld {
  val example = Greetings(
    title = GreetingTitle("Hello Kirk!"),
    headings = GreetingHeader("Hello Kirk, live long and prosper!"),
    message = GreetingMessage("This is some demo message...")
  )

  opaque type NameParameter = String
  object NameParameter {

    // FIXME It seems like custom codecs for opaque types are ignored in favour of the underlying type!
    given Codec[String, NameParameter, TextPlain] =
      Codec.string.mapDecode(str =>
        NameParameter
          .from(str)
          .fold(
            sttp.tapir.DecodeResult.Error(str, new IllegalArgumentException("Invalid name parameter value!"))
          )(name => sttp.tapir.DecodeResult.Value(name))
      )(_.toString)

    def apply(source: String): NameParameter = source

    def from(source: String): Option[NameParameter] = Option(source).filter(_.nonEmpty)

  }

  val greetings: Endpoint[Unit, NameParameter, StatusCode, Greetings, Any] =
    endpoint.get
      .in("hello")
      .in(query[NameParameter]("name"))
      .errorOut(statusCode)
      .out(jsonBody[Greetings].description("A JSON object demo").example(example))
      .description(
        "Returns a simple JSON object using the provided query parameter 'name' which must not be empty."
      )
}

You can use our http4s-tapir template (sbt new https://codeberg.org/wegtam/http4s-tapir.g8.git) to create a minimal project (the code above is from it) and look at the two FIXME marks in HelloWorld.scala.

If you comment out the whole Codec for NameParameter then everything will still compile just fine and the behaviour will be the same, so I guess that the codec is ignored.

jan0sch avatar Aug 25 '22 08:08 jan0sch

@jan0sch you've hit an interesting case :) Here's a simplified version of the test, also showing the solution:

package sttp.tapir

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

object Example {
  opaque type NameParameter = String

  object NameParameter {
    given Codec[String, NameParameter, CodecFormat.TextPlain] =
      Codec.string.mapDecode(str =>
        NameParameter
          .from(str)
          .fold(DecodeResult.Error(str, new IllegalArgumentException("Invalid name parameter value!")))(name =>
            sttp.tapir.DecodeResult.Value(name)
          )
      )(_.toString)

    def from(source: String): Option[NameParameter] = Option(source).filter(_.nonEmpty)
  }

  val q1 = query[NameParameter]("name")
}

class CodecScala3Test extends AnyFlatSpec with Matchers {
  it should "work" in {
    val q2 = query[Example.NameParameter]("name")

    println(Example.q1.codec.decode(List("")))
    println(q2.codec.decode(List("")))
  }
}

This outputs:

Value()
Error(,java.lang.IllegalArgumentException: Invalid name parameter value!)

So the q1 which is defined inside of Example doesn't use the given codec, but q2 which is defined outside does. I suspect that's because inside of Example, it is known that NameParameter == String. Hence the compiler substitutes the type right away, before implicits are resolved?

However, for q2, we are outside the definition of the opaque type, hence NameParameter is now a brand new type, so the implicit resolution works as expected.

adamw avatar Aug 29 '22 11:08 adamw

Btw. the point of this issue is to automatically derive schemas/codecs using the schema/codec for the base type (which can be a basis for further customisation)

adamw avatar Aug 29 '22 11:08 adamw

Thanks for the feedback Adam. Now I have a vector on which I can circumvent the problem. :-)

Sorry for spamming the issue comments. :pray:

jan0sch avatar Aug 29 '22 12:08 jan0sch