diff --git a/besom-json/src/main/scala/besom/json/JsonFormat.scala b/besom-json/src/main/scala/besom/json/JsonFormat.scala index 618bbf78..f64a5fca 100644 --- a/besom-json/src/main/scala/besom/json/JsonFormat.scala +++ b/besom-json/src/main/scala/besom/json/JsonFormat.scala @@ -31,6 +31,8 @@ object JsonReader { implicit def func2Reader[T](f: JsValue => T): JsonReader[T] = new JsonReader[T] { def read(json: JsValue) = f(json) } + + inline def derived[T <: Product](using JsonProtocol): JsonReader[T] = summon[JsonProtocol].jsonFormatN[T] } /** Provides the JSON serialization for type T. @@ -44,6 +46,8 @@ object JsonWriter { implicit def func2Writer[T](f: T => JsValue): JsonWriter[T] = new JsonWriter[T] { def write(obj: T) = f(obj) } + + inline def derived[T <: Product](using JsonProtocol): JsonWriter[T] = summon[JsonProtocol].jsonFormatN[T] } /** Provides the JSON deserialization and serialization for type T. @@ -51,7 +55,7 @@ object JsonWriter { trait JsonFormat[T] extends JsonReader[T] with JsonWriter[T] object JsonFormat: - inline def derived[T <: Product](using JsonProtocol) = summon[JsonProtocol].jsonFormatN[T] + inline def derived[T <: Product](using JsonProtocol): JsonFormat[T] = summon[JsonProtocol].jsonFormatN[T] /** A special JsonReader capable of reading a legal JSON root object, i.e. either a JSON array or a JSON object. */ diff --git a/besom-json/src/main/scala/besom/json/ProductFormats.scala b/besom-json/src/main/scala/besom/json/ProductFormats.scala index 7efe096e..ebe5f0fa 100644 --- a/besom-json/src/main/scala/besom/json/ProductFormats.scala +++ b/besom-json/src/main/scala/besom/json/ProductFormats.scala @@ -19,7 +19,8 @@ package besom.json trait ProductFormats: self: StandardFormats with AdditionalFormats => - def writeNulls: Boolean = false + def writeNulls: Boolean = false + def requireNullsForOptions: Boolean = false inline def jsonFormatN[T <: Product]: RootJsonFormat[T] = ${ ProductFormatsMacro.jsonFormatImpl[T]('self) } @@ -27,6 +28,35 @@ object ProductFormatsMacro: import scala.deriving.* import scala.quoted.* + private def findDefaultParams[T](using quotes: Quotes, tpe: Type[T]): Expr[Map[String, Any]] = + import quotes.reflect.* + + TypeRepr.of[T].classSymbol match + case None => '{ Map.empty[String, Any] } + case Some(sym) => + val comp = sym.companionClass + try + val mod = Ref(sym.companionModule) + val names = + for p <- sym.caseFields if p.flags.is(Flags.HasDefault) + yield p.name + val namesExpr: Expr[List[String]] = + Expr.ofList(names.map(Expr(_))) + + val body = comp.tree.asInstanceOf[ClassDef].body + val idents: List[Ref] = + for + case deff @ DefDef(name, _, _, _) <- body + if name.startsWith("$lessinit$greater$default") + yield mod.select(deff.symbol) + val typeArgs = TypeRepr.of[T].typeArgs + val identsExpr: Expr[List[Any]] = + if typeArgs.isEmpty then Expr.ofList(idents.map(_.asExpr)) + else Expr.ofList(idents.map(_.appliedToTypes(typeArgs).asExpr)) + + '{ $namesExpr.zip($identsExpr).toMap } + catch case cce: ClassCastException => '{ Map.empty[String, Any] } // TODO drop after https://github.com/lampepfl/dotty/issues/19732 + def jsonFormatImpl[T <: Product: Type](prodFormats: Expr[ProductFormats])(using Quotes): Expr[RootJsonFormat[T]] = Expr.summon[Mirror.Of[T]].get match case '{ @@ -50,25 +80,29 @@ object ProductFormatsMacro: // instances are in correct order of fields of the product val allInstancesExpr = Expr.ofList(prepareInstances(Type.of[elementLabels], Type.of[elementTypes])) + val defaultArguments = findDefaultParams[T] '{ new RootJsonFormat[T]: private val allInstances = ${ allInstancesExpr } private val fmts = ${ prodFormats } + private val defaultArgs = ${ defaultArguments } + def read(json: JsValue): T = json match case JsObject(fields) => val values = allInstances.map { case (fieldName, fieldFormat, isOption) => - val fieldValue = - try fieldFormat.read(fields(fieldName)) - catch - case e: NoSuchElementException => - if isOption then None - else - throw DeserializationException("Object is missing required member '" ++ fieldName ++ "'", null, fieldName :: Nil) - case DeserializationException(msg, cause, fieldNames) => - throw DeserializationException(msg, cause, fieldName :: fieldNames) - - fieldValue + try fieldFormat.read(fields(fieldName)) + catch + case e: NoSuchElementException => + // if field has a default value, use it, we didn't find anything in the JSON + if defaultArgs.contains(fieldName) then defaultArgs(fieldName) + // if field is optional and requireNullsForOptions is disabled, return None + // otherwise we require an explicit null value + else if isOption && !fmts.requireNullsForOptions then None + // it's missing so we throw an exception + else throw DeserializationException("Object is missing required member '" ++ fieldName ++ "'", null, fieldName :: Nil) + case DeserializationException(msg, cause, fieldNames) => + throw DeserializationException(msg, cause, fieldName :: fieldNames) } $m.fromProduct(Tuple.fromArray(values.toArray)) diff --git a/besom-json/src/main/scala/besom/json/package.scala b/besom-json/src/main/scala/besom/json/package.scala index b94919fa..8c81f660 100644 --- a/besom-json/src/main/scala/besom/json/package.scala +++ b/besom-json/src/main/scala/besom/json/package.scala @@ -14,37 +14,62 @@ * limitations under the License. */ -package besom +package besom.json import scala.language.implicitConversions -package object json { +def deserializationError(msg: String, cause: Throwable = null, fieldNames: List[String] = Nil) = + throw new DeserializationException(msg, cause, fieldNames) - type JsField = (String, JsValue) +def serializationError(msg: String) = throw new SerializationException(msg) + +case class DeserializationException(msg: String, cause: Throwable = null, fieldNames: List[String] = Nil) + extends RuntimeException(msg, cause) +class SerializationException(msg: String) extends RuntimeException(msg) - def deserializationError(msg: String, cause: Throwable = null, fieldNames: List[String] = Nil) = - throw new DeserializationException(msg, cause, fieldNames) - def serializationError(msg: String) = throw new SerializationException(msg) +private[json] class RichAny[T](any: T) { + def toJson(implicit writer: JsonWriter[T]): JsValue = writer.write(any) +} + +private[json] class RichString(string: String) { + def parseJson: JsValue = JsonParser(string) + def parseJson(settings: JsonParserSettings): JsValue = JsonParser(string, settings) +} + +private[json] trait DefaultExports: + type JsField = (String, JsValue) def jsonReader[T](implicit reader: JsonReader[T]) = reader def jsonWriter[T](implicit writer: JsonWriter[T]) = writer implicit def enrichAny[T](any: T): RichAny[T] = new RichAny(any) implicit def enrichString(string: String): RichString = new RichString(string) -} -package json { +private[json] trait DefaultProtocol: + implicit val defaultProtocol: JsonProtocol = DefaultJsonProtocol - case class DeserializationException(msg: String, cause: Throwable = null, fieldNames: List[String] = Nil) - extends RuntimeException(msg, cause) - class SerializationException(msg: String) extends RuntimeException(msg) +/** This allows to perform a single import: `import besom.json.*` to get basic JSON behaviour. If you need to extend JSON handling in any + * way, please use `import besom.json.custom.*`, then extend `DefaultJsonProtocol`: + * + * ``` + * object MyCustomJsonProtocol extends DefaultJsonProtocol: + * given someCustomTypeFormat: JsonFormat[A] = ... + * ``` + * build your customized protocol that way and set it up for your `derives` clauses using: + * ``` + * given JsonProtocol = MyCustomJsonProtocol + * + * case class MyCaseClass(a: String, b: Int) derives JsonFormat + * ``` + */ +object custom extends DefaultExports: + export besom.json.{JsonProtocol, DefaultJsonProtocol} + export besom.json.{JsonFormat, JsonReader, JsonWriter} + export besom.json.{RootJsonFormat, RootJsonReader, RootJsonWriter} + export besom.json.{DeserializationException, SerializationException} + export besom.json.{JsValue, JsObject, JsArray, JsString, JsNumber, JsBoolean, JsNull} - private[json] class RichAny[T](any: T) { - def toJson(implicit writer: JsonWriter[T]): JsValue = writer.write(any) - } +object DefaultJsonExports extends DefaultExports with DefaultProtocol - private[json] class RichString(string: String) { - def parseJson: JsValue = JsonParser(string) - def parseJson(settings: JsonParserSettings): JsValue = JsonParser(string, settings) - } -} +export DefaultJsonExports.* +export DefaultJsonProtocol.* diff --git a/besom-json/src/test/scala/besom/json/DerivedFormatsSpec.scala b/besom-json/src/test/scala/besom/json/DerivedFormatsSpec.scala index ef5200e8..4fbebfc8 100644 --- a/besom-json/src/test/scala/besom/json/DerivedFormatsSpec.scala +++ b/besom-json/src/test/scala/besom/json/DerivedFormatsSpec.scala @@ -1,4 +1,4 @@ -package besom.json +package besom.json.test import org.specs2.mutable.* @@ -6,13 +6,80 @@ class DerivedFormatsSpec extends Specification { "The derives keyword" should { "behave as expected" in { - import DefaultJsonProtocol.* - given JsonProtocol = DefaultJsonProtocol + import besom.json.* case class Color(name: String, red: Int, green: Int, blue: Int) derives JsonFormat val color = Color("CadetBlue", 95, 158, 160) color.toJson.convertTo[Color] mustEqual color } + + "be able to support default argument values" in { + import besom.json.* + + case class Color(name: String, red: Int, green: Int, blue: Int = 160) derives JsonFormat + val color = Color("CadetBlue", 95, 158) + + val json = """{"name":"CadetBlue","red":95,"green":158}""" + + color.toJson.convertTo[Color] mustEqual color + json.parseJson.convertTo[Color] mustEqual color + } + + "be able to support missing fields when there are default argument values" in { + import besom.json.* + + case class Color(name: String, red: Int, green: Int, blue: Option[Int] = None) derives JsonFormat + val color = Color("CadetBlue", 95, 158) + + val json = """{"green":158,"red":95,"name":"CadetBlue"}""" + + color.toJson.compactPrint mustEqual json + color.toJson.convertTo[Color] mustEqual color + json.parseJson.convertTo[Color] mustEqual color + } + + "be able to write and read nulls for optional fields" in { + import besom.json.custom.* + + locally { + given jp: JsonProtocol = new DefaultJsonProtocol { + override def writeNulls = true + override def requireNullsForOptions = true + } + import jp.* + + case class Color(name: String, red: Int, green: Int, blue: Option[Int]) derives JsonFormat + val color = Color("CadetBlue", 95, 158, None) + + val json = """{"blue":null,"green":158,"red":95,"name":"CadetBlue"}""" + + color.toJson.compactPrint mustEqual json + + color.toJson.convertTo[Color] mustEqual color + json.parseJson.convertTo[Color] mustEqual color + + val noExplicitNullJson = """{"green":158,"red":95,"name":"CadetBlue"}""" + noExplicitNullJson.parseJson.convertTo[Color] must throwA[DeserializationException] + } + + locally { + given jp2: JsonProtocol = new DefaultJsonProtocol { + override def writeNulls = false + override def requireNullsForOptions = false + } + import jp2.* + + case class Color(name: String, red: Int, green: Int, blue: Option[Int]) derives JsonFormat + val color = Color("CadetBlue", 95, 158, None) + + val json = """{"green":158,"red":95,"name":"CadetBlue"}""" + + color.toJson.compactPrint mustEqual json + + color.toJson.convertTo[Color] mustEqual color + json.parseJson.convertTo[Color] mustEqual color + } + } } } diff --git a/besom-json/src/test/scala/besom/json/ProductFormatsSpec.scala b/besom-json/src/test/scala/besom/json/ProductFormatsSpec.scala index 2718b2b7..c2d099bb 100644 --- a/besom-json/src/test/scala/besom/json/ProductFormatsSpec.scala +++ b/besom-json/src/test/scala/besom/json/ProductFormatsSpec.scala @@ -22,8 +22,9 @@ class ProductFormatsSpec extends Specification { case class Test0() case class Test2(a: Int, b: Option[Double]) - case class Test3[A, B](as: List[A], bs: List[B]) + case class Test3[A, B](as: List[A], bs: Option[List[B]] = Some(List.empty)) case class Test4(t2: Test2) + case class Test5(optA: Option[String] = Some("default")) case class TestTransient(a: Int, b: Option[Double]) { @transient var c = false } @@ -37,6 +38,7 @@ class ProductFormatsSpec extends Specification { implicit val test2Format: JsonFormat[Test2] = jsonFormatN[Test2] implicit def test3Format[A: JsonFormat, B: JsonFormat]: RootJsonFormat[Test3[A, B]] = jsonFormatN[Test3[A, B]] implicit def test4Format: JsonFormat[Test4] = jsonFormatN[Test4] + implicit def test5Format: JsonFormat[Test5] = jsonFormatN[Test5] implicit def testTransientFormat: JsonFormat[TestTransient] = jsonFormatN[TestTransient] implicit def testStaticFormat: JsonFormat[TestStatic] = jsonFormatN[TestStatic] implicit def testMangledFormat: JsonFormat[TestMangled] = jsonFormatN[TestMangled] @@ -44,6 +46,27 @@ class ProductFormatsSpec extends Specification { object TestProtocol1 extends DefaultJsonProtocol with TestProtocol object TestProtocol2 extends DefaultJsonProtocol with TestProtocol with NullOptions + case class Foo(a: Int, b: Int) + object Foo: + import DefaultJsonProtocol.* + given JsonFormat[Foo] = jsonFormatN + + "A JsonFormat derived for an inner class" should { + "compile" in { + + val compileErrors = scala.compiletime.testing.typeCheckErrors( + """ + class Test: + case class Foo(a: Int, b: Int) + object Foo: + import DefaultJsonProtocol.* + given JsonFormat[Foo] = jsonFormatN""" + ) + + compileErrors must beEmpty + } + } + "A JsonFormat created with `jsonFormat`, for a case class with 2 elements," should { import TestProtocol1.* val obj = Test2(42, Some(4.2)) @@ -96,7 +119,7 @@ class ProductFormatsSpec extends Specification { "A JsonFormat for a generic case class and created with `jsonFormat`" should { import TestProtocol1.* - val obj = Test3(42 :: 43 :: Nil, "x" :: "y" :: "z" :: Nil) + val obj = Test3(42 :: 43 :: Nil, Some("x" :: "y" :: "z" :: Nil)) val json = JsObject( "as" -> JsArray(JsNumber(42), JsNumber(43)), "bs" -> JsArray(JsString("x"), JsString("y"), JsString("z")) @@ -170,6 +193,20 @@ class ProductFormatsSpec extends Specification { } } + "A JsonFormat for a case class with default parameters and created with `jsonFormat`" should { + "read case classes with optional members from JSON with missing fields" in { + import TestProtocol1.* + JsObject().convertTo[Test5] mustEqual Test5(Some("default")) + } + + "read a generic case class with optional members from JSON with missing fields" in { + import TestProtocol1.* + val json = JsObject("as" -> JsArray(JsNumber(23), JsNumber(5))) + + json.convertTo[Test3[Int, String]] mustEqual Test3(List(23, 5), Some(List.empty)) + } + } + "A JsonFormat for a case class with static fields and created with `jsonFormat`" should { import TestProtocol1.* val obj = TestStatic(42, Some(4.2)) diff --git a/core/src/main/scala/besom/internal/Env.scala b/core/src/main/scala/besom/internal/Env.scala index 7b580dc8..a4896700 100644 --- a/core/src/main/scala/besom/internal/Env.scala +++ b/core/src/main/scala/besom/internal/Env.scala @@ -43,7 +43,7 @@ object Env: private[internal] def getMaybe(key: String): Option[NonEmptyString] = sys.env.get(key).flatMap(NonEmptyString(_)) - import besom.json.*, DefaultJsonProtocol.* + import besom.json.* given nesJF(using jfs: JsonFormat[String]): JsonFormat[NonEmptyString] = new JsonFormat[NonEmptyString]: diff --git a/core/src/main/scala/besom/json/interpolator.scala b/core/src/main/scala/besom/json/interpolator.scala new file mode 100644 index 00000000..b81bebab --- /dev/null +++ b/core/src/main/scala/besom/json/interpolator.scala @@ -0,0 +1,3 @@ +package besom.json + +export besom.util.JsonInterpolator.* diff --git a/core/src/main/scala/besom/util/JsonInterpolator.scala b/core/src/main/scala/besom/util/JsonInterpolator.scala new file mode 100644 index 00000000..617107c7 --- /dev/null +++ b/core/src/main/scala/besom/util/JsonInterpolator.scala @@ -0,0 +1,163 @@ +package besom.util + +import besom.json.* +import besom.internal.{Context, Output} +import scala.util.{Failure, Success} +import interpolator.interleave +import java.util.Objects +import besom.json.JsValue + +object JsonInterpolator: + import scala.quoted.* + + private val NL = System.lineSeparator() + + extension (inline sc: StringContext) + inline def json(inline args: Any*)(using ctx: besom.internal.Context): Output[JsValue] = ${ jsonImpl('sc, 'args, 'ctx) } + + private def jsonImpl(sc: Expr[StringContext], args: Expr[Seq[Any]], ctx: Expr[Context])(using Quotes): Expr[Output[JsValue]] = + import quotes.reflect.* + + def defaultFor(field: Expr[Any], tpe: Type[_], wrappers: List[Type[_]] = Nil): Any = tpe match + case '[String] => JsString("") + case '[Short] => JsNumber(0) + case '[Int] => JsNumber(0) + case '[Long] => JsNumber(0L) + case '[Float] => JsNumber(0f) + case '[Double] => JsNumber(0d) + case '[Boolean] => JsBoolean(true) + case '[JsValue] => JsNull + case '[Output[t]] => defaultFor(field, TypeRepr.of[t].asType, TypeRepr.of[Output[_]].asType :: wrappers) + case '[Option[t]] => defaultFor(field, TypeRepr.of[t].asType, TypeRepr.of[Option[_]].asType :: wrappers) + case '[t] => // this is a non-supported type! let's reduce wrappers (if any) and produce a nice error message + val tpeRepr = TypeRepr.of[t] + if wrappers.nonEmpty then + // we apply types from the most inner to the most outer + val fullyAppliedType = wrappers.foldLeft(tpeRepr) { case (inner, outer) => + outer match + case '[o] => + val outerSym = TypeRepr.of[o].typeSymbol + val applied = AppliedType(outerSym.typeRef, List(inner)) + + applied + } + + // and now we have a full type available for error! + report.errorAndAbort( + s"Value of type `${fullyAppliedType.show}` is not a valid JSON interpolation type because of type `${tpeRepr.show}`.$NL$NL" + + s"Types available for interpolation are: " + + s"String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types.$NL" + + s"If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue.$NL" + ) + else + // t is a simple type + report.errorAndAbort( + s"Value of type `${tpeRepr.show}: ${tpeRepr.typeSymbol.fullName}` is not a valid JSON interpolation type.$NL$NL" + + s"Types available for interpolation are: " + + s"String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types.$NL" + + s"If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue.$NL" + ) + end defaultFor + + // recursively convert all Exprs of arguments from user to Exprs of Output[JsValue] + def convert(arg: Expr[Any]): Expr[Output[JsValue]] = + arg match + case '{ $arg: String } => + '{ + Output($arg)(using $ctx).map { str => + val sb = java.lang.StringBuilder() + if str == null then JsNull + else + besom.json.CompactPrinter.print(JsString(str), sb) // escape strings + sb.toString() + JsString(str) + } + } + case '{ $arg: Int } => '{ Output(JsNumber($arg))(using $ctx) } + case '{ $arg: Short } => '{ Output(JsNumber($arg))(using $ctx) } + case '{ $arg: Long } => '{ Output(JsNumber($arg))(using $ctx) } + case '{ $arg: Float } => '{ Output(JsNumber($arg))(using $ctx) } + case '{ $arg: Double } => '{ Output(JsNumber($arg))(using $ctx) } + case '{ $arg: Boolean } => '{ Output(JsBoolean($arg))(using $ctx) } + case '{ $arg: JsValue } => '{ Output($arg)(using $ctx) } + case _ => + arg.asTerm.tpe.asType match + case '[Output[Option[t]]] => + '{ + $arg.asInstanceOf[Output[Option[t]]].flatMap { + case Some(value) => ${ convert('value) } + case None => Output(JsNull)(using $ctx) + } + } + case '[Output[t]] => + '{ + $arg.asInstanceOf[Output[t]].flatMap { value => + ${ convert('value) } + } + } + + case '[Option[t]] => + '{ + $arg.asInstanceOf[Option[t]] match + case Some(value) => ${ convert('value) } + case None => Output(JsNull)(using $ctx) + } + + case '[t] => + val tpeRepr = TypeRepr.of[t] + report.errorAndAbort( + s"Value of type `${tpeRepr.show}` is not a valid JSON interpolation type.$NL$NL" + + s"Types available for interpolation are: " + + s"String, Int, Short Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types.$NL" + + s"If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue.$NL$NL" + ) + end convert + + sc match + case '{ scala.StringContext.apply(${ Varargs(parts) }: _*) } => + args match + case Varargs(argExprs) => + if argExprs.isEmpty then + parts.map(_.valueOrAbort).mkString match + case "" => '{ Output(JsObject.empty)(using $ctx) } + case str => + scala.util.Try(JsonParser(str)) match + case Failure(exception) => + report.errorAndAbort(s"Failed to parse JSON:$NL ${exception.getMessage}") + case Success(value) => + '{ Output(JsonParser(ParserInput.apply(${ Expr(str) })))(using $ctx) } + else + val defaults = argExprs.map { arg => + defaultFor(arg, arg.asTerm.tpe.asType) + } + + val str = interleave(parts.map(_.valueOrAbort).toList, defaults.map(_.toString()).toList).reduce(_ + _) + + scala.util.Try(JsonParser(str)) match + case Failure(exception) => + report.errorAndAbort(s"Failed to parse JSON (default values inserted at compile time):$NL ${exception.getMessage}") + case Success(value) => + val liftedSeqOfExpr: Seq[Expr[Output[?]]] = argExprs.map(convert) + + val liftedExprOfSeq = Expr.ofSeq(liftedSeqOfExpr) + val liftedParts = Expr.ofSeq(parts) + + '{ + interleave(${ liftedParts }.toList, ${ liftedExprOfSeq }.toList) + .foldLeft(Output("")(using $ctx)) { case (acc, e) => + e match + case o: Output[?] => acc.flatMap(s => o.map(v => s + Objects.toString(v))) // handle nulls too + case s: String => acc.map(_ + s) + } + .map { str => + scala.util.Try(JsonParser(str)) match + case Failure(exception) => + throw Exception(s"Failed to parse JSON:\n$str", exception) + case Success(value) => + value + } + } + end match + end match + end jsonImpl +end JsonInterpolator diff --git a/core/src/test/scala/besom/internal/ConfigTest.scala b/core/src/test/scala/besom/internal/ConfigTest.scala index 1ae29864..66a84241 100644 --- a/core/src/test/scala/besom/internal/ConfigTest.scala +++ b/core/src/test/scala/besom/internal/ConfigTest.scala @@ -3,7 +3,6 @@ package besom.internal import besom.* import besom.internal.RunResult.{*, given} import besom.json.* -import besom.json.DefaultJsonProtocol.* class ConfigTest extends munit.FunSuite { @@ -203,9 +202,7 @@ class ConfigTest extends munit.FunSuite { assertEquals(actual, expected) } - case class Foo(a: Int, b: Int) - object Foo: - given JsonFormat[Foo] = jsonFormatN + case class Foo(a: Int, b: Int) derives JsonFormat testConfig("get case class Foo")( configMap = Map("foo" -> """{"a":1,"b":2}"""), configSecretKeys = Set("foo"), diff --git a/core/src/test/scala/besom/util/CompileAssertions.scala b/core/src/test/scala/besom/util/CompileAssertions.scala index 994c17cd..093a03d0 100644 --- a/core/src/test/scala/besom/util/CompileAssertions.scala +++ b/core/src/test/scala/besom/util/CompileAssertions.scala @@ -3,6 +3,11 @@ package besom.util trait CompileAssertions: self: munit.FunSuite => + import scala.language.dynamics + + object code extends Dynamic: + transparent inline def selectDynamic(name: String): name.type = name + private val NL = System.lineSeparator() inline def failsToCompile(inline code: String): Unit = diff --git a/core/src/test/scala/besom/util/JsonInterpolatorTest.scala b/core/src/test/scala/besom/util/JsonInterpolatorTest.scala new file mode 100644 index 00000000..561be543 --- /dev/null +++ b/core/src/test/scala/besom/util/JsonInterpolatorTest.scala @@ -0,0 +1,312 @@ +package besom.util + +import munit.* +import scala.annotation.unused + +class Outer[A](@unused a: A) + +class JsonInterpolatorTest extends FunSuite with CompileAssertions: + + test("json interpolator should compile with correct json strings") { + // baseline + compiles( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a": 1, "b": 2}"""` + ) + + compiles( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + val i = 2 + """ + + code.`val x = json"""{"a": 1, "b": $i}"""` + ) + } + + test("json interpolator should catch invalid jsons") { + failsToCompile( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a": 1, "b": 2"""` + ) + + failsToCompile( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a": 1, "b": 2,}"""` + ) + + failsToCompile( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a"": 1, "b": 2}"""` + ) + } + + test("json interpolator should interpolate primitives into json strings correctly") { + import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.given + import besom.internal.RunOutput.{*, given} + import besom.internal.Output + + given besom.internal.Context = DummyContext().unsafeRunSync() + + val str = "\"test" + val int = 1 + val long = 5L + val float = Output(2.3f) + val double = 3.4d + val bool = true + val jsonOutput = json"""{"a": 1, "b": $str, "c": $int, "d": $long, "e": $float, "f": $double, "g": $bool}""" + jsonOutput.unsafeRunSync() match + case None => fail("expected a json output") + case Some(json) => + assertEquals(json, JsonParser("""{"a": 1, "b": "\"test", "c": 1, "d": 5, "e": 2.3, "f": 3.4, "g": true}""")) + } + + test("json interpolator should interpolate JsValues into json strings correctly") { + import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.given + import besom.internal.RunOutput.{*, given} + + given besom.internal.Context = DummyContext().unsafeRunSync() + + val jsonValue = JsObject("a" -> JsNumber(1), "b" -> JsString("test")) + val jsonOutput = json"""{"a": 1, "b": $jsonValue}""" + jsonOutput.unsafeRunSync() match + case None => fail("expected a json output") + case Some(json) => + assertEquals(json, JsonParser("""{"a": 1, "b": {"a": 1, "b": "test"}}""")) + } + + test("json interpolator can interpolate wrapped values correctly") { + import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.given + import besom.internal.RunOutput.{*, given} + import besom.internal.Output + + given besom.internal.Context = DummyContext().unsafeRunSync() + + val str = Option(Some("test")) + val int = Output(Option(1)) + val long = Some(Output(5L)) + val float = Output(Some(2.3f)) + val double = Option(Output(3.4d)) + val bool = Output(Output(true)) + val nullValue = None + val anotherNull = Some(None) + val literalNull = null: String + + val jsonOutput = + json"""{"a": 1, "b": $str, "c": $int, "d": $long, "e": $float, "f": $double, "g": $bool, "h": $nullValue, "i": $anotherNull, "j": $literalNull}""" + + jsonOutput.unsafeRunSync() match + case None => fail("expected a json output") + case Some(json) => + assertEquals( + json, + JsonParser("""{"a": 1, "b": "test", "c": 1, "d": 5, "e": 2.3, "f": 3.4, "g": true, "h": null, "i": null, "j": null}""") + ) + } + + test("json interpolator fails to compile when interpolating values that can't be interpolated with nice error messages") { + failsToCompile( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a": 1, "b": $this}"""` + ) + + failsToCompile( + """import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.* + given besom.internal.Context = DummyContext().unsafeRunSync() + """ + + code.`val x = json"""{"a": 1, "b": $this}"""` + ) + + val errsWrappedOutput = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + def x = Output(java.time.Instant.now()) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOutput = + """Value of type `besom.internal.Output[java.time.Instant]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOutput.size == 1, s"expected 1 errors, got ${errsWrappedOutput.size}") + assertEquals(errsWrappedOutput.head.message, expectedErrorWrappedOutput) + + val errsWrappedOption = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = Option(java.time.Instant.now()) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOption = + """Value of type `scala.Option[java.time.Instant]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOption.size == 1, s"expected 1 errors, got ${errsWrappedOption.size}") + assertEquals(errsWrappedOption.head.message, expectedErrorWrappedOption) + + val errsWrappedOutputOption = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = Output(Option(java.time.Instant.now())) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOutputOption = + """Value of type `besom.internal.Output[scala.Option[java.time.Instant]]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOutputOption.size == 1, s"expected 1 errors, got ${errsWrappedOutputOption.size}") + assertEquals(errsWrappedOutputOption.head.message, expectedErrorWrappedOutputOption) + + val errsWrappedOptionOutput = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = Option(Output(java.time.Instant.now())) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOptionOutput = + """Value of type `scala.Option[besom.internal.Output[java.time.Instant]]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOptionOutput.size == 1, s"expected 1 errors, got ${errsWrappedOptionOutput.size}") + assertEquals(errsWrappedOptionOutput.head.message, expectedErrorWrappedOptionOutput) + + val errsWrappedOutputOutput = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = Output(Output(java.time.Instant.now())) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOutputOutput = + """Value of type `besom.internal.Output[besom.internal.Output[java.time.Instant]]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOutputOutput.size == 1, s"expected 1 errors, got ${errsWrappedOutputOutput.size}") + assertEquals(errsWrappedOutputOutput.head.message, expectedErrorWrappedOutputOutput) + + val errsWrappedOptionOption = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = Option(Option(java.time.Instant.now())) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOptionOption = + """Value of type `scala.Option[scala.Option[java.time.Instant]]` is not a valid JSON interpolation type because of type `java.time.Instant`. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOptionOption.size == 1, s"expected 1 errors, got ${errsWrappedOptionOption.size}") + assertEquals(errsWrappedOptionOption.head.message, expectedErrorWrappedOptionOption) + + val errsWrappedOuter = scala.compiletime.testing.typeCheckErrors( + """import besom.json.* + import besom.internal.{Output, DummyContext} + import besom.internal.RunOutput.{*, given} + given besom.internal.Context = DummyContext().unsafeRunSync() + + val x = new Outer(1) + """ + + code.`val json = json"""{"a": 1, "b": $x}"""` + ) + + val expectedErrorWrappedOuter = + """Value of type `x: besom.util.Outer` is not a valid JSON interpolation type. + | + |Types available for interpolation are: String, Int, Short, Long, Float, Double, Boolean, JsValue and Options and Outputs containing these types. + |If you want to interpolate a custom data type - derive or implement a JsonFormat for it and convert it to JsValue. + |""".stripMargin + + assert(errsWrappedOuter.size == 1, s"expected 1 errors, got ${errsWrappedOuter.size}") + assertEquals(errsWrappedOuter.head.message, expectedErrorWrappedOuter) + } + + test("json interpolator sanitizes strings when interpolating") { + import besom.json.* + import besom.internal.DummyContext + import besom.internal.RunResult.given + import besom.internal.RunOutput.{*, given} + + given besom.internal.Context = DummyContext().unsafeRunSync() + + val badString = """well well well", "this is onixpected": "23""" + json"""{"a": 1, "b": $badString}""".unsafeRunSync() match + case None => fail("expected a json output") + case Some(json) => + assertEquals(json, JsonParser("""{"a": 1, "b": "well well well\", \"this is onixpected\": \"23"}""")) + } + +end JsonInterpolatorTest diff --git a/integration-tests/resources/config-example/Main.scala b/integration-tests/resources/config-example/Main.scala index adf76210..33784279 100644 --- a/integration-tests/resources/config-example/Main.scala +++ b/integration-tests/resources/config-example/Main.scala @@ -14,9 +14,7 @@ import besom.* import besom.json.*, DefaultJsonProtocol.* val names = config.requireObject[List[String]]("names") - case class Foo(name: String, age: Int) derives Encoder - object Foo: - given JsonFormat[Foo] = jsonFormatN + case class Foo(name: String, age: Int) derives Encoder, JsonFormat val foo = config.requireObject[Foo]("foo") diff --git a/website/docs/changelog.md b/website/docs/changelog.md index 37ad1d28..7fc72bad 100644 --- a/website/docs/changelog.md +++ b/website/docs/changelog.md @@ -2,6 +2,79 @@ title: Changelog --- +0.3.0 (08-04-2024) +--- + +## API Changes and New Features + +* Added new `besom.json` interpolation API. Now this snippet from our tutorial: +```scala +s3.BucketPolicyArgs( + bucket = feedBucket.id, + policy = JsObject( + "Version" -> JsString("2012-10-17"), + "Statement" -> JsArray( + JsObject( + "Sid" -> JsString("PublicReadGetObject"), + "Effect" -> JsString("Allow"), + "Principal" -> JsObject( + "AWS" -> JsString("*") + ), + "Action" -> JsArray(JsString("s3:GetObject")), + "Resource" -> JsArray(JsString(s"arn:aws:s3:::${name}/*")) + ) + ) + ).prettyPrint +) +``` +can be rewritten as: +```scala +s3.BucketPolicyArgs( + bucket = feedBucket.id, + policy = json"""{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3::${name}/*"] + }] + }""".map(_.prettyPrint) +) +``` +The json interpolator returns an `Output[JsValue]` and is fully **compile-time type safe** and verifies JSON string for correctness. +The only types that can be interpolated are `String`, `Int`, `Short`, `Long`, `Float`, `Double`, `JsValue` and `Option` and `Output` +of the former (in whatever amount of nesting). If you need to interpolate a more complex type it's advised to derive a `JsonFormat` +for it and convert it to `JsValue`. + +* Package `besom.json` was modified to ease the use of `JsonFormat` derivation. This change is breaking compatibility by exporting +default instances from `DefaultJsonProtocol` and providing a given `JsonProtocol` instance for use with `derives JsonFormat`. +If you need to define a custom `JsonProcol` change the import to `import besom.json.custom.*` which preserves the older semantics +from spray-json and requires manual extension of `DefaultJsonProtocol`. + +* Derived `JsonFormat` from package `besom.json` now respects arguments with default values. In conjunction with the previous change +it means one can now use it like this: +```scala +import besom.json.* + +case class Color(name: String, red: Int, green: Int, blue: Int = 160) derives JsonFormat +val color = Color("CadetBlue", 95, 158) + +val json = """{"name":"CadetBlue","red":95,"green":158}""" + +assert(color.toJson.convertTo[Color] == color) +assert(json.parseJson.convertTo[Color] == color) +``` + +## Bug Fixes + +## Other Changes + +**Full Changelog**: https://github.com/VirtusLab/besom/compare/v0.2.2...v0.3.0 + 0.2.2 (22-02-2024) --- diff --git a/website/docs/json.md b/website/docs/json.md new file mode 100644 index 00000000..02b7ee81 --- /dev/null +++ b/website/docs/json.md @@ -0,0 +1,77 @@ +--- +title: JSON API +--- + +Besom comes with it's own JSON library to avoid any issues with classpath clashes with mainstream JSON libraries when embedding +Besom infrastructural programs in Scala applications via AutomationAPI. + +Package `besom.json` is a fork of well-known, battle-tested +[spray-json](https://github.com/spray/spray-json) library. Specifically, the `spray-json` has been ported to Scala 3 with some +breaking changes and received support for `derives` keyword. Another change is that import of `besom.json.*` brings all the +`JsonFormat` instances from `DefaultJsonProtocol` into scope and to get the old experience to how `spray-json` operated one needs +to import `besom.json.custom.*`. + +A sample use of the package: +```scala +import besom.json.* + +case class Color(name: String, red: Int, green: Int, blue: Int = 160) derives JsonFormat +val color = Color("CadetBlue", 95, 158) + +val json = """{"name":"CadetBlue","red":95,"green":158}""" + +assert(color.toJson.convertTo[Color] == color) +assert(json.parseJson.convertTo[Color] == color) +``` + +#### JSON Interpolator + +The `besom-json` package has also a convenient JSON interpolator that allows one to rewrite snippets like this +where a cloud provider API expects a JSON string: + +```scala +s3.BucketPolicyArgs( + bucket = feedBucket.id, + policy = JsObject( + "Version" -> JsString("2012-10-17"), + "Statement" -> JsArray( + JsObject( + "Sid" -> JsString("PublicReadGetObject"), + "Effect" -> JsString("Allow"), + "Principal" -> JsObject( + "AWS" -> JsString("*") + ), + "Action" -> JsArray(JsString("s3:GetObject")), + "Resource" -> JsArray(JsString(s"arn:aws:s3:::${name}/*")) + ) + ) + ).prettyPrint +) +``` + +into simpler and less clunky interpolated variant like this: + +```scala +s3.BucketPolicyArgs( + bucket = feedBucket.id, + policy = json"""{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3::${name}/*"] + }] + }""".map(_.prettyPrint) +) +``` + +For ease of use with Besom `Input`s in provider packages interpolator returns an `Output[JsValue]`. + +The JSON interpolator is available when using `import besom.json.*` or `import besom.util.JsonInterpolator.*` imports. Interpolator is +completely **compile-time type safe** and verifies JSON string for correctness by substituting. The only types that can be interpolated are +`String`, `Int`, `Short`, `Long`, `Float`, `Double`, `JsValue` and `Option` and `Output` of the former (in whatever amount of nesting). +If you need to interpolate a more complex type it's advised to derive a `JsonFormat` for it and convert it to `JsValue`. \ No newline at end of file diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 63281c4b..34f41acb 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -51,6 +51,8 @@ const config = { besomVersion: besomVersion }, + trailingSlash: true, + presets: [ [ 'classic', diff --git a/website/sidebars.js b/website/sidebars.js index a97d605d..788024e2 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -50,6 +50,7 @@ const sidebars = { 'lifting', 'interpolator', 'components', + 'json', 'compiler_plugin', 'missing' ],