diff --git a/.gitignore b/.gitignore index 0f11578f..51da3ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ node_modules # Ignore compiled assets /public/assets +.java-version \ No newline at end of file diff --git a/enumeratum-play-json/src/main/scala/enumeratum/EnumFormats.scala b/enumeratum-play-json/src/main/scala/enumeratum/EnumFormats.scala index 4ce4c83a..41677651 100644 --- a/enumeratum-play-json/src/main/scala/enumeratum/EnumFormats.scala +++ b/enumeratum-play-json/src/main/scala/enumeratum/EnumFormats.scala @@ -15,37 +15,47 @@ object EnumFormats { */ def reads[A <: EnumEntry]( @deprecatedName(Symbol("enum")) e: Enum[A], - insensitive: Boolean = false + insensitive: Boolean = false, + detailedError: Boolean = false ): Reads[A] = - readsAndExtracts[A](e) { s => + readsAndExtracts[A](e, detailedError) { s => if (insensitive) e.withNameInsensitiveOption(s) else e.withNameOption(s) } - def readsLowercaseOnly[A <: EnumEntry](@deprecatedName(Symbol("enum")) e: Enum[A]): Reads[A] = - readsAndExtracts[A](e)(e.withNameLowercaseOnlyOption) + def readsLowercaseOnly[A <: EnumEntry]( + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false + ): Reads[A] = + readsAndExtracts[A](e, detailedError)(e.withNameLowercaseOnlyOption) - def readsUppercaseOnly[A <: EnumEntry](@deprecatedName(Symbol("enum")) e: Enum[A]): Reads[A] = - readsAndExtracts[A](e)(e.withNameUppercaseOnlyOption) + def readsUppercaseOnly[A <: EnumEntry]( + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false + ): Reads[A] = + readsAndExtracts[A](e, detailedError)(e.withNameUppercaseOnlyOption) def keyReads[A <: EnumEntry]( @deprecatedName(Symbol("enum")) e: Enum[A], - insensitive: Boolean = false + insensitive: Boolean = false, + detailedError: Boolean = false ): KeyReads[A] = - readsKeyAndExtracts[A](e) { s => + readsKeyAndExtracts[A](e, detailedError) { s => if (insensitive) e.withNameInsensitiveOption(s) else e.withNameOption(s) } def keyReadsLowercaseOnly[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false ): KeyReads[A] = - readsKeyAndExtracts[A](e)(e.withNameLowercaseOnlyOption) + readsKeyAndExtracts[A](e, detailedError)(e.withNameLowercaseOnlyOption) def keyReadsUppercaseOnly[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false ): KeyReads[A] = - readsKeyAndExtracts[A](e)(e.withNameUppercaseOnlyOption) + readsKeyAndExtracts[A](e, detailedError)(e.withNameUppercaseOnlyOption) /** Returns a Json writes for a given enum [[Enum]] */ @@ -102,9 +112,10 @@ object EnumFormats { */ def formats[A <: EnumEntry]( @deprecatedName(Symbol("enum")) e: Enum[A], - insensitive: Boolean = false + insensitive: Boolean = false, + detailedError: Boolean = false ): Format[A] = { - Format(reads(e, insensitive), writes(e)) + Format(reads(e, insensitive, detailedError), writes(e)) } /** Returns a Json format for a given enum [[Enum]] for handling lower case transformations @@ -113,9 +124,10 @@ object EnumFormats { * The enum */ def formatsLowerCaseOnly[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false ): Format[A] = { - Format(readsLowercaseOnly(e), writesLowercaseOnly(e)) + Format(readsLowercaseOnly(e, detailedError), writesLowercaseOnly(e)) } /** Returns a Json format for a given enum [[Enum]] for handling upper case transformations @@ -124,31 +136,48 @@ object EnumFormats { * The enum */ def formatsUppercaseOnly[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean = false ): Format[A] = { - Format(readsUppercaseOnly(e), writesUppercaseOnly(e)) + Format(readsUppercaseOnly(e, detailedError), writesUppercaseOnly(e)) } // --- private def readsAndExtracts[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean )(extract: String => Option[A]): Reads[A] = Reads[A] { case JsString(s) => extract(s) match { case Some(obj) => JsSuccess(obj) - case None => JsError("error.expected.validenumvalue") + case None if detailedError => + JsError( + JsonValidationError( + "error.expected.validenumvalue", + s"valid enum values are: (${e.values.map(_.entryName).mkString(", ")}), but provided: $s" + ) + ) + case None => JsError("error.expected.validenumvalue") } case _ => JsError("error.expected.enumstring") } private def readsKeyAndExtracts[A <: EnumEntry]( - @deprecatedName(Symbol("enum")) e: Enum[A] + @deprecatedName(Symbol("enum")) e: Enum[A], + detailedError: Boolean )(extract: String => Option[A]): KeyReads[A] = new KeyReads[A] { def readKey(s: String): JsResult[A] = extract(s) match { case Some(obj) => JsSuccess(obj) - case None => JsError("error.expected.validenumvalue") + case None if detailedError => + JsError( + JsonValidationError( + "error.expected.validenumvalue", + s"valid enum values are: (${e.values.map(_.entryName).mkString(", ")}), but provided: $s" + ) + ) + case None => JsError("error.expected.validenumvalue") } } } diff --git a/enumeratum-play-json/src/main/scala/enumeratum/PlayDetailedErrorJsonEnum.scala b/enumeratum-play-json/src/main/scala/enumeratum/PlayDetailedErrorJsonEnum.scala new file mode 100644 index 00000000..a4bf1529 --- /dev/null +++ b/enumeratum-play-json/src/main/scala/enumeratum/PlayDetailedErrorJsonEnum.scala @@ -0,0 +1,20 @@ +package enumeratum + +import play.api.libs.json._ + +trait PlayDetailedErrorJsonEnum[A <: EnumEntry] { self: Enum[A] => + implicit val keyWrites: KeyWrites[A] = EnumFormats.keyWrites(this) + + implicit def contraKeyWrites[K <: A]: KeyWrites[K] = { + val w = this.keyWrites + + new KeyWrites[K] { + def writeKey(k: K) = w.writeKey(k) + } + } + + implicit val keyReads: KeyReads[A] = EnumFormats.keyReads(this, detailedError = true) + + implicit val jsonFormat: Format[A] = EnumFormats.formats(this, detailedError = true) + implicit def contraJsonWrites[B <: A]: Writes[B] = jsonFormat.contramap[B](b => b: A) +} diff --git a/enumeratum-play-json/src/test/scala/enumeratum/Dummy.scala b/enumeratum-play-json/src/test/scala/enumeratum/Dummy.scala index 1a5c5c8f..a8a36382 100644 --- a/enumeratum-play-json/src/test/scala/enumeratum/Dummy.scala +++ b/enumeratum-play-json/src/test/scala/enumeratum/Dummy.scala @@ -37,3 +37,11 @@ object UppercaseDummy extends Enum[UppercaseDummy] with PlayUppercaseJsonEnum[Up case object Cherry extends UppercaseDummy val values = findValues } + +sealed trait Operation extends EnumEntry +object Operation extends Enum[Operation] with PlayDetailedErrorJsonEnum[Operation] { + val values = findValues + case object Eq extends Operation + case object Add extends Operation + case object Not extends Operation +} diff --git a/enumeratum-play-json/src/test/scala/enumeratum/EnumFormatsSpec.scala b/enumeratum-play-json/src/test/scala/enumeratum/EnumFormatsSpec.scala index c06e2758..75160b6e 100644 --- a/enumeratum-play-json/src/test/scala/enumeratum/EnumFormatsSpec.scala +++ b/enumeratum-play-json/src/test/scala/enumeratum/EnumFormatsSpec.scala @@ -11,17 +11,50 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { descriptor = "normal operation", reads = EnumFormats.reads(Dummy), readSuccessExpectations = Map("A" -> Dummy.A), - readErrors = Map("C" -> Seq("error.expected.validenumvalue")), + readErrors = Map( + "C" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) + ), writes = EnumFormats.writes(Dummy), writeExpectations = Map(Dummy.A -> "A"), formats = EnumFormats.formats(Dummy) ) + testDetailedErrorScenario( + descriptor = "normal operation with detailed error", + reads = EnumFormats.reads(Dummy, detailedError = true), + readSuccessExpectations = Map("A" -> Dummy.A), + readErrors = Map( + "C" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: C") + ) + ), + writes = EnumFormats.writes(Dummy), + writeExpectations = Map(Dummy.A -> "A"), + formats = EnumFormats.formats(Dummy, detailedError = true) + ) + testKeyScenario( descriptor = "normal operation", reads = EnumFormats.keyReads(Dummy), readSuccessExpectations = Map("A" -> Dummy.A), - readErrors = Map("C" -> Seq("error.expected.validenumvalue")), + readErrors = Map( + "C" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) + ), + writes = EnumFormats.keyWrites(Dummy), + writeExpectations = Map(Dummy.A -> "A") + ) + + testKeyScenario( + descriptor = "normal operation with detailed error", + reads = EnumFormats.keyReads(Dummy, detailedError = true), + readSuccessExpectations = Map("A" -> Dummy.A), + readErrors = Map( + "C" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: C") + ) + ), writes = EnumFormats.keyWrites(Dummy), writeExpectations = Map(Dummy.A -> "A") ) @@ -51,6 +84,31 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { writeExpectations = Map(Dummy.A -> "A") ) + testDetailedErrorScenario( + descriptor = "case insensitive with detailed error", + reads = EnumFormats.reads(e = Dummy, insensitive = true, detailedError = true), + readSuccessExpectations = Map( + "A" -> Dummy.A, + "a" -> Dummy.A + ), + readErrors = Map.empty, + writes = EnumFormats.writes(Dummy), + writeExpectations = Map(Dummy.A -> "A"), + formats = EnumFormats.formats(e = Dummy, insensitive = true, detailedError = true) + ) + + testKeyScenario( + descriptor = "case insensitive with detailed error", + reads = EnumFormats.keyReads(e = Dummy, insensitive = true, detailedError = true), + readSuccessExpectations = Map( + "A" -> Dummy.A, + "a" -> Dummy.A + ), + readErrors = Map.empty, + writes = EnumFormats.keyWrites(Dummy), + writeExpectations = Map(Dummy.A -> "A") + ) + testScenario( descriptor = "lower case transformed", reads = EnumFormats.readsLowercaseOnly(Dummy), @@ -58,7 +116,7 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { "a" -> Dummy.A ), readErrors = Map( - "A" -> Seq("error.expected.validenumvalue") + "A" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) ), writes = EnumFormats.writesLowercaseOnly(Dummy), writeExpectations = Map(Dummy.A -> "a"), @@ -72,7 +130,40 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { "a" -> Dummy.A ), readErrors = Map( - "A" -> Seq("error.expected.validenumvalue") + "A" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) + ), + writes = EnumFormats.keyWritesLowercaseOnly(Dummy), + writeExpectations = Map(Dummy.A -> "a") + ) + + testDetailedErrorScenario( + descriptor = "lower case transformed with detailed error", + reads = EnumFormats.readsLowercaseOnly(Dummy, detailedError = true), + readSuccessExpectations = Map( + "a" -> Dummy.A + ), + readErrors = Map( + "A" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: A") + ) + ), + writes = EnumFormats.writesLowercaseOnly(Dummy), + writeExpectations = Map(Dummy.A -> "a"), + formats = EnumFormats.formatsLowerCaseOnly(Dummy, detailedError = true) + ) + + testKeyScenario( + descriptor = "lower case transformed with detailed error", + reads = EnumFormats.keyReadsLowercaseOnly(Dummy, detailedError = true), + readSuccessExpectations = Map( + "a" -> Dummy.A + ), + readErrors = Map( + "A" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: A") + ) ), writes = EnumFormats.keyWritesLowercaseOnly(Dummy), writeExpectations = Map(Dummy.A -> "a") @@ -86,7 +177,7 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { "C" -> Dummy.c ), readErrors = Map( - "a" -> Seq("error.expected.validenumvalue") + "a" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) ), writes = EnumFormats.writesUppercaseOnly(Dummy), writeExpectations = Map(Dummy.A -> "A"), @@ -101,7 +192,42 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { "C" -> Dummy.c ), readErrors = Map( - "a" -> Seq("error.expected.validenumvalue") + "a" -> ReadError.onlyMessages(Seq("error.expected.validenumvalue")) + ), + writes = EnumFormats.keyWritesUppercaseOnly(Dummy), + writeExpectations = Map(Dummy.A -> "A") + ) + + testDetailedErrorScenario( + descriptor = "upper case transformed with detailed error", + reads = EnumFormats.readsUppercaseOnly(Dummy, detailedError = true), + readSuccessExpectations = Map( + "A" -> Dummy.A, + "C" -> Dummy.c + ), + readErrors = Map( + "a" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: a") + ) + ), + writes = EnumFormats.writesUppercaseOnly(Dummy), + writeExpectations = Map(Dummy.A -> "A"), + formats = EnumFormats.formatsUppercaseOnly(Dummy, detailedError = true) + ) + + testKeyScenario( + descriptor = "upper case transformed with detailed error", + reads = EnumFormats.keyReadsUppercaseOnly(Dummy, detailedError = true), + readSuccessExpectations = Map( + "A" -> Dummy.A, + "C" -> Dummy.c + ), + readErrors = Map( + "a" -> ReadError( + errorMessages = Seq("error.expected.validenumvalue"), + errorArgs = Seq("valid enum values are: (A, B, c), but provided: a") + ) ), writes = EnumFormats.keyWritesUppercaseOnly(Dummy), writeExpectations = Map(Dummy.A -> "A") @@ -111,24 +237,56 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { private def errorMessages(jsResult: JsResult[_]): scala.collection.Seq[String] = jsResult.fold( - _.collect { case (path, errors) => + _.collect { case (_, errors) => errors.map(_.message).mkString }, _ => Seq.empty ) + private def errorArgs(jsResult: JsResult[_]): scala.collection.Seq[String] = + jsResult + .fold( + _.collect { case (_, errors) => + errors.flatMap(_.args) + }, + _ => Seq.empty + ) + .flatten + .map(_.toString) + private def testScenario( descriptor: String, reads: Reads[Dummy], readSuccessExpectations: Map[String, Dummy], - readErrors: Map[String, Seq[String]], + readErrors: Map[String, ReadError], + writes: Writes[Dummy], + writeExpectations: Map[Dummy, String], + formats: Format[Dummy], + detailedError: Boolean = false + ): Unit = describe(descriptor) { + testReads(reads, readSuccessExpectations, readErrors, detailedError) + testWrites(writes, writeExpectations) + testFormats(formats, readSuccessExpectations, readErrors, writeExpectations, detailedError) + } + + private def testDetailedErrorScenario( + descriptor: String, + reads: Reads[Dummy], + readSuccessExpectations: Map[String, Dummy], + readErrors: Map[String, ReadError], writes: Writes[Dummy], writeExpectations: Map[Dummy, String], formats: Format[Dummy] ): Unit = describe(descriptor) { - testReads(reads, readSuccessExpectations, readErrors) + testReads(reads, readSuccessExpectations, readErrors, detailedError = true) testWrites(writes, writeExpectations) - testFormats(formats, readSuccessExpectations, readErrors, writeExpectations) + testFormats( + formats, + readSuccessExpectations, + readErrors, + writeExpectations, + detailedError = true + ) } /** Shared scenarios for testing Reads @@ -136,14 +294,22 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { private def testReads( reads: Reads[Dummy], expectedSuccesses: Map[String, Dummy], - expectedErrors: Map[String, Seq[String]] + expectedErrors: Map[String, ReadError], + detailedError: Boolean ): Unit = describe("Reads") { - val expectedFails: Map[JsValue, Seq[String]] = { + val expectedFails: Map[JsValue, ReadError] = { val withJsValueKeys = expectedErrors.map { case (k, v) => JsString(k) -> v } // Add standard errors (withJsValueKeys ++ Map( - JsNumber(2) -> Seq("error.expected.enumstring"), - JsString("D") -> Seq("error.expected.validenumvalue") + JsNumber(2) -> ReadError.onlyMessages(Seq("error.expected.enumstring")), + JsString("D") -> (if (detailedError) { + ReadError( + Seq("error.expected.validenumvalue"), + Seq("valid enum values are: (A, B, c), but provided: D") + ) + } else { + ReadError.onlyMessages(Seq("error.expected.validenumvalue")) + }) )).toMap } @@ -157,7 +323,9 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { expectedFails.foreach { case (k, v) => val result = reads.reads(k) result.isError shouldBe true - errorMessages(result) shouldBe v + errorMessages(result) shouldBe v.errorMessages + val strings = errorArgs(result) + strings shouldBe v.errorArgs } } } @@ -178,10 +346,11 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { private def testFormats( formats: Format[Dummy], expectedReadSuccesses: Map[String, Dummy], - expectedReadErrors: Map[String, Seq[String]], - expectedWrites: Map[Dummy, String] + expectedReadErrors: Map[String, ReadError], + expectedWrites: Map[Dummy, String], + detailedError: Boolean ): Unit = describe("Formats") { - testReads(formats, expectedReadSuccesses, expectedReadErrors) + testReads(formats, expectedReadSuccesses, expectedReadErrors, detailedError) testWrites(formats, expectedWrites) } @@ -189,7 +358,7 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { descriptor: String, reads: KeyReads[Dummy], readSuccessExpectations: Map[String, Dummy], - readErrors: Map[String, Seq[String]], + readErrors: Map[String, ReadError], writes: KeyWrites[Dummy], writeExpectations: Map[Dummy, String] ): Unit = describe(descriptor) { @@ -200,7 +369,7 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { private def testKeyReads( reads: KeyReads[Dummy], expectedSuccesses: Map[String, Dummy], - expectedErrors: Map[String, Seq[String]] + expectedErrors: Map[String, ReadError] ): Unit = describe("KeyReads") { it("should create a KeyReads that works with valid values") { expectedSuccesses.foreach { case (name, expected) => @@ -212,7 +381,8 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers { expectedErrors.foreach { case (k, v) => val result = reads.readKey(k) result.isError shouldBe true - errorMessages(result) shouldBe v + errorMessages(result) shouldBe v.errorMessages + errorArgs(result) shouldBe v.errorArgs } } } diff --git a/enumeratum-play-json/src/test/scala/enumeratum/PlayJsonEnumSpec.scala b/enumeratum-play-json/src/test/scala/enumeratum/PlayJsonEnumSpec.scala index e3c5fa9d..f416ccb2 100644 --- a/enumeratum-play-json/src/test/scala/enumeratum/PlayJsonEnumSpec.scala +++ b/enumeratum-play-json/src/test/scala/enumeratum/PlayJsonEnumSpec.scala @@ -1,11 +1,12 @@ package enumeratum +import org.scalatest.Inside +import org.scalatest.OptionValues._ import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers -import play.api.libs.json.{JsNumber, JsString, JsSuccess, Json => PlayJson, Writes} -import org.scalatest.OptionValues._ +import play.api.libs.json.{Json => PlayJson, _} -class PlayJsonEnumSpec extends AnyFunSpec with Matchers { +class PlayJsonEnumSpec extends AnyFunSpec with Matchers with Inside { describe("JSON serdes") { @@ -62,6 +63,25 @@ class PlayJsonEnumSpec extends AnyFunSpec with Matchers { Map(UppercaseDummy.Apple -> 4) ) } + + describe("detailed error") { + it("should work with valid values") { + JsString("Add").asOpt[Operation].value shouldBe Operation.Add + JsString("Eq").asOpt[Operation].value shouldBe Operation.Eq + } + + it("should fail with detailed error message") { + val jsError = JsString("Subtract").validate[Operation] + jsError shouldBe an[JsError] + + inside(jsError) { case JsError((_, jve :: Nil) :: Nil) => + jve shouldBe JsonValidationError( + List("error.expected.validenumvalue"), + "valid enum values are: (Eq, Add, Not), but provided: Subtract" + ) + } + } + } } describe("serialisation") { diff --git a/enumeratum-play-json/src/test/scala/enumeratum/ReadError.scala b/enumeratum-play-json/src/test/scala/enumeratum/ReadError.scala new file mode 100644 index 00000000..dfbdc74d --- /dev/null +++ b/enumeratum-play-json/src/test/scala/enumeratum/ReadError.scala @@ -0,0 +1,8 @@ +package enumeratum + +case class ReadError(errorMessages: Iterable[String], errorArgs: Iterable[String]) + +object ReadError { + def onlyMessages(errorMessages: Iterable[String]): ReadError = + new ReadError(errorMessages, errorArgs = Iterable()) +}