softwaremill / tapir

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

[BUG] mapOutTo macro fails when generics are involved #2555

Open hochgi opened 1 year ago

hochgi commented 1 year ago

Tapir version: 1.1.4

Scala version: 2.12.16

Describe the bug Source: gitter thread

What is the problem? Code that should be valid fails to compile with an error like:

[error] …/src/main/scala/…/Base.scala:36:14: The type of the tuple field doesn't match the type of the case class field (value payload): T, T
[error]     .mapOutTo[MyWrappedResponse[T]]
[error]              ^
[error] one error found

How to reproduce? Defining an endpoint with generic output, e.g:

case class MyWrappedResponse[T](statusCode: Int, payload: T)
def genericResponse[T : JsonCodec : Schema : ClassTag]: PublicEndpoint[Unit, Unit, MyWrappedResponse[T], Any] = endpoint
  .get
  .in("entity" / implicitly[ClassTag[T]].runtimeClass.getSimpleName)
  .out(statusCode)
  .mapOut(sc => sc.code)(StatusCode.unsafeApply)
  .out(jsonBody[T])
  .mapOutTo[MyWrappedResponse[T]]
rafalambrozewicz commented 1 year ago

I've glanced at the issue, but haven't managed to solve it though. Still, I want to add some insights as they might be useful for someone looking at it in the future.

Let's notice, that this issue does not affect Scala 3. If possible, one should consider migration.

The following code reproduces the issue (I've changed it a bit to enable usage of circe, but I suppose that it does not alter the original issue):

package com.softwaremill.playground

import io.circe.{Decoder, Encoder}
import io.circe.generic.auto._
import sttp.tapir.generic.auto._
import sttp.model.StatusCode
import sttp.tapir.Codec.JsonCodec
import sttp.tapir._
import sttp.tapir.json.circe._

import scala.reflect.ClassTag

case class MyWrappedResponse[TYPE](statusCode: Int, payload: TYPE)
case class MyCaseClass(foo: Int)

object Endpoints {

  def main(args: Array[String]): Unit = {
    genericResponse[MyCaseClass]
  }

  private def genericResponse[T: JsonCodec : Schema : ClassTag : Encoder : Decoder]: PublicEndpoint[Unit, Unit, MyWrappedResponse[T], Any] = endpoint
    .get
    .in("entity" / implicitly[ClassTag[T]].runtimeClass.getSimpleName)
    .out(statusCode)
    .mapOut(sc => sc.code)(StatusCode.unsafeApply)
    .out(jsonBody[T])
    .mapOutTo[MyWrappedResponse[T]]
}

while using the following build.sbt.

ThisBuild / organization := "com.softwaremill"
ThisBuild / scalaVersion := "2.12.16"
ThisBuild / version := "0.0.1"

val tapirVersion = "1.1.4"

lazy val root = (project in file(".")).settings(
  name := "playground",
  libraryDependencies ++= Seq(
    "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion,
    "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion,
  )
)

On tapir side, problems lie in MapToMacro class logic, where;

  1. we check if the types of params tuple match the ones in the case class; not sure how we could retrieve what "real" type is behind generic, tried using reify / dealias functions, but with no luck
  2. we build functions converting from params tuple to case class and vice versa, in a place where case class instance is created to have CaseClass[TYPE] rather than CaseClass; seems fairly simple if the type param to use is known, for example, one could pass updated type tree to quasiquotes:
    
    val classTypeWithTypeParams: Tree = AppliedTypeTree(Ident(CASE_CLASS_TYPE_NAME), List(Ident(TYPE_PARAM_TYPE_NAME)))

q"(t: $classTypeWithTypeParams) => (..$tupleArgs)"