0

I have many wrapper classes which all they do is add type safety plus formatting. Besides the ClassName, each wrapper class is essentially the same.

import com.helpers.buildFormatter
import spray.json.JsonFormat

final case class ClassName(value: String)

object ClassName {
  implicit val jsonFormatter: JsonFormat[ClassName] =
    buildFormatter[ClassName]("ClassName", _.value, this.apply)
}

Is there a way to shorten this further? I'm wondering if an annotation, macro, or inheritance might work.

EDIT: buildFormatter provides custom json parsing which is why I'd like to keep it in the resulting answer

def buildStringFormatter[A](className: String, serializer: A => String, deserializer: String => A): JsonFormat[A]

From comments: Q: Will wrapper case classes always be single-parameter, will parameter type always be String, will parameter name always be value? A: You are correct, there would only be 1 parameter and the parameter name be value. I'm fine with restricting those restrictions

irregular
  • 1,437
  • 3
  • 20
  • 39
  • If this is working code and you're asking for advice on improving it, I suggest seeing if your question might be on-topic for Code Review. – chrylis -cautiouslyoptimistic- Oct 05 '20 at 20:50
  • This is working code but `buildFormatter` is a helper function I've left out for because it shouldn't affect any answers – irregular Oct 05 '20 at 20:53
  • 1
    the definition for `buildFormatter` is def `buildStringFormatter[A](label: String, serializer: A => String, deserializer: String => A): JsonFormat[A]`. I'll update the question – irregular Oct 05 '20 at 22:05
  • 1
    You are correct, there would only be 1 parameter and the parameter name be `value`. I'm fine with restricting those restrictions – irregular Oct 05 '20 at 22:07
  • Thank you @DmytroMitin! it looks like I should start reading into how to generate macros although those may be scary/dangerous to work with – irregular Oct 05 '20 at 22:36

2 Answers2

3

You can google spray json derivation

https://github.com/driver-oss/spray-json-derivation

https://github.com/milessabin/spray-json-shapeless

https://github.com/zackangelo/spray-json-macros

https://github.com/ExNexu/spray-json-annotation

etc.


Since the signature of buildFormatter is

def buildFormatter[T](str: String, value: T => String, apply: String => T): JsonFormat[T] = ???

(and you said that wrapper case classes are always single-parameter, parameter type is always String, parameter name is always value) you can try Shapeless

import shapeless.{::, Generic, HList, HNil, Typeable}

object caseClassJsonFormats {
  implicit def caseClassJsonFormat[A <: Product, L <: HList](implicit
    gen: Generic.Aux[A, String :: HNil],
    typeable: Typeable[A]
  ): JsonFormat[A] = 
    buildFormatter[A](typeable.describe, gen.to(_).head, s => gen.from(s :: HNil))
}

So you define a single implicit for all case classes (instead of an implicit per each case class).

Testing:

final case class ClassName(value: String)

import caseClassJsonFormats._
implicitly[JsonFormat[ClassName]] // compiles

Alternative approach is a macro annotation (sbt settings for macro projects)

import scala.annotation.{StaticAnnotation, compileTimeOnly}
import scala.language.experimental.macros
import scala.reflect.macros.blackbox
    
@compileTimeOnly("enable macro annotations")
class jsonFormat extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro JsonFormatMacro.impl
}

object JsonFormatMacro {
  def impl(c: blackbox.Context)(annottees: c.Tree*): c.Tree = {
    import c.universe._

    def jsonFormatImplicit(tpname: TypeName) =
      q"""implicit val jsonFormatter: _root_.spray.json.JsonFormat[$tpname] =
            buildFormatter[$tpname](${tpname.toString}, _.value, this.apply)"""

    annottees match {
      // if there is companion object, modify it
      case (clazz@q"$_ class $tpname[..$_] $_(...$_) extends { ..$_ } with ..$_ { $_ => ..$_ }") ::
        q"$mods object $tname extends { ..$earlydefns } with ..$parents { $self => ..$body }" :: Nil =>
        q"""
           $clazz

           $mods object $tname extends { ..$earlydefns } with ..$parents { $self =>
             ..$body

             ${jsonFormatImplicit(tpname)}
           }"""

      // if there is no companion object, create it
      case (clazz@q"$_ class $tpname[..$_] $_(...$_) extends { ..$_ } with ..$_ { $_ => ..$_ }") :: Nil =>
        q"""
           $clazz

           object ${tpname.toTermName} {
             ${jsonFormatImplicit(tpname)}
           }"""
    }
  }
}

So you define an implicit in companion object per each case class annotated.

Testing:

@jsonFormat
final case class ClassName(value: String)

implicitly[JsonFormat[ClassName]] // compiles

  // scalacOptions += "-Ymacro-debug-lite"
//scalac: {
//  final case class ClassName extends scala.Product with scala.Serializable {
//    <caseaccessor> <paramaccessor> val value: String = _;
//    def <init>(value: String) = {
//      super.<init>();
//      ()
//    }
//  };
//  object ClassName extends scala.AnyRef {
//    def <init>() = {
//      super.<init>();
//      ()
//    };
//    implicit val jsonFormatter: _root_.spray.json.JsonFormat[ClassName] = buildFormatter[ClassName]("ClassName", ((x$1) => x$1.value), this.apply)
//  };
//  ()
//}
Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
  • Does this apply indiscriminately to case classes? Would this need to go in each class's companion object? – irregular Oct 05 '20 at 22:08
  • @irregular This works with case classes (more precisely, case classes and *case-class-like* things). What do you need besides case classes? When you defined one implicit per each case class you defined these implicits in companion objects (implicit scope). Now you define the only implicit for all case classes where you want (in some object maybe) and import it into where you need (local scope). [Where does Scala look for implicits?](https://docs.scala-lang.org/tutorials/FAQ/finding-implicits.html) – Dmytro Mitin Oct 05 '20 at 22:16
  • @irregular Sealed-trait hierarchies are also ok, just my code is for product (case-class-like) case and not coproduct (sealed-trait-like) case. – Dmytro Mitin Oct 05 '20 at 22:25
  • I see. I'm just imagining how production code would look. Would I have an object Foo that contains all these implicit definitions? Then import Foo._ everywhere the JsonFormat is needed? (which could be many classes that contain these wrapper classes)? – irregular Oct 05 '20 at 22:31
  • In practical terms, this definitely DRYs the code a bit. I can imagine `import caseClassJsonFormats._; implicit val jsonFormatter: JsonFormat[ClassName] = implicitly[JsonFormat[ClassName]]` Though I do wonder if we can go even further to having the companion object extend caseClassJsonFormats maybe – irregular Oct 05 '20 at 22:33
  • @irregular I added code with macro annotation – Dmytro Mitin Oct 05 '20 at 22:47
  • @irregular *"Would I have an object Foo that contains all these implicit definitions?"* What all these implicit definitions? You have the only implicit `caseClassJsonFormat`. *"Then import Foo._ everywhere the JsonFormat is needed?"* You import `Foo._` (`caseClassJsonFormats._` in my notations) at the beginning of every file where you encode/decode case classes. – Dmytro Mitin Oct 05 '20 at 22:51
  • @irregular *"I can imagine `import caseClassJsonFormats._; implicit val jsonFormatter: JsonFormat[ClassName] = implicitly[JsonFormat[ClassName]]`"* You **either** import `caseClassJsonFormats._` (not into companion object but into where you encode/decode case classes) **or** define implicit `jsonFormatter` in every companion object per every case class. You do not do both. – Dmytro Mitin Oct 05 '20 at 22:55
  • @irregular I mean you can try to do both but it's not necessary. And what is important, `implicit val jsonFormatter: JsonFormat[ClassName] = implicitly[JsonFormat[ClassName]]` is wrong. Here right hand side is not `caseClassJsonFormats.caseClassJsonFormat[ClassName]`, it's actually the `jsonFormatter` that is being defined now, so you'll possibly have `null` or `NullPointerException`. If you want you can try `implicit val jsonFormatter: JsonFormat[ClassName] = { val jsonFormatter = ???; implicitly[JsonFormat[ClassName]] }` but it's not necessary. – Dmytro Mitin Oct 05 '20 at 23:01
  • @irregular See details about hiding name of implicit here: https://stackoverflow.com/questions/61917884/nullpointerexception-on-implicit-resolution – Dmytro Mitin Oct 05 '20 at 23:02
  • Is there a way I can debug the macro annotation? I've defined the macro but the implicit val within the companion object isn't reachable or doesn't exist. – irregular Oct 14 '20 at 01:22
  • @irregular Please read "Debugging macros" https://docs.scala-lang.org/overviews/macros/overview.html#debugging-macros You should switch on `scalacOptions += "-Ymacro-debug-lite"` in `build.sbt`. Then you'll see what code is generated. Also switch on `scalacOptions += "-Xlog-implicits"` because it's about implicits. Did you set up your build for using macro annotations (`scalacOptions += "-Ymacro-annotations"` in Scala 2.13 or macro paradise in 2.10-2.12, https://docs.scala-lang.org/overviews/macros/annotations.html )? If you still experience issues you should probably start a new question. – Dmytro Mitin Oct 14 '20 at 19:05
  • @irregular Please notice that I added `@compileTimeOnly` to my answer. In this way you can check that macro annotations are actually expanded (compiler removes macro annotations after expansion, so if macro annotations are not expanded the code will not compile). – Dmytro Mitin Oct 14 '20 at 19:12
  • Connected discussion https://www.reddit.com/r/scala/comments/jar3fd/how_do_people_normally_debug_macro_annotations/ – Dmytro Mitin Oct 15 '20 at 10:15
0

You can use this option:

import spray.json.DefaultJsonProtocol._

implicit val format = jsonFormat1(ClassName.apply)
Oleg Zinoviev
  • 549
  • 3
  • 14
  • The `buildFormatter` is a separate function i need to use which provides custom json parsing. I am more looking for an answer regarding shortening the code in the question while keeping the same functionality – irregular Oct 05 '20 at 20:55