Skip to content

Commit

Permalink
Play-JSON KeyReads & KeyWrites (#302)
Browse files Browse the repository at this point in the history
* Refactor PlayJson EnumFormats

* Provides KeyReads/Writes
  • Loading branch information
cchantep committed Feb 26, 2021
1 parent 3ce7fd1 commit 1d49a09
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 44 deletions.
104 changes: 66 additions & 38 deletions enumeratum-play-json/src/main/scala/enumeratum/EnumFormats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,66 +14,74 @@ object EnumFormats {
* @param insensitive bind in a case-insensitive way, defaults to false
*/
def reads[A <: EnumEntry](enum: Enum[A], insensitive: Boolean = false): Reads[A] =
new Reads[A] {
def reads(json: JsValue): JsResult[A] = json match {
case JsString(s) => {
val maybeBound =
if (insensitive) enum.withNameInsensitiveOption(s)
else enum.withNameOption(s)
maybeBound match {
case Some(obj) => JsSuccess(obj)
case None => JsError("error.expected.validenumvalue")
}
}
case _ => JsError("error.expected.enumstring")
}
readsAndExtracts[A](enum) { s =>
if (insensitive) enum.withNameInsensitiveOption(s)
else enum.withNameOption(s)
}

def readsLowercaseOnly[A <: EnumEntry](enum: Enum[A]): Reads[A] =
new Reads[A] {
def reads(json: JsValue): JsResult[A] = json match {
case JsString(s) =>
enum.withNameLowercaseOnlyOption(s) match {
case Some(obj) => JsSuccess(obj)
case None => JsError("error.expected.validenumvalue")
}
case _ => JsError("error.expected.enumstring")
}
}
readsAndExtracts[A](enum)(enum.withNameLowercaseOnlyOption)

def readsUppercaseOnly[A <: EnumEntry](enum: Enum[A]): Reads[A] =
new Reads[A] {
def reads(json: JsValue): JsResult[A] = json match {
case JsString(s) =>
enum.withNameUppercaseOnlyOption(s) match {
case Some(obj) => JsSuccess(obj)
case None => JsError("error.expected.validenumvalue")
}
case _ => JsError("error.expected.enumstring")
}
readsAndExtracts[A](enum)(enum.withNameUppercaseOnlyOption)

def keyReads[A <: EnumEntry](enum: Enum[A], insensitive: Boolean = false): KeyReads[A] =
readsKeyAndExtracts[A](enum) { s =>
if (insensitive) enum.withNameInsensitiveOption(s)
else enum.withNameOption(s)
}

def keyReadsLowercaseOnly[A <: EnumEntry](enum: Enum[A]): KeyReads[A] =
readsKeyAndExtracts[A](enum)(enum.withNameLowercaseOnlyOption)

def keyReadsUppercaseOnly[A <: EnumEntry](enum: Enum[A]): KeyReads[A] =
readsKeyAndExtracts[A](enum)(enum.withNameUppercaseOnlyOption)

/**
* Returns a Json writes for a given enum [[Enum]]
*/
def writes[A <: EnumEntry](enum: Enum[A]): Writes[A] = new Writes[A] {
def writes(v: A): JsValue = JsString(v.entryName)
def writes[A <: EnumEntry](enum: Enum[A]): Writes[A] = Writes[A] { e =>
JsString(e.entryName)
}

/**
* Returns a Json writes for a given enum [[Enum]] and transforms it to lower case
*/
def writesLowercaseOnly[A <: EnumEntry](enum: Enum[A]): Writes[A] =
new Writes[A] {
def writes(v: A): JsValue = JsString(v.entryName.toLowerCase)
Writes[A] { e =>
JsString(e.entryName.toLowerCase)
}

/**
* Returns a Json writes for a given enum [[Enum]] and transforms it to upper case
*/
def writesUppercaseOnly[A <: EnumEntry](enum: Enum[A]): Writes[A] =
new Writes[A] {
def writes(v: A): JsValue = JsString(v.entryName.toUpperCase)
Writes[A] { e =>
JsString(e.entryName.toUpperCase)
}

/**
* Returns a Json key writes for a given enum [[Enum]]
*/
def keyWrites[A <: EnumEntry](enum: Enum[A]): KeyWrites[A] =
new KeyWrites[A] {
def writeKey(e: A): String = e.entryName
}

/**
* Returns a Json key writes for a given enum [[Enum]] and transforms it to lower case
*/
def keyWritesLowercaseOnly[A <: EnumEntry](enum: Enum[A]): KeyWrites[A] =
new KeyWrites[A] {
def writeKey(e: A) = e.entryName.toLowerCase
}

/**
* Returns a Json key writes for a given enum [[Enum]] and transforms it to upper case
*/
def keyWritesUppercaseOnly[A <: EnumEntry](enum: Enum[A]): KeyWrites[A] =
new KeyWrites[A] {
def writeKey(e: A) = e.entryName.toUpperCase
}

/**
Expand Down Expand Up @@ -104,4 +112,24 @@ object EnumFormats {
Format(readsUppercaseOnly(enum), writesUppercaseOnly(enum))
}

// ---

private def readsAndExtracts[A <: EnumEntry](enum: Enum[A])(
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 _ => JsError("error.expected.enumstring")
}

private def readsKeyAndExtracts[A <: EnumEntry](enum: Enum[A])(
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")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package enumeratum

import play.api.libs.json.{Format, Writes}
import play.api.libs.json._

trait PlayInsensitiveJsonEnum[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, insensitive = true)

implicit val jsonFormat: Format[A] = EnumFormats.formats(this, insensitive = true)
implicit def contraJsonWrites[B <: A]: Writes[B] = jsonFormat.contramap[B](b => b: A)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package enumeratum

import play.api.libs.json.{Format, Writes}
import play.api.libs.json._

trait PlayJsonEnum[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)

implicit val jsonFormat: Format[A] = EnumFormats.formats(this)
implicit def contraJsonWrites[B <: A]: Writes[B] = jsonFormat.contramap[B](b => b: A)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package enumeratum

import play.api.libs.json.{Format, Writes}
import play.api.libs.json._

trait PlayLowercaseJsonEnum[A <: EnumEntry] { self: Enum[A] =>
implicit val keyWrites: KeyWrites[A] = EnumFormats.keyWritesLowercaseOnly(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.keyReadsLowercaseOnly(this)

implicit val jsonFormat: Format[A] = EnumFormats.formatsLowerCaseOnly(this)
implicit def contraJsonWrites[B <: A]: Writes[B] = jsonFormat.contramap[B](b => b: A)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package enumeratum

import play.api.libs.json.{Format, Writes}
import play.api.libs.json._

trait PlayUppercaseJsonEnum[A <: EnumEntry] { self: Enum[A] =>
implicit val keyWrites: KeyWrites[A] = EnumFormats.keyWritesUppercaseOnly(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.keyReadsUppercaseOnly(this)

implicit val jsonFormat: Format[A] = EnumFormats.formatsUppercaseOnly(this)
implicit def contraJsonWrites[B <: A]: Writes[B] = jsonFormat.contramap[B](b => b: A)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers {
formats = EnumFormats.formats(Dummy)
)

testKeyScenario(
descriptor = "normal operation",
reads = EnumFormats.keyReads(Dummy),
readSuccessExpectations = Map("A" -> Dummy.A),
readErrors = Map("C" -> Seq("error.expected.validenumvalue")),
writes = EnumFormats.keyWrites(Dummy),
writeExpectations = Map(Dummy.A -> "A")
)

testScenario(
descriptor = "case insensitive",
reads = EnumFormats.reads(enum = Dummy, insensitive = true),
Expand All @@ -30,6 +39,18 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers {
formats = EnumFormats.formats(enum = Dummy, insensitive = true)
)

testKeyScenario(
descriptor = "case insensitive",
reads = EnumFormats.keyReads(enum = Dummy, insensitive = 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),
Expand All @@ -44,6 +65,19 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers {
formats = EnumFormats.formatsLowerCaseOnly(Dummy)
)

testKeyScenario(
descriptor = "lower case transformed",
reads = EnumFormats.keyReadsLowercaseOnly(Dummy),
readSuccessExpectations = Map(
"a" -> Dummy.A
),
readErrors = Map(
"A" -> Seq("error.expected.validenumvalue")
),
writes = EnumFormats.keyWritesLowercaseOnly(Dummy),
writeExpectations = Map(Dummy.A -> "a")
)

testScenario(
descriptor = "upper case transformed",
reads = EnumFormats.readsUppercaseOnly(Dummy),
Expand All @@ -59,6 +93,20 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers {
formats = EnumFormats.formatsUppercaseOnly(Dummy)
)

testKeyScenario(
descriptor = "upper case transformed",
reads = EnumFormats.keyReadsUppercaseOnly(Dummy),
readSuccessExpectations = Map(
"A" -> Dummy.A,
"C" -> Dummy.c
),
readErrors = Map(
"a" -> Seq("error.expected.validenumvalue")
),
writes = EnumFormats.keyWritesUppercaseOnly(Dummy),
writeExpectations = Map(Dummy.A -> "A")
)

// Bunch of shared testing methods

private def errorMessages(jsResult: JsResult[_]): scala.collection.Seq[String] =
Expand Down Expand Up @@ -142,4 +190,51 @@ class EnumFormatsSpec extends AnyFunSpec with Matchers {
testReads(formats, expectedReadSuccesses, expectedReadErrors)
testWrites(formats, expectedWrites)
}

private def testKeyScenario(
descriptor: String,
reads: KeyReads[Dummy],
readSuccessExpectations: Map[String, Dummy],
readErrors: Map[String, Seq[String]],
writes: KeyWrites[Dummy],
writeExpectations: Map[Dummy, String]
): Unit = describe(descriptor) {
testKeyReads(reads, readSuccessExpectations, readErrors)
testKeyWrites(writes, writeExpectations)
}

private def testKeyReads(
reads: KeyReads[Dummy],
expectedSuccesses: Map[String, Dummy],
expectedErrors: Map[String, Seq[String]]
): Unit = describe("KeyReads") {
it("should create a KeyReads that works with valid values") {
expectedSuccesses.foreach {
case (name, expected) =>
reads.readKey(name).asOpt.value should be(expected)
}
}

it("should create a KeyReads that fails with invalid values") {
expectedErrors.foreach {
case (k, v) =>
val result = reads.readKey(k)
result.isError shouldBe true
errorMessages(result) shouldBe v
}
}
}

/**
* Shared scenarios for testing KeyWrites
*/
private def testKeyWrites(writer: KeyWrites[Dummy], expectations: Map[Dummy, String]): Unit =
describe("KeyWrites") {
it("should create a KeyWrites that writes enum values to String") {
expectations.foreach {
case (k, v) =>
writer.writeKey(k) should be(v)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package enumeratum

import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import play.api.libs.json.{JsNumber, JsString, Json => PlayJson}
import play.api.libs.json.{JsNumber, JsString, JsSuccess, Json => PlayJson, Writes}
import org.scalatest.OptionValues._

class PlayJsonEnumSpec extends AnyFunSpec with Matchers {
Expand Down Expand Up @@ -49,17 +49,33 @@ class PlayJsonEnumSpec extends AnyFunSpec with Matchers {
JsNumber(2).asOpt[Dummy] shouldBe None
}
}

it("should deserialise from Map keys") {
PlayJson.obj("A" -> 1).validate[Map[Dummy, Int]] shouldBe JsSuccess(Map(Dummy.A -> 1))
PlayJson.obj("A" -> 2).validate[Map[InsensitiveDummy, Int]] shouldBe JsSuccess(
Map(InsensitiveDummy.A -> 2))
PlayJson.obj("apple" -> 3).validate[Map[LowercaseDummy, Int]] shouldBe JsSuccess(
Map(LowercaseDummy.Apple -> 3))
PlayJson.obj("APPLE" -> 4).validate[Map[UppercaseDummy, Int]] shouldBe JsSuccess(
Map(UppercaseDummy.Apple -> 4))
}
}

describe("serialisation") {

it("should serialise values to JsString") {
PlayJson.toJson(Dummy.A) shouldBe JsString("A")
PlayJson.toJson(InsensitiveDummy.A) shouldBe JsString("A")
PlayJson.toJson(LowercaseDummy.Apple) shouldBe JsString("apple")
PlayJson.toJson(UppercaseDummy.Apple) shouldBe JsString("APPLE")
}

it("should serialise as Map keys") {
PlayJson.toJson(Map(Dummy.A -> 1)) shouldBe PlayJson.obj("A" -> 1)

PlayJson.toJson(Map(InsensitiveDummy.A -> 2)) shouldBe PlayJson.obj("A" -> 2)
PlayJson.toJson(Map(LowercaseDummy.Apple -> 3)) shouldBe PlayJson.obj("apple" -> 3)
PlayJson.toJson(Map(UppercaseDummy.Apple -> 4)) shouldBe PlayJson.obj("APPLE" -> 4)
}
}

}
Expand Down

0 comments on commit 1d49a09

Please sign in to comment.