diff --git a/play-json/js/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala b/play-json/js/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala new file mode 100644 index 00000000..90a13daa --- /dev/null +++ b/play-json/js/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala @@ -0,0 +1,9 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.api.libs.json + +private[json] trait EnvFormatRepresentation { self: Format.Representation.type => + private[json] trait EnvRepresentations { scope: Format.Representation.Implicits.type => } +} diff --git a/play-json/jvm/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala b/play-json/jvm/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala new file mode 100644 index 00000000..dad0dbb4 --- /dev/null +++ b/play-json/jvm/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala @@ -0,0 +1,62 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.api.libs.json + +import java.util.Locale + +import java.time.{ + Instant, + LocalDate, + LocalDateTime, + LocalTime, + OffsetDateTime, + OffsetTime, + ZoneId, + ZonedDateTime, + Duration => JDuration +} + +import scala.concurrent.duration.FiniteDuration + +private[json] trait EnvFormatRepresentation { self: Format.Representation.type => + import Format.Representation + + implicit val zoneId: Representation[ZoneId, JsString] = + asString[ZoneId] + + implicit val locale: Representation[Locale, JsString] = + asString[Locale] + + // `Representation` instances that can be optionally be imported in the scope + // according to the selected `Reads`/`Writes`. + private[json] trait EnvRepresentations { scope: Format.Representation.Implicits.type => + implicit val localDateTime: Representation[LocalDateTime, JsString] = + asString[LocalDateTime] + + implicit val localDate: Representation[LocalDate, JsString] = + asString[LocalDate] + + implicit val localTime: Representation[LocalTime, JsString] = + asString[LocalTime] + + implicit val offsetDateTime: Representation[OffsetDateTime, JsString] = + asString[OffsetDateTime] + + implicit val offsetTime: Representation[OffsetTime, JsString] = + asString[OffsetTime] + + implicit val zonedDateTime: Representation[ZonedDateTime, JsString] = + asString[ZonedDateTime] + + implicit val instant: Representation[Instant, JsString] = + asString[Instant] + + implicit val javaDuration: Representation[JDuration, JsString] = + asString[JDuration] + + implicit val finiteDuration: Representation[FiniteDuration, JsString] = + asString[FiniteDuration] + } +} diff --git a/play-json/jvm/src/test/scala/play/api/libs/json/FormatSpec.scala b/play-json/jvm/src/test/scala/play/api/libs/json/FormatSpec.scala new file mode 100644 index 00000000..739fbc7f --- /dev/null +++ b/play-json/jvm/src/test/scala/play/api/libs/json/FormatSpec.scala @@ -0,0 +1,57 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.api.libs.json + +import java.time.{ + Instant, + LocalDateTime, + LocalDate, + LocalTime, + OffsetTime, + OffsetDateTime, + ZonedDateTime, + ZoneOffset, + ZoneId +} + +final class FormatSpec extends org.specs2.mutable.Specification { + "JSON Format".title + + "Representation" should { + import Format.Representation + + "be found" in { + implicitly[Representation[ZoneId, JsString]] + + implicitly[Representation[java.util.Locale, JsString]] + + ok + } + + "only be found from optional Implicits._" in { + import Representation.Implicits._ + + implicitly[Representation[LocalDateTime, JsString]] + + implicitly[Representation[LocalDate, JsString]] + + implicitly[Representation[LocalTime, JsString]] + + implicitly[Representation[OffsetDateTime, JsString]] + + implicitly[Representation[OffsetTime, JsString]] + + implicitly[Representation[ZonedDateTime, JsString]] + + implicitly[Representation[Instant, JsString]] + + implicitly[Representation[java.time.Duration, JsString]] + + implicitly[Representation[scala.concurrent.duration.FiniteDuration, JsString]] + + ok + } + } +} diff --git a/play-json/native/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala b/play-json/native/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala new file mode 100644 index 00000000..90a13daa --- /dev/null +++ b/play-json/native/src/main/scala/play/api/libs/json/EnvFormatRepresentation.scala @@ -0,0 +1,9 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.api.libs.json + +private[json] trait EnvFormatRepresentation { self: Format.Representation.type => + private[json] trait EnvRepresentations { scope: Format.Representation.Implicits.type => } +} diff --git a/play-json/shared/src/main/scala/play/api/libs/json/Format.scala b/play-json/shared/src/main/scala/play/api/libs/json/Format.scala index 19560711..0cca8784 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/Format.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/Format.scala @@ -78,6 +78,43 @@ object Format extends PathFormat with ConstraintFormat with DefaultFormat with S def reads(json: JsValue) = fjs.reads(json) def writes(o: A) = tjs.writes(o) } + + /** + * Evidence that values of type `T` are represented as values of type `R`. + * + * This type carries no runtime information and is only used to provide + * compile-time evidence of the representation. + * + * @tparam T the represented type + * @tparam R the representation type + */ + @implicitNotFound( + "No JSON representation found for ${T} as ${R}. An implicit Format.Representation[${T}, ${R}] is required." + ) + trait Representation[T, R <: JsValue] + + object Representation extends EnvFormatRepresentation { + private object Unsafe extends Representation[Nothing, JsValue] {} + + def asString[T]: Representation[T, JsString] = + Unsafe.asInstanceOf[Representation[T, JsString]] + + implicit val uriReadsRepresentation: Representation[java.net.URI, JsString] = + asString[java.net.URI] + + implicit val uuidReadsRepresentation: Representation[java.util.UUID, JsString] = + asString[java.util.UUID] + + /** + * Provides [[Representation]] instances for types that may be represented + * as JSON strings depending on the [[Writes]] instance in scope. + * + * These instances are not available implicitly by default and must be + * explicitly imported when a string representation is required, for example + * when using such types as JSON object keys. + */ + object Implicits extends EnvRepresentations + } } /** diff --git a/play-json/shared/src/main/scala/play/api/libs/json/KeyReads.scala b/play-json/shared/src/main/scala/play/api/libs/json/KeyReads.scala index 9f3ca515..3083c932 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/KeyReads.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/KeyReads.scala @@ -69,7 +69,29 @@ object KeyReads extends EnvKeyReads with LowPriorityKeyReads { } private[json] sealed trait LowPriorityKeyReads { - implicit def readableKeyReads[T](implicit r: Reads[T]): KeyReads[T] = + + /** + * Creates a [[KeyReads]] for values represented as JSON strings. + * + * The supplied [[Reads]] is applied to a [[JsString]] containing the object key. + * A [[Format.Representation]] is required to explicitly indicate that `T` is + * represented as a JSON string. + * + * @tparam T the type to read + * @return a [[KeyReads]] for `T` + */ + implicit def stringRepresentedKeyReads[T](implicit + r: Reads[T], + repr: Format.Representation[T, JsString] + ): KeyReads[T] = + KeyReads[T] { key => + r.reads(JsString(key)) + } + + @deprecated( + "Use stringRepresentedKeyReads instead. A Format.Representation[T, JsString] is required to ensure that T is represented as a JSON string.", + "3.1" + ) def readableKeyReads[T](implicit r: Reads[T]): KeyReads[T] = KeyReads[T] { key => r.reads(JsString(key)) } diff --git a/play-json/shared/src/main/scala/play/api/libs/json/KeyWrites.scala b/play-json/shared/src/main/scala/play/api/libs/json/KeyWrites.scala index db13cbfc..ae35b82e 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/KeyWrites.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/KeyWrites.scala @@ -13,7 +13,7 @@ trait KeyWrites[T] { def writeKey(key: T): String } -object KeyWrites extends EnvKeyWrites { +object KeyWrites extends EnvKeyWrites with LowPriorityKeyWrites { /** * Returns an instance which uses `f` as [[KeyWrites.writeKey]] function. @@ -22,6 +22,32 @@ object KeyWrites extends EnvKeyWrites { def writeKey(key: T) = f(key) } + /** + * Creates a [[KeyWrites]] for values represented as JSON strings. + * + * The supplied [[Writes]] is expected to produce a [[JsString]] for every value. + * This method is unsafe in that an [[IllegalArgumentException]] is thrown if the + * [[Writes]] produces any other JSON value. + * + * @tparam T the type to write + * @return a [[KeyWrites]] for `T` + */ + implicit def stringRepresentedKeyWrites[T](implicit + w: Writes[T], + repr: Format.Representation[T, JsString] + ): KeyWrites[T] = KeyWrites[T] { + w.writes(_) match { + case JsString(str) => + str + + case js => + throw new IllegalArgumentException(s"${Json.prettyPrint(js)} is not represented as JSON string") + } + } +} + +private[json] sealed trait LowPriorityKeyWrites { implicit def anyValKeyWrites[T <: AnyVal]: KeyWrites[T] = KeyWrites[T](_.toString) + } diff --git a/play-json/shared/src/main/scala/play/api/libs/json/Reads.scala b/play-json/shared/src/main/scala/play/api/libs/json/Reads.scala index 843cbb5b..0a2a388a 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/Reads.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/Reads.scala @@ -286,7 +286,9 @@ trait LowPriorityDefaultReads extends EnvReads { ts.iterator.zipWithIndex .foldLeft(JsSuccess { val b = bf.newBuilder + b.sizeHint(ts) + b }: JsResult[Builder[A, F[A]]]) { case (acc, (elem, idx)) => (acc, ra.reads(elem)) match { @@ -297,9 +299,11 @@ trait LowPriorityDefaultReads extends EnvReads { } } .map(_.result()) + case _ => JsError(Seq(JsPath -> Seq(JsonValidationError("error.expected.jsarray")))) } } + } /** @@ -529,6 +533,7 @@ trait DefaultReads extends LowPriorityDefaultReads { implicit def mapReads[K, V](k: String => JsResult[K])(implicit fmtv: Reads[V]): Reads[Map[K, V]] = Reads[Map[K, V]] { case JsObject(m) => { type Errors = Seq[(JsPath, Seq[JsonValidationError])] + def locate(e: Errors, key: String) = e.map { case (p, valerr) => (JsPath \ key) ++ p -> valerr } @@ -566,9 +571,8 @@ trait DefaultReads extends LowPriorityDefaultReads { /** * Deserializer for Array[T] types. */ - implicit def ArrayReads[T: Reads: ClassTag]: Reads[Array[T]] = new Reads[Array[T]] { - def reads(json: JsValue) = json.validate[List[T]].map(_.toArray) - } + implicit def ArrayReads[T: Reads: ClassTag]: Reads[Array[T]] = + Reads[Array[T]](_.validate[List[T]].map(_.toArray)) /** * Deserializer for java.net.URI @@ -585,9 +589,8 @@ trait DefaultReads extends LowPriorityDefaultReads { class UUIDReader(checkValidity: Boolean) extends Reads[java.util.UUID] { import java.util.UUID - import scala.util.Try + def check(s: String)(u: UUID): Boolean = u != null && s == u.toString - def check(s: String)(u: UUID): Boolean = u != null && s == u.toString() def parseUuid(s: String): Option[UUID] = { val uncheckedUuid = Try(UUID.fromString(s)).toOption diff --git a/play-json/shared/src/main/scala/play/api/libs/json/Writes.scala b/play-json/shared/src/main/scala/play/api/libs/json/Writes.scala index 33123639..7eb4e981 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/Writes.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/Writes.scala @@ -4,13 +4,15 @@ package play.api.libs.json -import play.api.libs.functional.ContravariantFunctor - import java.util.Date + import scala.annotation.implicitNotFound import scala.collection._ + import scala.reflect.ClassTag +import play.api.libs.functional.ContravariantFunctor + /** * Json serializer: write an implicit to define a serializer for any type */ @@ -96,6 +98,7 @@ object OWrites extends PathWrites with ConstraintWrites with ScalaCompatOWrites new OWritesFromFields[A ~ B] { def writeFields(fieldsMap: mutable.Map[String, JsValue], obj: A ~ B): Unit = { val a ~ b = obj + mergeIn(fieldsMap, wa, a) mergeIn(fieldsMap, wb, b) } @@ -104,6 +107,7 @@ object OWrites extends PathWrites with ConstraintWrites with ScalaCompatOWrites @inline final def mergeIn[A](fieldsMap: mutable.Map[String, JsValue], wa: OWrites[A], a: A): Unit = wa match { case wff: OWritesFromFields[A] => wff.writeFields(fieldsMap, a) + case w: OWrites[A] => w.writes(a).underlying.foreach { case (key, value: JsObject) => @@ -114,6 +118,7 @@ object OWrites extends PathWrites with ConstraintWrites with ScalaCompatOWrites case _ => value } ) + case (key, value) => fieldsMap.put(key, value) } @@ -129,7 +134,9 @@ object OWrites extends PathWrites with ConstraintWrites with ScalaCompatOWrites def writes(a: A): JsObject = { import scala.collection.JavaConverters._ val fieldsMap = new java.util.LinkedHashMap[String, JsValue]() + writeFields(fieldsMap.asScala, a) + JsObject(new ImmutableLinkedHashMap(fieldsMap)) } } @@ -352,7 +359,7 @@ trait DefaultWrites extends LowPriorityWrites with EnumerationWrites { } /** - * Serializer for Array[T] types. + * Serializer for `Array[T]` types. */ implicit def arrayWrites[T: ClassTag: Writes]: Writes[Array[T]] = { val w = implicitly[Writes[T]] @@ -363,7 +370,7 @@ trait DefaultWrites extends LowPriorityWrites with EnumerationWrites { } /** - * Serializer for Map[String,V] types. + * Serializer for `Map[String,V]` types. */ @deprecated("Use `genericMapWrites`", "2.8.0") def mapWrites[V: Writes]: OWrites[MapWrites.Map[String, V]] = MapWrites.mapWrites @@ -380,7 +387,7 @@ trait DefaultWrites extends LowPriorityWrites with EnumerationWrites { } /** - * Serializer for Map[String,V] types. + * Serializer for `Map[String,V]` types. */ implicit def genericMapWrites[V, M[A, B] <: MapWrites.Map[A, B]](implicit w: Writes[V]): OWrites[M[String, V]] = OWrites[M[String, V]] { ts => @@ -503,9 +510,11 @@ sealed trait LowPriorityWrites extends EnvWrites { Writes[Traversable[A]] { as => val builder = mutable.ArrayBuilder.make[JsValue] + as.foreach { a => builder += w.writes(a) } + JsArray(builder.result()) } // Avoid resolution ambiguity with more specific Traversable Writes, diff --git a/play-json/shared/src/test/scala/play/api/libs/json/FormatSharedSpec.scala b/play-json/shared/src/test/scala/play/api/libs/json/FormatSharedSpec.scala index 6d93d228..47445286 100644 --- a/play-json/shared/src/test/scala/play/api/libs/json/FormatSharedSpec.scala +++ b/play-json/shared/src/test/scala/play/api/libs/json/FormatSharedSpec.scala @@ -15,6 +15,7 @@ final class FormatSharedSpec extends AnyWordSpec with Matchers { strFormat.bimap(_.size, List.fill(_: Int)('X').mkString) intFormat.reads(JsString("foo")).mustEqual(JsSuccess(3)) + intFormat.writes(5).mustEqual(JsString("XXXXX")) } } @@ -27,9 +28,57 @@ final class FormatSharedSpec extends AnyWordSpec with Matchers { val expectedRepr = Json.obj("name" -> "bar") strFormat.reads(expectedRepr).mustEqual(JsSuccess("bar")) + strFormat.writes("bar").mustEqual(expectedRepr) } } + "Representation" should { + "not be found by default" in { + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.LocalDateTime, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.LocalDate, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.LocalTime, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.OffsetDateTime, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.OffsetTime, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.ZonedDateTime, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.Instant, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[java.time.Duration, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.Format.Representation[scala.concurrent.duration.FiniteDuration, _root_.play.api.libs.json.JsString]]" + .mustNot(typeCheck) + } + } + + "Map Format" should { + "be resolved" when { + "using Reads'able keys represented as JSON string (e.g. URI) as success" in { + val key = "https://www.playframework.com/documentation/2.8.x/api/scala/index.html#play.api.libs.json.JsResult" + + implicitly[OFormat[Map[java.net.URI, Int]]] + } + + "using key not represented as JSON string (tuple keys) as failure" in { + "implicitly[_root_.play.api.libs.json.Format[Map[(Int, Int), String]]]".mustNot(typeCheck) + + "implicitly[_root_.play.api.libs.json.OFormat[Map[(Int, Int), String]]]".mustNot(typeCheck) + } + } + } + case class Foo(name: String) } diff --git a/play-json/shared/src/test/scala/play/api/libs/json/MacroSpec.scala b/play-json/shared/src/test/scala/play/api/libs/json/MacroSpec.scala index 35c2a9e0..f01d415b 100644 --- a/play-json/shared/src/test/scala/play/api/libs/json/MacroSpec.scala +++ b/play-json/shared/src/test/scala/play/api/libs/json/MacroSpec.scala @@ -34,7 +34,8 @@ final class TextId(val value: String) extends AnyVal import org.scalacheck.Gen -class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalacheck.ScalaCheckPropertyChecks { +final class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalacheck.ScalaCheckPropertyChecks { + import MacroSpec._ "Reads" should { @@ -308,10 +309,17 @@ class MacroSpec extends AnyWordSpec with Matchers with org.scalatestplus.scalach "handle generic case class with multiple generic parameters and self references" when { // import TestFormats._ // Doesn't work in Scala 3.0.0-RC1 - implicit def eitherReads[A: Reads, B: Reads]: Reads[Either[A, B]] = TestFormats.eitherReads[A, B] - implicit def eitherWrites[A: Writes, B: Writes]: Writes[Either[A, B]] = TestFormats.eitherWrites[A, B] - implicit def tuple2Reads[A: Reads, B: Reads]: Reads[(A, B)] = TestFormats.tuple2Reads[A, B] - implicit def tuple2OWrites[A: Writes, B: Writes]: OWrites[(A, B)] = TestFormats.tuple2OWrites[A, B] + implicit def eitherReads[A: Reads, B: Reads]: Reads[Either[A, B]] = + TestFormats.eitherReads[A, B] + + implicit def eitherWrites[A: Writes, B: Writes]: Writes[Either[A, B]] = + TestFormats.eitherWrites[A, B] + + implicit def tuple2Reads[A: Reads, B: Reads]: Reads[(A, B)] = + TestFormats.tuple2Reads[A, B] + + implicit def tuple2OWrites[A: Writes, B: Writes]: OWrites[(A, B)] = + TestFormats.tuple2OWrites[A, B] val nestedLeft = Json.obj("id" -> 2, "a" -> 0.2F, "b" -> 0.3F, "c" -> 3) diff --git a/play-json/shared/src/test/scala/play/api/libs/json/ReadsSharedSpec.scala b/play-json/shared/src/test/scala/play/api/libs/json/ReadsSharedSpec.scala index 07dae58d..4e299587 100644 --- a/play-json/shared/src/test/scala/play/api/libs/json/ReadsSharedSpec.scala +++ b/play-json/shared/src/test/scala/play/api/libs/json/ReadsSharedSpec.scala @@ -64,7 +64,7 @@ final class ReadsSharedSpec extends AnyWordSpec with Matchers with Inside { } "Map" should { - "be successfully read with string keys" in { + "be successfully read" in { Json .fromJson[Map[String, Int]](Json.obj("foo" -> 1, "bar" -> 2)) .mustEqual(JsSuccess(Map("foo" -> 1, "bar" -> 2))) @@ -148,12 +148,18 @@ final class ReadsSharedSpec extends AnyWordSpec with Matchers with Inside { .mustEqual(JsSuccess(Map(1.23D -> "foo", 23.4D -> "bar"))) } - "be read with Reads'able keys" in { - val key = "https://www.playframework.com/documentation/2.8.x/api/scala/index.html#play.api.libs.json.JsResult" + "be read with Reads'able keys".which { + "are represented as JSON string (URI) as success" in { + val key = "https://www.playframework.com/documentation/2.8.x/api/scala/index.html#play.api.libs.json.JsResult" - implicitly[KeyReads[URI]] + implicitly[KeyReads[URI]] - Json.fromJson[Map[URI, String]](Json.obj(key -> "foo")).mustEqual(JsSuccess(Map((new URI(key)) -> "foo"))) + Json.fromJson[Map[URI, String]](Json.obj(key -> "foo")).mustEqual(JsSuccess(Map((new URI(key)) -> "foo"))) + } + + "are not represented as JSON string (tuple keys) as failure" in { + "implicitly[_root_.play.api.libs.json.Reads[Map[(Int, Int), String]]]".mustNot(typeCheck) + } } } diff --git a/play-json/shared/src/test/scala/play/api/libs/json/WritesSharedSpec.scala b/play-json/shared/src/test/scala/play/api/libs/json/WritesSharedSpec.scala index b38b0d33..e6a19d42 100644 --- a/play-json/shared/src/test/scala/play/api/libs/json/WritesSharedSpec.scala +++ b/play-json/shared/src/test/scala/play/api/libs/json/WritesSharedSpec.scala @@ -71,28 +71,55 @@ final class WritesSharedSpec extends AnyWordSpec with Matchers { "write SortedSets" in { import scala.collection.immutable.SortedSet + Json.toJson(SortedSet(1, 2, 3, 4, 5)).mustEqual(Json.arr(1, 2, 3, 4, 5)) } "write mutable SortedSets" in { import scala.collection.mutable.SortedSet + Json.toJson(SortedSet(1, 2, 3, 4, 5)).mustEqual(Json.arr(1, 2, 3, 4, 5)) } } "Map Writes" should { - "write lazy maps" in { - Json.toJson(Map("a" -> 1).map(kv => kv._1 -> (kv._2 + 1))).mustEqual(Json.obj("a" -> 2)) + "be successfully written" when { + "using string keys" in { + Json + .toJson(Map[String, Int]("foo" -> 1, "bar" -> 2)) + .mustEqual(Json.obj("foo" -> 1, "bar" -> 2)) + } + + "using AnyVal (Int) keys" in { + Json.toJson(Map(1 -> "one")).mustEqual(Json.obj("1" -> "one")) + } + + "using string represented keys" in { + val uriRepr = "https://www.playframework.org" + + implicitly[KeyWrites[java.net.URI]] + + Json.toJson(Map((new java.net.URI(uriRepr)) -> "foo")).mustEqual(Json.obj(uriRepr -> "foo")) + } } - "write a map nested in a seq" in { - Json.toJson(Seq(Map("a" -> 1))).mustEqual(Json.arr(Json.obj("a" -> 1))) + "write lazy maps" in { + Json.toJson(Map("a" -> 1).map(kv => kv._1 -> (kv._2 + 1))).mustEqual(Json.obj("a" -> 2)) } } "Iterable writes" should { - "write maps" in { - Json.toJson(Map(1 -> "one")).mustEqual(Json.obj("1" -> "one")) + "write array for Map with unsupported key type" in { + // Limitation: This case is not symmetrical to `Map` reads which fails + // at compile-time for unsupported key type. + + Json + .toJson(Map[(Int, Int), String]((1, 2) -> "foo")) + .mustEqual(Json.arr(Json.arr(Json.arr(1, 2), JsString("foo")))) + } + + "write a map nested in a seq" in { + Json.toJson(Seq(Map("a" -> 1))).mustEqual(Json.arr(Json.obj("a" -> 1))) } }