From bdf9ec57f213eb652ba5fb3b21973d028034d40e Mon Sep 17 00:00:00 2001 From: Sergey Nastich Date: Tue, 3 Apr 2018 10:57:14 -0700 Subject: Add enumeratum support to JSON format and generators (#144) * Add enumeratum support to JSON format and generators * Move enumeratum serializers into their own object. Add enumeratum unmarshaller. Add entities to derive JsonFormats instead of having to type them externally. --- build.sbt | 3 +- src/main/scala/xyz/driver/core/date.scala | 17 ++--- src/main/scala/xyz/driver/core/generators.scala | 3 + src/main/scala/xyz/driver/core/json.scala | 53 ++++++++++++---- .../scala/xyz/driver/core/GeneratorsTest.scala | 20 ++++++ src/test/scala/xyz/driver/core/JsonTest.scala | 72 +++++++++++++++++++++- 6 files changed, 148 insertions(+), 20 deletions(-) diff --git a/build.sbt b/build.sbt index 88e4582..1249336 100644 --- a/build.sbt +++ b/build.sbt @@ -19,7 +19,8 @@ lazy val core = (project in file(".")) "com.typesafe.scala-logging" %% "scala-logging" % "3.5.0", "eu.timepit" %% "refined" % "0.8.4", "com.typesafe.slick" %% "slick" % "3.2.1", - "org.mockito" % "mockito-core" % "1.9.5" % "test", + "com.beachape" %% "enumeratum" % "1.5.13", + "org.mockito" % "mockito-core" % "1.9.5" % Test, "com.amazonaws" % "aws-java-sdk-s3" % "1.11.26", "com.google.cloud" % "google-cloud-pubsub" % "0.25.0-beta", "com.google.cloud" % "google-cloud-storage" % "1.7.0", diff --git a/src/main/scala/xyz/driver/core/date.scala b/src/main/scala/xyz/driver/core/date.scala index fe35c91..5454093 100644 --- a/src/main/scala/xyz/driver/core/date.scala +++ b/src/main/scala/xyz/driver/core/date.scala @@ -2,12 +2,13 @@ package xyz.driver.core import java.util.Calendar -import scala.util.Try - +import enumeratum._ import scalaz.std.anyVal._ -import scalaz.Scalaz.stringInstance import scalaz.syntax.equal._ +import scala.collection.immutable.IndexedSeq +import scala.util.Try + /** * Driver Date type and related validators/extractors. * Day, Month, and Year extractors are from ISO 8601 strings => driver...Date integers. @@ -15,8 +16,8 @@ import scalaz.syntax.equal._ */ object date { - sealed trait DayOfWeek - object DayOfWeek { + sealed trait DayOfWeek extends EnumEntry + object DayOfWeek extends Enum[DayOfWeek] { case object Monday extends DayOfWeek case object Tuesday extends DayOfWeek case object Wednesday extends DayOfWeek @@ -25,9 +26,11 @@ object date { case object Saturday extends DayOfWeek case object Sunday extends DayOfWeek - val All: Set[DayOfWeek] = Set(Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday) + val values: IndexedSeq[DayOfWeek] = findValues + + val All: Set[DayOfWeek] = values.toSet - def fromString(day: String): Option[DayOfWeek] = All.find(_.toString === day) + def fromString(day: String): Option[DayOfWeek] = withNameInsensitiveOption(day) } type Day = Int @@ Day.type diff --git a/src/main/scala/xyz/driver/core/generators.scala b/src/main/scala/xyz/driver/core/generators.scala index 143044c..3c85447 100644 --- a/src/main/scala/xyz/driver/core/generators.scala +++ b/src/main/scala/xyz/driver/core/generators.scala @@ -1,5 +1,6 @@ package xyz.driver.core +import enumeratum._ import java.math.MathContext import java.util.UUID @@ -91,6 +92,8 @@ object generators { def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) + def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) + def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = Array.fill(nextInt(maxLength, minLength))(generator) diff --git a/src/main/scala/xyz/driver/core/json.scala b/src/main/scala/xyz/driver/core/json.scala index 4d7fa04..06a8837 100644 --- a/src/main/scala/xyz/driver/core/json.scala +++ b/src/main/scala/xyz/driver/core/json.scala @@ -3,21 +3,23 @@ package xyz.driver.core import java.net.InetAddress import java.util.{TimeZone, UUID} -import scala.reflect.runtime.universe._ -import scala.util.Try +import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} import akka.http.scaladsl.model.Uri.Path -import akka.http.scaladsl.server._ import akka.http.scaladsl.server.PathMatcher.{Matched, Unmatched} -import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} +import akka.http.scaladsl.server._ import akka.http.scaladsl.unmarshalling.Unmarshaller +import enumeratum._ +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV import spray.json._ import xyz.driver.core.auth.AuthCredentials import xyz.driver.core.date.{Date, DayOfWeek, Month} import xyz.driver.core.domain.{Email, PhoneNumber} import xyz.driver.core.time.{Time, TimeOfDay} -import eu.timepit.refined.refineV -import eu.timepit.refined.api.{Refined, Validate} -import eu.timepit.refined.collection.NonEmpty + +import scala.reflect.runtime.universe._ +import scala.util.Try object json { import DefaultJsonProtocol._ @@ -107,8 +109,7 @@ object json { implicit val timeOfDayFormat: RootJsonFormat[TimeOfDay] = jsonFormat2(TimeOfDay.apply) - implicit val dayOfWeekFormat: JsonFormat[DayOfWeek] = - new EnumJsonFormat[DayOfWeek](DayOfWeek.All.map(w => w.toString -> w)(collection.breakOut): _*) + implicit val dayOfWeekFormat: JsonFormat[DayOfWeek] = new enumeratum.EnumJsonFormat(DayOfWeek) implicit val dateFormat = new RootJsonFormat[Date] { def write(date: Date) = JsString(date.toString) @@ -136,9 +137,9 @@ object json { } implicit def revisionFromStringUnmarshaller[T]: Unmarshaller[String, Revision[T]] = - Unmarshaller.strict[String, Revision[T]](Revision[T](_)) + Unmarshaller.strict[String, Revision[T]](Revision[T]) - implicit def revisionFormat[T] = new RootJsonFormat[Revision[T]] { + implicit def revisionFormat[T]: RootJsonFormat[Revision[T]] = new RootJsonFormat[Revision[T]] { def write(revision: Revision[T]) = JsString(revision.id.toString) def read(value: JsValue): Revision[T] = value match { @@ -186,6 +187,36 @@ object json { JsString(obj.getHostAddress) } + object enumeratum { + + def enumUnmarshaller[T <: EnumEntry](enum: Enum[T]): Unmarshaller[String, T] = + Unmarshaller.strict { value => + enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) + } + + trait HasJsonFormat[T <: EnumEntry] { enum: Enum[T] => + + implicit val format: JsonFormat[T] = new EnumJsonFormat(enum) + + implicit val unmarshaller: Unmarshaller[String, T] = + Unmarshaller.strict { value => + enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) + } + } + + class EnumJsonFormat[T <: EnumEntry](enum: Enum[T]) extends JsonFormat[T] { + override def read(json: JsValue): T = json match { + case JsString(name) => enum.withNameOption(name).getOrElse(unrecognizedValue(name, enum.values)) + case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) + } + + override def write(obj: T): JsValue = JsString(obj.entryName) + } + + private def unrecognizedValue(value: String, possibleValues: Seq[Any]): Nothing = + deserializationError(s"Unexpected value $value. Expected one of: ${possibleValues.mkString("[", ", ", "]")}") + } + class EnumJsonFormat[T](mapping: (String, T)*) extends RootJsonFormat[T] { private val map = mapping.toMap diff --git a/src/test/scala/xyz/driver/core/GeneratorsTest.scala b/src/test/scala/xyz/driver/core/GeneratorsTest.scala index 62ba7ae..53a3aa9 100644 --- a/src/test/scala/xyz/driver/core/GeneratorsTest.scala +++ b/src/test/scala/xyz/driver/core/GeneratorsTest.scala @@ -2,6 +2,8 @@ package xyz.driver.core import org.scalatest.{Assertions, FlatSpec, Matchers} +import scala.collection.immutable.IndexedSeq + class GeneratorsTest extends FlatSpec with Matchers with Assertions { import generators._ @@ -175,6 +177,24 @@ class GeneratorsTest extends FlatSpec with Matchers with Assertions { Set(pick1, pick2, pick3, pick4, pick5, pick6).size should be >= 1 } + it should "be able to generate a specific value from an enumeratum enum" in { + + import enumeratum._ + sealed trait TestEnumValue extends EnumEntry + object TestEnum extends Enum[TestEnumValue] { + case object Value1 extends TestEnumValue + case object Value2 extends TestEnumValue + case object Value3 extends TestEnumValue + case object Value4 extends TestEnumValue + val values: IndexedSeq[TestEnumValue] = findValues + } + + val picks = (1 to 100).map(_ => generators.oneOf(TestEnum)) + + TestEnum.values should contain allElementsOf picks + picks.toSet.size should be >= 1 + } + it should "be able to generate array with values generated by generators" in { val arrayOfTimes = arrayOf(nextTime(), 16) diff --git a/src/test/scala/xyz/driver/core/JsonTest.scala b/src/test/scala/xyz/driver/core/JsonTest.scala index 827624c..7e8dba2 100644 --- a/src/test/scala/xyz/driver/core/JsonTest.scala +++ b/src/test/scala/xyz/driver/core/JsonTest.scala @@ -2,6 +2,7 @@ package xyz.driver.core import java.net.InetAddress +import enumeratum._ import eu.timepit.refined.collection.NonEmpty import eu.timepit.refined.numeric.Positive import eu.timepit.refined.refineMV @@ -11,8 +12,11 @@ import xyz.driver.core.time.provider.SystemTimeProvider import spray.json._ import xyz.driver.core.TestTypes.CustomGADT import xyz.driver.core.domain.{Email, PhoneNumber} +import xyz.driver.core.json.enumeratum.HasJsonFormat import xyz.driver.core.time.TimeOfDay +import scala.collection.immutable.IndexedSeq + class JsonTest extends FlatSpec with Matchers { import DefaultJsonProtocol._ @@ -116,7 +120,7 @@ class JsonTest extends FlatSpec with Matchers { parsedPhoneNumber should be(referencePhoneNumber) } - "Json format for Enums" should "read and write correct JSON" in { + "Json format for ADT mappings" should "read and write correct JSON" in { sealed trait EnumVal case object Val1 extends EnumVal @@ -141,6 +145,72 @@ class JsonTest extends FlatSpec with Matchers { parsedEnumValue2 should be(referenceEnumValue2) } + "Json format for Enums (external)" should "read and write correct JSON" in { + + sealed trait MyEnum extends EnumEntry + object MyEnum extends Enum[MyEnum] { + case object Val1 extends MyEnum + case object `Val 2` extends MyEnum + case object `Val/3` extends MyEnum + + val values: IndexedSeq[MyEnum] = findValues + } + + val format = new enumeratum.EnumJsonFormat(MyEnum) + + val referenceEnumValue1 = MyEnum.`Val 2` + val referenceEnumValue2 = MyEnum.`Val/3` + + val writtenJson1 = format.write(referenceEnumValue1) + writtenJson1 shouldBe JsString("Val 2") + + val writtenJson2 = format.write(referenceEnumValue2) + writtenJson2 shouldBe JsString("Val/3") + + val parsedEnumValue1 = format.read(writtenJson1) + val parsedEnumValue2 = format.read(writtenJson2) + + parsedEnumValue1 shouldBe referenceEnumValue1 + parsedEnumValue2 shouldBe referenceEnumValue2 + + intercept[DeserializationException] { + format.read(JsString("Val4")) + }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" + } + + "Json format for Enums (automatic)" should "read and write correct JSON and not require import" in { + + sealed trait MyEnum extends EnumEntry + object MyEnum extends Enum[MyEnum] with HasJsonFormat[MyEnum] { + case object Val1 extends MyEnum + case object `Val 2` extends MyEnum + case object `Val/3` extends MyEnum + + val values: IndexedSeq[MyEnum] = findValues + } + + val referenceEnumValue1: MyEnum = MyEnum.`Val 2` + val referenceEnumValue2: MyEnum = MyEnum.`Val/3` + + val writtenJson1 = referenceEnumValue1.toJson + writtenJson1 shouldBe JsString("Val 2") + + val writtenJson2 = referenceEnumValue2.toJson + writtenJson2 shouldBe JsString("Val/3") + + import spray.json._ + + val parsedEnumValue1 = writtenJson1.prettyPrint.parseJson.convertTo[MyEnum] + val parsedEnumValue2 = writtenJson2.prettyPrint.parseJson.convertTo[MyEnum] + + parsedEnumValue1 should be(referenceEnumValue1) + parsedEnumValue2 should be(referenceEnumValue2) + + intercept[DeserializationException] { + JsString("Val4").convertTo[MyEnum] + }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" + } + // Should be defined outside of case to have a TypeTag case class CustomWrapperClass(value: Int) -- cgit v1.2.3