diff --git a/build.sbt b/build.sbt index 52f5bd2..3f71865 100644 --- a/build.sbt +++ b/build.sbt @@ -120,3 +120,16 @@ lazy val playJson = project defaultSettings, ) .dependsOn(derivation) + +lazy val logstage = project + .in(modules / "logstage") + .settings( + name := "derivation-logstage", + libraryDependencies ++= Seq( + "io.7mind.izumi" %% "logstage-core" % Version.logstage, + "io.7mind.izumi" %% "logstage-rendering-circe" % Version.logstage % Test, + "io.circe" %% "circe-parser" % Version.circe % Test, + ), + defaultSettings, + ) + .dependsOn(derivation) diff --git a/modules/core/src/main/scala/evo/derivation/LazySummon.scala b/modules/core/src/main/scala/evo/derivation/LazySummon.scala index 85a9faa..02f5dfd 100644 --- a/modules/core/src/main/scala/evo/derivation/LazySummon.scala +++ b/modules/core/src/main/scala/evo/derivation/LazySummon.scala @@ -92,6 +92,16 @@ object LazySummon: } .toVector + def useForeach[U, Info](fields: Fields, infos: Vector[Info])( + f: [A] => (Info, A, TC[A]) => U, + ): Unit = + fields.toArray + .lazyZip(all) + .lazyZip(infos) + .foreach[U] { (field, inst, info) => + f(info, field.asInstanceOf[inst.FieldType], inst.tc) + } + def useEitherFast[Info, E](infos: IArray[Info])( f: (summon: Of[TC], info: Info) => Either[E, summon.FieldType], ): Either[E, Fields] = diff --git a/modules/logstage/src/main/scala/evo/derivation/logstage/EvoLog.scala b/modules/logstage/src/main/scala/evo/derivation/logstage/EvoLog.scala new file mode 100644 index 0000000..512ced9 --- /dev/null +++ b/modules/logstage/src/main/scala/evo/derivation/logstage/EvoLog.scala @@ -0,0 +1,129 @@ +package evo.derivation.logstage + +import evo.derivation.LazySummon.All +import evo.derivation.{LazySummon, ValueClass} +import evo.derivation.config.{Config, ForField} +import evo.derivation.internal.{Matching, tupleFromProduct} +import evo.derivation.template.{ConsistentTemplate, HomogenicTemplate, SummonHierarchy, Template} +import izumi.logstage.api.rendering.{LogstageCodec, LogstageWriter} +import logstage.LogstageCodec + +import scala.deriving.Mirror +import scala.deriving.Mirror.SumOf + +/** Unable to extend LogstageCodec here due to problems with derivations for contravariant type classes + * + * LogstageWriter api is too strict to support @Embed, @Discriminator annotations here without additional methods such + * as `writeInner` + */ +trait EvoLog[A]: + def write(writer: LogstageWriter, value: A): Unit + def writeInner: Option[(LogstageWriter, A) => Unit] +end EvoLog + +object EvoLog extends EvoLogTemplate: + def fromLogstage[A](using codec: => LogstageCodec[A]): EvoLog[A] = + new EvoLog[A]: + override def writeInner: Option[(LogstageWriter, A) => Unit] = None + override def write(writer: LogstageWriter, value: A): Unit = codec.write(writer, value) + + given [A](using LogstageCodec[A]): EvoLog[A] = fromLogstage +end EvoLog + +trait EvoLogTemplate extends HomogenicTemplate[EvoLog], SummonHierarchy: + override def product[A](using mirror: Mirror.ProductOf[A])( + all: LazySummon.All[OfField, mirror.MirroredElemTypes], + )(using config: => Config[A], ev: A <:< Product): EvoLog[A] = + lazy val infos = config.top.fields.map(_._2) + + new EvoLog[A]: + override def write(writer: LogstageWriter, value: A): Unit = + writer.openMap() + writeInnerImpl(writer, value) + writer.closeMap() + + override val writeInner: Option[(LogstageWriter, A) => Unit] = + Some(writeInnerImpl) + + def writeInnerImpl(writer: LogstageWriter, value: A): Unit = + val fields = tupleFromProduct(value) + + all.useForeach[Unit, ForField[_]](fields, infos) { + [X] => + (info: ForField[_], a: X, codec: EvoLog[X]) => + val maskOpt = info.annotations.collectFirst { case Masked(mask) => mask } + val writeField = (writer: LogstageWriter, a: X) => + maskOpt.fold(codec.write(writer, a))(mask => + LogstageCodec[String].write(writer, mask) + ) + + val writeFieldInner = + codec.writeInner.map(f => + (writer: LogstageWriter, a: X) => + maskOpt.fold(f(writer, a))(mask => + LogstageCodec[String].write(writer, info.name) + writer.mapElementSplitter() + LogstageCodec[String].write(writer, mask) + ) + ) + + writeFieldInner.filter(_ => info.embed).fold { + writer.nextMapElementOpen() + LogstageCodec[String].write(writer, info.name) + writer.mapElementSplitter() + writeField(writer, a) + writer.nextMapElementClose() + }(_(writer, a)) + } + end writeInnerImpl + end new + end product + + override def sum[A](using mirror: SumOf[A])( + subs: All[EvoLog, mirror.MirroredElemTypes], + mkSubMap: => Map[String, EvoLog[A]], + )(using config: => Config[A], matching: Matching[A]): EvoLog[A] = + lazy val cfg = config + lazy val codecs: Map[String, EvoLog[A]] = mkSubMap + + new EvoLog[A]: + override def write(writer: LogstageWriter, value: A): Unit = + writer.openMap() + writeInnerImpl(writer, value) + writer.closeMap() + + override val writeInner: Option[(LogstageWriter, A) => Unit] = + Some(writeInnerImpl) + + def writeInnerImpl(writer: LogstageWriter, value: A): Unit = + val constructor = matching.matched(value) + val discrimValue = cfg.name(constructor) + + (codecs.get(constructor).map(c => (c, c.writeInner)), config.discriminator) match + case (Some((_, Some(writeInn))), Some(discr)) => + writer.nextMapElementOpen() + LogstageCodec[String].write(writer, discr) + writer.mapElementSplitter() + LogstageCodec[String].write(writer, discrimValue) + writer.nextMapElementClose() + writeInn(writer, value) + case (Some(codec, _), _) => + writer.nextMapElementOpen() + LogstageCodec[String].write(writer, discrimValue) + writer.mapElementSplitter() + codec.write(writer, value) + writer.nextMapElementClose() + case (_, _) => () // throw exception ? + end match + end writeInnerImpl + end new + end sum + + override def newtype[A](using nt: ValueClass[A])(using codec: EvoLog[nt.Representation]): EvoLog[A] = + new EvoLog[A]: + override def write(writer: LogstageWriter, value: A): Unit = + codec.write(writer, nt.to(value)) + + override val writeInner: Option[(LogstageWriter, A) => Unit] = + codec.writeInner.map(impl => (writer: LogstageWriter, value: A) => impl(writer, nt.to(value))) +end EvoLogTemplate diff --git a/modules/logstage/src/main/scala/evo/derivation/logstage/Masked.scala b/modules/logstage/src/main/scala/evo/derivation/logstage/Masked.scala new file mode 100644 index 0000000..1f3725c --- /dev/null +++ b/modules/logstage/src/main/scala/evo/derivation/logstage/Masked.scala @@ -0,0 +1,5 @@ +package evo.derivation.logstage + +import evo.derivation.Custom + +case class Masked(template: String = "***masked***") extends Custom \ No newline at end of file diff --git a/modules/logstage/src/main/scala/evo/derivation/logstage/instances.scala b/modules/logstage/src/main/scala/evo/derivation/logstage/instances.scala new file mode 100644 index 0000000..05530e9 --- /dev/null +++ b/modules/logstage/src/main/scala/evo/derivation/logstage/instances.scala @@ -0,0 +1,9 @@ +package evo.derivation.logstage + +import izumi.logstage.api.rendering.{LogstageCodec, LogstageWriter} + +trait EvoLogInstances: + given [A](using e: EvoLog[A]): LogstageCodec[A] = + (writer: LogstageWriter, value: A) => e.write(writer, value) + +object instances extends EvoLogInstances diff --git a/modules/logstage/src/test/scala/evo/derivation/logstage/LogstageTest.scala b/modules/logstage/src/test/scala/evo/derivation/logstage/LogstageTest.scala new file mode 100644 index 0000000..9d6d4cf --- /dev/null +++ b/modules/logstage/src/test/scala/evo/derivation/logstage/LogstageTest.scala @@ -0,0 +1,168 @@ +package evo.derivation.logstage + +import evo.derivation.config.Config +import io.circe.{Json, Encoder} +import logstage.LogstageCodec +import logstage.circe.LogstageCirceCodec +import izumi.logstage.api.rendering.json.LogstageCirceWriter +import EvoLogTest.{WithProps, OneOf, SimpleRec, Custom, OneOfCustom} +import evo.derivation.{Discriminator, Embed, Rename, SnakeCase} +import izumi.logstage.api.rendering.{LogstageCodec, LogstageWriter} +import io.circe.parser.parse + +class LogstageTest extends munit.FunSuite: + import evo.derivation.logstage.instances.given // arggh + + extension [A](a: A) + def toLog(using codec: LogstageCodec[A]): Json = + LogstageCirceWriter.write(codec, a) + + test("simple recursive data") { + val chebKekLol = + """ + |{ + | "bazar" : { + | "foo" : { + | "bazar" : { + | "foo" : { + | "bazar" : { + | "foo" : { + | "no" : {} + | }, + | "param" : "cheb" + | } + | }, + | "param" : "kek" + | } + | }, + | "param" : "lol" + | } + |} + |""".stripMargin + + assertEquals( + parse(chebKekLol), + Right( + (SimpleRec.Bar(SimpleRec.Bar(SimpleRec.Bar(SimpleRec.No, "cheb"), "kek"), "lol"): SimpleRec).toLog, + ), + ) + } + + test("WithProps") { + val case1 = + """ + |{ + | "type" : "Case1", + | "foo" : 1, + | "param" : "cheb", + | "props" : { + | "lol" : "kek", + | "sad" : "pet" + | } + |} + |""".stripMargin + + assertEquals( + parse(case1), + Right(WithProps(OneOf.Case1(1, "cheb"), Map("lol" -> "kek", "sad" -> "pet")).toLog), + ) + + val case2 = + """ + |{ + | "type" : "Case2", + | "foo": "***masked***", + | "props" : {} + |} + |""".stripMargin + + assertEquals( + parse(case2), + Right(WithProps(OneOf.Case2(10), Map()).toLog), + ) + } + + test("simple recursive data") { + val chebKekLol = + """ + |{ + | "bazar" : { + | "foo" : { + | "bazar" : { + | "foo" : { + | "bazar" : { + | "foo" : { + | "no" : {} + | }, + | "param" : "cheb" + | } + | }, + | "param" : "kek" + | } + | }, + | "param" : "lol" + | } + |} + |""".stripMargin + + assertEquals( + parse(chebKekLol), + Right( + (SimpleRec.Bar(SimpleRec.Bar(SimpleRec.Bar(SimpleRec.No, "cheb"), "kek"), "lol"): SimpleRec).toLog, + ), + ) + } + + test("with custom instance") { + val customJson = + """ + |{ + | "variant" : { + | "foo" : 100 + | }, + | "props" : "***masked***" + |} + |""".stripMargin + + assertEquals( + parse(customJson), + Right( + Custom(OneOfCustom.Case2(100), Map("lol" -> "zoo")).toLog, + ), + ) + } +end LogstageTest + +object EvoLogTest: + @SnakeCase + enum SimpleRec derives Config, EvoLog: + @Rename("bazar") case Bar(foo: SimpleRec, param: String) + case No + case Bazz(i: Int) + + @Discriminator("type") + enum OneOf derives Config, EvoLog: + case Case1(foo: Int, param: String) + case Case2(@Masked foo: Int) + case Case3(param: String) + + case class WithProps(@Embed variant: OneOf, props: Map[String, String]) derives Config, EvoLog + + enum OneOfCustom: + case Case1(foo: Int, param: String) + case Case2(foo: Int) + case Case3(param: String) + + object OneOfCustom: + given Encoder[OneOfCustom] = Encoder.instance { + case OneOfCustom.Case1(foo, _) => Json.obj("foo" -> Json.fromInt(foo)) + case OneOfCustom.Case2(foo) => Json.obj("foo" -> Json.fromInt(foo)) + case OneOfCustom.Case3(_) => Json.obj() + } + + given LogstageCodec[OneOfCustom] = LogstageCirceCodec.derived + end OneOfCustom + + // Embed is ignored in this case + case class Custom(@Embed variant: OneOfCustom, @Masked props: Map[String, String]) derives Config, EvoLog +end EvoLogTest diff --git a/project/Version.scala b/project/Version.scala index ab6a01c..3e3dbf4 100644 --- a/project/Version.scala +++ b/project/Version.scala @@ -10,4 +10,6 @@ object Version { val playJson = "2.9.3" val cats = "2.9.0" + + val logstage = "1.1.0-M23" }