tapir
tapir copied to clipboard
Support Codec derivation for opaque types
Create new macro in scala3 sources that will try to derive codec for "value classes" implemented as opaque types.
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 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.
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)
Thanks for the feedback Adam. Now I have a vector on which I can circumvent the problem. :-)
Sorry for spamming the issue comments. :pray: