diff --git a/.gitignore b/.gitignore index ad3def41..59c4d40b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ src_managed *.deb *.changes *.worksheet.sc +metals.sbt +.vscode +.metals +.bloop diff --git a/build.sbt b/build.sbt index a35516a6..60a9b686 100644 --- a/build.sbt +++ b/build.sbt @@ -6,7 +6,7 @@ lazy val scala3 = "3.5.2" // sbt-github-actions needs configuration in `ThisBuild` ThisBuild / crossScalaVersions := Seq(scala2_12, scala2_13, scala3) -ThisBuild / scalaVersion := scala3 +ThisBuild / scalaVersion := scala2_13 ThisBuild / githubWorkflowPublishTargetBranches := List() ThisBuild / githubWorkflowJavaVersions := List(JavaSpec.temurin("17")) ThisBuild / githubWorkflowBuildPreamble ++= List( @@ -77,6 +77,11 @@ lazy val `sphere-libs` = project publish := {} ) .aggregate( + // Scala 3 modules + `sphere-util-3`, + `sphere-json-3`, + + // Scala 2 modules `sphere-util`, `sphere-json`, `sphere-json-core`, @@ -88,6 +93,23 @@ lazy val `sphere-libs` = project `benchmarks` ) +// Scala 3 modules + +lazy val `sphere-util-3` = project + .in(file("./util-3")) + .settings(scalaVersion := scala3) + .settings(standardSettings: _*) + .settings(homepage := Some(url("https://github.com/commercetools/sphere-scala-libs/README.md"))) + +lazy val `sphere-json-3` = project + .in(file("./json/json-3")) + .settings(scalaVersion := scala3) + .settings(standardSettings: _*) + .settings(Fmpp.settings: _*) + .dependsOn(`sphere-util-3`) + +// Scala 2 modules + lazy val `sphere-util` = project .in(file("./util")) .settings(standardSettings: _*) @@ -147,8 +169,6 @@ lazy val `sphere-mongo` = project url("https://github.com/commercetools/sphere-scala-libs/blob/master/mongo/README.md"))) .dependsOn(`sphere-mongo-core`, `sphere-mongo-derivation`) -// benchmarks - lazy val benchmarks = project .settings(standardSettings: _*) .settings(publishArtifact := false, publish := {}) diff --git a/json/json-3/dependencies.sbt b/json/json-3/dependencies.sbt new file mode 100644 index 00000000..0dc28bc8 --- /dev/null +++ b/json/json-3/dependencies.sbt @@ -0,0 +1,5 @@ +libraryDependencies ++= Seq( + "org.json4s" %% "json4s-jackson" % "4.0.7", + "com.fasterxml.jackson.core" % "jackson-databind" % "2.17.2", + "org.typelevel" %% "cats-core" % "2.12.0" +) diff --git a/json/json-3/src/main/scala/io/sphere/json/FromJSON.scala b/json/json-3/src/main/scala/io/sphere/json/FromJSON.scala new file mode 100644 index 00000000..ed92252e --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/FromJSON.scala @@ -0,0 +1,434 @@ +package io.sphere.json + +import scala.util.control.NonFatal +import scala.collection.mutable.ListBuffer +import java.util.{Currency, Locale, UUID} + +import cats.data.NonEmptyList +import cats.data.Validated.{Invalid, Valid} +import cats.syntax.apply._ +import cats.syntax.traverse._ +import io.sphere.json.field +import io.sphere.util.{BaseMoney, HighPrecisionMoney, LangTag, Money} +import org.json4s.JsonAST._ +import org.joda.time.format.ISODateTimeFormat + +import scala.annotation.implicitNotFound +import java.time +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.YearMonth +import org.joda.time.LocalTime +import org.joda.time.LocalDate + +/** Type class for types that can be read from JSON. */ +@implicitNotFound("Could not find an instance of FromJSON for ${A}") +trait FromJSON[@specialized A] extends Serializable { + def read(jval: JValue): JValidation[A] + final protected def fail(msg: String) = jsonParseError(msg) + + /** needed JSON fields - ignored if empty */ + val fields: Set[String] = FromJSON.emptyFieldsSet +} + +object FromJSON extends FromJSONInstances { + + private[FromJSON] val emptyFieldsSet: Set[String] = Set.empty + + @inline def apply[A](implicit instance: FromJSON[A]): FromJSON[A] = instance + + private val validNone = Valid(None) + private val validNil = Valid(Nil) + private val validEmptyAnyVector: Valid[Vector[Any]] = Valid(Vector.empty) + private def validList[A]: Valid[List[A]] = validNil + private def validEmptyVector[A]: Valid[Vector[A]] = + validEmptyAnyVector.asInstanceOf[Valid[Vector[A]]] + + implicit def optionMapReader[@specialized A](implicit + c: FromJSON[A]): FromJSON[Option[Map[String, A]]] = + new FromJSON[Option[Map[String, A]]] { + private val internalMapReader = mapReader[A] + + def read(jval: JValue): JValidation[Option[Map[String, A]]] = jval match { + case JNothing | JNull => validNone + case x => internalMapReader.read(x).map(Some.apply) + } + } + + implicit def optionReader[@specialized A](implicit c: FromJSON[A]): FromJSON[Option[A]] = + new FromJSON[Option[A]] { + def read(jval: JValue): JValidation[Option[A]] = jval match { + case JNothing | JNull | JObject(Nil) => validNone + case JObject(s) if fields.nonEmpty && s.forall(t => !fields.contains(t._1)) => + validNone // if none of the optional fields are in the JSON + case x => c.read(x).map(Option.apply) + } + override val fields: Set[String] = c.fields + } + + implicit def listReader[@specialized A](implicit r: FromJSON[A]): FromJSON[List[A]] = + new FromJSON[List[A]] { + + def read(jval: JValue): JValidation[List[A]] = jval match { + case JArray(l) => + if (l.isEmpty) validList[A] + else { + // "imperative" style for performances + val errors = new ListBuffer[JSONError]() + val valids = new ListBuffer[A]() + var failedOnce: Boolean = false + l.foreach { jval => + r.read(jval) match { + case Valid(s) if !failedOnce => + valids += s + case Invalid(nel) => + errors ++= nel.toList + failedOnce = true + case _ => () + } + } + if (errors.isEmpty) + Valid(valids.result()) + else + Invalid(NonEmptyList.fromListUnsafe(errors.result())) + } + case _ => fail("JSON Array expected.") + } + } + + implicit def seqReader[@specialized A](implicit r: FromJSON[A]): FromJSON[Seq[A]] = + new FromJSON[Seq[A]] { + def read(jval: JValue): JValidation[Seq[A]] = listReader(r).read(jval) + } + + implicit def setReader[@specialized A](implicit r: FromJSON[A]): FromJSON[Set[A]] = + new FromJSON[Set[A]] { + def read(jval: JValue): JValidation[Set[A]] = jval match { + case JArray(l) => + if (l.isEmpty) Valid(Set.empty) + else listReader(r).read(jval).map(Set.apply(_*)) + case _ => fail("JSON Array expected.") + } + } + + implicit def vectorReader[@specialized A](implicit r: FromJSON[A]): FromJSON[Vector[A]] = + new FromJSON[Vector[A]] { + import scala.collection.immutable.VectorBuilder + + def read(jval: JValue): JValidation[Vector[A]] = jval match { + case JArray(l) => + if (l.isEmpty) validEmptyVector + else { + // "imperative" style for performances + val errors = new ListBuffer[JSONError]() + val valids = new VectorBuilder[A]() + var failedOnce: Boolean = false + l.foreach { jval => + r.read(jval) match { + case Valid(s) if !failedOnce => + valids += s + case Invalid(nel) => + errors ++= nel.toList + failedOnce = true + case _ => () + } + } + if (errors.isEmpty) + Valid(valids.result()) + else + Invalid(NonEmptyList.fromListUnsafe(errors.result())) + } + case _ => fail("JSON Array expected.") + } + } + + implicit def nonEmptyListReader[A](implicit r: FromJSON[A]): FromJSON[NonEmptyList[A]] = + new FromJSON[NonEmptyList[A]] { + def read(jval: JValue): JValidation[NonEmptyList[A]] = + fromJValue[List[A]](jval).andThen { + case head :: tail => Valid(NonEmptyList(head, tail)) + case Nil => fail("Non-empty JSON array expected") + } + } + + implicit val intReader: FromJSON[Int] = new FromJSON[Int] { + def read(jval: JValue): JValidation[Int] = jval match { + case JInt(i) if i.isValidInt => Valid(i.toInt) + case JLong(i) if i.isValidInt => Valid(i.toInt) + case _ => fail("JSON Number in the range of an Int expected.") + } + } + + implicit val stringReader: FromJSON[String] = new FromJSON[String] { + def read(jval: JValue): JValidation[String] = jval match { + case JString(s) => Valid(s) + case _ => fail("JSON String expected.") + } + } + + implicit val bigIntReader: FromJSON[BigInt] = new FromJSON[BigInt] { + def read(jval: JValue): JValidation[BigInt] = jval match { + case JInt(i) => Valid(i) + case JLong(l) => Valid(l) + case _ => fail("JSON Number in the range of a BigInt expected.") + } + } + + implicit val shortReader: FromJSON[Short] = new FromJSON[Short] { + def read(jval: JValue): JValidation[Short] = jval match { + case JInt(i) if i.isValidShort => Valid(i.toShort) + case JLong(l) if l.isValidShort => Valid(l.toShort) + case _ => fail("JSON Number in the range of a Short expected.") + } + } + + implicit val longReader: FromJSON[Long] = new FromJSON[Long] { + def read(jval: JValue): JValidation[Long] = jval match { + case JInt(i) => Valid(i.toLong) + case JLong(l) => Valid(l) + case _ => fail("JSON Number in the range of a Long expected.") + } + } + + implicit val floatReader: FromJSON[Float] = new FromJSON[Float] { + def read(jval: JValue): JValidation[Float] = jval match { + case JDouble(d) => Valid(d.toFloat) + case _ => fail("JSON Number in the range of a Float expected.") + } + } + + implicit val doubleReader: FromJSON[Double] = new FromJSON[Double] { + def read(jval: JValue): JValidation[Double] = jval match { + case JDouble(d) => Valid(d) + case JInt(i) => Valid(i.toDouble) + case JLong(l) => Valid(l.toDouble) + case _ => fail("JSON Number in the range of a Double expected.") + } + } + + implicit val booleanReader: FromJSON[Boolean] = new FromJSON[Boolean] { + private val cachedTrue = Valid(true) + private val cachedFalse = Valid(false) + def read(jval: JValue): JValidation[Boolean] = jval match { + case JBool(b) => if (b) cachedTrue else cachedFalse + case _ => fail("JSON Boolean expected") + } + } + + implicit def mapReader[A: FromJSON]: FromJSON[Map[String, A]] = new FromJSON[Map[String, A]] { + def read(json: JValue): JValidation[Map[String, A]] = json match { + case JObject(fs) => + // Perf note: an imperative implementation does not seem faster + fs.traverse[JValidation, (String, A)] { f => + fromJValue[A](f._2).map(v => (f._1, v)) + }.map(_.toMap) + case _ => fail("JSON Object expected") + } + } + + implicit val moneyReader: FromJSON[Money] = new FromJSON[Money] { + import Money._ + + override val fields = Set(CentAmountField, CurrencyCodeField) + + def read(value: JValue): JValidation[Money] = value match { + case o: JObject => + (field[Long](CentAmountField)(o), field[Currency](CurrencyCodeField)(o)) match { + case (Valid(centAmount), Valid(currencyCode)) => + Valid(Money.fromCentAmount(centAmount, currencyCode)) + case (Invalid(e1), Invalid(e2)) => Invalid(e1.concat(e2.toList)) + case (e1 @ Invalid(_), _) => e1 + case (_, e2 @ Invalid(_)) => e2 + } + + case _ => fail("JSON object expected.") + } + } + + implicit val highPrecisionMoneyReader: FromJSON[HighPrecisionMoney] = + new FromJSON[HighPrecisionMoney] { + import HighPrecisionMoney._ + + override val fields = Set(PreciseAmountField, CurrencyCodeField, FractionDigitsField) + + def read(value: JValue): JValidation[HighPrecisionMoney] = value match { + case o: JObject => + val validatedFields = ( + field[Long](PreciseAmountField)(o), + field[Int](FractionDigitsField)(o), + field[Currency](CurrencyCodeField)(o), + field[Option[Long]](CentAmountField)(o)) + + validatedFields.tupled.andThen { + case (preciseAmount, fractionDigits, currencyCode, centAmount) => + HighPrecisionMoney + .fromPreciseAmount(preciseAmount, fractionDigits, currencyCode, centAmount) + .leftMap(_.map(JSONParseError.apply)) + } + + case _ => + fail("JSON object expected.") + } + } + + implicit val baseMoneyReader: FromJSON[BaseMoney] = new FromJSON[BaseMoney] { + def read(value: JValue): JValidation[BaseMoney] = value match { + case o: JObject => + field[Option[String]](BaseMoney.TypeField)(o).andThen { + case None => moneyReader.read(value) + case Some(Money.TypeName) => moneyReader.read(value) + case Some(HighPrecisionMoney.TypeName) => highPrecisionMoneyReader.read(value) + case Some(tpe) => + fail( + s"Unknown money type '$tpe'. Available types are: '${Money.TypeName}', '${HighPrecisionMoney.TypeName}'.") + } + + case _ => fail("JSON object expected.") + } + } + + implicit val currencyReader: FromJSON[Currency] = new FromJSON[Currency] { + val failMsg = "ISO 4217 code JSON String expected." + def failMsgFor(input: String) = s"Currency '$input' not valid as ISO 4217 code." + + private val cachedEUR = Valid(Currency.getInstance("EUR")) + private val cachedUSD = Valid(Currency.getInstance("USD")) + private val cachedGBP = Valid(Currency.getInstance("GBP")) + private val cachedJPY = Valid(Currency.getInstance("JPY")) + + def read(jval: JValue): JValidation[Currency] = jval match { + case JString(s) => + s match { + case "EUR" => cachedEUR + case "USD" => cachedUSD + case "GBP" => cachedGBP + case "JPY" => cachedJPY + case _ => + try Valid(Currency.getInstance(s)) + catch { + case _: IllegalArgumentException => fail(failMsgFor(s)) + } + } + case _ => fail(failMsg) + } + } + + implicit val jValueReader: FromJSON[JValue] = new FromJSON[JValue] { + def read(jval: JValue): JValidation[JValue] = Valid(jval) + } + + implicit val jObjectReader: FromJSON[JObject] = new FromJSON[JObject] { + def read(jval: JValue): JValidation[JObject] = jval match { + case o: JObject => Valid(o) + case _ => fail("JSON object expected") + } + } + + private val validUnit = Valid(()) + + implicit val unitReader: FromJSON[Unit] = new FromJSON[Unit] { + def read(jval: JValue): JValidation[Unit] = jval match { + case JNothing | JNull | JObject(Nil) => validUnit + case _ => fail("Unexpected JSON") + } + } + + private def jsonStringReader[T](errorMessageTemplate: String)( + fromString: String => T): FromJSON[T] = + new FromJSON[T] { + def read(jval: JValue): JValidation[T] = jval match { + case JString(s) => + try Valid(fromString(s)) + catch { + case NonFatal(_) => fail(errorMessageTemplate.format(s)) + } + case _ => fail("JSON string expected.") + } + } + + // Joda Time + implicit val dateTimeReader: FromJSON[DateTime] = { + val UTCDateTimeComponents = raw"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z".r + + jsonStringReader("Failed to parse date/time: %s") { + case UTCDateTimeComponents(year, month, days, hours, minutes, seconds, millis) => + new DateTime( + year.toInt, + month.toInt, + days.toInt, + hours.toInt, + minutes.toInt, + seconds.toInt, + millis.toInt, + DateTimeZone.UTC) + case otherwise => + new DateTime(otherwise, DateTimeZone.UTC) + } + } + + implicit val timeReader: FromJSON[LocalTime] = jsonStringReader("Failed to parse time: %s") { + ISODateTimeFormat.localTimeParser.parseDateTime(_).toLocalTime + } + + implicit val dateReader: FromJSON[LocalDate] = jsonStringReader("Failed to parse date: %s") { + ISODateTimeFormat.localDateParser.parseDateTime(_).toLocalDate + } + + implicit val yearMonthReader: FromJSON[YearMonth] = + jsonStringReader("Failed to parse year/month: %s") { + new YearMonth(_) + } + + // java.time + // this formatter is used to parse instant in an extra lenient way + // similar to what the joda `DateTime` constructor accepts + // the accepted grammar for joda is described here: https://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTimeParser-- + // this only supports the part where the date is specified + private val lenientInstantParser = + new time.format.DateTimeFormatterBuilder() + .appendPattern("uuuu[-MM[-dd]]") + .optionalStart() + .appendPattern("'T'[HH[:mm[:ss]]]") + .appendFraction(time.temporal.ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalStart() + .appendOffset("+HHmm", "Z") + .optionalEnd() + .optionalEnd() + .parseDefaulting(time.temporal.ChronoField.MONTH_OF_YEAR, 1L) + .parseDefaulting(time.temporal.ChronoField.DAY_OF_MONTH, 1L) + .parseDefaulting(time.temporal.ChronoField.HOUR_OF_DAY, 0L) + .parseDefaulting(time.temporal.ChronoField.MINUTE_OF_HOUR, 0L) + .parseDefaulting(time.temporal.ChronoField.SECOND_OF_MINUTE, 0L) + .parseDefaulting(time.temporal.ChronoField.NANO_OF_SECOND, 0L) + .parseDefaulting(time.temporal.ChronoField.OFFSET_SECONDS, 0L) + .toFormatter() + + implicit val javaInstantReader: FromJSON[time.Instant] = + jsonStringReader("Failed to parse date/time: %s")(s => + time.Instant.from(lenientInstantParser.parse(s))) + + implicit val javaLocalTimeReader: FromJSON[time.LocalTime] = + jsonStringReader("Failed to parse time: %s")( + time.LocalTime.parse(_, time.format.DateTimeFormatter.ISO_LOCAL_TIME)) + + implicit val javaLocalDateReader: FromJSON[time.LocalDate] = + jsonStringReader("Failed to parse date: %s")( + time.LocalDate.parse(_, time.format.DateTimeFormatter.ISO_LOCAL_DATE)) + + implicit val javaYearMonthReader: FromJSON[time.YearMonth] = + jsonStringReader("Failed to parse year/month: %s")( + time.YearMonth.parse(_, JavaYearMonthFormatter)) + + implicit val uuidReader: FromJSON[UUID] = jsonStringReader("Invalid UUID: '%s'")(UUID.fromString) + + implicit val localeReader: FromJSON[Locale] = new FromJSON[Locale] { + def read(jval: JValue): JValidation[Locale] = jval match { + case JString(s) => + s match { + case LangTag(langTag) => Valid(langTag) + case _ => fail(LangTag.invalidLangTagMessage(s)) + } + case _ => fail("JSON string expected.") + } + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/JSON.scala b/json/json-3/src/main/scala/io/sphere/json/JSON.scala new file mode 100644 index 00000000..3e2366a2 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/JSON.scala @@ -0,0 +1,30 @@ +package io.sphere.json + +import org.json4s.JsonAST.JValue + +import scala.annotation.implicitNotFound + +@implicitNotFound("Could not find an instance of JSON for ${A}") +trait JSON[A] extends FromJSON[A] with ToJSON[A] + +object JSON extends JSONInstances with JSONLowPriorityImplicits { + @inline def apply[A](implicit instance: JSON[A]): JSON[A] = instance +} + +trait JSONLowPriorityImplicits { + implicit def fromJSONAndToJSON[A](implicit fromJSON: FromJSON[A], toJSON: ToJSON[A]): JSON[A] = + new JSON[A] { + override def read(jval: JValue): JValidation[A] = fromJSON.read(jval) + override def write(value: A): JValue = toJSON.write(value) + } +} + +class JSONException(msg: String) extends RuntimeException(msg) + +sealed abstract class JSONError +case class JSONFieldError(path: List[String], message: String) extends JSONError { + override def toString = path.mkString(" -> ") + ": " + message +} +case class JSONParseError(message: String) extends JSONError { + override def toString = message +} diff --git a/json/json-3/src/main/scala/io/sphere/json/SphereJsonParser.scala b/json/json-3/src/main/scala/io/sphere/json/SphereJsonParser.scala new file mode 100644 index 00000000..12714da6 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/SphereJsonParser.scala @@ -0,0 +1,19 @@ +package io.sphere.json + +import com.fasterxml.jackson.databind.DeserializationFeature.{ + USE_BIG_DECIMAL_FOR_FLOATS, + USE_BIG_INTEGER_FOR_INTS +} +import com.fasterxml.jackson.databind.ObjectMapper +import org.json4s.jackson.{Json4sScalaModule, JsonMethods} + +// extends the default JsonMethods to configure a different default jackson parser +private object SphereJsonParser extends JsonMethods { + override val mapper: ObjectMapper = { + val m = new ObjectMapper() + m.registerModule(new Json4sScalaModule) + m.configure(USE_BIG_INTEGER_FOR_INTS, false) + m.configure(USE_BIG_DECIMAL_FOR_FLOATS, false) + m + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/ToJSON.scala b/json/json-3/src/main/scala/io/sphere/json/ToJSON.scala new file mode 100644 index 00000000..8cf778ea --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/ToJSON.scala @@ -0,0 +1,229 @@ +package io.sphere.json + +import cats.data.NonEmptyList +import java.util.{Currency, Locale, UUID} + +import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} +import org.json4s.JsonAST._ +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.LocalTime +import org.joda.time.LocalDate +import org.joda.time.YearMonth +import org.joda.time.format.ISODateTimeFormat + +import scala.annotation.implicitNotFound +import java.time + +/** Type class for types that can be written to JSON. */ +@implicitNotFound("Could not find an instance of ToJSON for ${A}") +trait ToJSON[@specialized A] extends Serializable { + def write(value: A): JValue +} + +class JSONWriteException(msg: String) extends JSONException(msg) + +object ToJSON extends ToJSONInstances { + + private val emptyJArray = JArray(Nil) + private val emptyJObject = JObject(Nil) + + @inline def apply[A](implicit instance: ToJSON[A]): ToJSON[A] = instance + + /** construct an instance from a function + */ + def instance[T](toJson: T => JValue): ToJSON[T] = new ToJSON[T] { + override def write(value: T): JValue = toJson(value) + } + + implicit def optionWriter[@specialized A](implicit c: ToJSON[A]): ToJSON[Option[A]] = + new ToJSON[Option[A]] { + def write(opt: Option[A]): JValue = opt match { + case Some(a) => c.write(a) + case None => JNothing + } + } + + implicit def listWriter[@specialized A](implicit w: ToJSON[A]): ToJSON[List[A]] = + new ToJSON[List[A]] { + def write(l: List[A]): JValue = + if (l.isEmpty) emptyJArray + else JArray(l.map(w.write)) + } + + implicit def nonEmptyListWriter[A](implicit w: ToJSON[A]): ToJSON[NonEmptyList[A]] = + new ToJSON[NonEmptyList[A]] { + def write(l: NonEmptyList[A]): JValue = JArray(l.toList.map(w.write)) + } + + implicit def seqWriter[@specialized A](implicit w: ToJSON[A]): ToJSON[Seq[A]] = + new ToJSON[Seq[A]] { + def write(s: Seq[A]): JValue = + if (s.isEmpty) emptyJArray + else JArray(s.iterator.map(w.write).toList) + } + + implicit def setWriter[@specialized A](implicit w: ToJSON[A]): ToJSON[Set[A]] = + new ToJSON[Set[A]] { + def write(s: Set[A]): JValue = + if (s.isEmpty) emptyJArray + else JArray(s.iterator.map(w.write).toList) + } + + implicit def vectorWriter[@specialized A](implicit w: ToJSON[A]): ToJSON[Vector[A]] = + new ToJSON[Vector[A]] { + def write(v: Vector[A]): JValue = + if (v.isEmpty) emptyJArray + else JArray(v.iterator.map(w.write).toList) + } + + implicit val intWriter: ToJSON[Int] = new ToJSON[Int] { + def write(i: Int): JValue = JLong(i) + } + + implicit val stringWriter: ToJSON[String] = new ToJSON[String] { + def write(s: String): JValue = JString(s) + } + + implicit val bigIntWriter: ToJSON[BigInt] = new ToJSON[BigInt] { + def write(i: BigInt): JValue = JInt(i) + } + + implicit val shortWriter: ToJSON[Short] = new ToJSON[Short] { + def write(s: Short): JValue = JLong(s) + } + + implicit val longWriter: ToJSON[Long] = new ToJSON[Long] { + def write(l: Long): JValue = JLong(l) + } + + implicit val floatWriter: ToJSON[Float] = new ToJSON[Float] { + def write(f: Float): JValue = JDouble(f) + } + + implicit val doubleWriter: ToJSON[Double] = new ToJSON[Double] { + def write(d: Double): JValue = JDouble(d) + } + + implicit val booleanWriter: ToJSON[Boolean] = new ToJSON[Boolean] { + def write(b: Boolean): JValue = if (b) JBool.True else JBool.False + } + + implicit def mapWriter[A: ToJSON]: ToJSON[Map[String, A]] = new ToJSON[Map[String, A]] { + def write(m: Map[String, A]) = + if (m.isEmpty) emptyJObject + else + JObject(m.iterator.map { case (k, v) => + JField(k, toJValue(v)) + }.toList) + } + + implicit val moneyWriter: ToJSON[Money] = new ToJSON[Money] { + import Money._ + + def write(m: Money): JValue = JObject( + JField(BaseMoney.TypeField, toJValue(m.`type`)) :: + JField(CurrencyCodeField, toJValue(m.currency)) :: + JField(CentAmountField, toJValue(m.centAmount)) :: + JField(FractionDigitsField, toJValue(m.currency.getDefaultFractionDigits)) :: + Nil + ) + } + + implicit val highPrecisionMoneyWriter: ToJSON[HighPrecisionMoney] = + new ToJSON[HighPrecisionMoney] { + import HighPrecisionMoney._ + def write(m: HighPrecisionMoney): JValue = JObject( + JField(BaseMoney.TypeField, toJValue(m.`type`)) :: + JField(CurrencyCodeField, toJValue(m.currency)) :: + JField(CentAmountField, toJValue(m.centAmount)) :: + JField(PreciseAmountField, toJValue(m.preciseAmount)) :: + JField(FractionDigitsField, toJValue(m.fractionDigits)) :: + Nil + ) + } + + implicit val baseMoneyWriter: ToJSON[BaseMoney] = new ToJSON[BaseMoney] { + def write(m: BaseMoney): JValue = m match { + case m: Money => moneyWriter.write(m) + case m: HighPrecisionMoney => highPrecisionMoneyWriter.write(m) + } + } + + implicit val currencyWriter: ToJSON[Currency] = new ToJSON[Currency] { + def write(c: Currency): JValue = toJValue(c.getCurrencyCode) + } + + implicit val jValueWriter: ToJSON[JValue] = new ToJSON[JValue] { + def write(jval: JValue): JValue = jval + } + + implicit val jObjectWriter: ToJSON[JObject] = new ToJSON[JObject] { + def write(jObj: JObject): JValue = jObj + } + + implicit val unitWriter: ToJSON[Unit] = new ToJSON[Unit] { + def write(u: Unit): JValue = JNothing + } + + // Joda time + implicit val dateTimeWriter: ToJSON[DateTime] = new ToJSON[DateTime] { + def write(dt: DateTime): JValue = JString( + ISODateTimeFormat.dateTime.print(dt.withZone(DateTimeZone.UTC))) + } + + implicit val timeWriter: ToJSON[LocalTime] = new ToJSON[LocalTime] { + def write(lt: LocalTime): JValue = JString(ISODateTimeFormat.time.print(lt)) + } + + implicit val dateWriter: ToJSON[LocalDate] = new ToJSON[LocalDate] { + def write(ld: LocalDate): JValue = JString(ISODateTimeFormat.date.print(ld)) + } + + implicit val yearMonthWriter: ToJSON[YearMonth] = new ToJSON[YearMonth] { + def write(ym: YearMonth): JValue = JString(ISODateTimeFormat.yearMonth().print(ym)) + } + + // java.time + + // always format the milliseconds + private val javaInstantFormatter = new time.format.DateTimeFormatterBuilder() + .appendInstant(3) + .toFormatter() + implicit val javaInstantWriter: ToJSON[time.Instant] = new ToJSON[time.Instant] { + def write(value: time.Instant): JValue = JString( + javaInstantFormatter.format(time.OffsetDateTime.ofInstant(value, time.ZoneOffset.UTC))) + } + + // always format the milliseconds + private val javaLocalTimeFormatter = new time.format.DateTimeFormatterBuilder() + .appendPattern("HH:mm:ss.SSS") + .toFormatter() + implicit val javaTimeWriter: ToJSON[time.LocalTime] = new ToJSON[time.LocalTime] { + def write(value: time.LocalTime): JValue = JString(javaLocalTimeFormatter.format(value)) + } + + implicit val javaDateWriter: ToJSON[time.LocalDate] = new ToJSON[time.LocalDate] { + def write(value: time.LocalDate): JValue = JString( + time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(value)) + } + + implicit val javaYearMonth: ToJSON[time.YearMonth] = new ToJSON[time.YearMonth] { + def write(value: time.YearMonth): JValue = JString(JavaYearMonthFormatter.format(value)) + } + + implicit val uuidWriter: ToJSON[UUID] = new ToJSON[UUID] { + def write(uuid: UUID): JValue = JString(uuid.toString) + } + + implicit val localeWriter: ToJSON[Locale] = new ToJSON[Locale] { + def write(locale: Locale): JValue = JString(locale.toLanguageTag) + } + + implicit def eitherWriter[A: ToJSON, B: ToJSON]: ToJSON[Either[A, B]] = new ToJSON[Either[A, B]] { + def write(e: Either[A, B]): JValue = e match { + case Left(l) => toJValue(l) + case Right(r) => toJValue(r) + } + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/catsinstances/package.scala b/json/json-3/src/main/scala/io/sphere/json/catsinstances/package.scala new file mode 100644 index 00000000..779fd45d --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/catsinstances/package.scala @@ -0,0 +1,41 @@ +package io.sphere.json + +import _root_.cats.{Contravariant, Functor, Invariant} +import org.json4s.JValue + +/** Cats instances for [[JSON]], [[FromJSON]] and [[ToJSON]] + */ +package object catsinstances extends JSONInstances with FromJSONInstances with ToJSONInstances + +trait JSONInstances { + implicit val catsInvariantForJSON: Invariant[JSON] = new JSONInvariant +} + +trait FromJSONInstances { + implicit val catsFunctorForFromJSON: Functor[FromJSON] = new FromJSONFunctor +} + +trait ToJSONInstances { + implicit val catsContravariantForToJSON: Contravariant[ToJSON] = new ToJSONContravariant +} + +class JSONInvariant extends Invariant[JSON] { + override def imap[A, B](fa: JSON[A])(f: A => B)(g: B => A): JSON[B] = new JSON[B] { + override def write(b: B): JValue = fa.write(g(b)) + override def read(jval: JValue): JValidation[B] = fa.read(jval).map(f) + override val fields: Set[String] = fa.fields + } +} + +class FromJSONFunctor extends Functor[FromJSON] { + override def map[A, B](fa: FromJSON[A])(f: A => B): FromJSON[B] = new FromJSON[B] { + override def read(jval: JValue): JValidation[B] = fa.read(jval).map(f) + override val fields: Set[String] = fa.fields + } +} + +class ToJSONContravariant extends Contravariant[ToJSON] { + override def contramap[A, B](fa: ToJSON[A])(f: B => A): ToJSON[B] = new ToJSON[B] { + override def write(b: B): JValue = fa.write(f(b)) + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/generic/AnnotationReader.scala b/json/json-3/src/main/scala/io/sphere/json/generic/AnnotationReader.scala new file mode 100644 index 00000000..69c64576 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/generic/AnnotationReader.scala @@ -0,0 +1,153 @@ +package io.sphere.json.generic + +import io.sphere.json.generic.JSONAnnotation +import io.sphere.json.generic.JSONTypeHint + +import scala.quoted.{Expr, Quotes, Type, Varargs} + +private type MA = JSONAnnotation + +case class Field( + name: String, + embedded: Boolean, + ignored: Boolean, + jsonKey: Option[JSONKey], + defaultArgument: Option[Any]) { + val fieldName: String = jsonKey.map(_.value).getOrElse(name) +} + +case class CaseClassMetaData( + name: String, + typeHintRaw: Option[JSONTypeHint], + fields: Vector[Field] +) { + val typeHint: Option[String] = + typeHintRaw.map(_.value).filterNot(_.toList.forall(_ == ' ')) +} + +case class TraitMetaData( + top: CaseClassMetaData, + typeHintFieldRaw: Option[JSONTypeHintField], + subtypes: Map[String, CaseClassMetaData] +) { + val typeDiscriminator: String = typeHintFieldRaw.map(_.value).getOrElse("type") +} + +class AnnotationReader(using q: Quotes) { + + import q.reflect.* + + def readCaseClassMetaData[T: Type]: Expr[CaseClassMetaData] = { + val sym = TypeRepr.of[T].typeSymbol + caseClassMetaData(sym) + } + + def readTraitMetaData[T: Type]: Expr[TraitMetaData] = { + val sym = TypeRepr.of[T].typeSymbol + val typeHintField = + sym.annotations.map(findJSONTypeHintField).find(_.isDefined).flatten match { + case Some(thf) => '{ Some($thf) } + case None => '{ None } + } + + '{ + TraitMetaData( + top = ${ caseClassMetaData(sym) }, + typeHintFieldRaw = $typeHintField, + subtypes = ${ subtypeAnnotations(sym) } + ) + } + } + + private def annotationTree(tree: Tree): Option[Expr[MA]] = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[MA]).map(_.asExprOf[MA]) + + private def findEmbedded(tree: Tree): Boolean = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[JSONEmbedded]).isDefined + + private def findIgnored(tree: Tree): Boolean = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[JSONIgnore]).isDefined + + private def findKey(tree: Tree): Option[Expr[JSONKey]] = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[JSONKey]).map(_.asExprOf[JSONKey]) + + private def findTypeHint(tree: Tree): Option[Expr[JSONTypeHint]] = + Option + .when(tree.isExpr)(tree.asExpr) + .filter(_.isExprOf[JSONTypeHint]) + .map(_.asExprOf[JSONTypeHint]) + + private def findJSONTypeHintField(tree: Tree): Option[Expr[JSONTypeHintField]] = + Option + .when(tree.isExpr)(tree.asExpr) + .filter(_.isExprOf[JSONTypeHintField]) + .map(_.asExprOf[JSONTypeHintField]) + + private def collectFieldInfo(companion: Symbol)(s: Symbol, paramIdx: Int): Expr[Field] = { + val embedded = Expr(s.annotations.exists(findEmbedded)) + val ignored = Expr(s.annotations.exists(findIgnored)) + val name = Expr(s.name) + val key = s.annotations.map(findKey).find(_.isDefined).flatten match { + case Some(k) => '{ Some($k) } + case None => '{ None } + } + val defArgOpt = companion + .methodMember(s"$$lessinit$$greater$$default$$${paramIdx + 1}") + .headOption + .map(dm => Ref(dm).asExprOf[Any]) match { + case Some(k) => '{ Some($k) } + case None => '{ None } + } + + '{ + Field( + name = $name, + embedded = $embedded, + ignored = $ignored, + jsonKey = $key, + defaultArgument = $defArgOpt) + } + } + + private def caseClassMetaData(sym: Symbol): Expr[CaseClassMetaData] = { + val caseParams = sym.primaryConstructor.paramSymss.take(1).flatten + val fields = Varargs(caseParams.zipWithIndex.map(collectFieldInfo(sym.companionModule))) + val name = Expr(sym.name) + val typeHint = sym.annotations.map(findTypeHint).find(_.isDefined).flatten match { + case Some(th) => '{ Some($th) } + case None => '{ None } + } + + '{ + CaseClassMetaData( + name = $name, + typeHintRaw = $typeHint, + fields = Vector($fields*) + ) + } + } + + private def subtypeAnnotation(sym: Symbol): Expr[(String, CaseClassMetaData)] = { + val name = Expr(sym.name) + val annots = caseClassMetaData(sym) + '{ ($name, $annots) } + } + + private def subtypeAnnotations(sym: Symbol): Expr[Map[String, CaseClassMetaData]] = { + val subtypes = Varargs(sym.children.map(subtypeAnnotation)) + '{ Map($subtypes*) } + } + +} + +object AnnotationReader { + inline def readCaseClassMetaData[T]: CaseClassMetaData = ${ readCaseClassMetaDataImpl[T] } + + inline def readTraitMetaData[T]: TraitMetaData = ${ readTraitMetaDataImpl[T] } + + private def readCaseClassMetaDataImpl[T: Type](using Quotes): Expr[CaseClassMetaData] = + AnnotationReader().readCaseClassMetaData[T] + + private def readTraitMetaDataImpl[T: Type](using Quotes): Expr[TraitMetaData] = + AnnotationReader().readTraitMetaData[T] +} diff --git a/json/json-3/src/main/scala/io/sphere/json/generic/Annotations.scala b/json/json-3/src/main/scala/io/sphere/json/generic/Annotations.scala new file mode 100644 index 00000000..7d3ace8d --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/generic/Annotations.scala @@ -0,0 +1,11 @@ +package io.sphere.json.generic + +import scala.annotation.StaticAnnotation + +sealed trait JSONAnnotation extends StaticAnnotation + +case class JSONEmbedded() extends JSONAnnotation +case class JSONIgnore() extends JSONAnnotation +case class JSONKey(value: String) extends JSONAnnotation +case class JSONTypeHintField(value: String) extends JSONAnnotation +case class JSONTypeHint(value: String) extends JSONAnnotation diff --git a/json/json-3/src/main/scala/io/sphere/json/generic/Derivation.scala b/json/json-3/src/main/scala/io/sphere/json/generic/Derivation.scala new file mode 100644 index 00000000..67a26666 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/generic/Derivation.scala @@ -0,0 +1,126 @@ +package io.sphere.json.generic + +import cats.data.Validated +import cats.implicits.* +import io.sphere.json.{JSON, JSONParseError, JValidation} +import org.json4s.DefaultJsonFormats.given +import org.json4s.JsonAST.JValue +import org.json4s.{DefaultJsonFormats, JObject, JString, jvalue2monadic, jvalue2readerSyntax} + +import scala.deriving.Mirror + +inline def deriveJSON[A](using Mirror.Of[A]): JSON[A] = JSON.derived + +object JSON { + private val emptyFieldsSet: Vector[String] = Vector.empty + + inline def apply[A: JSON]: JSON[A] = summon[JSON[A]] + inline given derived[A](using Mirror.Of[A]): JSON[A] = Derivation.derived[A] + + private def addField(jObject: JObject, field: Field, jValue: JValue): JValue = + jValue match { + case o: JObject => + if (field.embedded) JObject(jObject.obj ++ o.obj) + else JObject(jObject.obj :+ (field.fieldName -> o)) + case other => JObject(jObject.obj :+ (field.fieldName -> other)) + } + + private object Derivation { + + import scala.compiletime.{constValue, constValueTuple, erasedValue, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): JSON[A] = + inline m match { + case s: Mirror.SumOf[A] => deriveTrait(s) + case p: Mirror.ProductOf[A] => deriveCaseClass(p) + } + + inline private def deriveTrait[A](mirrorOfSum: Mirror.SumOf[A]): JSON[A] = + new JSON[A] { + private val traitMetaData: TraitMetaData = AnnotationReader.readTraitMetaData[A] + private val typeHintMap: Map[String, String] = traitMetaData.subtypes.collect { + case (name, classMeta) if classMeta.typeHint.isDefined => + name -> classMeta.typeHint.get + } + private val reverseTypeHintMap: Map[String, String] = typeHintMap.map((on, n) => (n, on)) + private val jsons: Seq[JSON[Any]] = summonFormatters[mirrorOfSum.MirroredElemTypes] + private val names: Seq[String] = + constValueTuple[mirrorOfSum.MirroredElemLabels].productIterator.toVector + .asInstanceOf[Vector[String]] + private val jsonsByNames: Map[String, JSON[Any]] = names.zip(jsons).toMap + + override def read(jValue: JValue): JValidation[A] = + jValue match { + case jObject: JObject => + val typeName = (jObject \ traitMetaData.typeDiscriminator).as[String] + val originalTypeName = reverseTypeHintMap.getOrElse(typeName, typeName) + jsonsByNames(originalTypeName).read(jObject).map(_.asInstanceOf[A]) + case x => + Validated.invalidNel(JSONParseError(s"JSON object expected. Got: '$jValue'")) + } + + override def write(value: A): JValue = { + // we never get a trait here, only classes, it's safe to assume Product + val originalTypeName = value.asInstanceOf[Product].productPrefix + val typeName = typeHintMap.getOrElse(originalTypeName, originalTypeName) + val json = jsonsByNames(originalTypeName).write(value).asInstanceOf[JObject] + val typeDiscriminator = traitMetaData.typeDiscriminator -> JString(typeName) + JObject(typeDiscriminator :: json.obj) + } + + } + + inline private def deriveCaseClass[A](mirrorOfProduct: Mirror.ProductOf[A]): JSON[A] = + new JSON[A] { + private val caseClassMetaData: CaseClassMetaData = AnnotationReader.readCaseClassMetaData[A] + private val jsons: Vector[JSON[Any]] = summonFormatters[mirrorOfProduct.MirroredElemTypes] + private val fieldsAndJsons: Vector[(Field, JSON[Any])] = caseClassMetaData.fields.zip(jsons) + + private val fieldNames: Vector[String] = fieldsAndJsons.flatMap { (field, json) => + if (field.embedded) json.fields.toVector :+ field.name + else Vector(field.name) + } + + override val fields: Set[String] = fieldNames.toSet + + override def write(value: A): JValue = { + val caseClassFields = value.asInstanceOf[Product].productIterator + jsons + .zip(caseClassFields) + .zip(caseClassMetaData.fields) + .foldLeft[JValue](JObject()) { case (jObject, ((json, fieldValue), field)) => + addField(jObject.asInstanceOf[JObject], field, json.write(fieldValue)) + } + } + + override def read(jValue: JValue): JValidation[A] = + jValue match { + case jObject: JObject => + for { + fieldsAsAList <- fieldsAndJsons + .map((field, format) => readField(field, format, jObject)) + .sequence + fieldsAsTuple = Tuple.fromArray(fieldsAsAList.toArray) + + } yield mirrorOfProduct.fromTuple( + fieldsAsTuple.asInstanceOf[mirrorOfProduct.MirroredElemTypes]) + + case x => + Validated.invalidNel(JSONParseError(s"JSON object expected. $x")) + } + + private def readField(field: Field, json: JSON[Any], jObject: JObject): JValidation[Any] = + if (field.embedded) json.read(jObject) + else io.sphere.json.field(field.fieldName, field.defaultArgument)(jObject)(json) + + } + + inline private def summonFormatters[T <: Tuple]: Vector[JSON[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[JSON[t]] + .asInstanceOf[JSON[Any]] +: summonFormatters[ts] + } + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/generic/DeriveSingleton.scala b/json/json-3/src/main/scala/io/sphere/json/generic/DeriveSingleton.scala new file mode 100644 index 00000000..2b65c923 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/generic/DeriveSingleton.scala @@ -0,0 +1,82 @@ +package io.sphere.json.generic + +import cats.data.Validated +import io.sphere.json.{JSON, JSONParseError, JValidation} +import org.json4s.{JNull, JString, JValue} + +import scala.deriving.Mirror + +inline def deriveSingletonJSON[A](using Mirror.Of[A]): JSON[A] = DeriveSingleton.derived + +object DeriveSingleton { + + inline given derived[A](using Mirror.Of[A]): JSON[A] = Derivation.derived[A] + + private object Derivation { + + import scala.compiletime.{constValue, constValueTuple, erasedValue, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): JSON[A] = + inline m match { + case s: Mirror.SumOf[A] => deriveTrait(s) + case p: Mirror.ProductOf[A] => deriveObject(p) + } + + inline private def deriveTrait[A](mirrorOfSum: Mirror.SumOf[A]): JSON[A] = + new JSON[A] { + private val traitMetaData: TraitMetaData = AnnotationReader.readTraitMetaData[A] + private val typeHintMap: Map[String, String] = traitMetaData.subtypes.collect { + case (name, classMeta) if classMeta.typeHint.isDefined => + name -> classMeta.typeHint.get + } + private val reverseTypeHintMap: Map[String, String] = typeHintMap.map((on, n) => (n, on)) + private val jsons: Seq[JSON[Any]] = summonFormatters[mirrorOfSum.MirroredElemTypes] + private val names: Seq[String] = + constValueTuple[mirrorOfSum.MirroredElemLabels].productIterator.toVector + .asInstanceOf[Vector[String]] + private val jsonsByNames: Map[String, JSON[Any]] = names.zip(jsons).toMap + + override def read(jValue: JValue): JValidation[A] = + jValue match { + case JString(typeName) => + val originalTypeName = reverseTypeHintMap.getOrElse(typeName, typeName) + jsonsByNames.get(originalTypeName) match { + case Some(json) => + json.read(JNull).map(_.asInstanceOf[A]) + case None => + Validated.invalidNel(JSONParseError(s"'$typeName' is not a valid value")) + } + + case x => + Validated.invalidNel(JSONParseError(s"JSON string expected. Got >>> $jValue")) + } + + override def write(value: A): JValue = { + val originalTypeName = value.asInstanceOf[Product].productPrefix + val typeName = typeHintMap.getOrElse(originalTypeName, originalTypeName) + JString(typeName) + } + + } + + inline private def deriveObject[A](mirrorOfProduct: Mirror.ProductOf[A]): JSON[A] = + new JSON[A] { + override def write(value: A): JValue = ??? // This is already taken care of in `deriveTrait` + + override def read(jValue: JValue): JValidation[A] = { + // Just create the object instance, no need to do anything else + val tuple = Tuple.fromArray(Array.empty[Any]) + val obj = mirrorOfProduct.fromTuple(tuple.asInstanceOf[mirrorOfProduct.MirroredElemTypes]) + Validated.Valid(obj) + } + } + + inline private def summonFormatters[T <: Tuple]: Vector[JSON[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[JSON[t]] + .asInstanceOf[JSON[Any]] +: summonFormatters[ts] + } + } +} diff --git a/json/json-3/src/main/scala/io/sphere/json/package.scala b/json/json-3/src/main/scala/io/sphere/json/package.scala new file mode 100644 index 00000000..cc1d4101 --- /dev/null +++ b/json/json-3/src/main/scala/io/sphere/json/package.scala @@ -0,0 +1,116 @@ +package io.sphere + +import cats.data.Validated.{Invalid, Valid} +import cats.data.{NonEmptyList, ValidatedNel} +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.exc.{InputCoercionException, StreamConstraintsException} +import com.fasterxml.jackson.databind.JsonMappingException +import io.sphere.util.Logging +import org.json4s.{DefaultFormats, JsonInput, StringInput} +import org.json4s.JsonAST._ +import org.json4s.ParserUtil.ParseException +import org.json4s.jackson.compactJson +import java.time.format.DateTimeFormatter + +/** Provides functions for reading & writing JSON, via type classes JSON/JSONR/JSONW. */ +package object json extends Logging { + + private[json] val JavaYearMonthFormatter = + DateTimeFormatter.ofPattern("uuuu-MM") + + implicit val liftJsonFormats: DefaultFormats = DefaultFormats + + type JValidation[A] = ValidatedNel[JSONError, A] + + def parseJsonUnsafe(json: JsonInput): JValue = + SphereJsonParser.parse(json, useBigDecimalForDouble = false, useBigIntForLong = false) + + def parseJSON(json: JsonInput): JValidation[JValue] = + try Valid(parseJsonUnsafe(json)) + catch { + case e: ParseException => jsonParseError(e.getMessage) + case e: JsonMappingException => jsonParseError(e.getOriginalMessage) + case e: JsonParseException => jsonParseError(e.getOriginalMessage) + case e: InputCoercionException => jsonParseError(e.getOriginalMessage) + case e: StreamConstraintsException => jsonParseError(e.getOriginalMessage) + } + + def parseJSON(json: String): JValidation[JValue] = + parseJSON(StringInput(json)) + + def jsonParseError[A](msg: String): Invalid[NonEmptyList[JSONError]] = + Invalid(NonEmptyList.one(JSONParseError(msg))) + + def fromJSON[A: FromJSON](json: JsonInput): JValidation[A] = + parseJSON(json).andThen(fromJValue[A]) + + def fromJSON[A: FromJSON](json: String): JValidation[A] = + parseJSON(json).andThen(fromJValue[A]) + + private val jNothingStr = "{}" + + def toJSON[A: ToJSON](a: A): String = toJValue(a) match { + case JNothing => jNothingStr + case jval => compactJson(jval) + } + + /** Parses a JSON string into a type A. Throws a [[JSONException]] on failure. + * + * @param json + * The JSON string to parse. + * @return + * An instance of type A. + */ + def getFromJSON[A: FromJSON](json: JsonInput): A = + getFromJValue[A](parseJsonUnsafe(json)) + + def getFromJSON[A: FromJSON](json: String): A = + getFromJSON(StringInput(json)) + + def fromJValue[A](jval: JValue)(implicit json: FromJSON[A]): JValidation[A] = + json.read(jval) + + def toJValue[A](a: A)(implicit json: ToJSON[A]): JValue = + json.write(a) + + def getFromJValue[A: FromJSON](jval: JValue): A = + fromJValue[A](jval) match { + case Valid(a) => a + case Invalid(errs) => throw new JSONException(errs.toList.mkString(", ")) + } + + /** Extracts a JSON value of type A from a named field of a JSON object. + * + * @param name + * The name of the field. + * @param jObject + * The JObject from which to extract the field. + * @return + * A success with a value of type A or a non-empty list of errors. + */ + def field[A]( + name: String, + default: Option[A] = None + )(jObject: JObject)(implicit jsonr: FromJSON[A]): JValidation[A] = { + val fields = jObject.obj + // Perf note: avoiding Some(f) with fields.indexWhere and then constant time access is not faster + fields.find(f => f._1 == name && f._2 != JNull && f._2 != JNothing) match { + case Some(f) => + jsonr + .read(f._2) + .leftMap(errs => + errs.map { + case JSONParseError(msg) => JSONFieldError(name :: Nil, msg) + case JSONFieldError(path, msg) => JSONFieldError(name :: path, msg) + }) + case None => + default + .map(Valid(_)) + .orElse( + jsonr.read(JNothing).fold(_ => None, x => Some(Valid(x))) + ) // orElse(jsonr.default) + .getOrElse( + Invalid(NonEmptyList.one(JSONFieldError(name :: Nil, "Missing required value")))) + } + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/BigNumberParsingSpec.scala b/json/json-3/src/test/scala/io/sphere/json/BigNumberParsingSpec.scala new file mode 100644 index 00000000..11c192fe --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/BigNumberParsingSpec.scala @@ -0,0 +1,24 @@ +package io.sphere.json + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class BigNumberParsingSpec extends AnyWordSpec with Matchers { + import BigNumberParsingSpec._ + + "parsing a big number" should { + "not take much time when parsed as Double" in { + fromJSON[Double](bigNumberAsString).isValid should be(false) + } + "not take much time when parsed as Long" in { + fromJSON[Long](bigNumberAsString).isValid should be(false) + } + "not take much time when parsed as Int" in { + fromJSON[Int](bigNumberAsString).isValid should be(false) + } + } +} + +object BigNumberParsingSpec { + private val bigNumberAsString = "9" * 10000000 +} diff --git a/json/json-3/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala b/json/json-3/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala new file mode 100644 index 00000000..562e9c3b --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala @@ -0,0 +1,173 @@ +package io.sphere.json + +import org.json4s.JString +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import java.time.Instant +import cats.data.Validated.Valid + +class DateTimeParsingSpec extends AnyWordSpec with Matchers { + + import FromJSON.dateTimeReader + import FromJSON.javaInstantReader + def jsonDateStringWith( + year: String = "2035", + dayOfTheMonth: String = "23", + monthOfTheYear: String = "11", + hourOfTheDay: String = "13", + minuteOfTheHour: String = "45", + secondOfTheMinute: String = "34", + millis: String = "543"): JString = + JString( + s"$year-$monthOfTheYear-${dayOfTheMonth}T$hourOfTheDay:$minuteOfTheHour:$secondOfTheMinute.${millis}Z") + + val beValid = be(Symbol("valid")) + val outOfIntRange = "999999999999999" + + "parsing a DateTime" should { + + "reject strings with invalid year" in { + dateTimeReader.read(jsonDateStringWith(year = "999999999")) shouldNot beValid + } + + "reject strings with years that are out of range for integers" in { + dateTimeReader.read(jsonDateStringWith(year = outOfIntRange)) shouldNot beValid + } + + "reject strings that are out of range for other fields" in { + dateTimeReader.read( + jsonDateStringWith( + monthOfTheYear = outOfIntRange, + dayOfTheMonth = outOfIntRange, + hourOfTheDay = outOfIntRange, + minuteOfTheHour = outOfIntRange, + secondOfTheMinute = outOfIntRange, + millis = outOfIntRange + )) shouldNot beValid + } + + "reject strings with invalid days" in { + dateTimeReader.read(jsonDateStringWith(dayOfTheMonth = "59")) shouldNot beValid + } + + "reject strings with invalid months" in { + dateTimeReader.read(jsonDateStringWith(monthOfTheYear = "39")) shouldNot beValid + } + + "reject strings with invalid hours" in { + dateTimeReader.read(jsonDateStringWith(hourOfTheDay = "39")) shouldNot beValid + } + + "reject strings with invalid minutes" in { + dateTimeReader.read(jsonDateStringWith(minuteOfTheHour = "87")) shouldNot beValid + } + + "reject strings with invalid seconds" in { + dateTimeReader.read(jsonDateStringWith(secondOfTheMinute = "87")) shouldNot beValid + } + } + + "parsing an Instant" should { + + "reject strings with invalid year" in { + javaInstantReader.read(jsonDateStringWith(year = "999999999")) shouldNot beValid + } + + "reject strings with years that are out of range for integers" in { + javaInstantReader.read(jsonDateStringWith(year = outOfIntRange)) shouldNot beValid + } + + "reject strings that are out of range for other fields" in { + javaInstantReader.read( + jsonDateStringWith( + monthOfTheYear = outOfIntRange, + dayOfTheMonth = outOfIntRange, + hourOfTheDay = outOfIntRange, + minuteOfTheHour = outOfIntRange, + secondOfTheMinute = outOfIntRange, + millis = outOfIntRange + )) shouldNot beValid + } + + "reject strings with invalid days" in { + javaInstantReader.read(jsonDateStringWith(dayOfTheMonth = "59")) shouldNot beValid + } + + "reject strings with invalid months" in { + javaInstantReader.read(jsonDateStringWith(monthOfTheYear = "39")) shouldNot beValid + } + + "reject strings with invalid hours" in { + javaInstantReader.read(jsonDateStringWith(hourOfTheDay = "39")) shouldNot beValid + } + + "reject strings with invalid minutes" in { + javaInstantReader.read(jsonDateStringWith(minuteOfTheHour = "87")) shouldNot beValid + } + + "reject strings with invalid seconds" in { + javaInstantReader.read(jsonDateStringWith(secondOfTheMinute = "87")) shouldNot beValid + } + } + + // ported from https://github.com/JodaOrg/joda-time/blob/4a1402a47cab4636bf4c73d42a62bfa80c1535ca/src/test/java/org/joda/time/convert/TestStringConverter.java#L114-L156 + // ensures that we accept similar patterns as joda when parsing instants + "parsing a Java instant" should { + "accept a full instant with milliseconds and offset" in { + javaInstantReader.read(JString("2004-06-09T12:24:48.501+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:24:48.501Z")) + } + + "accept a year with offset" in { + javaInstantReader.read(JString("2004T+0800")) shouldBe Valid( + Instant.parse("2004-01-01T00:00:00+08:00")) + } + + "accept a year month with offset" in { + javaInstantReader.read(JString("2004-06T+0800")) shouldBe Valid( + Instant.parse("2004-06-01T00:00:00+08:00")) + } + + "accept a year month day with offset" in { + javaInstantReader.read(JString("2004-06-09T+0800")) shouldBe Valid( + Instant.parse("2004-06-09T00:00:00+08:00")) + } + + "accept a year month day with hour and offset" in { + javaInstantReader.read(JString("2004-06-09T12+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:00:00Z")) + } + + "accept a year month day with hour, minute, and offset" in { + javaInstantReader.read(JString("2004-06-09T12:24+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:24:00Z")) + } + + "accept a year month day with hour, minute, second, and offset" in { + javaInstantReader.read(JString("2004-06-09T12:24:48+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:24:48Z")) + } + + "accept a year month day with hour, fraction, and offset" in { + javaInstantReader.read(JString("2004-06-09T12.5+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:00:00.5Z")) + } + + "accept a year month day with hour, minute, fraction, and offset" in { + javaInstantReader.read(JString("2004-06-09T12:24.5+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:24:00.5Z")) + } + + "accept a year month day with hour, minute, second, fraction, and offset" in { + javaInstantReader.read(JString("2004-06-09T12:24:48.5+0800")) shouldBe Valid( + Instant.parse("2004-06-09T04:24:48.5Z")) + } + + "accept a year month day with hour, minute, second, fraction, but no offset" in { + javaInstantReader.read(JString("2004-06-09T12:24:48.501")) shouldBe Valid( + Instant.parse("2004-06-09T12:24:48.501Z")) + } + + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala b/json/json-3/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala new file mode 100644 index 00000000..dc334403 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala @@ -0,0 +1,171 @@ +package io.sphere.json + +import cats.data.Validated.Valid +import io.sphere.json.generic.* +import org.json4s.DefaultJsonFormats.given +import org.json4s.{DynamicJValueImplicits, JArray, JObject, JValue} +import org.json4s.JsonAST.{JField, JNothing} +import org.json4s.jackson.JsonMethods.{compact, render} +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class DeriveSingletonJSONSpec extends AnyWordSpec with Matchers { + "DeriveSingletonJSON" must { + "read normal singleton values" in { + val user = getFromJSON[UserWithPicture](""" + { + "userId": "foo-123", + "pictureSize": "Medium", + "pictureUrl": "http://exmple.com" + } + """) + + user must be(UserWithPicture("foo-123", Medium, "http://exmple.com")) + } + + "fail to read if singleton value is unknown" in { + a[JSONException] must be thrownBy getFromJSON[UserWithPicture](""" + { + "userId": "foo-123", + "pictureSize": "foo", + "pictureUrl": "http://exmple.com" + } + """) + } + + "write normal singleton values" in { + val userJson = toJValue(UserWithPicture("foo-123", Medium, "http://exmple.com")) + + val Valid(expectedJson) = parseJSON(""" + { + "userId": "foo-123", + "pictureSize": "Medium", + "pictureUrl": "http://exmple.com" + } + """): @unchecked + + filter(userJson) must be(expectedJson) + } + + "read custom singleton values" in { + val user = getFromJSON[UserWithPicture](""" + { + "userId": "foo-123", + "pictureSize": "bar", + "pictureUrl": "http://exmple.com" + } + """) + + user must be(UserWithPicture("foo-123", Custom, "http://exmple.com")) + } + + "write custom singleton values" in { + val userJson = toJValue(UserWithPicture("foo-123", Custom, "http://exmple.com")) + + val Valid(expectedJson) = parseJSON(""" + { + "userId": "foo-123", + "pictureSize": "bar", + "pictureUrl": "http://exmple.com" + } + """): @unchecked + + filter(userJson) must be(expectedJson) + } + + "write and consequently read, which must produce the original value" in { + val originalUser = UserWithPicture("foo-123", Medium, "http://exmple.com") + val newUser = getFromJSON[UserWithPicture](compact(render(toJValue(originalUser)))) + + newUser must be(originalUser) + } + + "read and write sealed trait with only one subtype" in { + val json = """ + { + "userId": "foo-123", + "pictureSize": "Medium", + "pictureUrl": "http://example.com", + "access": { + "type": "Authorized", + "project": "internal" + } + } + """ + val user = getFromJSON[UserWithPicture](json) + + user must be( + UserWithPicture( + "foo-123", + Medium, + "http://example.com", + Some(Access.Authorized("internal")))) + + val newJson = toJValue[UserWithPicture](user) + Valid(newJson) must be(parseJSON(json)) + + val Valid(newUser) = fromJValue[UserWithPicture](newJson): @unchecked + newUser must be(user) + } + } + + private def filter(jvalue: JValue): JValue = + jvalue.removeField { + case (_, JNothing) => true + case _ => false + } + + extension (jv: JValue) { + def removeField(p: JField => Boolean): JValue = jv.transform { case JObject(l) => + JObject(l.filterNot(p)) + } + + def transform(f: PartialFunction[JValue, JValue]): JValue = map { x => + f.applyOrElse[JValue, JValue](x, _ => x) + } + + def map(f: JValue => JValue): JValue = { + def rec(v: JValue): JValue = v match { + case JObject(l) => f(JObject(l.map { case (n, va) => (n, rec(va)) })) + case JArray(l) => f(JArray(l.map(rec))) + case x => f(x) + } + + rec(jv) + } + } +} + +sealed abstract class PictureSize(val weight: Int, val height: Int) + +case object Small extends PictureSize(100, 100) +case object Medium extends PictureSize(500, 450) +case object Big extends PictureSize(1024, 2048) + +@JSONTypeHint("bar") +case object Custom extends PictureSize(1, 2) + +object PictureSize { + import DeriveSingleton.derived + + given JSON[PictureSize] = deriveSingletonJSON +} + +sealed trait Access +object Access { + // only one sub-type + import JSON.derived + case class Authorized(project: String) extends Access + + given JSON[Access] = deriveJSON +} + +case class UserWithPicture( + userId: String, + pictureSize: PictureSize, + pictureUrl: String, + access: Option[Access] = None) + +object UserWithPicture { + given JSON[UserWithPicture] = deriveJSON[UserWithPicture] +} diff --git a/json/json-3/src/test/scala/io/sphere/json/JSONEmbeddedSpec.scala b/json/json-3/src/test/scala/io/sphere/json/JSONEmbeddedSpec.scala new file mode 100644 index 00000000..f5c7eba4 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/JSONEmbeddedSpec.scala @@ -0,0 +1,131 @@ +package io.sphere.json + +import org.scalatest.OptionValues +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import io.sphere.json.generic._ + +object JSONEmbeddedSpec { + + case class Embedded(value1: String, value2: Int) + + object Embedded { + given JSON[Embedded] = deriveJSON[Embedded] + } + + case class Test1(name: String, @JSONEmbedded embedded: Embedded) + + object Test1 { + given JSON[Test1] = deriveJSON[Test1] + } + + case class Test2(name: String, @JSONEmbedded embedded: Option[Embedded] = None) + + object Test2 { + given JSON[Test2] = deriveJSON + } + + case class SubTest4(@JSONEmbedded embedded: Embedded) + object SubTest4 { + given JSON[SubTest4] = deriveJSON + } + + case class Test4(subField: Option[SubTest4] = None) + object Test4 { + given JSON[Test4] = deriveJSON + } +} + +class JSONEmbeddedSpec extends AnyWordSpec with Matchers with OptionValues { + import JSONEmbeddedSpec._ + + "JSONEmbedded" should { + "flatten the json in one object" in { + val json = + """{ + | "name": "ze name", + | "value1": "ze value1", + | "value2": 45 + |} + """.stripMargin + val test1 = getFromJSON[Test1](json) + test1.name mustEqual "ze name" + test1.embedded.value1 mustEqual "ze value1" + test1.embedded.value2 mustEqual 45 + + val result = toJSON(test1) + parseJSON(result) mustEqual parseJSON(json) + } + + "validate that the json contains all needed fields" in { + val json = + """{ + | "name": "ze name", + | "value1": "ze value1" + |} + """.stripMargin + fromJSON[Test1](json).isInvalid must be(true) + fromJSON[Test1]("""{"name": "a"}""").isInvalid must be(true) + } + + "support optional embedded attribute" in { + val json = + """{ + | "name": "ze name", + | "value1": "ze value1", + | "value2": 45 + |} + """.stripMargin + val test2 = getFromJSON[Test2](json) + test2.name mustEqual "ze name" + test2.embedded.value.value1 mustEqual "ze value1" + test2.embedded.value.value2 mustEqual 45 + + val result = toJSON(test2) + parseJSON(result) mustEqual parseJSON(json) + } + + "ignore unknown fields" in { + val json = + """{ + | "name": "ze name", + | "value1": "ze value1", + | "value2": 45, + | "value3": true + |} + """.stripMargin + val test2 = getFromJSON[Test2](json) + test2.name mustEqual "ze name" + test2.embedded.value.value1 mustEqual "ze value1" + test2.embedded.value.value2 mustEqual 45 + } + + "check for sub-fields" in { + val json = + """ + { + "subField": { + "value1": "ze value1", + "value2": 45 + } + } + """ + val test4 = getFromJSON[Test4](json) + test4.subField.value.embedded.value1 mustEqual "ze value1" + test4.subField.value.embedded.value2 mustEqual 45 + } + + "support the absence of optional embedded attribute" in { + val json = """{ "name": "ze name" }""" + val test2 = getFromJSON[Test2](json) + test2.name mustEqual "ze name" + test2.embedded mustEqual None + } + + "validate the absence of some embedded attributes" in { + val json = """{ "name": "ze name", "value1": "ze value1" }""" + fromJSON[Test2](json).isInvalid must be(true) + } + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/JSONProperties.scala b/json/json-3/src/test/scala/io/sphere/json/JSONProperties.scala new file mode 100644 index 00000000..40ba1b5b --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/JSONProperties.scala @@ -0,0 +1,170 @@ +package io.sphere.json + +import scala.language.higherKinds +import io.sphere.util.Money +import java.util.{Currency, Locale, UUID} + +import cats.Eq +import cats.data.NonEmptyList +import cats.syntax.eq._ +import org.joda.time._ +import org.scalacheck._ +import java.time + +import scala.math.BigDecimal.RoundingMode + +object JSONProperties extends Properties("JSON") { + private def check[A: FromJSON: ToJSON: Eq](a: A): Boolean = { + val json = s"""[${toJSON(a)}]""" + val result = fromJSON[Seq[A]](json).toOption.map(_.head).get + val r = result === a + if (!r) println(s"result: $result - expected: $a") + r + } + + implicit def arbitraryVector[A: Arbitrary]: Arbitrary[Vector[A]] = + Arbitrary(Arbitrary.arbitrary[List[A]].map(_.toVector)) + + implicit def arbitraryNEL[A: Arbitrary]: Arbitrary[NonEmptyList[A]] = + Arbitrary(for { + a <- Arbitrary.arbitrary[A] + l <- Arbitrary.arbitrary[List[A]] + } yield NonEmptyList(a, l)) + + implicit def arbitraryCurrency: Arbitrary[Currency] = + Arbitrary(Gen + .oneOf(Currency.getInstance("EUR"), Currency.getInstance("USD"), Currency.getInstance("JPY"))) + + implicit def arbitraryLocale: Arbitrary[Locale] = { + // Filter because OS X thinks that 'C' and 'POSIX' are valid locales... + val locales = Locale.getAvailableLocales().filter(_.toLanguageTag() != "und") + Arbitrary(for { + i <- Gen.choose(0, locales.length - 1) + } yield locales(i)) + } + + implicit def arbitraryDateTime: Arbitrary[DateTime] = + Arbitrary(for { + y <- Gen.choose(-4000, 4000) + m <- Gen.choose(1, 12) + d <- Gen.choose(1, 28) + h <- Gen.choose(0, 23) + min <- Gen.choose(0, 59) + s <- Gen.choose(0, 59) + ms <- Gen.choose(0, 999) + } yield new DateTime(y, m, d, h, min, s, ms, DateTimeZone.UTC)) + + // generate dates between years -4000 and +4000 + implicit val javaInstant: Arbitrary[time.Instant] = + Arbitrary(Gen.choose(-188395027761000L, 64092207599999L).map(time.Instant.ofEpochMilli(_))) + + implicit val javaLocalTime: Arbitrary[time.LocalTime] = Arbitrary( + Gen.choose(0, 3600 * 24).map(time.LocalTime.ofSecondOfDay(_))) + + implicit def arbitraryDate: Arbitrary[LocalDate] = + Arbitrary(Arbitrary.arbitrary[DateTime].map(_.toLocalDate)) + + implicit def arbitraryTime: Arbitrary[LocalTime] = + Arbitrary(Arbitrary.arbitrary[DateTime].map(_.toLocalTime)) + + implicit def arbitraryYearMonth: Arbitrary[YearMonth] = + Arbitrary(Arbitrary.arbitrary[DateTime].map(dt => new YearMonth(dt.getYear, dt.getMonthOfYear))) + + implicit def arbitraryMoney: Arbitrary[Money] = + Arbitrary(for { + c <- Arbitrary.arbitrary[Currency] + i <- Arbitrary.arbitrary[Int] + } yield Money.fromDecimalAmount(i, c)(RoundingMode.HALF_EVEN)) + + implicit def arbitraryUUID: Arbitrary[UUID] = + Arbitrary(for { + most <- Arbitrary.arbitrary[Long] + least <- Arbitrary.arbitrary[Long] + } yield new UUID(most, least)) + + implicit val currencyEqual: Eq[Currency] = new Eq[Currency] { + def eqv(c1: Currency, c2: Currency) = c1.getCurrencyCode == c2.getCurrencyCode + } + implicit val localeEqual: Eq[Locale] = new Eq[Locale] { + def eqv(l1: Locale, l2: Locale) = l1.toLanguageTag == l2.toLanguageTag + } + implicit val moneyEqual: Eq[Money] = new Eq[Money] { + override def eqv(x: Money, y: Money): Boolean = x == y + } + implicit val dateTimeEqual: Eq[DateTime] = new Eq[DateTime] { + def eqv(dt1: DateTime, dt2: DateTime) = dt1 == dt2 + } + implicit val localTimeEqual: Eq[LocalTime] = new Eq[LocalTime] { + def eqv(dt1: LocalTime, dt2: LocalTime) = dt1 == dt2 + } + implicit val localDateEqual: Eq[LocalDate] = new Eq[LocalDate] { + def eqv(dt1: LocalDate, dt2: LocalDate) = dt1 == dt2 + } + implicit val yearMonthEqual: Eq[YearMonth] = new Eq[YearMonth] { + def eqv(dt1: YearMonth, dt2: YearMonth) = dt1 == dt2 + } + implicit val javaInstantEqual: Eq[time.Instant] = Eq.fromUniversalEquals + implicit val javaLocalDateEqual: Eq[time.LocalDate] = Eq.fromUniversalEquals + implicit val javaLocalTimeEqual: Eq[time.LocalTime] = Eq.fromUniversalEquals + implicit val javaYearMonthEqual: Eq[time.YearMonth] = Eq.fromUniversalEquals + + private def checkC[C[_]](name: String)(implicit + jri: FromJSON[C[Int]], + jwi: ToJSON[C[Int]], + arbi: Arbitrary[C[Int]], + eqi: Eq[C[Int]], + jrs: FromJSON[C[Short]], + jws: ToJSON[C[Short]], + arbs: Arbitrary[C[Short]], + eqs: Eq[C[Short]], + jrl: FromJSON[C[Long]], + jwl: ToJSON[C[Long]], + arbl: Arbitrary[C[Long]], + eql: Eq[C[Long]], + jrss: FromJSON[C[String]], + jwss: ToJSON[C[String]], + arbss: Arbitrary[C[String]], + eqss: Eq[C[String]], + jrf: FromJSON[C[Float]], + jwf: ToJSON[C[Float]], + arbf: Arbitrary[C[Float]], + eqf: Eq[C[Float]], + jrd: FromJSON[C[Double]], + jwd: ToJSON[C[Double]], + arbd: Arbitrary[C[Double]], + eqd: Eq[C[Double]], + jrb: FromJSON[C[Boolean]], + jwb: ToJSON[C[Boolean]], + arbb: Arbitrary[C[Boolean]], + eqb: Eq[C[Boolean]] + ) = { + property(s"read/write $name of Ints") = Prop.forAll((l: C[Int]) => check(l)) + property(s"read/write $name of Shorts") = Prop.forAll((l: C[Short]) => check(l)) + property(s"read/write $name of Longs") = Prop.forAll((l: C[Long]) => check(l)) + property(s"read/write $name of Strings") = Prop.forAll((l: C[String]) => check(l)) + property(s"read/write $name of Floats") = Prop.forAll((l: C[Float]) => check(l)) + property(s"read/write $name of Doubles") = Prop.forAll((l: C[Double]) => check(l)) + property(s"read/write $name of Booleans") = Prop.forAll((l: C[Boolean]) => check(l)) + } + + checkC[List]("List") + checkC[Vector]("Vector") + checkC[Set]("Set") + checkC[NonEmptyList]("NonEmptyList") + checkC[Option]("Option") + checkC[({ type l[v] = Map[String, v] })#l]("Map") + + property("read/write Unit") = Prop.forAll((u: Unit) => check(u)) + property("read/write Currency") = Prop.forAll((c: Currency) => check(c)) + property("read/write Money") = Prop.forAll((m: Money) => check(m)) + property("read/write Locale") = Prop.forAll((l: Locale) => check(l)) + property("read/write UUID") = Prop.forAll((u: UUID) => check(u)) + property("read/write DateTime") = Prop.forAll((u: DateTime) => check(u)) + property("read/write LocalDate") = Prop.forAll((u: LocalDate) => check(u)) + property("read/write LocalTime") = Prop.forAll((u: LocalTime) => check(u)) + property("read/write YearMonth") = Prop.forAll((u: YearMonth) => check(u)) + property("read/write java.time.Instant") = Prop.forAll((i: time.Instant) => check(i)) + property("read/write java.time.LocalDate") = Prop.forAll((d: time.LocalDate) => check(d)) + property("read/write java.time.LocalTime") = Prop.forAll((t: time.LocalTime) => check(t)) + property("read/write java.time.YearMonth") = Prop.forAll((ym: time.YearMonth) => check(ym)) +} diff --git a/json/json-3/src/test/scala/io/sphere/json/JSONSpec.scala b/json/json-3/src/test/scala/io/sphere/json/JSONSpec.scala new file mode 100644 index 00000000..d3c86330 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/JSONSpec.scala @@ -0,0 +1,410 @@ +package io.sphere.json + +import cats.data.Validated.{Invalid, Valid} +import cats.data.ValidatedNel +import cats.syntax.apply.* +import org.json4s.JsonAST.* +import io.sphere.json.field +import io.sphere.json.generic.* +import io.sphere.util.Money +import org.joda.time.* +import org.scalatest.matchers.must.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.json4s.DefaultJsonFormats.given + +object JSONSpec { + case class Test(a: String) + + case class Project(nr: Int, name: String, version: Int = 1, milestones: List[Milestone] = Nil) + case class Milestone(name: String, date: Option[DateTime] = None) + + sealed abstract class Animal + case class Dog(name: String) extends Animal + case class Cat(name: String) extends Animal + case class Bird(name: String) extends Animal + + sealed trait GenericBase[A] + case class GenericA[A](a: A) extends GenericBase[A] + case class GenericB[A](a: A) extends GenericBase[A] + + object Singleton + + sealed abstract class SingletonEnum + case object SingletonA extends SingletonEnum + case object SingletonB extends SingletonEnum + case object SingletonC extends SingletonEnum + + sealed trait Mixed + case object SingletonMixed extends Mixed + case class RecordMixed(i: Int) extends Mixed + + object ScalaEnum extends Enumeration { + val One, Two, Three = Value + } + + // JSON instances for recursive data types cannot be derived + case class Node(value: Option[List[Node]]) +} + +class JSONSpec extends AnyFunSpec with Matchers { + import JSONSpec._ + + describe("JSON.apply") { + it("must find possible JSON instance") { + implicit val testJson: JSON[Test] = new JSON[Test] { + override def read(jval: JValue): JValidation[Test] = ??? + override def write(value: Test): JValue = ??? + } + + JSON[Test] must be(testJson) + } + + it("must create instance from FromJSON and ToJSON") { + JSON[Int] + JSON[List[Double]] + JSON[Map[String, Int]] + } + } + + describe("JSON") { + it("must read/write a custom class using custom typeclass instances") { + import JSONSpec.{Milestone, Project} + + implicit object MilestoneJSON extends JSON[Milestone] { + def write(m: Milestone): JValue = JObject( + JField("name", JString(m.name)) :: + JField("date", toJValue(m.date)) :: Nil + ) + def read(j: JValue): ValidatedNel[JSONError, Milestone] = j match { + case o: JObject => + (field[String]("name")(o), field[Option[DateTime]]("date")(o)).mapN(Milestone.apply) + case _ => fail("JSON object expected.") + } + } + implicit object ProjectJSON extends JSON[Project] { + def write(p: Project): JValue = JObject( + JField("nr", JInt(p.nr)) :: + JField("name", JString(p.name)) :: + JField("version", JInt(p.version)) :: + JField("milestones", toJValue(p.milestones)) :: + Nil + ) + def read(jval: JValue): ValidatedNel[JSONError, Project] = jval match { + case o: JObject => + ( + field[Int]("nr")(o), + field[String]("name")(o), + field[Int]("version", Some(1))(o), + field[List[Milestone]]("milestones", Some(Nil))(o)).mapN(Project.apply) + case _ => fail("JSON object expected.") + } + } + + val proj = Project(42, "Linux") + fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) + + // Now some invalid JSON to test the error accumulation + val wrongTypeJSON = """ + { + "nr":"1", + "name":23, + "version":1, + "milestones":[{"name":"Bravo", "date": "xxx"}] + } + """ + + val Invalid(errs) = fromJSON[Project](wrongTypeJSON): @unchecked + errs.toList must equal( + List( + JSONFieldError(List("nr"), "JSON Number in the range of an Int expected."), + JSONFieldError(List("name"), "JSON String expected."), + JSONFieldError(List("milestones", "date"), "Failed to parse date/time: xxx") + )) + + // Now without a version value and without a milestones list. Defaults should apply. + val noVersionJSON = """{"nr":1,"name":"Linux"}""" + fromJSON[Project](noVersionJSON) must equal(Valid(Project(1, "Linux"))) + } + + it("must fail reading wrong currency code.") { + val wrongMoney = """{"currencyCode":"WRONG","centAmount":1000}""" + fromJSON[Money](wrongMoney).isInvalid must be(true) + } + + it("must provide derived JSON instances for product types (case classes)") { + import JSONSpec.{Milestone, Project} + given JSON[Milestone] = deriveJSON[Milestone] + given JSON[Project] = deriveJSON[Project] + val proj = + Project(42, "Linux", 7, Milestone("1.0") :: Milestone("2.0") :: Milestone("3.0") :: Nil) + fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) + } + + it("must handle empty String") { + val Invalid(err) = fromJSON[Int](""): @unchecked + err.toList.head mustBe a[JSONParseError] + } + + it("must provide user-friendly error by empty String") { + val Invalid(err) = fromJSON[Int](""): @unchecked + err.toList mustEqual List(JSONParseError("No content to map due to end-of-input")) + } + + it("must handle incorrect json") { + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked + err.toList.head mustBe a[JSONParseError] + } + + it("must provide user-friendly error by incorrect json") { + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked + err.toList mustEqual List(JSONParseError( + "Unexpected character ('v' (code 118)): was expecting a colon to separate field name and value")) + } + + it("must provide derived JSON instances for sum types") { + import io.sphere.json.generic.JSON.derived + given JSON[Animal] = deriveJSON + List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { animal => + fromJSON[Animal](toJSON(animal)) must equal(Valid(animal)) + } + } + + it("must provide derived instances for product types with concrete type parameters") { + given JSON[GenericA[String]] = deriveJSON[GenericA[String]] + val a = GenericA("hello") + fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) + } + + it("must provide derived instances for product types with generic type parameters") { + implicit def aJSON[A: JSON]: JSON[GenericA[A]] = deriveJSON[GenericA[A]] + val a = GenericA("hello") + fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) + } + +// it("must provide derived instances for singleton objects") { +// import io.sphere.json.generic.JSON.derived +// implicit val singletonJSON: JSON[JSONSpec.Singleton.type] = deriveJSON[JSONSpec.Singleton.type] +// +// val json = s"""[${toJSON(Singleton)}]""" +// withClue(json) { +// fromJSON[Seq[Singleton.type]](json) must equal(Valid(Seq(Singleton))) +// } +// +// implicit val singleEnumJSON: JSON[SingletonEnum] = deriveJSON[SingletonEnum] +// List(SingletonA, SingletonB, SingletonC).foreach { s => +// fromJSON[SingletonEnum](toJSON(s)) must equal(Valid(s)) +// } +// } + + it("must provide derived instances for sum types with a mix of case class / object") { + import io.sphere.json.generic.JSON.derived + given JSON[Mixed] = deriveJSON + List(SingletonMixed, RecordMixed(1)).foreach { m => + fromJSON[Mixed](toJSON(m)) must equal(Valid(m)) + } + } +// +// it("must provide derived instances for scala.Enumeration") { +// import io.sphere.json.generic.JSON.derived +// implicit val scalaEnumJSON: JSON[JSONSpec.ScalaEnum.Value] = deriveJSON[ScalaEnum.Value] +// ScalaEnum.values.foreach { v => +// val json = s"""[${toJSON(v)}]""" +// withClue(json) { +// fromJSON[Seq[ScalaEnum.Value]](json) must equal(Valid(Seq(v))) +// } +// } +// } +// +// it("must handle subclasses correctly in `jsonTypeSwitch`") { +// implicit val jsonImpl = TestSubjectBase.json +// +// val testSubjects = List[TestSubjectBase]( +// TestSubjectConcrete1("testSubject1"), +// TestSubjectConcrete2("testSubject2"), +// TestSubjectConcrete3("testSubject3"), +// TestSubjectConcrete4("testSubject4") +// ) +// +// testSubjects.foreach { testSubject => +// val json = toJSON(testSubject) +// withClue(json) { +// fromJSON[TestSubjectBase](json) must equal(Valid(testSubject)) +// } +// } +// +// } +// +// } +// +// describe("ToJSON and FromJSON") { +// it("must provide derived JSON instances for sum types") { +// // ToJSON +// implicit val birdToJSON = deriveJSON[Bird].write(Bird.apply _) +// implicit val dogToJSON = deriveJSON[Dog].write(Dog.apply _) +// implicit val catToJSON = toJsonProduct(Cat.apply _) +// implicit val animalToJSON = toJsonTypeSwitch[Animal, Bird, Dog, Cat](Nil) +// // FromJSON +// implicit val birdFromJSON = fromJsonProduct(Bird.apply _) +// implicit val dogFromJSON = fromJsonProduct(Dog.apply _) +// implicit val catFromJSON = fromJsonProduct(Cat.apply _) +// implicit val animalFromJSON = fromJsonTypeSwitch[Animal, Bird, Dog, Cat](Nil) +// +// List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { +// a: Animal => +// fromJSON[Animal](toJSON(a)) must equal(Valid(a)) +// } +// } +// +// it("must provide derived instances for product types with concrete type parameters") { +// implicit val aToJSON = toJsonProduct(GenericA.apply[String] _) +// implicit val aFromJSON = fromJsonProduct(GenericA.apply[String] _) +// val a = GenericA("hello") +// fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) +// } +// +// it("must provide derived instances for singleton objects") { +// implicit val toSingletonJSON = toJsonSingleton(Singleton) +// implicit val fromSingletonJSON = fromJsonSingleton(Singleton) +// val json = s"""[${toJSON(Singleton)}]""" +// withClue(json) { +// fromJSON[Seq[Singleton.type]](json) must equal(Valid(Seq(Singleton))) +// } +// +// // ToJSON +// implicit val toSingleAJSON = toJsonSingleton(SingletonA) +// implicit val toSingleBJSON = toJsonSingleton(SingletonB) +// implicit val toSingleCJSON = toJsonSingleton(SingletonC) +// implicit val toSingleEnumJSON = +// toJsonSingletonEnumSwitch[SingletonEnum, SingletonA.type, SingletonB.type, SingletonC.type]( +// Nil) +// // FromJSON +// implicit val fromSingleAJSON = fromJsonSingleton(SingletonA) +// implicit val fromSingleBJSON = fromJsonSingleton(SingletonB) +// implicit val fromSingleCJSON = fromJsonSingleton(SingletonC) +// implicit val fromSingleEnumJSON = fromJsonSingletonEnumSwitch[ +// SingletonEnum, +// SingletonA.type, +// SingletonB.type, +// SingletonC.type](Nil) +// +// List(SingletonA, SingletonB, SingletonC).foreach { +// s: SingletonEnum => +// fromJSON[SingletonEnum](toJSON(s)) must equal(Valid(s)) +// } +// } +// +// it("must provide derived instances for sum types with a mix of case class / object") { +// // ToJSON +// implicit val toSingleJSON = toJsonProduct0(SingletonMixed) +// implicit val toRecordJSON = toJsonProduct(RecordMixed.apply _) +// implicit val toMixedJSON = toJsonTypeSwitch[Mixed, SingletonMixed.type, RecordMixed](Nil) +// // FromJSON +// implicit val fromSingleJSON = fromJsonProduct0(SingletonMixed) +// implicit val fromRecordJSON = fromJsonProduct(RecordMixed.apply _) +// implicit val fromMixedJSON = fromJsonTypeSwitch[Mixed, SingletonMixed.type, RecordMixed](Nil) +// List(SingletonMixed, RecordMixed(1)).foreach { +// m: Mixed => +// fromJSON[Mixed](toJSON(m)) must equal(Valid(m)) +// } +// } +// +// it("must provide derived instances for scala.Enumeration") { +// implicit val toScalaEnumJSON = toJsonEnum(ScalaEnum) +// implicit val fromScalaEnumJSON = fromJsonEnum(ScalaEnum) +// ScalaEnum.values.foreach { v => +// val json = s"""[${toJSON(v)}]""" +// withClue(json) { +// fromJSON[Seq[ScalaEnum.Value]](json) must equal(Valid(Seq(v))) +// } +// } +// } +// +// it("must handle subclasses correctly in `jsonTypeSwitch`") { +// // ToJSON +// implicit val to1 = toJsonProduct(TestSubjectConcrete1.apply _) +// implicit val to2 = toJsonProduct(TestSubjectConcrete2.apply _) +// implicit val to3 = toJsonProduct(TestSubjectConcrete3.apply _) +// implicit val to4 = toJsonProduct(TestSubjectConcrete4.apply _) +// implicit val toA = +// toJsonTypeSwitch[TestSubjectCategoryA, TestSubjectConcrete1, TestSubjectConcrete2](Nil) +// implicit val toB = +// toJsonTypeSwitch[TestSubjectCategoryB, TestSubjectConcrete3, TestSubjectConcrete4](Nil) +// implicit val toBase = +// toJsonTypeSwitch[TestSubjectBase, TestSubjectCategoryA, TestSubjectCategoryB](Nil) +// +// // FromJSON +// implicit val from1 = fromJsonProduct(TestSubjectConcrete1.apply _) +// implicit val from2 = fromJsonProduct(TestSubjectConcrete2.apply _) +// implicit val from3 = fromJsonProduct(TestSubjectConcrete3.apply _) +// implicit val from4 = fromJsonProduct(TestSubjectConcrete4.apply _) +// implicit val fromA = +// fromJsonTypeSwitch[TestSubjectCategoryA, TestSubjectConcrete1, TestSubjectConcrete2](Nil) +// implicit val fromB = +// fromJsonTypeSwitch[TestSubjectCategoryB, TestSubjectConcrete3, TestSubjectConcrete4](Nil) +// implicit val fromBase = +// fromJsonTypeSwitch[TestSubjectBase, TestSubjectCategoryA, TestSubjectCategoryB](Nil) +// +// val testSubjects = List[TestSubjectBase]( +// TestSubjectConcrete1("testSubject1"), +// TestSubjectConcrete2("testSubject2"), +// TestSubjectConcrete3("testSubject3"), +// TestSubjectConcrete4("testSubject4") +// ) +// +// testSubjects.foreach { testSubject => +// val json = toJSON(testSubject) +// withClue(json) { +// fromJSON[TestSubjectBase](json) must equal(Valid(testSubject)) +// } +// } +// +// } +// +// it("must provide derived JSON instances for product types (case classes)") { +// import JSONSpec.{Milestone, Project} +// // ToJSON +// implicit val milestoneToJSON = toJsonProduct(Milestone.apply _) +// implicit val projectToJSON = toJsonProduct(Project.apply _) +// // FromJSON +// implicit val milestoneFromJSON = fromJsonProduct(Milestone.apply _) +// implicit val projectFromJSON = fromJsonProduct(Project.apply _) +// +// val proj = +// Project(42, "Linux", 7, Milestone("1.0") :: Milestone("2.0") :: Milestone("3.0") :: Nil) +// fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) +// } + } +} + +abstract class TestSubjectBase + +sealed abstract class TestSubjectCategoryA extends TestSubjectBase +sealed abstract class TestSubjectCategoryB extends TestSubjectBase + +@JSONTypeHint("foo") +case class TestSubjectConcrete1(c1: String) extends TestSubjectCategoryA +case class TestSubjectConcrete2(c2: String) extends TestSubjectCategoryA + +case class TestSubjectConcrete3(c3: String) extends TestSubjectCategoryB +case class TestSubjectConcrete4(c4: String) extends TestSubjectCategoryB + +object TestSubjectCategoryA { + + import io.sphere.json.generic.JSON.derived + val json: JSON[TestSubjectCategoryA] = deriveJSON[TestSubjectCategoryA] +} + +object TestSubjectCategoryB { + + import io.sphere.json.generic.JSON.derived + val json: JSON[TestSubjectCategoryB] = deriveJSON[TestSubjectCategoryB] +} + +//object TestSubjectBase { +// val json: JSON[TestSubjectBase] = { +// implicit val jsonA = TestSubjectCategoryA.json +// implicit val jsonB = TestSubjectCategoryB.json +// +// jsonTypeSwitch[TestSubjectBase, TestSubjectCategoryA, TestSubjectCategoryB](Nil) +// } +//} diff --git a/json/json-3/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala b/json/json-3/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala new file mode 100644 index 00000000..21437cfb --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala @@ -0,0 +1,68 @@ +package io.sphere.json + +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.joda.time.YearMonth +import org.scalacheck.Arbitrary +import org.scalacheck.Gen +import org.scalacheck.Prop +import org.scalacheck.Properties +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.time.{Instant => JInstant} +import java.time.{LocalDate => JLocalDate} +import java.time.{LocalTime => JLocalTime} +import java.time.{YearMonth => JYearMonth} +import cats.data.Validated + +class JodaJavaTimeCompat extends Properties("Joda - java.time compat") { + val epochMillis = Gen.choose(-188395027761000L, 64092207599999L) + + implicit def arbitraryDateTime: Arbitrary[DateTime] = + Arbitrary(epochMillis.map(new DateTime(_, DateTimeZone.UTC))) + + // generate dates between years -4000 and +4000 + implicit val javaInstant: Arbitrary[JInstant] = + Arbitrary(epochMillis.map(JInstant.ofEpochMilli(_))) + + implicit val javaLocalTime: Arbitrary[JLocalTime] = Arbitrary( + Gen.choose(0, 3600 * 24).map(JLocalTime.ofSecondOfDay(_))) + + property("compatibility between serialized Instant and DateTime") = Prop.forAll { + (instant: JInstant) => + val dateTime = new DateTime(instant.toEpochMilli(), DateTimeZone.UTC) + val serializedInstant = ToJSON[JInstant].write(instant) + val serializedDateTime = ToJSON[DateTime].write(dateTime) + serializedInstant == serializedDateTime + } + + property("compatibility between serialized java.time.LocalTime and org.joda.time.LocalTime") = + Prop.forAll { (javaTime: JLocalTime) => + val jodaTime = LocalTime.fromMillisOfDay(javaTime.toNanoOfDay() / 1000000) + val serializedJavaTime = ToJSON[JLocalTime].write(javaTime) + val serializedJodaTime = ToJSON[LocalTime].write(jodaTime) + serializedJavaTime == serializedJodaTime + } + + property("roundtrip from java.time.Instant") = Prop.forAll { (instant: JInstant) => + FromJSON[DateTime] + .read(ToJSON[JInstant].write(instant)) + .andThen { dateTime => + FromJSON[JInstant].read(ToJSON[DateTime].write(dateTime)) + } + .fold(_ => false, _ == instant) + } + + property("roundtrip from org.joda.time.DateTime") = Prop.forAll { (dateTime: DateTime) => + FromJSON[JInstant] + .read(ToJSON[DateTime].write(dateTime)) + .andThen { instant => + FromJSON[DateTime].read(ToJSON[JInstant].write(instant)) + } + .fold(_ => false, _ == dateTime) + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala b/json/json-3/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala new file mode 100644 index 00000000..048971d5 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala @@ -0,0 +1,110 @@ +package io.sphere.json + +import java.util.Currency + +import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} +import cats.data.Validated.Valid +import org.json4s.jackson.compactJson +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class MoneyMarshallingSpec extends AnyWordSpec with Matchers { + "money encoding/decoding" should { + "be symmetric" in { + val money = Money.EUR(34.56) + val jsonAst = toJValue(money) + val jsonAsString = compactJson(jsonAst) + val Valid(readAst) = parseJSON(jsonAsString): @unchecked + + jsonAst should equal(readAst) + } + + "decode with type info" in { + val json = + """ + { + "type" : "centPrecision", + "currencyCode" : "USD", + "centAmount" : 3298 + } + """ + + fromJSON[BaseMoney](json) should be(Valid(Money.USD(BigDecimal("32.98")))) + } + + "decode without type info" in { + val json = + """ + { + "currencyCode" : "USD", + "centAmount" : 3298 + } + """ + + fromJSON[BaseMoney](json) should be(Valid(Money.USD(BigDecimal("32.98")))) + } + } + + "High precision money encoding/decoding" should { + "be symmetric" in { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + + val money = HighPrecisionMoney.fromDecimalAmount(34.123456, 6, Currency.getInstance("EUR")) + val jsonAst = toJValue(money) + val jsonAsString = compactJson(jsonAst) + val Valid(readAst) = parseJSON(jsonAsString): @unchecked + val Valid(decodedMoney) = fromJSON[HighPrecisionMoney](jsonAsString): @unchecked + val Valid(decodedBaseMoney) = fromJSON[BaseMoney](jsonAsString): @unchecked + + jsonAst should equal(readAst) + decodedMoney should equal(money) + decodedBaseMoney should equal(money) + } + + "decode with type info" in { + val json = + """ + { + "type": "highPrecision", + "currencyCode": "USD", + "preciseAmount": 42, + "fractionDigits": 4 + } + """ + + fromJSON[BaseMoney](json) should be( + Valid(HighPrecisionMoney.USD(BigDecimal("0.0042"), Some(4)))) + } + + "decode with centAmount" in { + val Valid(json) = parseJSON(""" + { + "type": "highPrecision", + "currencyCode": "USD", + "preciseAmount": 42, + "centAmount": 1, + "fractionDigits": 4 + } + """): @unchecked + + val Valid(parsed) = fromJValue[BaseMoney](json): @unchecked + + toJValue(parsed) should be(json) + } + + "validate data when decoded from JSON" in { + val json = + """ + { + "type": "highPrecision", + "currencyCode": "USD", + "preciseAmount": 42, + "fractionDigits": 1 + } + """ + + fromJSON[BaseMoney](json).isValid should be(false) + } + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/NullHandlingSpec.scala b/json/json-3/src/test/scala/io/sphere/json/NullHandlingSpec.scala new file mode 100644 index 00000000..5450d9e2 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/NullHandlingSpec.scala @@ -0,0 +1,68 @@ +package io.sphere.json + +import io.sphere.json.generic._ +import org.json4s.JsonAST.{JNothing, JObject} +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class NullHandlingSpec extends AnyWordSpec with Matchers { + "JSON deserialization" must { + "accept undefined fields and use default values for them" in { + val jeans = getFromJSON[Jeans]("{}") + + jeans must be(Jeans(None, None, Set.empty, "secret")) + } + + "accept null values and use default values for them" in { + val jeans = getFromJSON[Jeans](""" + { + "leftPocket": null, + "rightPocket": null, + "backPocket": null, + "hiddenPocket": null + } + """) + + jeans must be(Jeans(None, None, Set.empty, "secret")) + } + + "accept JNothing values and use default values for them" in { + val jeans = getFromJValue[Jeans]( + JObject( + "leftPocket" -> JNothing, + "rightPocket" -> JNothing, + "backPocket" -> JNothing, + "hiddenPocket" -> JNothing)) + + jeans must be(Jeans(None, None, Set.empty, "secret")) + } + + "accept not-null values and use them" in { + val jeans = getFromJSON[Jeans](""" + { + "leftPocket": "Axe", + "rightPocket": "Magic powder", + "backPocket": ["Magic wand", "Rusty sword"], + "hiddenPocket": "The potion of healing" + } + """) + + jeans must be( + Jeans( + Some("Axe"), + Some("Magic powder"), + Set("Magic wand", "Rusty sword"), + "The potion of healing")) + } + } +} + +case class Jeans( + leftPocket: Option[String] = None, + rightPocket: Option[String], + backPocket: Set[String] = Set.empty, + hiddenPocket: String = "secret") + +object Jeans { + given JSON[Jeans] = deriveJSON[Jeans] +} diff --git a/json/json-3/src/test/scala/io/sphere/json/OptionReaderSpec.scala b/json/json-3/src/test/scala/io/sphere/json/OptionReaderSpec.scala new file mode 100644 index 00000000..8461d962 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/OptionReaderSpec.scala @@ -0,0 +1,150 @@ +package io.sphere.json + +import io.sphere.json.generic._ +import org.json4s.{JArray, JLong, JNothing, JObject, JString} +import org.scalatest.OptionValues +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.json4s.DefaultJsonFormats.given + +object OptionReaderSpec { + + case class SimpleClass(value1: String, value2: Int) + + object SimpleClass { + given JSON[SimpleClass] = deriveJSON[SimpleClass] + } + + case class ComplexClass(name: String, simpleClass: Option[SimpleClass]) + + object ComplexClass { + given JSON[ComplexClass] = deriveJSON[ComplexClass] + } + + case class MapClass(id: Long, map: Option[Map[String, String]]) + object MapClass { + given JSON[MapClass] = deriveJSON[MapClass] + } + + case class ListClass(id: Long, list: Option[List[String]]) + object ListClass { + given JSON[ListClass] = deriveJSON[ListClass] + } +} + +class OptionReaderSpec extends AnyWordSpec with Matchers with OptionValues { + import OptionReaderSpec._ + + "OptionReader" should { + "handle presence of all fields" in { + val json = + """{ + | "value1": "a", + | "value2": 45 + |} + """.stripMargin + val result = getFromJSON[Option[SimpleClass]](json) + result.value.value1 mustEqual "a" + result.value.value2 mustEqual 45 + } + + "handle presence of all fields mixed with ignored fields" in { + val json = + """{ + | "value1": "a", + | "value2": 45, + | "value3": "b" + |} + """.stripMargin + val result = getFromJSON[Option[SimpleClass]](json) + result.value.value1 mustEqual "a" + result.value.value2 mustEqual 45 + } + + "handle presence of not all the fields" in { + val json = """{ "value1": "a" }""" + fromJSON[Option[SimpleClass]](json).isInvalid must be(true) + } + + "handle absence of all fields" in { + val json = "{}" + val result = getFromJSON[Option[SimpleClass]](json) + result must be(None) + } + + "handle optional map" in { + getFromJValue[MapClass](JObject("id" -> JLong(1L))) mustEqual MapClass(1L, None) + + getFromJValue[MapClass](JObject("id" -> JLong(1L), "map" -> JObject())) mustEqual + MapClass(1L, Some(Map.empty)) + + getFromJValue[MapClass]( + JObject("id" -> JLong(1L), "map" -> JObject("a" -> JString("b")))) mustEqual + MapClass(1L, Some(Map("a" -> "b"))) + + toJValue[MapClass](MapClass(1L, None)) mustEqual + JObject("id" -> JLong(1L), "map" -> JNothing) + toJValue[MapClass](MapClass(1L, Some(Map()))) mustEqual + JObject("id" -> JLong(1L), "map" -> JObject()) + toJValue[MapClass](MapClass(1L, Some(Map("a" -> "b")))) mustEqual + JObject("id" -> JLong(1L), "map" -> JObject("a" -> JString("b"))) + } + + "handle optional list" in { + getFromJValue[ListClass]( + JObject("id" -> JLong(1L), "list" -> JArray(List(JString("hi"))))) mustEqual + ListClass(1L, Some(List("hi"))) + getFromJValue[ListClass](JObject("id" -> JLong(1L), "list" -> JArray(List.empty))) mustEqual + ListClass(1L, Some(List())) + getFromJValue[ListClass](JObject("id" -> JLong(1L))) mustEqual + ListClass(1L, None) + + toJValue(ListClass(1L, Some(List("hi")))) mustEqual JObject( + "id" -> JLong(1L), + "list" -> JArray(List(JString("hi")))) + toJValue(ListClass(1L, Some(List.empty))) mustEqual JObject( + "id" -> JLong(1L), + "list" -> JArray(List.empty)) + toJValue(ListClass(1L, None)) mustEqual JObject("id" -> JLong(1L), "list" -> JNothing) + } + + "handle absence of all fields mixed with ignored fields" in { + val json = """{ "value3": "a" }""" + val result = getFromJSON[Option[SimpleClass]](json) + result must be(None) + } + + "consider all fields if the data type does not impose any restriction" in { + val json = + """{ + | "key1": "value1", + | "key2": "value2" + |} + """.stripMargin + val expected = Map("key1" -> "value1", "key2" -> "value2") + val result = getFromJSON[Map[String, String]](json) + result mustEqual expected + + val maybeResult = getFromJSON[Option[Map[String, String]]](json) + maybeResult.value mustEqual expected + } + + "parse optional element" in { + val json = + """{ + | "name": "ze name", + | "simpleClass": { + | "value1": "value1", + | "value2": 42 + | } + |} + """.stripMargin + val result = getFromJSON[ComplexClass](json) + result.simpleClass.value.value1 mustEqual "value1" + result.simpleClass.value.value2 mustEqual 42 + + parseJSON(toJSON(result)) mustEqual parseJSON(json) + } + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/SetHandlingSpec.scala b/json/json-3/src/test/scala/io/sphere/json/SetHandlingSpec.scala new file mode 100644 index 00000000..74a2d069 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/SetHandlingSpec.scala @@ -0,0 +1,17 @@ +package io.sphere.json + +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class SetHandlingSpec extends AnyWordSpec with Matchers { + "JSON deserialization" must { + + "should accept same elements in array to create a set" in { + val jeans = getFromJSON[Set[String]](""" + ["mobile", "mobile"] + """) + + jeans must be(Set("mobile")) + } + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/SphereJsonExample.scala b/json/json-3/src/test/scala/io/sphere/json/SphereJsonExample.scala new file mode 100644 index 00000000..69cd62ac --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/SphereJsonExample.scala @@ -0,0 +1,46 @@ +package io.sphere.json + +import io.sphere.json._ +import org.json4s.{JObject, JValue} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class SphereJsonExample extends AnyWordSpec with Matchers { + + case class User(name: String, age: Int, location: String) + + object User { + + // update https://github.com/commercetools/sphere-scala-libs/blob/master/json/README.md in case of changed + implicit val json: JSON[User] = new JSON[User] { + import cats.data.ValidatedNel + import cats.syntax.apply._ + + def read(jval: JValue): ValidatedNel[JSONError, User] = jval match { + case o: JObject => + (field[String]("name")(o), field[Int]("age")(o), field[String]("location")(o)) + .mapN(User.apply) + case _ => fail("JSON object expected.") + } + + def write(u: User): JValue = JObject( + List( + "name" -> toJValue(u.name), + "age" -> toJValue(u.age), + "location" -> toJValue(u.location) + )) + } + } + + "JSON[User]" should { + "serialize and deserialize an user" in { + val user = User("name", 23, "earth") + val json = toJSON(user) + parseJSON(json).isValid should be(true) + + val newUser = getFromJSON[User](json) + newUser should be(user) + } + } + +} diff --git a/json/json-3/src/test/scala/io/sphere/json/SphereJsonParserSpec.scala b/json/json-3/src/test/scala/io/sphere/json/SphereJsonParserSpec.scala new file mode 100644 index 00000000..024348fd --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/SphereJsonParserSpec.scala @@ -0,0 +1,14 @@ +package io.sphere.json + +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class SphereJsonParserSpec extends AnyWordSpec with Matchers { + "Object mapper" must { + + "accept strings with 20_000_000 bytes" in { + SphereJsonParser.mapper.getFactory.streamReadConstraints().getMaxStringLength must be( + 20000000) + } + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/ToJSONSpec.scala b/json/json-3/src/test/scala/io/sphere/json/ToJSONSpec.scala new file mode 100644 index 00000000..acaeea18 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/ToJSONSpec.scala @@ -0,0 +1,34 @@ +package io.sphere.json + +import java.util.UUID + +import org.json4s._ +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class ToJSONSpec extends AnyWordSpec with Matchers { + + case class User(id: UUID, firstName: String, age: Int) + + "ToJSON.apply" must { + "create a ToJSON" in { + implicit val encodeUser: ToJSON[User] = ToJSON.instance[User](u => + JObject( + List( + "id" -> toJValue(u.id), + "first_name" -> toJValue(u.firstName), + "age" -> toJValue(u.age) + ))) + + val id = UUID.randomUUID() + val json = toJValue(User(id, "bidule", 109)) + json must be( + JObject( + List( + "id" -> JString(id.toString), + "first_name" -> JString("bidule"), + "age" -> JLong(109) + ))) + } + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/TypesSwitchSpec.scala b/json/json-3/src/test/scala/io/sphere/json/TypesSwitchSpec.scala new file mode 100644 index 00000000..88f49334 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/TypesSwitchSpec.scala @@ -0,0 +1,87 @@ +//package io.sphere.json +// +//import io.sphere.json.generic.{TypeSelectorContainer, deriveJSON, jsonTypeSwitch} +//import org.json4s._ +//import org.scalatest.matchers.must.Matchers +//import org.scalatest.wordspec.AnyWordSpec +// +//class TypesSwitchSpec extends AnyWordSpec with Matchers { +// import TypesSwitchSpec._ +// +// "jsonTypeSwitch" must { +// "combine different sum types tree" in { +// val m: Seq[Message] = List( +// TypeA.ClassA1(23), +// TypeA.ClassA2("world"), +// TypeB.ClassB1(valid = false), +// TypeB.ClassB2(Seq("a23", "c62"))) +// +// val jsons = m.map(Message.json.write) +// jsons must be( +// List( +// JObject("number" -> JLong(23), "type" -> JString("ClassA1")), +// JObject("name" -> JString("world"), "type" -> JString("ClassA2")), +// JObject("valid" -> JBool(false), "type" -> JString("ClassB1")), +// JObject( +// "references" -> JArray(List(JString("a23"), JString("c62"))), +// "type" -> JString("ClassB2")) +// )) +// +// val messages = jsons.map(Message.json.read).map(_.toOption.get) +// messages must be(m) +// } +// } +// +// "TypeSelectorContainer" must { +// "have information about type value discriminators" in { +// val selectors = Message.json.typeSelectors +// selectors.map(_.typeValue) must contain.allOf( +// "ClassA1", +// "ClassA2", +// "TypeA", +// "ClassB1", +// "ClassB2", +// "TypeB") +// +// // I don't think it's useful to allow different type fields. How is it possible to deserialize one json +// // if different type fields are used? +// selectors.map(_.typeField) must be(List("type", "type", "type", "type", "type", "type")) +// +// selectors.map(_.clazz.getName) must contain.allOf( +// "io.sphere.json.TypesSwitchSpec$TypeA$ClassA1", +// "io.sphere.json.TypesSwitchSpec$TypeA$ClassA2", +// "io.sphere.json.TypesSwitchSpec$TypeA", +// "io.sphere.json.TypesSwitchSpec$TypeB$ClassB1", +// "io.sphere.json.TypesSwitchSpec$TypeB$ClassB2", +// "io.sphere.json.TypesSwitchSpec$TypeB" +// ) +// } +// } +// +//} +// +//object TypesSwitchSpec { +// +// trait Message +// object Message { +// // this can be dangerous is the same class name is used in both sum types +// // ex if we define TypeA.Class1 && TypeB.Class1 +// // as both will use the same type value discriminator +// implicit val json: JSON[Message] with TypeSelectorContainer = +// jsonTypeSwitch[Message, TypeA, TypeB](Nil) +// } +// +// sealed trait TypeA extends Message +// object TypeA { +// case class ClassA1(number: Int) extends TypeA +// case class ClassA2(name: String) extends TypeA +// implicit val json: JSON[TypeA] = deriveJSON[TypeA] +// } +// +// sealed trait TypeB extends Message +// object TypeB { +// case class ClassB1(valid: Boolean) extends TypeB +// case class ClassB2(references: Seq[String]) extends TypeB +// implicit val json: JSON[TypeB] = deriveJSON[TypeB] +// } +//} diff --git a/json/json-3/src/test/scala/io/sphere/json/catsinstances/JSONCatsInstancesTest.scala b/json/json-3/src/test/scala/io/sphere/json/catsinstances/JSONCatsInstancesTest.scala new file mode 100644 index 00000000..01c483fb --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/catsinstances/JSONCatsInstancesTest.scala @@ -0,0 +1,53 @@ +package io.sphere.json.catsinstances + +import cats.syntax.invariant._ +import cats.syntax.functor._ +import cats.syntax.contravariant._ +import io.sphere.json.JSON +import io.sphere.json._ +import org.json4s.JsonAST +import org.json4s.JsonAST.JString +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class JSONCatsInstancesTest extends AnyWordSpec with Matchers { + import JSONCatsInstancesTest._ + + "Invariant[JSON]" must { + "allow imaping a default format" in { + val myId = MyId("test") + val json = toJValue(myId) + json must be(JString("test")) + val myNewId = getFromJValue[MyId](json) + myNewId must be(myId) + } + } + + "Functor[FromJson] and Contramap[ToJson]" must { + "allow mapping and contramapping a default format" in { + val myId = MyId2("test") + val json = toJValue(myId) + json must be(JString("test")) + val myNewId = getFromJValue[MyId2](json) + myNewId must be(myId) + } + } +} + +object JSONCatsInstancesTest { + private val stringJson: JSON[String] = new JSON[String] { + override def write(value: String): JsonAST.JValue = ToJSON[String].write(value) + override def read(jval: JsonAST.JValue): JValidation[String] = FromJSON[String].read(jval) + } + + case class MyId(id: String) extends AnyVal + object MyId { + implicit val json: JSON[MyId] = stringJson.imap(MyId.apply)(_.id) + } + + case class MyId2(id: String) extends AnyVal + object MyId2 { + implicit val fromJson: FromJSON[MyId2] = FromJSON[String].map(apply) + implicit val toJson: ToJSON[MyId2] = ToJSON[String].contramap(_.id) + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala b/json/json-3/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala new file mode 100644 index 00000000..7bca45fe --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala @@ -0,0 +1,44 @@ +package io.sphere.json.generic + +import io.sphere.json._ +import io.sphere.json.generic._ +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class DefaultValuesSpec extends AnyWordSpec with Matchers { + import DefaultValuesSpec._ + + "deriving JSON" must { + "handle default values" in { + val json = "{ }" + val test = getFromJSON[Test](json) + test.value1 must be("hello") + test.value2 must be(None) + test.value3 must be(Some("hi")) + } + "handle Option with no explicit default values" in { + val json = "{ }" + val test2 = getFromJSON[Test2](json) + test2.value1 must be("hello") + test2.value2 must be(None) + } + } +} + +object DefaultValuesSpec { + case class Test( + value1: String = "hello", + value2: Option[String] = None, + value3: Option[String] = Some("hi") + ) + object Test { + given JSON[Test] = deriveJSON[Test] + } + case class Test2( + value1: String = "hello", + value2: Option[String] + ) + object Test2 { + given JSON[Test2] = deriveJSON[Test2] + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala b/json/json-3/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala new file mode 100644 index 00000000..df2bb582 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala @@ -0,0 +1,47 @@ +package io.sphere.json.generic + +import org.json4s.MonadicJValue.jvalueToMonadic +import org.json4s.jvalue2readerSyntax +import io.sphere.json._ +import io.sphere.json.generic._ +import org.json4s.DefaultReaders._ +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class JSONKeySpec extends AnyWordSpec with Matchers { + import JSONKeySpec._ + + "deriving JSON" must { + "rename fields annotated with @JSONKey" in { + val test = + Test(value1 = "value1", value2 = "value2", subTest = SubTest(value2 = "other_value2")) + + val json = toJValue(test) + (json \ "value1").as[Option[String]] must be(Some("value1")) + (json \ "value2").as[Option[String]] must be(None) + (json \ "new_value_2").as[Option[String]] must be(Some("value2")) + (json \ "new_sub_value_2").as[Option[String]] must be(Some("other_value2")) + + val newTest = getFromJValue[Test](json) + newTest must be(test) + } + } +} + +object JSONKeySpec { + case class SubTest( + @JSONKey("new_sub_value_2") value2: String + ) + object SubTest { + given JSON[SubTest] = deriveJSON[SubTest] + } + + case class Test( + value1: String, + @JSONKey("new_value_2") value2: String, + @JSONEmbedded subTest: SubTest + ) + object Test { + given JSON[Test] = deriveJSON[Test] + } +} diff --git a/json/json-3/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala b/json/json-3/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala new file mode 100644 index 00000000..4658d1d0 --- /dev/null +++ b/json/json-3/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala @@ -0,0 +1,63 @@ +package io.sphere.json.generic + +import cats.data.Validated.Valid +import io.sphere.json.* +import org.json4s.* +import org.scalatest.Inside +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers with Inside { + import JsonTypeHintFieldSpec._ + + "JSONTypeHintField" must { + "allow to set another field to distinguish between types (toJValue)" in { + val user = UserWithPicture("foo-123", Medium, "http://example.com") + val expected = JObject( + List( + "userId" -> JString("foo-123"), + "pictureSize" -> JObject(List("pictureType" -> JString("Medium"))), + "pictureUrl" -> JString("http://example.com"))) + + val json = toJValue[UserWithPicture](user) + json must be(expected) + + inside(fromJValue[UserWithPicture](json)) { case Valid(parsedUser) => + parsedUser must be(user) + } + } + + "allow to set another field to distinguish between types (fromJSON)" in { + val json = + """ + { + "userId": "foo-123", + "pictureSize": { "pictureType": "Medium" }, + "pictureUrl": "http://example.com" + } + """ + + val Valid(user) = fromJSON[UserWithPicture](json): @unchecked + + user must be(UserWithPicture("foo-123", Medium, "http://example.com")) + } + } + +} + +object JsonTypeHintFieldSpec { + + @JSONTypeHintField(value = "pictureType") + sealed trait PictureSize + case object Small extends PictureSize + case object Medium extends PictureSize + case object Big extends PictureSize + + case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) + + object UserWithPicture { + import io.sphere.json.generic.JSON.given + import io.sphere.json.generic.deriveJSON + given JSON[UserWithPicture] = deriveJSON[UserWithPicture] + } +} diff --git a/util-3/dependencies.sbt b/util-3/dependencies.sbt new file mode 100644 index 00000000..cb4ce29b --- /dev/null +++ b/util-3/dependencies.sbt @@ -0,0 +1,7 @@ +libraryDependencies ++= Seq( + "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4", + "joda-time" % "joda-time" % "2.12.7", + "org.joda" % "joda-convert" % "2.2.3", + ("org.typelevel" % "cats-core" % "2.12.0").cross(CrossVersion.binary), + "org.json4s" %% "json4s-scalap" % "4.0.7" +) diff --git a/util-3/src/main/scala/Concurrent.scala b/util-3/src/main/scala/Concurrent.scala new file mode 100644 index 00000000..4dcbf7ca --- /dev/null +++ b/util-3/src/main/scala/Concurrent.scala @@ -0,0 +1,13 @@ +package io.sphere.util + +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicInteger + +object Concurrent { + def namedThreadFactory(poolName: String): ThreadFactory = + new ThreadFactory { + val count = new AtomicInteger(0) + override def newThread(r: Runnable) = + new Thread(r, poolName + "-" + count.incrementAndGet) + } +} diff --git a/util-3/src/main/scala/LangTag.scala b/util-3/src/main/scala/LangTag.scala new file mode 100644 index 00000000..0005c9f3 --- /dev/null +++ b/util-3/src/main/scala/LangTag.scala @@ -0,0 +1,19 @@ +package io.sphere.util + +import java.util.Locale + +/** Extractor for Locales, e.g. for use in pattern-matching request paths. */ +object LangTag { + + final val UNDEFINED: String = "und" + + class LocaleOpt(val locale: Locale) extends AnyVal { + // if toLanguageTag returns "und", it means the language tag is undefined + def isEmpty: Boolean = UNDEFINED == locale.toLanguageTag + def get: Locale = locale + } + + def unapply(s: String): LocaleOpt = new LocaleOpt(Locale.forLanguageTag(s)) + + def invalidLangTagMessage(invalidLangTag: String) = s"Invalid language tag: '$invalidLangTag'" +} diff --git a/util-3/src/main/scala/Logging.scala b/util-3/src/main/scala/Logging.scala new file mode 100644 index 00000000..e5035c4e --- /dev/null +++ b/util-3/src/main/scala/Logging.scala @@ -0,0 +1,5 @@ +package io.sphere.util + +trait Logging extends com.typesafe.scalalogging.StrictLogging { + protected val log = logger +} diff --git a/util-3/src/main/scala/Memoizer.scala b/util-3/src/main/scala/Memoizer.scala new file mode 100644 index 00000000..9c6ea674 --- /dev/null +++ b/util-3/src/main/scala/Memoizer.scala @@ -0,0 +1,28 @@ +package io.sphere.util + +import java.util.concurrent._ + +/** Straight port from the Java impl. of "Java Concurrency in Practice". */ +final class Memoizer[K, V](action: K => V) extends (K => V) { + private val cache = new ConcurrentHashMap[K, Future[V]] + def apply(k: K): V = { + while (true) { + var f = cache.get(k) + if (f == null) { + val eval = new Callable[V] { def call(): V = action(k) } + val ft = new FutureTask[V](eval) + f = cache.putIfAbsent(k, ft) + if (f == null) { + f = ft + ft.run() + } + } + try return f.get + catch { + case _: CancellationException => cache.remove(k, f) + case e: ExecutionException => throw e.getCause + } + } + sys.error("Failed to compute result.") + } +} diff --git a/util-3/src/main/scala/Money.scala b/util-3/src/main/scala/Money.scala new file mode 100644 index 00000000..b3ffdac7 --- /dev/null +++ b/util-3/src/main/scala/Money.scala @@ -0,0 +1,666 @@ +package io.sphere.util + +import language.implicitConversions +import java.math.MathContext +import java.text.NumberFormat +import java.util.{Currency, Locale} + +import cats.Monoid +import cats.data.ValidatedNel +import cats.syntax.validated._ + +import scala.math._ +import BigDecimal.RoundingMode._ +import scala.math.BigDecimal.RoundingMode +import ValidatedFlatMapFeature._ +import io.sphere.util.BaseMoney.bigDecimalToMoneyLong +import io.sphere.util.Money.ImplicitsDecimal.MoneyNotation + +class MoneyOverflowException extends RuntimeException("A Money operation resulted in an overflow.") + +sealed trait BaseMoney { + def `type`: String + + def currency: Currency + + // Use with CAUTION! will loose precision in case of a high precision money value + def centAmount: Long + + /** Normalized representation. + * + * for centPrecision: + * - centAmount: 1234 EUR + * - amount: 12.34 + * + * for highPrecision: preciseAmount: + * - 123456 EUR (with fractionDigits = 4) + * - amount: 12.3456 + */ + def amount: BigDecimal + + def fractionDigits: Int + + def toMoneyWithPrecisionLoss: Money + + def +(m: Money)(implicit mode: RoundingMode): BaseMoney + def +(m: HighPrecisionMoney)(implicit mode: RoundingMode): BaseMoney + def +(m: BaseMoney)(implicit mode: RoundingMode): BaseMoney + def +(m: BigDecimal)(implicit mode: RoundingMode): BaseMoney + + def -(m: Money)(implicit mode: RoundingMode): BaseMoney + def -(m: HighPrecisionMoney)(implicit mode: RoundingMode): BaseMoney + def -(m: BaseMoney)(implicit mode: RoundingMode): BaseMoney + def -(m: BigDecimal)(implicit mode: RoundingMode): BaseMoney + + def *(m: Money)(implicit mode: RoundingMode): BaseMoney + def *(m: HighPrecisionMoney)(implicit mode: RoundingMode): BaseMoney + def *(m: BaseMoney)(implicit mode: RoundingMode): BaseMoney + def *(m: BigDecimal)(implicit mode: RoundingMode): BaseMoney +} + +object BaseMoney { + val TypeField: String = "type" + + def requireSameCurrency(m1: BaseMoney, m2: BaseMoney): Unit = + require(m1.currency eq m2.currency, s"${m1.currency} != ${m2.currency}") + + def toScalaRoundingMode(mode: java.math.RoundingMode): RoundingMode.Value = + BigDecimal.RoundingMode(mode.ordinal) + + implicit def baseMoneyMonoid(implicit c: Currency, mode: RoundingMode): Monoid[BaseMoney] = + new Monoid[BaseMoney] { + def combine(x: BaseMoney, y: BaseMoney): BaseMoney = x + y + val empty: BaseMoney = Money.zero(c) + } + + private[util] def bigDecimalToMoneyLong(amount: BigDecimal): Long = + try amount.toLongExact + catch { case _: ArithmeticException => throw new MoneyOverflowException } +} + +/** Represents an amount of money in a certain currency. + * + * This implementation does not support fractional money units (eg a tenth cent). Amounts are + * always rounded to the nearest, smallest unit of the respective currency. The rounding mode can + * be specified using an implicit `BigDecimal.RoundingMode`. + * + * @param centAmount + * The amount in the smallest indivisible unit of the respective currency represented as a single + * Long value. + * @param currency + * The currency of the amount. + */ +case class Money private[util] (centAmount: Long, currency: Currency) + extends BaseMoney + with Ordered[Money] { + import Money._ + + private val centFactor: Double = 1 / pow(10, currency.getDefaultFractionDigits) + private val backwardsCompatibleRoundingModeForOperations = BigDecimal.RoundingMode.HALF_EVEN + + val `type`: String = TypeName + + override def fractionDigits: Int = currency.getDefaultFractionDigits + override lazy val amount: BigDecimal = BigDecimal(centAmount) * cachedCentFactor(fractionDigits) + + def withCentAmount(centAmount: Long): Money = + copy(centAmount = centAmount) + + def toHighPrecisionMoney(fractionDigits: Int): HighPrecisionMoney = + HighPrecisionMoney.fromMoney(this, fractionDigits) + + /** Creates a new Money instance with the same currency and the amount conforming to the given + * MathContext (scale and rounding mode). + */ + def apply(mc: MathContext): Money = + fromDecimalAmount(this.amount(mc), this.currency)(RoundingMode.HALF_EVEN) + + def +(m: Money)(implicit mode: RoundingMode): Money = { + BaseMoney.requireSameCurrency(this, m) + + fromDecimalAmount(this.amount + m.amount, this.currency)( + backwardsCompatibleRoundingModeForOperations) + } + + def +(m: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + this.toHighPrecisionMoney(m.fractionDigits) + m + + def +(money: BaseMoney)(implicit mode: RoundingMode): BaseMoney = money match { + case m: Money => this + m + case m: HighPrecisionMoney => this + m + } + + def +(m: BigDecimal)(implicit mode: RoundingMode): Money = + this + fromDecimalAmount(m, this.currency) + + def -(m: Money)(implicit mode: RoundingMode): Money = { + BaseMoney.requireSameCurrency(this, m) + fromDecimalAmount(this.amount - m.amount, this.currency) + } + + def -(money: BaseMoney)(implicit mode: RoundingMode): BaseMoney = money match { + case m: Money => this - m + case m: HighPrecisionMoney => this - m + } + + def -(m: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + this.toHighPrecisionMoney(m.fractionDigits) - m + + def -(m: BigDecimal)(implicit mode: RoundingMode): Money = + this - fromDecimalAmount(m, this.currency) + + def *(m: Money)(implicit mode: RoundingMode): Money = { + BaseMoney.requireSameCurrency(this, m) + this * m.amount + } + + def *(m: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + this.toHighPrecisionMoney(m.fractionDigits) * m + + def *(money: BaseMoney)(implicit mode: RoundingMode): BaseMoney = money match { + case m: Money => this * m + case m: HighPrecisionMoney => this * m + } + + def *(m: BigDecimal)(implicit mode: RoundingMode): Money = + fromDecimalAmount((this.amount * m).setScale(this.amount.scale, mode), this.currency) + + /** Divide to integral value + remainder */ + def /%(m: BigDecimal)(implicit mode: RoundingMode): (Money, Money) = { + val (result, remainder) = this.amount /% m + + (fromDecimalAmount(result, this.currency), fromDecimalAmount(remainder, this.currency)) + } + + def %(m: Money)(implicit mode: RoundingMode): Money = this.remainder(m) + + def %(m: BigDecimal)(implicit mode: RoundingMode): Money = + this.remainder(fromDecimalAmount(m, this.currency)) + + def remainder(m: Money)(implicit mode: RoundingMode): Money = { + BaseMoney.requireSameCurrency(this, m) + + fromDecimalAmount(this.amount.remainder(m.amount), this.currency) + } + + def remainder(m: BigDecimal)(implicit mode: RoundingMode): Money = + this.remainder(fromDecimalAmount(m, this.currency)(RoundingMode.HALF_EVEN)) + + def unary_- : Money = + fromDecimalAmount(-this.amount, this.currency)(BigDecimal.RoundingMode.UNNECESSARY) + + /** Partitions this amount of money into several parts where the size of the individual parts are + * defined by the given ratios. The partitioning takes care of not losing or gaining any money by + * distributing any remaining "cents" evenly across the partitions. + * + *

Example: (0.05 EUR) partition (3,7) == Seq(0.02 EUR, 0.03 EUR)

+ */ + def partition(ratios: Int*): Seq[Money] = { + val total = ratios.sum + val amountInCents = BigInt(this.centAmount) + val amounts = ratios.map(amountInCents * _ / total) + var remainder = amounts.foldLeft(amountInCents)(_ - _) + amounts.map { amount => + remainder -= 1 + fromDecimalAmount( + BigDecimal(amount + (if (remainder >= 0) 1 else 0)) * centFactor, + this.currency)(backwardsCompatibleRoundingModeForOperations) + } + } + + def toMoneyWithPrecisionLoss: Money = this + + def compare(that: Money): Int = { + BaseMoney.requireSameCurrency(this, that) + this.centAmount.compare(that.centAmount) + } + + override def toString: String = Money.toString(centAmount, fractionDigits, currency) + + def toString(nf: NumberFormat, locale: Locale): String = { + require(nf.getCurrency eq this.currency) + nf.format(this.amount.doubleValue) + " " + this.currency.getSymbol(locale) + } +} + +object Money { + object ImplicitsDecimal { + final implicit class MoneyNotation(val amount: BigDecimal) extends AnyVal { + def EUR: Money = Money.EUR(amount) + def USD: Money = Money.USD(amount) + def GBP: Money = Money.GBP(amount) + def JPY: Money = Money.JPY(amount) + } + + implicit def doubleMoneyNotation(amount: Double): MoneyNotation = + new ImplicitsDecimal.MoneyNotation(BigDecimal(amount)) + } + + object ImplicitsString { + implicit def stringMoneyNotation(amount: String): MoneyNotation = + new ImplicitsDecimal.MoneyNotation(BigDecimal(amount)) + } + + private def decimalAmountWithCurrencyAndHalfEvenRounding(amount: BigDecimal, currency: String) = + fromDecimalAmount(amount, Currency.getInstance(currency))(BigDecimal.RoundingMode.HALF_EVEN) + + def EUR(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "EUR") + def USD(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "USD") + def GBP(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "GBP") + def JPY(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "JPY") + + final val CurrencyCodeField: String = "currencyCode" + final val CentAmountField: String = "centAmount" + final val FractionDigitsField: String = "fractionDigits" + final val TypeName: String = "centPrecision" + + def fromDecimalAmount(amount: BigDecimal, currency: Currency)(implicit + mode: RoundingMode): Money = { + val fractionDigits = currency.getDefaultFractionDigits + val centAmountBigDecimal = amount * cachedCentPower(fractionDigits) + val centAmountBigDecimalZeroScale = centAmountBigDecimal.setScale(0, mode) + Money(bigDecimalToMoneyLong(centAmountBigDecimalZeroScale), currency) + } + + def apply(amount: BigDecimal, currency: Currency): Money = { + println("this is called") + require( + amount.scale == currency.getDefaultFractionDigits, + "The scale of the given amount does not match the scale of the provided currency." + + " - " + amount.scale + " <-> " + currency.getDefaultFractionDigits + ) + fromDecimalAmount(amount, currency)(BigDecimal.RoundingMode.UNNECESSARY) + } + + private final val bdOne: BigDecimal = BigDecimal(1) + final val bdTen: BigDecimal = BigDecimal(10) + + private final val centPowerZeroFractionDigit = bdOne + private final val centPowerOneFractionDigit = bdTen + private final val centPowerTwoFractionDigit = bdTen.pow(2) + private final val centPowerThreeFractionDigit = bdTen.pow(3) + private final val centPowerFourFractionDigit = bdTen.pow(4) + + private[util] def cachedCentPower(currencyFractionDigits: Int): BigDecimal = + currencyFractionDigits match { + case 0 => centPowerZeroFractionDigit + case 1 => centPowerOneFractionDigit + case 2 => centPowerTwoFractionDigit + case 3 => centPowerThreeFractionDigit + case 4 => centPowerFourFractionDigit + case other => bdTen.pow(other) + } + + private val centFactorZeroFractionDigit = bdOne / bdTen.pow(0) + private val centFactorOneFractionDigit = bdOne / bdTen.pow(1) + private val centFactorTwoFractionDigit = bdOne / bdTen.pow(2) + private val centFactorThreeFractionDigit = bdOne / bdTen.pow(3) + private val centFactorFourFractionDigit = bdOne / bdTen.pow(4) + + private[util] def cachedCentFactor(currencyFractionDigits: Int): BigDecimal = + currencyFractionDigits match { + case 0 => centFactorZeroFractionDigit + case 1 => centFactorOneFractionDigit + case 2 => centFactorTwoFractionDigit + case 3 => centFactorThreeFractionDigit + case 4 => centFactorFourFractionDigit + case other => bdOne / bdTen.pow(other) + } + + def fromCentAmount(centAmount: Long, currency: Currency): Money = + new Money(centAmount, currency) + + private val cachedZeroEUR = fromCentAmount(0L, Currency.getInstance("EUR")) + private val cachedZeroUSD = fromCentAmount(0L, Currency.getInstance("USD")) + private val cachedZeroGBP = fromCentAmount(0L, Currency.getInstance("GBP")) + private val cachedZeroJPY = fromCentAmount(0L, Currency.getInstance("JPY")) + + def zero(currency: Currency): Money = + currency.getCurrencyCode match { + case "EUR" => cachedZeroEUR + case "USD" => cachedZeroUSD + case "GBP" => cachedZeroGBP + case "JPY" => cachedZeroJPY + case _ => fromCentAmount(0L, currency) + } + + implicit def moneyMonoid(implicit c: Currency, mode: RoundingMode): Monoid[Money] = + new Monoid[Money] { + def combine(x: Money, y: Money): Money = x + y + val empty: Money = Money.zero(c) + } + + def toString(amount: Long, fractionDigits: Int, currency: Currency): String = { + val amountDigits = amount.toString.toList + val leadingZerosLength = fractionDigits - amountDigits.length + 1 + val leadingZeros = List.fill(leadingZerosLength)('0') + val allDigits = leadingZeros ::: amountDigits + val radixPosition = allDigits.length - fractionDigits + val (integer, fractional) = allDigits.splitAt(radixPosition) + if (fractional.nonEmpty) + s"${integer.mkString}.${fractional.mkString} ${currency.getCurrencyCode}" + else + s"${integer.mkString} ${currency.getCurrencyCode}" + } +} + +case class HighPrecisionMoney private ( + preciseAmount: Long, + fractionDigits: Int, + centAmount: Long, + currency: Currency) + extends BaseMoney + with Ordered[Money] { + import HighPrecisionMoney._ + + require( + fractionDigits >= currency.getDefaultFractionDigits, + "`fractionDigits` should be >= than the default fraction digits of the currency.") + + val `type`: String = TypeName + + lazy val amount: BigDecimal = + (BigDecimal(preciseAmount) * factor(fractionDigits)).setScale(fractionDigits) + + def withFractionDigits(fd: Int)(implicit mode: RoundingMode): HighPrecisionMoney = { + val scaledAmount = amount.setScale(fd, mode) + val newCentAmount = roundToCents(scaledAmount, currency) + HighPrecisionMoney(amountToPreciseAmount(scaledAmount, fd), fd, newCentAmount, currency) + } + + def updateCentAmountWithRoundingMode(implicit mode: RoundingMode): HighPrecisionMoney = + copy(centAmount = roundToCents(amount, currency)) + + def +(other: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + calc(this, other, _ + _) + + def +(m: Money)(implicit mode: RoundingMode): HighPrecisionMoney = + this + m.toHighPrecisionMoney(fractionDigits) + + def +(money: BaseMoney)(implicit mode: RoundingMode): HighPrecisionMoney = money match { + case m: Money => this + m + case m: HighPrecisionMoney => this + m + } + + def +(other: BigDecimal)(implicit mode: RoundingMode): HighPrecisionMoney = + this + fromDecimalAmount(other, this.fractionDigits, this.currency) + + def -(other: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + calc(this, other, _ - _) + + def -(m: Money)(implicit mode: RoundingMode): HighPrecisionMoney = + this - m.toHighPrecisionMoney(fractionDigits) + + def -(money: BaseMoney)(implicit mode: RoundingMode): HighPrecisionMoney = money match { + case m: Money => this - m + case m: HighPrecisionMoney => this - m + } + + def -(other: BigDecimal)(implicit mode: RoundingMode): HighPrecisionMoney = + this - fromDecimalAmount(other, this.fractionDigits, this.currency) + + def *(other: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + calc(this, other, _ * _) + + def *(m: Money)(implicit mode: RoundingMode): HighPrecisionMoney = + this * m.toHighPrecisionMoney(fractionDigits) + + def *(money: BaseMoney)(implicit mode: RoundingMode): HighPrecisionMoney = money match { + case m: Money => this * m + case m: HighPrecisionMoney => this * m + } + + def *(other: BigDecimal)(implicit mode: RoundingMode): HighPrecisionMoney = + this * fromDecimalAmount(other, this.fractionDigits, this.currency) + + /** Divide to integral value + remainder */ + def /%(m: BigDecimal)(implicit mode: RoundingMode): (HighPrecisionMoney, HighPrecisionMoney) = { + val (result, remainder) = this.amount /% m + + fromDecimalAmount(result, fractionDigits, this.currency) -> + fromDecimalAmount(remainder, fractionDigits, this.currency) + } + + def %(other: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + this.remainder(other) + + def %(m: Money)(implicit mode: RoundingMode): HighPrecisionMoney = + this.remainder(m.toHighPrecisionMoney(fractionDigits)) + + def %(other: BigDecimal)(implicit mode: RoundingMode): HighPrecisionMoney = + this.remainder(fromDecimalAmount(other, this.fractionDigits, this.currency)) + + def remainder(other: HighPrecisionMoney)(implicit mode: RoundingMode): HighPrecisionMoney = + calc(this, other, _ remainder _) + + def remainder(other: BigDecimal)(implicit mode: RoundingMode): HighPrecisionMoney = + this.remainder(fromDecimalAmount(other, this.fractionDigits, this.currency)) + + def unary_- : HighPrecisionMoney = + fromDecimalAmount(-this.amount, this.fractionDigits, this.currency)( + BigDecimal.RoundingMode.UNNECESSARY) + + /** Partitions this amount of money into several parts where the size of the individual parts are + * defined by the given ratios. The partitioning takes care of not losing or gaining any money by + * distributing any remaining "cents" evenly across the partitions. + * + *

Example: (0.05 EUR) partition (3,7) == Seq(0.02 EUR, 0.03 EUR)

+ */ + def partition(ratios: Int*)(implicit mode: RoundingMode): Seq[HighPrecisionMoney] = { + val total = ratios.sum + val factor = Money.cachedCentFactor(fractionDigits) + val amountAsInt = BigInt(this.preciseAmount) + val portionAmounts = ratios.map(amountAsInt * _ / total) + var remainder = portionAmounts.foldLeft(amountAsInt)(_ - _) + + portionAmounts.map { portionAmount => + remainder -= 1 + + fromDecimalAmount( + BigDecimal(portionAmount + (if (remainder >= 0) 1 else 0)) * factor, + this.fractionDigits, + this.currency) + } + } + + def toMoneyWithPrecisionLoss: Money = + Money.fromCentAmount(this.centAmount, currency) + + def compare(other: Money): Int = { + BaseMoney.requireSameCurrency(this, other) + + this.amount.compare(other.amount) + } + + override def toString: String = Money.toString(preciseAmount, fractionDigits, currency) + + def toString(nf: NumberFormat, locale: Locale): String = { + require(nf.getCurrency eq this.currency) + + nf.format(this.amount.doubleValue) + " " + this.currency.getSymbol(locale) + } +} + +object HighPrecisionMoney { + object ImplicitsDecimal { + final implicit class HighPrecisionMoneyNotation(val amount: BigDecimal) extends AnyVal { + def EUR: HighPrecisionMoney = HighPrecisionMoney.EUR(amount) + def USD: HighPrecisionMoney = HighPrecisionMoney.USD(amount) + def GBP: HighPrecisionMoney = HighPrecisionMoney.GBP(amount) + def JPY: HighPrecisionMoney = HighPrecisionMoney.JPY(amount) + } + } + + object ImplicitsDecimalPrecise { + final implicit class HighPrecisionPreciseMoneyNotation(val amount: BigDecimal) extends AnyVal { + def EUR_PRECISE(precision: Int): HighPrecisionMoney = + HighPrecisionMoney.EUR(amount, Some(precision)) + def USD_PRECISE(precision: Int): HighPrecisionMoney = + HighPrecisionMoney.USD(amount, Some(precision)) + def GBP_PRECISE(precision: Int): HighPrecisionMoney = + HighPrecisionMoney.GBP(amount, Some(precision)) + def JPY_PRECISE(precision: Int): HighPrecisionMoney = + HighPrecisionMoney.JPY(amount, Some(precision)) + } + } + + object ImplicitsString { + implicit def stringMoneyNotation(amount: String): ImplicitsDecimal.HighPrecisionMoneyNotation = + new ImplicitsDecimal.HighPrecisionMoneyNotation(BigDecimal(amount)) + } + + object ImplicitsStringPrecise { + implicit def stringPreciseMoneyNotation( + amount: String): ImplicitsDecimalPrecise.HighPrecisionPreciseMoneyNotation = + new ImplicitsDecimalPrecise.HighPrecisionPreciseMoneyNotation(BigDecimal(amount)) + } + + def EUR(amount: BigDecimal, fractionDigits: Option[Int] = None): HighPrecisionMoney = + simpleValueMeantToBeUsedOnlyInTests(amount, "EUR", fractionDigits) + def USD(amount: BigDecimal, fractionDigits: Option[Int] = None): HighPrecisionMoney = + simpleValueMeantToBeUsedOnlyInTests(amount, "USD", fractionDigits) + def GBP(amount: BigDecimal, fractionDigits: Option[Int] = None): HighPrecisionMoney = + simpleValueMeantToBeUsedOnlyInTests(amount, "GBP", fractionDigits) + def JPY(amount: BigDecimal, fractionDigits: Option[Int] = None): HighPrecisionMoney = + simpleValueMeantToBeUsedOnlyInTests(amount, "JPY", fractionDigits) + + val CurrencyCodeField: String = "currencyCode" + val CentAmountField: String = "centAmount" + val PreciseAmountField: String = "preciseAmount" + val FractionDigitsField: String = "fractionDigits" + + val TypeName: String = "highPrecision" + val MaxFractionDigits = 20 + + private def simpleValueMeantToBeUsedOnlyInTests( + amount: BigDecimal, + currencyCode: String, + fractionDigits: Option[Int]): HighPrecisionMoney = { + val currency = Currency.getInstance(currencyCode) + val fd = fractionDigits.getOrElse(currency.getDefaultFractionDigits) + + fromDecimalAmount(amount, fd, currency)(BigDecimal.RoundingMode.HALF_EVEN) + } + + def roundToCents(amount: BigDecimal, currency: Currency)(implicit mode: RoundingMode): Long = + bigDecimalToMoneyLong( + amount.setScale(currency.getDefaultFractionDigits, mode) / centFactor(currency)) + + def sameScale(m1: HighPrecisionMoney, m2: HighPrecisionMoney): (BigDecimal, BigDecimal, Int) = { + val newFractionDigits = math.max(m1.fractionDigits, m2.fractionDigits) + + def scale(m: HighPrecisionMoney, s: Int) = + if (m.fractionDigits < s) m.amount.setScale(s) + else if (m.fractionDigits == s) m.amount + else throw new IllegalStateException("Downscale is not allowed/expected at this point!") + + (scale(m1, newFractionDigits), scale(m2, newFractionDigits), newFractionDigits) + } + + def calc( + m1: HighPrecisionMoney, + m2: HighPrecisionMoney, + fn: (BigDecimal, BigDecimal) => BigDecimal)(implicit + mode: RoundingMode): HighPrecisionMoney = { + BaseMoney.requireSameCurrency(m1, m2) + + val (a1, a2, fd) = sameScale(m1, m2) + + fromDecimalAmount(fn(a1, a2), fd, m1.currency) + } + + def factor(fractionDigits: Int): BigDecimal = Money.cachedCentFactor(fractionDigits) + def centFactor(currency: Currency): BigDecimal = factor(currency.getDefaultFractionDigits) + + private def amountToPreciseAmount(amount: BigDecimal, fractionDigits: Int): Long = + bigDecimalToMoneyLong(amount * Money.cachedCentPower(fractionDigits)) + + def fromDecimalAmount(amount: BigDecimal, fractionDigits: Int, currency: Currency)(implicit + mode: RoundingMode): HighPrecisionMoney = { + val scaledAmount = amount.setScale(fractionDigits, mode) + val preciseAmount = amountToPreciseAmount(scaledAmount, fractionDigits) + val newCentAmount = roundToCents(scaledAmount, currency) + HighPrecisionMoney(preciseAmount, fractionDigits, newCentAmount, currency) + } + + private def centToPreciseAmount( + centAmount: Long, + fractionDigits: Int, + currency: Currency): Long = { + val centDigits = fractionDigits - currency.getDefaultFractionDigits + if (centDigits >= 19) + throw new IllegalArgumentException("Cannot represent number bigger than 10^19 with a Long") + else + Math.pow(10, centDigits).toLong * centAmount + } + + def fromCentAmount( + centAmount: Long, + fractionDigits: Int, + currency: Currency): HighPrecisionMoney = + HighPrecisionMoney( + centToPreciseAmount(centAmount, fractionDigits, currency), + fractionDigits, + centAmount, + currency) + + def zero(fractionDigits: Int, currency: Currency): HighPrecisionMoney = + fromCentAmount(0L, fractionDigits, currency) + + /* centAmount provides an escape hatch in cases where the default rounding mode is not applicable */ + def fromPreciseAmount( + preciseAmount: Long, + fractionDigits: Int, + currency: Currency, + centAmount: Option[Long]): ValidatedNel[String, HighPrecisionMoney] = + for { + fd <- validateFractionDigits(fractionDigits, currency) + amount = BigDecimal(preciseAmount) * factor(fd) + scaledAmount = amount.setScale(fd, BigDecimal.RoundingMode.UNNECESSARY) + ca <- validateCentAmount(scaledAmount, centAmount, currency) + // TODO: revisit this part! the rounding mode might be dynamic and configured elsewhere + actualCentAmount = ca.getOrElse( + roundToCents(scaledAmount, currency)(BigDecimal.RoundingMode.HALF_EVEN)) + } yield HighPrecisionMoney(preciseAmount, fd, actualCentAmount, currency) + + private def validateFractionDigits( + fractionDigits: Int, + currency: Currency): ValidatedNel[String, Int] = + if (fractionDigits <= currency.getDefaultFractionDigits) + s"fractionDigits must be > ${currency.getDefaultFractionDigits} (default fraction digits defined by currency ${currency.getCurrencyCode}).".invalidNel + else if (fractionDigits > MaxFractionDigits) + s"fractionDigits must be <= $MaxFractionDigits.".invalidNel + else + fractionDigits.validNel + + private def validateCentAmount( + amount: BigDecimal, + centAmount: Option[Long], + currency: Currency): ValidatedNel[String, Option[Long]] = + centAmount match { + case Some(actual) => + val min = roundToCents(amount, currency)(RoundingMode.FLOOR) + val max = roundToCents(amount, currency)(RoundingMode.CEILING) + + if (actual < min || actual > max) + s"centAmount must be correctly rounded preciseAmount (a number between $min and $max).".invalidNel + else + centAmount.validNel + + case _ => + centAmount.validNel + } + + def fromMoney(money: Money, fractionDigits: Int): HighPrecisionMoney = + HighPrecisionMoney( + centToPreciseAmount(money.centAmount, fractionDigits, money.currency), + fractionDigits, + money.centAmount, + money.currency) + + def monoid(fractionDigits: Int, c: Currency)(implicit + mode: RoundingMode): Monoid[HighPrecisionMoney] = new Monoid[HighPrecisionMoney] { + def combine(x: HighPrecisionMoney, y: HighPrecisionMoney): HighPrecisionMoney = x + y + val empty: HighPrecisionMoney = HighPrecisionMoney.zero(fractionDigits, c) + } +} diff --git a/util-3/src/main/scala/Reflect.scala b/util-3/src/main/scala/Reflect.scala new file mode 100644 index 00000000..bb299b67 --- /dev/null +++ b/util-3/src/main/scala/Reflect.scala @@ -0,0 +1,61 @@ +package io.sphere.util + +import org.json4s.scalap.scalasig._ + +object Reflect extends Logging { + case class CaseClassMeta(fields: IndexedSeq[CaseClassFieldMeta]) + case class CaseClassFieldMeta(name: String, default: Option[Any] = None) + + /** Obtains minimal meta information about a case class or object via scalap. The meta information + * contains a list of names and default values which represent the arguments of the case class + * constructor and their default values, in the order they are defined. + * + * Note: Does not work for case classes or objects nested in other classes or traits (nesting + * inside other objects is fine). Note: Only a single default value is obtained for each field. + * Thus avoid default values that are different on each invocation (e.g. new DateTime()). In + * other words, the case class constructors should be pure functions. + */ + val getCaseClassMeta = new Memoizer[Class[?], CaseClassMeta](clazz => { + logger.trace( + "Initializing reflection metadata for case class or object %s".format(clazz.getName)) + CaseClassMeta(getCaseClassFieldMeta(clazz)) + }) + + private def getCompanionClass(clazz: Class[?]): Class[?] = + Class.forName(clazz.getName + "$", true, clazz.getClassLoader) + private def getCompanionObject(companionClass: Class[?]): Object = + companionClass.getField("MODULE$").get(null) + private def getCaseClassFieldMeta(clazz: Class[?]): IndexedSeq[CaseClassFieldMeta] = + if (clazz.getName.endsWith("$")) IndexedSeq.empty[CaseClassFieldMeta] + else { + val companionClass = getCompanionClass(clazz) + val companionObject = getCompanionObject(companionClass) + + val maybeSym = clazz.getName.split("\\$") match { + case Array(_) => ScalaSigParser.parse(clazz).flatMap(_.topLevelClasses.headOption) + case Array(h, t @ _*) => + val name = t.last + val topSymbol = ScalaSigParser.parse(Class.forName(h, true, clazz.getClassLoader)) + topSymbol.flatMap(_.symbols.collectFirst { case s: ClassSymbol if s.name == name => s }) + } + + val sym = maybeSym.getOrElse { + throw new IllegalArgumentException( + "Unable to find class symbol through ScalaSigParser for class %s." + .format(clazz.getName)) + } + + sym.children.iterator + .collect { case m: MethodSymbol if m.isCaseAccessor && !m.isPrivate => m } + .zipWithIndex + .map { case (ms, idx) => + val defaultValue = + try Some(companionClass.getMethod("apply$default$" + (idx + 1)).invoke(companionObject)) + catch { + case _: NoSuchMethodException => None + } + CaseClassFieldMeta(ms.name, defaultValue) + } + .toIndexedSeq + } +} diff --git a/util-3/src/main/scala/ValidatedFlatMap.scala b/util-3/src/main/scala/ValidatedFlatMap.scala new file mode 100644 index 00000000..9c9f1991 --- /dev/null +++ b/util-3/src/main/scala/ValidatedFlatMap.scala @@ -0,0 +1,23 @@ +package io.sphere.util + +import cats.data.Validated + +class ValidatedFlatMap[E, A](val v: Validated[E, A]) extends AnyVal { + def flatMap[EE >: E, B](f: A => Validated[EE, B]): Validated[EE, B] = + v.andThen(f) +} + +/** Cats [[Validated]] does not provide `flatMap` because its purpose is to accumulate errors. + * + * To combine [[Validated]] in for-comprehension, it is possible to import this implicit conversion + * - with the knowledge that the `flatMap` short-circuits errors. + * http://typelevel.org/cats/datatypes/validated.html + */ +object ValidatedFlatMapFeature { + import scala.language.implicitConversions + + @inline implicit def ValidationFlatMapRequested[E, A]( + d: Validated[E, A]): ValidatedFlatMap[E, A] = + new ValidatedFlatMap(d) + +} diff --git a/util-3/src/test/scala/DomainObjectsGen.scala b/util-3/src/test/scala/DomainObjectsGen.scala new file mode 100644 index 00000000..b654f020 --- /dev/null +++ b/util-3/src/test/scala/DomainObjectsGen.scala @@ -0,0 +1,25 @@ +package io.sphere.util + +import java.util.Currency + +import org.scalacheck.Gen + +import scala.jdk.CollectionConverters._ + +object DomainObjectsGen { + + private val currency: Gen[Currency] = + Gen.oneOf(Currency.getAvailableCurrencies.asScala.toSeq) + + val money: Gen[Money] = for { + currency <- currency + amount <- Gen.chooseNum[Long](Long.MinValue, Long.MaxValue) + } yield Money(amount, currency) + + val highPrecisionMoney: Gen[HighPrecisionMoney] = for { + money <- money + } yield HighPrecisionMoney.fromMoney(money, money.currency.getDefaultFractionDigits) + + val baseMoney: Gen[BaseMoney] = Gen.oneOf(money, highPrecisionMoney) + +} diff --git a/util-3/src/test/scala/HighPrecisionMoneySpec.scala b/util-3/src/test/scala/HighPrecisionMoneySpec.scala new file mode 100644 index 00000000..f73d181c --- /dev/null +++ b/util-3/src/test/scala/HighPrecisionMoneySpec.scala @@ -0,0 +1,218 @@ +package io.sphere.util + +import java.util.Currency +import cats.data.Validated.Invalid +import io.sphere.util.HighPrecisionMoney.ImplicitsDecimalPrecise.HighPrecisionPreciseMoneyNotation +import org.scalatest.funspec.AnyFunSpec +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks +import org.scalatest.matchers.must.Matchers + +import scala.collection.mutable.ArrayBuffer +import scala.language.postfixOps +import scala.math.BigDecimal + +class HighPrecisionMoneySpec extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { + import HighPrecisionMoney.ImplicitsString._ + import HighPrecisionMoney.ImplicitsStringPrecise._ + + implicit val defaultRoundingMode: BigDecimal.RoundingMode.Value = + BigDecimal.RoundingMode.HALF_EVEN + + val Euro: Currency = Currency.getInstance("EUR") + + describe("High Precision Money") { + + it("should allow creation of high precision money") { + ("0.01".EUR) must equal("0.01".EUR) + } + + it( + "should not allow creation of high precision money with less fraction digits than the currency has") { + val thrown = intercept[IllegalArgumentException] { + "0.01".EUR_PRECISE(1) + } + + assert( + thrown.getMessage == "requirement failed: `fractionDigits` should be >= than the default fraction digits of the currency.") + } + + it("should convert precise amount to long value correctly") { + "0.0001".EUR_PRECISE(4).preciseAmount must equal(1) + } + + it("should reduce fraction digits as expected") { + "0.0001".EUR_PRECISE(4).withFractionDigits(2).preciseAmount must equal(0) + } + + it("should support the unary '-' operator.") { + -"0.01".EUR_PRECISE(2) must equal("-0.01".EUR_PRECISE(2)) + } + + it("should throw error on overflow in the unary '-' operator.") { + a[MoneyOverflowException] must be thrownBy { + -(BigDecimal(Long.MinValue) / 1000).EUR_PRECISE(3) + } + } + + it("should support the binary '+' operator.") { + ("0.001".EUR_PRECISE(3)) + ("0.002".EUR_PRECISE(3)) must equal( + "0.003".EUR_PRECISE(3) + ) + + ("0.005".EUR_PRECISE(3)) + Money.fromDecimalAmount(BigDecimal("0.01"), Euro) must equal( + "0.015".EUR_PRECISE(3) + ) + + ("0.005".EUR_PRECISE(3)) + BigDecimal("0.005") must equal( + "0.010".EUR_PRECISE(3) + ) + } + + it("should throw error on overflow in the binary '+' operator.") { + a[MoneyOverflowException] must be thrownBy { + (BigDecimal(Long.MaxValue) / 1000).EUR_PRECISE(3) + 1 + } + } + + it("should support the binary '-' operator.") { + ("0.002".EUR_PRECISE(3)) - ("0.001".EUR_PRECISE(3)) must equal( + "0.001".EUR_PRECISE(3) + ) + + ("0.015".EUR_PRECISE(3)) - Money.fromDecimalAmount(BigDecimal("0.01"), Euro) must equal( + "0.005".EUR_PRECISE(3) + ) + + ("0.005".EUR_PRECISE(3)) - BigDecimal("0.005") must equal( + "0.000".EUR_PRECISE(3) + ) + } + + it("should throw error on overflow in the binary '-' operator.") { + a[MoneyOverflowException] must be thrownBy { + (BigDecimal(Long.MinValue) / 1000).EUR_PRECISE(3) - 1 + } + } + + it("should support the binary '*' operator.") { + ("0.002".EUR_PRECISE(3)) * ("5.00".EUR_PRECISE(2)) must equal( + "0.010".EUR_PRECISE(3) + ) + + ("0.015".EUR_PRECISE(3)) * Money.fromDecimalAmount(BigDecimal("100.00"), Euro) must equal( + "1.500".EUR_PRECISE(3) + ) + + ("0.005".EUR_PRECISE(3)) * BigDecimal("0.005") must equal( + "0.000".EUR_PRECISE(3) + ) + } + + it("should throw error on overflow in the binary '*' operator.") { + a[MoneyOverflowException] must be thrownBy { + (BigDecimal(Long.MaxValue / 1000) / 2 + 1).EUR_PRECISE(3) * 2 + } + } + + it("should support the binary '%' operator.") { + ("0.010".EUR_PRECISE(3)) % ("5.00".EUR_PRECISE(2)) must equal( + "0.010".EUR_PRECISE(3) + ) + + ("100.000".EUR_PRECISE(3)) % Money.fromDecimalAmount(BigDecimal("100.00"), Euro) must equal( + "0.000".EUR_PRECISE(3) + ) + + ("0.015".EUR_PRECISE(3)) % BigDecimal("0.002") must equal( + "0.001".EUR_PRECISE(3) + ) + } + + it("should throw error on overflow in the binary '%' operator.") { + noException must be thrownBy { + BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3) % 0.5 + } + } + + it("should support the binary '/%' operator.") { + "10.000".EUR_PRECISE(3)./%(3.00) must equal( + ("3.000".EUR_PRECISE(3), "1.000".EUR_PRECISE(3)) + ) + } + + it("should throw error on overflow in the binary '/%' operator.") { + a[MoneyOverflowException] must be thrownBy { + BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3) /% 0.5 + } + } + + it("should support the remainder operator.") { + "10.000".EUR_PRECISE(3).remainder(3.00) must equal("1.000".EUR_PRECISE(3)) + + "10.000".EUR_PRECISE(3).remainder("3.000".EUR_PRECISE(3)) must equal("1.000".EUR_PRECISE(3)) + } + + it("should not overflow when getting the remainder of a division ('%').") { + noException must be thrownBy { + BigDecimal(Long.MaxValue / 1000).EUR_PRECISE(3).remainder(0.5) + } + } + + it("should partition the value properly.") { + "10.000".EUR_PRECISE(3).partition(1, 2, 3) must equal( + ArrayBuffer( + "1.667".EUR_PRECISE(3), + "3.333".EUR_PRECISE(3), + "5.000".EUR_PRECISE(3) + ) + ) + } + + it("should validate fractionDigits (min)") { + val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 1, Euro, None): @unchecked + + errors.toList must be( + List("fractionDigits must be > 2 (default fraction digits defined by currency EUR).")) + } + + it("should validate fractionDigits (max)") { + val Invalid(errors) = + HighPrecisionMoney.fromPreciseAmount(123456L, 100, Euro, None): @unchecked + + errors.toList must be(List("fractionDigits must be <= 20.")) + } + + it("should validate centAmount") { + val Invalid(errors) = + HighPrecisionMoney.fromPreciseAmount(123456L, 4, Euro, Some(1)): @unchecked + + errors.toList must be( + List( + "centAmount must be correctly rounded preciseAmount (a number between 1234 and 1235).")) + } + + it("should provide convenient toString") { + "10.000".EUR_PRECISE(3).toString must be("10.000 EUR") + "0.100".EUR_PRECISE(3).toString must be("0.100 EUR") + "0.010".EUR_PRECISE(3).toString must be("0.010 EUR") + "0.000".EUR_PRECISE(3).toString must be("0.000 EUR") + "94.500".EUR_PRECISE(3).toString must be("94.500 EUR") + "94".JPY_PRECISE(0).toString must be("94 JPY") + } + + it("should not fail on toString") { + forAll(DomainObjectsGen.highPrecisionMoney) { m => + m.toString + } + } + + it("should fail on too big fraction decimal") { + val thrown = intercept[IllegalArgumentException] { + val tooManyDigits = Euro.getDefaultFractionDigits + 19 + HighPrecisionMoney.fromCentAmount(100003, tooManyDigits, Euro) + } + + assert(thrown.getMessage == "Cannot represent number bigger than 10^19 with a Long") + } + } +} diff --git a/util-3/src/test/scala/LangTagSpec.scala b/util-3/src/test/scala/LangTagSpec.scala new file mode 100644 index 00000000..091fd6d9 --- /dev/null +++ b/util-3/src/test/scala/LangTagSpec.scala @@ -0,0 +1,27 @@ +package io.sphere.util + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.must.Matchers + +import scala.language.postfixOps + +class LangTagSpec extends AnyFunSpec with Matchers { + describe("LangTag") { + it("should accept valid language tags") { + LangTag.unapply("de").isEmpty must be(false) + LangTag.unapply("fr").isEmpty must be(false) + LangTag.unapply("de-DE").isEmpty must be(false) + LangTag.unapply("de-AT").isEmpty must be(false) + LangTag.unapply("de-CH").isEmpty must be(false) + LangTag.unapply("fr-FR").isEmpty must be(false) + LangTag.unapply("fr-CA").isEmpty must be(false) + LangTag.unapply("he-IL-u-ca-hebrew-tz-jeruslm").isEmpty must be(false) + } + + it("should not accept invalid language tags") { + LangTag.unapply(" de").isEmpty must be(true) + LangTag.unapply("de_DE").isEmpty must be(true) + LangTag.unapply("e-DE").isEmpty must be(true) + } + } +} diff --git a/util-3/src/test/scala/MoneySpec.scala b/util-3/src/test/scala/MoneySpec.scala new file mode 100644 index 00000000..7d4c1a4a --- /dev/null +++ b/util-3/src/test/scala/MoneySpec.scala @@ -0,0 +1,162 @@ +package io.sphere.util + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.must.Matchers +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import scala.language.postfixOps +import scala.math.BigDecimal + +class MoneySpec extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { + import Money.ImplicitsDecimal._ + import Money._ + + implicit val mode: BigDecimal.RoundingMode.Value = BigDecimal.RoundingMode.UNNECESSARY + + def euroCents(cents: Long): Money = EUR(0).withCentAmount(cents) + + describe("Money") { + it("should have value semantics.") { + (1.23 EUR) must equal(1.23 EUR) + } + + it( + "should default to HALF_EVEN rounding mode when using monetary notation and use provided rounding mode when performing operations.") { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + + (1.001 EUR) must equal(1.00 EUR) + (1.005 EUR) must equal(1.00 EUR) + (1.015 EUR) must equal(1.02 EUR) + ((1.00 EUR) + 0.001) must equal(1.00 EUR) + ((1.00 EUR) + 0.005) must equal(1.00 EUR) + ((1.00 EUR) + 0.015) must equal(1.02 EUR) + ((1.00 EUR) - 0.005) must equal(1.00 EUR) + ((1.00 EUR) - 0.015) must equal(0.98 EUR) + ((1.00 EUR) + 0.0115) must equal(1.01 EUR) + } + + it( + "should not accept an amount with an invalid scale for the used currency when using the constructor directly.") { + an[IllegalArgumentException] must be thrownBy { + Money(1.0001, java.util.Currency.getInstance("EUR")) + } + } + + it("should not be prone to common rounding errors known from floating point numbers.") { + var m = 0.00 EUR + + for (i <- 1 to 10) m = m + 0.10 + + m must equal(1.00 EUR) + } + + it("should support the unary '-' operator.") { + -EUR(1.00) must equal(-1.00 EUR) + } + + it("should throw error on overflow in the unary '-' operator.") { + a[MoneyOverflowException] must be thrownBy { + -euroCents(Long.MinValue) + } + } + + it("should support the binary '+' operator.") { + (1.42 EUR) + (1.58 EUR) must equal(3.00 EUR) + } + + it("should support the binary '+' operator on different currencies.") { + an[IllegalArgumentException] must be thrownBy { + (1.42 EUR) + (1.58 USD) + } + } + + it("should throw error on overflow in the binary '+' operator.") { + a[MoneyOverflowException] must be thrownBy { + euroCents(Long.MaxValue) + 1 + } + } + + it("should support the binary '-' operator.") { + (1.33 EUR) - (0.33 EUR) must equal(1.00 EUR) + } + + it("should throw error on overflow in the binary '-' operator.") { + a[MoneyOverflowException] must be thrownBy { + euroCents(Long.MinValue) - 1 + } + } + + it("should support the binary '*' operator, requiring a rounding mode.") { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + (1.33 EUR) * (1.33 EUR) must equal(1.77 EUR) + } + + it("should throw error on overflow in the binary '*' operator.") { + a[MoneyOverflowException] must be thrownBy { + euroCents(Long.MaxValue / 2 + 1) * 2 + } + } + + it("should support the binary '/%' (divideAndRemainder) operator.") { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + (1.33 EUR) /% 0.3 must equal(4.00 EUR, 0.13 EUR) + (1.33 EUR) /% 0.003 must equal(443.00 EUR, 0.00 EUR) + } + + it("should throw error on overflow in the binary '/%' (divideAndRemainder) operator.") { + a[MoneyOverflowException] must be thrownBy { + euroCents(Long.MaxValue) /% 0.5 + } + } + + it("should support getting the remainder of a division ('%').") { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + (1.25 EUR).remainder(1.1) must equal(0.15 EUR) + (1.25 EUR) % 1.1 must equal(0.15 EUR) + } + + it("should not overflow when getting the remainder of a division ('%').") { + noException must be thrownBy { + euroCents(Long.MaxValue).remainder(0.5) + } + } + + it("should support partitioning an amount without losing or gaining money.") { + (0.05 EUR).partition(3, 7) must equal(Seq(0.02 EUR, 0.03 EUR)) + (10 EUR).partition(1, 2) must equal(Seq(3.34 EUR, 6.66 EUR)) + (10 EUR).partition(3, 1, 3) must equal(Seq(4.29 EUR, 1.43 EUR, 4.28 EUR)) + } + + it("should allow comparing money with the same currency.") { + ((1.10 EUR) > (1.00 EUR)) must be(true) + ((1.00 EUR) >= (1.00 EUR)) must be(true) + ((1.00 EUR) < (1.10 EUR)) must be(true) + ((1.00 EUR) <= (1.00 EUR)) must be(true) + } + + it("should support currencies with a scale of 0 (i.e. Japanese Yen)") { + (1 JPY) must equal(1 JPY) + } + + it("should be able to update the centAmount") { + (1.10 EUR).withCentAmount(170) must be(1.70 EUR) + (1.10 EUR).withCentAmount(1711) must be(17.11 EUR) + (1 JPY).withCentAmount(34) must be(34 JPY) + } + + it("should provide convenient toString") { + (1 JPY).toString must be("1 JPY") + (1.00 EUR).toString must be("1.00 EUR") + (0.10 EUR).toString must be("0.10 EUR") + (0.01 EUR).toString must be("0.01 EUR") + (0.00 EUR).toString must be("0.00 EUR") + (94.5 EUR).toString must be("94.50 EUR") + } + + it("should not fail on toString") { + forAll(DomainObjectsGen.money) { m => + m.toString + } + } + } +} diff --git a/util-3/src/test/scala/ScalaLoggingCompatiblitySpec.scala b/util-3/src/test/scala/ScalaLoggingCompatiblitySpec.scala new file mode 100644 index 00000000..35de2b23 --- /dev/null +++ b/util-3/src/test/scala/ScalaLoggingCompatiblitySpec.scala @@ -0,0 +1,18 @@ +import com.typesafe.scalalogging.Logger +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.must.Matchers + +class ScalaLoggingCompatiblitySpec extends AnyFunSpec with Matchers { + + describe("Ensure we skip ScalaLogging 3.9.5, because varargs will not compile under 3.9.5") { + // Github issue about the bug: https://github.com/lightbend-labs/scala-logging/issues/354 + // This test can be removed if it compiles with scala-logging versions bigger than 3.9.5 + object Log extends com.typesafe.scalalogging.StrictLogging { + val log: Logger = logger + } + val list: List[AnyRef] = List("log", "Some more") + + Log.log.warn("Message1", list*) + } + +}