Skip to content

Commit ffd2ea4

Browse files
Fix #4354: [BUG] mapTo fails to compile if case class is defined inside object (#5377)
Closes #4354
1 parent 09d4194 commit ffd2ea4

7 files changed

Lines changed: 240 additions & 17 deletions

File tree

core/src/main/scala-2/sttp/tapir/internal/CaseClassUtil.scala

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,27 @@ private[tapir] class CaseClassUtil[C <: blackbox.Context, T: C#WeakTypeTag](val
1818
.paramLists
1919
.head
2020

21-
lazy val companion: Ident = Ident(TermName(t.typeSymbol.name.decodedName.toString))
21+
// the reference to the companion object must be qualified with the case class's prefix, as a bare-name identifier
22+
// doesn't resolve for classes nested in objects or classes (see: https://github.com/softwaremill/tapir/issues/4354);
23+
// local classes have no prefix, but there a bare name is in scope
24+
lazy val companion: Tree = {
25+
val name = TermName(t.typeSymbol.name.decodedName.toString)
26+
t.dealias match {
27+
case TypeRef(pre, _, _) if pre != NoPrefix => Select(c.internal.gen.mkAttributedQualifier(pre), name)
28+
case _ => Ident(name)
29+
}
30+
}
2231

23-
lazy val instanceFromValues: Tree = if (fields.size == 1) {
24-
q"$companion.apply(values.head.asInstanceOf[${fields.head.typeSignature}])"
25-
} else {
26-
q"($companion.apply _).tupled.asInstanceOf[Any => $t].apply(sttp.tapir.internal.SeqToParams(values))"
32+
// apply is called with explicit arguments instead of eta-expansion (`(apply _).tupled`), as the latter fails to
33+
// compile when the companion defines additional apply overloads
34+
lazy val instanceFromValues: Tree = {
35+
val args = fields.zipWithIndex.map { case (field, i) => q"values($i).asInstanceOf[${field.typeSignature}]" }
36+
q"$companion.apply(..$args)"
2737
}
2838

2939
lazy val schema: Tree = c.typecheck(q"_root_.scala.Predef.implicitly[_root_.sttp.tapir.Schema[$t]]")
3040

3141
lazy val classSymbol: ClassSymbol = t.typeSymbol.asClass
32-
lazy val className: TermName = classSymbol.asType.name.toTermName
3342

3443
def annotated(field: Symbol, annotationType: c.Type): Boolean =
3544
findAnnotation(field, annotationType).isDefined

core/src/main/scala-2/sttp/tapir/internal/EndpointAnnotationsMacro.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ private[tapir] abstract class EndpointAnnotationsMacro(val c: blackbox.Context)
9595
}
9696

9797
protected def mapToTargetFunc[A](inputIdxToFieldIdx: mutable.Map[Int, Int], util: CaseClassUtil[c.type, A]): Tree = {
98-
val className = util.className
98+
val companion = util.companion
9999
if (inputIdxToFieldIdx.size > 1) {
100100
val tupleTypeComponents = (0 until inputIdxToFieldIdx.size) map { idx =>
101101
val field = util.fields(inputIdxToFieldIdx(idx))
@@ -110,11 +110,11 @@ private[tapir] abstract class EndpointAnnotationsMacro(val c: blackbox.Context)
110110
q"t.$fieldName"
111111
}
112112

113-
q"(t: $tupleType) => $className(..$ctorArgs)"
113+
q"(t: $tupleType) => $companion(..$ctorArgs)"
114114
} else if (inputIdxToFieldIdx.size == 1) {
115-
q"(t: ${util.fields.head.info}) => $className(t)"
115+
q"(t: ${util.fields.head.info}) => $companion(t)"
116116
} else {
117-
q"(t: ${}) => $className()"
117+
q"(t: ${}) => $companion()"
118118
}
119119
}
120120

core/src/main/scala-2/sttp/tapir/internal/MapToMacro.scala

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,14 @@ private[tapir] object MapToMacro {
3535
val tupleType = weakTypeOf[TUPLE]
3636
val tupleTypeArgs = tupleType.dealias.typeArgs
3737
if (caseClassUtil.fields.size == 0) {
38-
q"(t: ${tupleType.dealias}) => ${caseClassUtil.className}()"
38+
q"(t: ${tupleType.dealias}) => ${caseClassUtil.companion}()"
3939
} else if (caseClassUtil.fields.size == 1) {
4040
verifySingleFieldCaseClass(c)(caseClassUtil, tupleType)
41-
// Compilation failure if `CaseClass` gets passed as `[Wrapper.CaseClass]` caused by invalid `className`
42-
// retrieval below, workaround available (see: https://github.com/softwaremill/tapir/issues/2540)
43-
q"(t: ${tupleType.dealias}) => ${caseClassUtil.className}(t)"
41+
q"(t: ${tupleType.dealias}) => ${caseClassUtil.companion}(t)"
4442
} else {
4543
verifyCaseClassMatchesTuple(c)(caseClassUtil, tupleType, tupleTypeArgs)
4644
val ctorArgs = (1 to tupleTypeArgs.length).map(idx => q"t.${TermName(s"_$idx")}")
47-
// Compilation failure if `CaseClass` gets passed as `[Wrapper.CaseClass]` caused by invalid `className`
48-
// retrieval below, workaround available (see: https://github.com/softwaremill/tapir/issues/2540)
49-
q"(t: ${tupleType.dealias}) => ${caseClassUtil.className}(..$ctorArgs)"
45+
q"(t: ${tupleType.dealias}) => ${caseClassUtil.companion}(..$ctorArgs)"
5046
}
5147
}
5248

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package sttp.tapir
2+
3+
import org.scalatest.flatspec.AnyFlatSpec
4+
import org.scalatest.matchers.should.Matchers
5+
6+
object CompanionApplyTestFixtures {
7+
case class Normalized(s: String)
8+
object Normalized {
9+
def apply(s: String): Normalized = new Normalized(s.trim)
10+
}
11+
12+
case class FormWithApply(a: String, b: Int)
13+
object FormWithApply {
14+
def apply(a: String, b: Int): FormWithApply = new FormWithApply(a.trim, b)
15+
}
16+
17+
case class Guarded private (i: Int)
18+
19+
case class FormWithOverload(a: String, b: Int)
20+
object FormWithOverload {
21+
def apply(a: String): FormWithOverload = new FormWithOverload(a, 0)
22+
}
23+
}
24+
25+
// on Scala 2, macro-generated mappings construct case classes through the companion's `apply`; these tests document
26+
// that a custom same-signature `apply` is used, and that case classes with private constructors remain supported
27+
// (on Scala 3, mappings are `Mirror`-based and invoke the constructor directly)
28+
class CompanionApplyTest extends AnyFlatSpec with Matchers {
29+
import CompanionApplyTestFixtures._
30+
31+
"mapTo" should "use a custom companion apply if one is defined" in {
32+
val codec = plainBody[String].mapTo[Normalized].codec
33+
34+
codec.decode(" x ") shouldBe DecodeResult.Value(Normalized("x"))
35+
}
36+
37+
"mapTo" should "compile for a case class with a private constructor" in {
38+
plainBody[Int].mapTo[Guarded]
39+
}
40+
41+
"formBody" should "use a custom companion apply if one is defined" in {
42+
import sttp.tapir.generic.auto._
43+
44+
val codec = implicitly[Codec[String, FormWithApply, CodecFormat.XWwwFormUrlencoded]]
45+
46+
codec.decode("a=+x+&b=1") shouldBe DecodeResult.Value(FormWithApply("x", 1))
47+
}
48+
49+
"formBody" should "compile when the companion defines additional apply overloads" in {
50+
import sttp.tapir.generic.auto._
51+
52+
val codec = implicitly[Codec[String, FormWithOverload, CodecFormat.XWwwFormUrlencoded]]
53+
54+
codec.decode("a=x&b=1") shouldBe DecodeResult.Value(FormWithOverload("x", 1))
55+
}
56+
}

core/src/test/scala/sttp/tapir/EndpointTest.scala

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,96 @@ class EndpointTest extends AnyFlatSpec with EndpointTestExtensions with Matchers
403403
mapping.decode((10, "x")) shouldBe DecodeResult.Value(Wrapper(10, "x"))
404404
}
405405

406+
"mapTo" should "properly map to a case class defined inside an object (arity 1)" in {
407+
// given
408+
val mapped = plainBody[String].mapTo[EndpointTestFixtures.NestedWrapper]
409+
val codec: Codec[String, EndpointTestFixtures.NestedWrapper, CodecFormat] = mapped.codec
410+
411+
// when
412+
codec.encode(EndpointTestFixtures.NestedWrapper("x")) shouldBe "x"
413+
codec.decode("x") shouldBe DecodeResult.Value(EndpointTestFixtures.NestedWrapper("x"))
414+
}
415+
416+
"mapTo" should "properly map to a case class defined inside an object (arity 2)" in {
417+
// given
418+
val mapped = query[Int]("q1").and(query[String]("q2")).mapTo[EndpointTestFixtures.NestedWrapper2]
419+
val mapping: Mapping[(Int, String), EndpointTestFixtures.NestedWrapper2] = mapped match {
420+
case EndpointInput.MappedPair(_, m) => m.asInstanceOf[Mapping[(Int, String), EndpointTestFixtures.NestedWrapper2]]
421+
case _ => fail()
422+
}
423+
424+
// when
425+
mapping.encode(EndpointTestFixtures.NestedWrapper2(10, "x")) shouldBe ((10, "x"))
426+
mapping.decode((10, "x")) shouldBe DecodeResult.Value(EndpointTestFixtures.NestedWrapper2(10, "x"))
427+
}
428+
429+
"mapTo" should "properly map to a case class defined inside an object nested inside another object (arity 1)" in {
430+
// given
431+
val mapped = plainBody[String].mapTo[EndpointTestFixtures.Inner.DoublyNestedWrapper]
432+
val codec: Codec[String, EndpointTestFixtures.Inner.DoublyNestedWrapper, CodecFormat] = mapped.codec
433+
434+
// when
435+
codec.encode(EndpointTestFixtures.Inner.DoublyNestedWrapper("x")) shouldBe "x"
436+
codec.decode("x") shouldBe DecodeResult.Value(EndpointTestFixtures.Inner.DoublyNestedWrapper("x"))
437+
}
438+
439+
"mapTo" should "properly map to a case class defined inside an object nested inside another object (arity 2)" in {
440+
// given
441+
val mapped = query[Int]("q1").and(query[String]("q2")).mapTo[EndpointTestFixtures.Inner.DoublyNestedWrapper2]
442+
val mapping: Mapping[(Int, String), EndpointTestFixtures.Inner.DoublyNestedWrapper2] = mapped match {
443+
case EndpointInput.MappedPair(_, m) => m.asInstanceOf[Mapping[(Int, String), EndpointTestFixtures.Inner.DoublyNestedWrapper2]]
444+
case _ => fail()
445+
}
446+
447+
// when
448+
mapping.encode(EndpointTestFixtures.Inner.DoublyNestedWrapper2(10, "x")) shouldBe ((10, "x"))
449+
mapping.decode((10, "x")) shouldBe DecodeResult.Value(EndpointTestFixtures.Inner.DoublyNestedWrapper2(10, "x"))
450+
}
451+
452+
object ClassNestedFixtures {
453+
case class NestedWrapper(x: String)
454+
}
455+
456+
"mapTo" should "properly map to a case class defined inside an object nested in a class" in {
457+
// given
458+
val mapped = plainBody[String].mapTo[ClassNestedFixtures.NestedWrapper]
459+
val codec: Codec[String, ClassNestedFixtures.NestedWrapper, CodecFormat] = mapped.codec
460+
461+
// when
462+
codec.encode(ClassNestedFixtures.NestedWrapper("x")) shouldBe "x"
463+
codec.decode("x") shouldBe DecodeResult.Value(ClassNestedFixtures.NestedWrapper("x"))
464+
}
465+
466+
"mapTo" should "properly map to a case class defined inside an object local to a method" in {
467+
// given
468+
object Wrapper {
469+
case class MyCaseClass(x: String)
470+
}
471+
val mapped = plainBody[String].mapTo[Wrapper.MyCaseClass]
472+
val codec: Codec[String, Wrapper.MyCaseClass, CodecFormat] = mapped.codec
473+
474+
// when
475+
codec.encode(Wrapper.MyCaseClass("x")) shouldBe "x"
476+
codec.decode("x") shouldBe DecodeResult.Value(Wrapper.MyCaseClass("x"))
477+
}
478+
479+
"mapInTo" should "map endpoint input to case classes defined inside objects" in {
480+
object Wrapper {
481+
case class MyCaseClass(s: String)
482+
}
483+
484+
endpoint
485+
.in(query[String]("q1"))
486+
.mapInTo[EndpointTestFixtures.NestedWrapper]: PublicEndpoint[EndpointTestFixtures.NestedWrapper, Unit, Unit, Any]
487+
endpoint
488+
.in(query[String]("q1"))
489+
.mapInTo[EndpointTestFixtures.Inner.DoublyNestedWrapper]: PublicEndpoint[EndpointTestFixtures.Inner.DoublyNestedWrapper, Unit, Unit, Any]
490+
endpoint
491+
.in(query[String]("q1"))
492+
.mapInTo[ClassNestedFixtures.NestedWrapper]: PublicEndpoint[ClassNestedFixtures.NestedWrapper, Unit, Unit, Any]
493+
endpoint.in(query[String]("q1")).mapInTo[Wrapper.MyCaseClass]: PublicEndpoint[Wrapper.MyCaseClass, Unit, Unit, Any]
494+
}
495+
406496
"mapTo" should "fail on invalid field type" in {
407497
assertDoesNotCompile("""
408498
|case class Wrapper(i: Int, i2: Int)
@@ -451,3 +541,13 @@ class EndpointTest extends AnyFlatSpec with EndpointTestExtensions with Matchers
451541
.showDetail shouldBe "Endpoint(securityin: -, in: POST /p1 auth(api key, via ?par2) /[par3](>=1), errout: -, out: -)"
452542
}
453543
}
544+
545+
object EndpointTestFixtures {
546+
case class NestedWrapper(x: String)
547+
case class NestedWrapper2(i: Int, s: String)
548+
549+
object Inner {
550+
case class DoublyNestedWrapper(x: String)
551+
case class DoublyNestedWrapper2(i: Int, s: String)
552+
}
553+
}

core/src/test/scala/sttp/tapir/annotations/DeriveEndpointIOTest.scala

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,24 @@ object TapirRequestTest16 {
185185
@endpointInput("some/path")
186186
final case class TapirRequestTest17()
187187

188+
object TapirRequestTest18 {
189+
final case class Nested(
190+
@query
191+
field1: Int,
192+
@query
193+
field2: String
194+
)
195+
196+
object Inner {
197+
final case class DoublyNested(
198+
@query
199+
field1: Int,
200+
@query
201+
field2: String
202+
)
203+
}
204+
}
205+
188206
class DeriveEndpointIOTest extends AnyFlatSpec with Matchers with TableDrivenPropertyChecks with Tapir {
189207

190208
"@endpointInput" should "derive correct input for @query, @cookie, @header" in {
@@ -202,6 +220,18 @@ class DeriveEndpointIOTest extends AnyFlatSpec with Matchers with TableDrivenPro
202220
compareTransputs(EndpointInput.derived[TapirRequestTest1], expectedInput) shouldBe true
203221
}
204222

223+
it should "derive correct input for a case class defined inside an object" in {
224+
val expectedInput = query[Int]("field1").and(query[String]("field2")).mapTo[TapirRequestTest18.Nested]
225+
226+
compareTransputs(EndpointInput.derived[TapirRequestTest18.Nested], expectedInput) shouldBe true
227+
}
228+
229+
it should "derive correct input for a case class defined inside an object nested inside another object" in {
230+
val expectedInput = query[Int]("field1").and(query[String]("field2")).mapTo[TapirRequestTest18.Inner.DoublyNested]
231+
232+
compareTransputs(EndpointInput.derived[TapirRequestTest18.Inner.DoublyNested], expectedInput) shouldBe true
233+
}
234+
205235
it should "derive correct input for dealiased bodies" in {
206236
import JsonCodecs._
207237

core/src/test/scala/sttp/tapir/generic/FormCodecDerivationTest.scala

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,29 @@ class FormCodecDerivationTest extends AnyFlatSpec with FormCodecDerivationTestEx
202202
codec.decode("f1=str&f2=42&f3=228&f4=322&f5=69&f6=27.0&f7=33.0&f8=true&f9=1337.7331&f10=31337.73313") shouldBe DecodeResult.Value(test1)
203203
}
204204

205+
it should "generate a codec for a case class defined inside an object" in {
206+
// given
207+
val codec1 = implicitly[Codec[String, FormCodecDerivationTestFixtures.NestedTest1, CodecFormat.XWwwFormUrlencoded]]
208+
val codec2 = implicitly[Codec[String, FormCodecDerivationTestFixtures.NestedTest2, CodecFormat.XWwwFormUrlencoded]]
209+
210+
// when
211+
codec1.encode(FormCodecDerivationTestFixtures.NestedTest1(10)) shouldBe "f1=10"
212+
codec1.decode("f1=10") shouldBe DecodeResult.Value(FormCodecDerivationTestFixtures.NestedTest1(10))
213+
214+
codec2.encode(FormCodecDerivationTestFixtures.NestedTest2("v1", 10)) shouldBe "f1=v1&f2=10"
215+
codec2.decode("f1=v1&f2=10") shouldBe DecodeResult.Value(FormCodecDerivationTestFixtures.NestedTest2("v1", 10))
216+
}
217+
218+
it should "generate a codec for a case class defined inside an object nested inside another object" in {
219+
// given
220+
val codec =
221+
implicitly[Codec[String, FormCodecDerivationTestFixtures.Inner.DoublyNestedTest1, CodecFormat.XWwwFormUrlencoded]]
222+
223+
// when
224+
codec.encode(FormCodecDerivationTestFixtures.Inner.DoublyNestedTest1("v1", 10)) shouldBe "f1=v1&f2=10"
225+
codec.decode("f1=v1&f2=10") shouldBe DecodeResult.Value(FormCodecDerivationTestFixtures.Inner.DoublyNestedTest1("v1", 10))
226+
}
227+
205228
it should "generate a codec with custom field name" in {
206229
// given
207230
case class Test1(@encodedName("g1") f1: Int)
@@ -214,3 +237,12 @@ class FormCodecDerivationTest extends AnyFlatSpec with FormCodecDerivationTestEx
214237
}
215238

216239
case class CaseClassWithComplicatedName(complicatedName: Int)
240+
241+
object FormCodecDerivationTestFixtures {
242+
case class NestedTest1(f1: Int)
243+
case class NestedTest2(f1: String, f2: Int)
244+
245+
object Inner {
246+
case class DoublyNestedTest1(f1: String, f2: Int)
247+
}
248+
}

0 commit comments

Comments
 (0)