diff --git a/build.sbt b/build.sbt index d0d2f83d..2e65a80c 100644 --- a/build.sbt +++ b/build.sbt @@ -313,7 +313,7 @@ lazy val sprayJsonSupport = project lazy val playJsonSupport = project .in(file("play-json")) - .dependsOn(macroUtils) + .dependsOn(macroUtils, instances) .settings(playJsonSettings: _*) .settings(publishSettings: _*) .settings(disableScala("3")) @@ -326,7 +326,7 @@ lazy val playJsonSupport = project lazy val circeSupport = project .in(file("circe")) - .dependsOn(macroUtils) + .dependsOn(macroUtils, instances) .settings(circeSettings: _*) .settings(crossBuildSettings: _*) .settings(publishSettings: _*) diff --git a/circe/src/main/scala/pl/iterators/kebs/circe/KebsCirce.scala b/circe/src/main/scala/pl/iterators/kebs/circe/KebsCirce.scala index 13813f80..3d977105 100644 --- a/circe/src/main/scala/pl/iterators/kebs/circe/KebsCirce.scala +++ b/circe/src/main/scala/pl/iterators/kebs/circe/KebsCirce.scala @@ -2,6 +2,7 @@ package pl.iterators.kebs.circe import io.circe.generic.AutoDerivation import io.circe.{Decoder, Encoder} +import pl.iterators.kebs.instances.InstanceConverter import pl.iterators.kebs.macros.CaseClass1Rep import scala.language.experimental.macros @@ -12,6 +13,12 @@ trait KebsCirce extends AutoDerivation { decoder.emap(obj => Try(rep.apply(obj)).toEither.left.map(_.getMessage)) implicit def flatEncoder[T, A](implicit rep: CaseClass1Rep[T, A], encoder: Encoder[A]): Encoder[T] = encoder.contramap(rep.unapply) + + implicit def instanceConverterEncoder[T, A](implicit rep: InstanceConverter[T, A], encoder: Encoder[A]): Encoder[T] = + encoder.contramap(rep.encode) + + implicit def instanceConverterDecoder[T, A](implicit rep: InstanceConverter[T, A], decoder: Decoder[A]): Decoder[T] = + decoder.emap(obj => Try(rep.decode(obj)).toEither.left.map(_.getMessage)) } object KebsCirce { diff --git a/circe/src/test/scala/instances/NetInstancesTests.scala b/circe/src/test/scala/instances/NetInstancesTests.scala new file mode 100644 index 00000000..8a62ed22 --- /dev/null +++ b/circe/src/test/scala/instances/NetInstancesTests.scala @@ -0,0 +1,35 @@ +package instances + +import io.circe.{Decoder, Encoder, Json} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.circe.KebsCirce +import pl.iterators.kebs.instances.net.URIString + +import java.net.URI + +class NetInstancesTests extends AnyFunSuite with Matchers with KebsCirce with URIString { + + test("URI standard format") { + val decoder = implicitly[Decoder[URI]] + val encoder = implicitly[Encoder[URI]] + val value = "iteratorshq.com" + val obj = new URI(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("URI wrong format exception") { + val decoder = implicitly[Decoder[URI]] + val value = "not a URI" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + } +} diff --git a/circe/src/test/scala/instances/TimeInstancesMixinTests.scala b/circe/src/test/scala/instances/TimeInstancesMixinTests.scala new file mode 100644 index 00000000..feb1b0d6 --- /dev/null +++ b/circe/src/test/scala/instances/TimeInstancesMixinTests.scala @@ -0,0 +1,112 @@ +package instances + +import io.circe.{Decoder, Encoder, Json} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.circe.KebsCirce +import pl.iterators.kebs.instances.time.LocalDateTimeString +import pl.iterators.kebs.instances.time.mixins.{DurationNanosLong, InstantEpochMilliLong} +import pl.iterators.kebs.instances.{InstanceConverter, TimeInstances} + +import java.time._ +import java.time.format.DateTimeFormatter + +class TimeInstancesMixinTests extends AnyFunSuite with Matchers { + + test("Instant epoch milli format") { + object TimeInstancesProtocol extends KebsCirce with InstantEpochMilliLong + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + + val decoder = implicitly[Decoder[Instant]] + val encoder = implicitly[Encoder[Instant]] + val value = 123456789 + val obj = Instant.ofEpochMilli(value) + + encoder(obj) shouldBe Json.fromInt(value) + decoder(Json.fromInt(value).hcursor) shouldBe Right(obj) + } + + test("Duration nanos format, Instant epoch milli format") { + object TimeInstancesProtocol extends KebsCirce with DurationNanosLong with InstantEpochMilliLong + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Duration, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Duration]]" shouldNot typeCheck + + val decoder_duration = implicitly[Decoder[Duration]] + val encoder_duration = implicitly[Encoder[Duration]] + val value_duration = 123456789 + val obj_duration = Duration.ofNanos(value_duration) + + val decoder_instant = implicitly[Decoder[Instant]] + val encoder_instant = implicitly[Encoder[Instant]] + val value_instant = 123456789 + val obj_instant = Instant.ofEpochMilli(value_instant) + + encoder_duration(obj_duration) shouldBe Json.fromInt(value_duration) + decoder_duration(Json.fromInt(value_duration).hcursor) shouldBe Right(obj_duration) + + encoder_instant(obj_instant) shouldBe Json.fromInt(value_instant) + decoder_instant(Json.fromInt(value_instant).hcursor) shouldBe Right(obj_instant) + } + + test("LocalDateTime custom format using companion object") { + object TimeInstancesProtocol extends KebsCirce with LocalDateTimeString { + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm") + + override implicit val localDateTimeFormatter: InstanceConverter[LocalDateTime, String] = + InstanceConverter.apply[LocalDateTime, String](_.format(formatter), LocalDateTime.parse(_, formatter)) + } + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + + val encoder = implicitly[Encoder[LocalDateTime]] + val decoder = implicitly[Decoder[LocalDateTime]] + val value = "2007/12/03 10:30" + val obj = LocalDateTime.parse(value, formatter) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("LocalDateTime custom format with error handling") { + object TimeInstancesProtocol extends KebsCirce with TimeInstances { + val pattern = "yyyy/MM/dd HH:mm" + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern(pattern) + + override implicit val localDateTimeFormatter: InstanceConverter[LocalDateTime, String] = + new InstanceConverter[LocalDateTime, String] { + override def encode(obj: LocalDateTime): String = obj.format(formatter) + override def decode(value: String): LocalDateTime = + try { + LocalDateTime.parse(value, formatter) + } catch { + case e: DateTimeException => + throw new IllegalArgumentException( + s"${classOf[LocalDateTime]} cannot be parsed from $value – should be in format $pattern", + e) + case e: Throwable => throw e + } + } + } + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + + val encoder = implicitly[Encoder[LocalDateTime]] + val decoder = implicitly[Decoder[LocalDateTime]] + val value = "2007/12/03 10:30" + val obj = LocalDateTime.parse(value, formatter) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } +} diff --git a/circe/src/test/scala/instances/TimeInstancesTests.scala b/circe/src/test/scala/instances/TimeInstancesTests.scala new file mode 100644 index 00000000..8dee8f63 --- /dev/null +++ b/circe/src/test/scala/instances/TimeInstancesTests.scala @@ -0,0 +1,322 @@ +package instances + +import io.circe.{Decoder, Encoder, Json} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.circe.KebsCirce +import pl.iterators.kebs.instances.InstanceConverter.DecodeErrorException +import pl.iterators.kebs.instances.TimeInstances + +import java.time._ + +class TimeInstancesTests extends AnyFunSuite with Matchers with KebsCirce with TimeInstances { + + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Duration, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Duration]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Instant, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalDate, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDate]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Month, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, Month]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[MonthDay, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, MonthDay]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[OffsetDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, OffsetDateTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[OffsetTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, OffsetTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Period, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Period]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Year, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Year]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[YearMonth, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, YearMonth]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZoneId, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZoneId]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZoneOffset, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZoneOffset]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZonedDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZonedDateTime]]" shouldNot typeCheck + } + + test("DayOfWeek standard format") { + val encoder = implicitly[Encoder[DayOfWeek]] + val decoder = implicitly[Decoder[DayOfWeek]] + val value = 1 + val obj = DayOfWeek.of(value) + + encoder(obj) shouldBe Json.fromInt(value) + decoder(Json.fromInt(value).hcursor) shouldBe Right(obj) + } + + test("DayOfWeek wrong format exception") { + val decoder = implicitly[Decoder[DayOfWeek]] + val value = 8 + + decoder(Json.fromInt(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Duration standard format") { + val encoder = implicitly[Encoder[Duration]] + val decoder = implicitly[Decoder[Duration]] + val value = "PT1H" + val obj = Duration.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("Duration wrong format exception") { + val decoder = implicitly[Decoder[Duration]] + val value = "NotADuration" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Instant standard format") { + val encoder = implicitly[Encoder[Instant]] + val decoder = implicitly[Decoder[Instant]] + val value = "2007-12-03T10:15:30Z" + val obj = Instant.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("Instant wrong format exception") { + val decoder = implicitly[Decoder[Instant]] + val value = "NotAnInstant" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("LocalDate standard format") { + val encoder = implicitly[Encoder[LocalDate]] + val decoder = implicitly[Decoder[LocalDate]] + val value = "2007-12-03" + val obj = LocalDate.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("LocalDate wrong format exception") { + val decoder = implicitly[Decoder[LocalDate]] + val value = "NotALocalDate" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("LocalDateTime standard format") { + val encoder = implicitly[Encoder[LocalDateTime]] + val decoder = implicitly[Decoder[LocalDateTime]] + val value = "2007-12-03T10:15:30" + val obj = LocalDateTime.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("LocalDateTime wrong format exception") { + val decoder = implicitly[Decoder[LocalDateTime]] + val value = "NotALocalDateTime" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("LocalTime standard format") { + val encoder = implicitly[Encoder[LocalTime]] + val decoder = implicitly[Decoder[LocalTime]] + val value = "10:15:30" + val obj = LocalTime.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("LocalTime wrong format exception") { + val decoder = implicitly[Decoder[LocalTime]] + val value = "NotALocalTime" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Month standard format") { + val encoder = implicitly[Encoder[Month]] + val decoder = implicitly[Decoder[Month]] + val value = 12 + val obj = Month.of(value) + + encoder(obj) shouldBe Json.fromInt(value) + decoder(Json.fromInt(value).hcursor) shouldBe Right(obj) + } + + test("Month wrong format exception") { + val decoder = implicitly[Decoder[Month]] + val value = 13 + + decoder(Json.fromInt(value).hcursor) shouldBe a [Left[_, _]] + } + + test("MonthDay standard format") { + val encoder = implicitly[Encoder[MonthDay]] + val decoder = implicitly[Decoder[MonthDay]] + val value = "--12-03" + val obj = MonthDay.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("MonthDay wrong format exception") { + val decoder = implicitly[Decoder[MonthDay]] + val value = "NotAMonthDay" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("OffsetDateTime standard format") { + val encoder = implicitly[Encoder[OffsetDateTime]] + val decoder = implicitly[Decoder[OffsetDateTime]] + val value = "2011-12-03T10:15:30+01:00" + val obj = OffsetDateTime.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("OffsetDateTime wrong format exception") { + val decoder = implicitly[Decoder[OffsetDateTime]] + val value = "NotAnOffsetDateTime" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("OffsetTime standard format") { + val encoder = implicitly[Encoder[OffsetTime]] + val decoder = implicitly[Decoder[OffsetTime]] + val value = "10:15:30+01:00" + val obj = OffsetTime.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("OffsetTime wrong format exception") { + val decoder = implicitly[Decoder[OffsetTime]] + val value = "NotAnOffsetTime" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Period standard format") { + val encoder = implicitly[Encoder[Period]] + val decoder = implicitly[Decoder[Period]] + val value = "P2Y" + val obj = Period.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("Period wrong format exception") { + val decoder = implicitly[Decoder[Period]] + val value = "NotAPeriod" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Year standard format") { + val encoder = implicitly[Encoder[Year]] + val decoder = implicitly[Decoder[Year]] + val value = 2007 + val obj = Year.of(value) + + encoder(obj) shouldBe Json.fromInt(value) + decoder(Json.fromInt(value).hcursor) shouldBe Right(obj) + } + + test("Year wrong format exception") { + val decoder = implicitly[Decoder[Year]] + val value = "NotAYear" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("YearMonth standard format") { + val encoder = implicitly[Encoder[YearMonth]] + val decoder = implicitly[Decoder[YearMonth]] + val value = "2011-12" + val obj = YearMonth.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("YearMonth wrong format exception") { + val decoder = implicitly[Decoder[YearMonth]] + val value = "NotAYearMonth" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("ZoneId standard format") { + val encoder = implicitly[Encoder[ZoneId]] + val decoder = implicitly[Decoder[ZoneId]] + val value = "Europe/Warsaw" + val obj = ZoneId.of(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("ZoneId wrong format exception") { + val decoder = implicitly[Decoder[ZoneId]] + val value = "NotAZoneId" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("ZoneOffset standard format") { + val encoder = implicitly[Encoder[ZoneOffset]] + val decoder = implicitly[Decoder[ZoneOffset]] + val value = "+01:00" + val obj = ZoneOffset.of(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("ZoneOffset wrong format exception") { + val decoder = implicitly[Decoder[ZoneOffset]] + val value = "NotAZoneOffset" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("ZonedDateTime standard format") { + val encoder = implicitly[Encoder[ZonedDateTime]] + val decoder = implicitly[Decoder[ZonedDateTime]] + val value = "2011-12-03T10:15:30+01:00[Europe/Warsaw]" + val obj = ZonedDateTime.parse(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("ZonedDateTime wrong format exception") { + val decoder = implicitly[Decoder[ZonedDateTime]] + val value = "NotAZoneOffset" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + +} diff --git a/circe/src/test/scala/instances/UtilInstancesTests.scala b/circe/src/test/scala/instances/UtilInstancesTests.scala new file mode 100644 index 00000000..a5b1c025 --- /dev/null +++ b/circe/src/test/scala/instances/UtilInstancesTests.scala @@ -0,0 +1,67 @@ +package instances + +import io.circe.{Decoder, Encoder, Json} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.circe.KebsCirce +import pl.iterators.kebs.instances.InstanceConverter.DecodeErrorException +import pl.iterators.kebs.instances.UtilInstances + +import java.util.{Currency, Locale, UUID} + +class UtilInstancesTests extends AnyFunSuite with Matchers with KebsCirce with UtilInstances { + + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[Currency, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Currency]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Locale, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Locale]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[UUID, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, UUID]]" shouldNot typeCheck + } + + test("Currency standard format") { + val encoder = implicitly[Encoder[Currency]] + val decoder = implicitly[Decoder[Currency]] + val value = "PLN" + val obj = Currency.getInstance(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("Currency wrong format exception") { + val decoder = implicitly[Decoder[Currency]] + val value = "not a Currency" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } + + test("Locale standard format") { + val encoder = implicitly[Encoder[Locale]] + val decoder = implicitly[Decoder[Locale]] + val value = "pl-PL" + val obj = Locale.forLanguageTag(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("UUID standard format") { + val encoder = implicitly[Encoder[UUID]] + val decoder = implicitly[Decoder[UUID]] + val value = "123e4567-e89b-12d3-a456-426614174000" + val obj = UUID.fromString(value) + + encoder(obj) shouldBe Json.fromString(value) + decoder(Json.fromString(value).hcursor) shouldBe Right(obj) + } + + test("UUID wrong format exception") { + val decoder = implicitly[Decoder[UUID]] + val value = "not an UUID" + + decoder(Json.fromString(value).hcursor) shouldBe a [Left[_, _]] + } +} diff --git a/play-json/src/main/scala/pl/iterators/kebs/json/KebsPlay.scala b/play-json/src/main/scala/pl/iterators/kebs/json/KebsPlay.scala index 07e64876..b2dcb4fc 100644 --- a/play-json/src/main/scala/pl/iterators/kebs/json/KebsPlay.scala +++ b/play-json/src/main/scala/pl/iterators/kebs/json/KebsPlay.scala @@ -1,5 +1,6 @@ package pl.iterators.kebs.json +import pl.iterators.kebs.instances.InstanceConverter import pl.iterators.kebs.macros.CaseClass1Rep import play.api.libs.json._ @@ -7,4 +8,8 @@ trait KebsPlay { implicit def flatReads[T, A](implicit rep: CaseClass1Rep[T, A], reads: Reads[A]): Reads[T] = reads.map(rep.apply) implicit def flatWrites[T, B](implicit rep: CaseClass1Rep[T, B], writes: Writes[B]): Writes[T] = Writes((obj: T) => writes.writes(rep.unapply(obj))) + + implicit def instanceConverterReads[T, A](implicit rep: InstanceConverter[T, A], reads: Reads[A]): Reads[T] = reads.map(rep.decode) + implicit def instanceConverterWrites[T, B](implicit rep: InstanceConverter[T, B], writes: Writes[B]): Writes[T] = + Writes((obj: T) => writes.writes(rep.encode(obj))) } diff --git a/play-json/src/test/scala/instances/NetInstancesTests.scala b/play-json/src/test/scala/instances/NetInstancesTests.scala new file mode 100644 index 00000000..85ae5e06 --- /dev/null +++ b/play-json/src/test/scala/instances/NetInstancesTests.scala @@ -0,0 +1,35 @@ +package instances + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.instances.InstanceConverter.DecodeErrorException +import pl.iterators.kebs.instances.net.URIString +import play.api.libs.json.{Format, JsString, JsSuccess} + +import java.net.URI + +class NetInstancesTests extends AnyFunSuite with Matchers with URIString { + import pl.iterators.kebs.json._ + + test("URI standard format") { + val jf = implicitly[Format[URI]] + val value = "iteratorshq.com" + val obj = new URI(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("URI wrong format exception") { + val jf = implicitly[Format[URI]] + val value = "not a URI" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + } +} diff --git a/play-json/src/test/scala/instances/TimeInstancesMixinTests.scala b/play-json/src/test/scala/instances/TimeInstancesMixinTests.scala new file mode 100644 index 00000000..8685869c --- /dev/null +++ b/play-json/src/test/scala/instances/TimeInstancesMixinTests.scala @@ -0,0 +1,107 @@ +package instances + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.instances.time.LocalDateTimeString +import pl.iterators.kebs.instances.time.mixins.{DurationNanosLong, InstantEpochMilliLong} +import pl.iterators.kebs.instances.{InstanceConverter, TimeInstances} +import play.api.libs.json.{Format, JsNumber, JsString, JsSuccess} + +import java.time._ +import java.time.format.DateTimeFormatter + +class TimeInstancesMixinTests extends AnyFunSuite with Matchers { + import pl.iterators.kebs.json._ + + test("Instant epoch milli format") { + object TimeInstancesProtocol extends InstantEpochMilliLong + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + + val jf = implicitly[Format[Instant]] + val value = 123456789 + val obj = Instant.ofEpochMilli(value) + + jf.writes(obj) shouldBe JsNumber(value) + jf.reads(JsNumber(value)) shouldBe JsSuccess(obj) + } + + test("Duration nanos format, Instant epoch milli format") { + object TimeInstancesProtocol extends DurationNanosLong with InstantEpochMilliLong + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Duration, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Duration]]" shouldNot typeCheck + + val jf_duration = implicitly[Format[Duration]] + val value_duration = 123456789 + val obj_duration = Duration.ofNanos(value_duration) + + val jf_instant = implicitly[Format[Instant]] + val value_instant = 123456789 + val obj_instant = Instant.ofEpochMilli(value_instant) + + jf_duration.writes(obj_duration) shouldBe JsNumber(value_duration) + jf_duration.reads(JsNumber(value_duration)) shouldBe JsSuccess(obj_duration) + + jf_instant.writes(obj_instant) shouldBe JsNumber(value_instant) + jf_instant.reads(JsNumber(value_instant)) shouldBe JsSuccess(obj_instant) + } + + test("LocalDateTime custom format using companion object") { + object TimeInstancesProtocol extends LocalDateTimeString { + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm") + + override implicit val localDateTimeFormatter: InstanceConverter[LocalDateTime, String] = + InstanceConverter.apply[LocalDateTime, String](_.format(formatter), LocalDateTime.parse(_, formatter)) + } + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + + val jf = implicitly[Format[LocalDateTime]] + val value = "2007/12/03 10:30" + val obj = LocalDateTime.parse(value, formatter) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("LocalDateTime custom format with error handling") { + object TimeInstancesProtocol extends TimeInstances { + val pattern = "yyyy/MM/dd HH:mm" + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern(pattern) + + override implicit val localDateTimeFormatter: InstanceConverter[LocalDateTime, String] = + new InstanceConverter[LocalDateTime, String] { + override def encode(obj: LocalDateTime): String = obj.format(formatter) + override def decode(value: String): LocalDateTime = + try { + LocalDateTime.parse(value, formatter) + } catch { + case e: DateTimeException => + throw new IllegalArgumentException( + s"${classOf[LocalDateTime]} cannot be parsed from $value – should be in format $pattern", + e) + case e: Throwable => throw e + } + } + } + import TimeInstancesProtocol._ + + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + + val jf = implicitly[Format[LocalDateTime]] + val value = "2007/12/03 10:30" + val obj = LocalDateTime.parse(value, formatter) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } +} diff --git a/play-json/src/test/scala/instances/TimeInstancesTests.scala b/play-json/src/test/scala/instances/TimeInstancesTests.scala new file mode 100644 index 00000000..3f74aa00 --- /dev/null +++ b/play-json/src/test/scala/instances/TimeInstancesTests.scala @@ -0,0 +1,305 @@ +package instances + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.instances.InstanceConverter.DecodeErrorException +import pl.iterators.kebs.instances.TimeInstances +import play.api.libs.json.{Format, JsNumber, JsString, JsSuccess} + +import java.time._ + +class TimeInstancesTests extends AnyFunSuite with Matchers with TimeInstances { + import pl.iterators.kebs.json._ + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Duration, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Duration]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Instant, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalDate, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDate]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalDateTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[LocalTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, LocalTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Month, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, Month]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[MonthDay, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, MonthDay]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[OffsetDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, OffsetDateTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[OffsetTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, OffsetTime]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Period, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Period]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Year, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Year]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[YearMonth, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, YearMonth]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZoneId, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZoneId]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZoneOffset, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZoneOffset]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[ZonedDateTime, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, ZonedDateTime]]" shouldNot typeCheck + } + + test("DayOfWeek standard format") { + val jf = implicitly[Format[DayOfWeek]] + val value = 1 + val obj = DayOfWeek.of(value) + + jf.writes(obj) shouldBe JsNumber(value) + jf.reads(JsNumber(value)) shouldBe JsSuccess(obj) + } + + test("DayOfWeek wrong format exception") { + val jf = implicitly[Format[DayOfWeek]] + val value = 8 + + assertThrows[DecodeErrorException](jf.reads(JsNumber(value))) + } + + test("Duration standard format") { + val jf = implicitly[Format[Duration]] + val value = "PT1H" + val obj = Duration.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("Duration wrong format exception") { + val jf = implicitly[Format[Duration]] + val value = "NotADuration" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("Instant standard format") { + val jf = implicitly[Format[Instant]] + val value = "2007-12-03T10:15:30Z" + val obj = Instant.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("Instant wrong format exception") { + val jf = implicitly[Format[Instant]] + val value = "NotAnInstant" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("LocalDate standard format") { + val jf = implicitly[Format[LocalDate]] + val value = "2007-12-03" + val obj = LocalDate.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("LocalDate wrong format exception") { + val jf = implicitly[Format[LocalDate]] + val value = "NotALocalDate" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("LocalDateTime standard format") { + val jf = implicitly[Format[LocalDateTime]] + val value = "2007-12-03T10:15:30" + val obj = LocalDateTime.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("LocalDateTime wrong format exception") { + val jf = implicitly[Format[LocalDateTime]] + val value = "NotALocalDateTime" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("LocalTime standard format") { + val jf = implicitly[Format[LocalTime]] + val value = "10:15:30" + val obj = LocalTime.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("LocalTime wrong format exception") { + val jf = implicitly[Format[LocalTime]] + val value = "NotALocalTime" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("Month standard format") { + val jf = implicitly[Format[Month]] + val value = 12 + val obj = Month.of(value) + + jf.writes(obj) shouldBe JsNumber(value) + jf.reads(JsNumber(value)) shouldBe JsSuccess(obj) + } + + test("Month wrong format exception") { + val jf = implicitly[Format[Month]] + val value = 13 + + assertThrows[DecodeErrorException](jf.reads(JsNumber(value))) + } + + test("MonthDay standard format") { + val jf = implicitly[Format[MonthDay]] + val value = "--12-03" + val obj = MonthDay.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("MonthDay wrong format exception") { + val jf = implicitly[Format[MonthDay]] + val value = "NotAMonthDay" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("OffsetDateTime standard format") { + val jf = implicitly[Format[OffsetDateTime]] + val value = "2011-12-03T10:15:30+01:00" + val obj = OffsetDateTime.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("OffsetDateTime wrong format exception") { + val jf = implicitly[Format[OffsetDateTime]] + val value = "NotAnOffsetDateTime" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("OffsetTime standard format") { + val jf = implicitly[Format[OffsetTime]] + val value = "10:15:30+01:00" + val obj = OffsetTime.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("OffsetTime wrong format exception") { + val jf = implicitly[Format[OffsetTime]] + val value = "NotAnOffsetTime" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("Period standard format") { + val jf = implicitly[Format[Period]] + val value = "P2Y" + val obj = Period.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("Period wrong format exception") { + val jf = implicitly[Format[Period]] + val value = "NotAPeriod" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("Year standard format") { + val jf = implicitly[Format[Year]] + val value = 2007 + val obj = Year.of(value) + + jf.writes(obj) shouldBe JsNumber(value) + jf.reads(JsNumber(value)) shouldBe JsSuccess(obj) + } + + test("Year wrong format exception") { + val jf = implicitly[Format[Year]] + val value = Int.MinValue + + assertThrows[DecodeErrorException](jf.reads(JsNumber(value))) + } + + test("YearMonth standard format") { + val jf = implicitly[Format[YearMonth]] + val value = "2011-12" + val obj = YearMonth.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("YearMonth wrong format exception") { + val jf = implicitly[Format[YearMonth]] + val value = "NotAYearMonth" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("ZoneId standard format") { + val jf = implicitly[Format[ZoneId]] + val value = "Europe/Warsaw" + val obj = ZoneId.of(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("ZoneId wrong format exception") { + val jf = implicitly[Format[ZoneId]] + val value = "NotAZoneId" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("ZoneOffset standard format") { + val jf = implicitly[Format[ZoneOffset]] + val value = "+01:00" + val obj = ZoneOffset.of(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("ZoneOffset wrong format exception") { + val jf = implicitly[Format[ZoneOffset]] + val value = "NotAZoneOffset" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("ZonedDateTime standard format") { + val jf = implicitly[Format[ZonedDateTime]] + val value = "2011-12-03T10:15:30+01:00[Europe/Warsaw]" + val obj = ZonedDateTime.parse(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("ZonedDateTime wrong format exception") { + val jf = implicitly[Format[ZonedDateTime]] + val value = "NotAZoneOffset" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + +} diff --git a/play-json/src/test/scala/instances/UtilInstancesTests.scala b/play-json/src/test/scala/instances/UtilInstancesTests.scala new file mode 100644 index 00000000..c59cfa7e --- /dev/null +++ b/play-json/src/test/scala/instances/UtilInstancesTests.scala @@ -0,0 +1,63 @@ +package instances + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.instances.InstanceConverter.DecodeErrorException +import pl.iterators.kebs.instances.UtilInstances +import play.api.libs.json.{Format, JsString, JsSuccess} + +import java.util.{Currency, Locale, UUID} + +class UtilInstancesTests extends AnyFunSuite with Matchers with UtilInstances { + import pl.iterators.kebs.json._ + test("No CaseClass1Rep implicits derived") { + + "implicitly[CaseClass1Rep[Currency, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Currency]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Locale, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, Locale]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[UUID, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, UUID]]" shouldNot typeCheck + } + + test("Currency standard format") { + val jf = implicitly[Format[Currency]] + val value = "PLN" + val obj = Currency.getInstance(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("Currency wrong format exception") { + val jf = implicitly[Format[Currency]] + val value = "not a Currency" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } + + test("Locale standard format") { + val jf = implicitly[Format[Locale]] + val value = "pl-PL" + val obj = Locale.forLanguageTag(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("UUID standard format") { + val jf = implicitly[Format[UUID]] + val value = "123e4567-e89b-12d3-a456-426614174000" + val obj = UUID.fromString(value) + + jf.writes(obj) shouldBe JsString(value) + jf.reads(JsString(value)) shouldBe JsSuccess(obj) + } + + test("UUID wrong format exception") { + val jf = implicitly[Format[UUID]] + val value = "not an UUID" + + assertThrows[DecodeErrorException](jf.reads(JsString(value))) + } +}