Skip to content

Commit d99c5ac

Browse files
authored
Merge pull request #34 from rallyhealth/reads-recover
Add .recover methods to Reads and fix missing package object for Play 2.7
2 parents 94b02f4 + d2a3b41 commit d99c5ac

File tree

20 files changed

+635
-39
lines changed

20 files changed

+635
-39
lines changed

README.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ Pretty much all of these tools become available when you `import `[`play.api.lib
5252

5353
## Implicits
5454

55-
By importing `play.api.libs.json.ops._`, you get access to implicits that provide:
55+
By importing `play.api.libs.json.ops._`, you get access to:
5656

57+
* `PlayJsonMacros.nullableReads` macro that will read `null` as `[]` for all container fields of a `case class`
58+
* `Reads`, `Format`, and `OFormat` extension methods to recover from exceptions
5759
* Many extension methods for the `play.api.libs.json.Json`
5860
- `Format.of[A]`, `OFormat.of[A]`, and `OWrites.of[A]` for summoning formats the same as `Reads.of[A]` and `Writes.of[A]`
5961
- `Format.asEither[A, B]` for reading and writing an either value based on some condition
@@ -63,10 +65,64 @@ By importing `play.api.libs.json.ops._`, you get access to implicits that provid
6365
- In Play 2.3, the `Json.format` and `Json.writes` macros would return `Format` and `Writes` instead of `OFormat` and
6466
`OWrites`, even though the macros would only produce these types. The play-json-ops for Play 2.3 provides a `Json.oformat`
6567
and `Json.owrites` which uses the underlying Play Json macros, but it casts the results.
66-
* `Reads` and `Writes` for tuple types by writing the result as a `JsArray`
68+
* `Reads` and `Writes` implicits for tuple types (encoded as a `JsArray`)
6769
* The `JsValue` extension method `.asOrThrow[A]` which throws a better exception that `.as[A]`
6870
* And handy syntax for the features listed below
6971

72+
## Tolerant Container Reads Macro
73+
74+
Extending the `TolerantContainerFormats` trait or importing from its companion object will give you the ability to call
75+
`.readNullableContainer` on a `Reads` instance. This will allow you to parse `null` fields as empty collections.
76+
77+
You can also use `PlayJsonMacros.nullableReads` to create a `Reads` for a `case class` that will accept either `null`
78+
or missing field values for any container fields (`Seq`, `Set`, `Map`, etc) using the same method.
79+
80+
```scala
81+
case class Example(values: Seq[Int])
82+
object Example extends TolerantContainerFormats {
83+
84+
val nonMacroExample: Reads[Seq[Int]] = (__ \ "values").readNullableContainer[Seq, Int]
85+
assert(Json.parse("null").as(nonMacroExample) == JsSuccess(Seq()))
86+
assert(Json.parse("[]").as[Example] == JsSuccess(Seq()))
87+
assert(Json.parse("[1]").as[Example] == JsSuccess(Seq(1)))
88+
89+
val macroExample: Reads[Example] = PlayJsonMacros.nullableReads[Example]
90+
assert(Json.parse("{}").as(macroExample) == JsSuccess(Example(Seq())))
91+
assert(Json.parse("""{"values":null}""").as(macroExample) == JsSuccess(Example(Seq())))
92+
assert(Json.parse("""{"values":[]}""").as(macroExample) == JsSuccess(Example(Seq())))
93+
assert(Json.parse("""{"values":[1]}""").as(macroExample) == JsSuccess(Example(Seq(1))))
94+
}
95+
```
96+
97+
## Reads Recovery Methods
98+
99+
You can call `.recoverJsError`, `.recoverTotal`, or `.recoverWith` on a `Reads`, `Format`, or `OFormat` instance.
100+
These methods allow you to recover from exceptions thrown during the reading process into an appropriate `JsResult`.
101+
102+
```scala
103+
object ReadsRecoveryExamples {
104+
105+
// converts all exceptions into a JsError with the exception captured as an argument in the JsonValidationError
106+
val readIntAsString = Reads.of[String].map(_.toInt).recoverJsError
107+
assert(readIntAsString.reads("not a number").isError) // no exception thrown
108+
109+
// converts only the matched exceptions to JsResults, all others continue to throw
110+
val invertReader = Reads.of[String].map(1 / _.toDouble).recoverWith {
111+
case _: ArithmeticException => JsSuccess(Double.MaxValue)
112+
}
113+
invertReader.reads("not a number") // throws NumberFormatException
114+
assert(invertReader.reads("0") == JsSuccess(Double.MaxValue)) // handles ArithmeticException
115+
116+
// converts all exceptions into some value of the right type
117+
val readAbsValueOrSentinel = Reads.of[String].map(_.toInt.abs).recoverTotal(_ => -1)
118+
assert(readAbsValueOrSentinel.reads("not a number") == JsSuccess(-1))
119+
120+
// these can be combined, of course
121+
val safeInvertReader = invertReader.recoverJsError
122+
assert(safeInvertReader.reads("not a number").isError) // no exception thrown
123+
}
124+
```
125+
70126
## Automatic Automated Tests
71127

72128
To get free test coverage, just extend `PlayJsonFormatSpec[T]` where `T` is a serializable type that you

build.sbt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def commonProject(id: String, projectPath: String, scalacVersion: String): Proje
4444
classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Flat,
4545

4646
scalacOptions ++= Seq(
47-
"-deprecation",
47+
"-deprecation:false",
4848
"-feature",
4949
"-Xfatal-warnings",
5050
"-Ywarn-dead-code",
@@ -82,15 +82,7 @@ def playJsonOpsCommon(scalacVersion: String, includePlayVersion: String): Projec
8282
commonProject(id, projectPath, scalacVersion).settings(
8383
libraryDependencies ++= Seq(
8484
playJson(includePlayVersion)
85-
) ++ {
86-
// Test-only dependencies
87-
includePlayVersion match {
88-
case Play_2_7 => Seq(
89-
scalaTest(scalaCheckVersion)
90-
)
91-
case _ => Seq()
92-
}
93-
}.map(_ % Test)
85+
)
9486
)
9587
}
9688

@@ -117,8 +109,8 @@ def playJsonOps(scalacVersion: String, includePlayVersion: String): Project = {
117109
scalaCheckOps(scalaCheckVersion),
118110
scalaTest(scalaCheckVersion)
119111
) ++ {
120-
includePlayVersion match {
121-
case Play_2_7 => Seq(
112+
scalaCheckVersion match {
113+
case ScalaCheck_1_14 => Seq(
122114
scalaTestPlusScalaCheck(scalaCheckVersion)
123115
)
124116
case _ => Seq()

play-json-ops-common/src/main/scala/play/api/libs/json/ops/FormatOps.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ object FormatOps {
159159
(implicit
160160
leftType: ClassTag[Left], rightType: ClassTag[Right],
161161
leftAsX: Left <:< X, rightAsX: Right <:< X
162-
): Format[X] = {
162+
): Format[X] = {
163163
from(jsonIsRight, {
164164
case leftType(_) => false
165165
case rightType(_) => true
@@ -200,4 +200,3 @@ object FormatOps {
200200
}
201201
}
202202
}
203-
File renamed without changes.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package play.api.libs.json.ops
2+
3+
import play.api.data.validation.ValidationError
4+
import play.api.libs.json._
5+
6+
import scala.language.higherKinds
7+
import scala.reflect.ClassTag
8+
import scala.util.control.NonFatal
9+
10+
trait RecoverOps[F[x] <: Reads[x], A] extends Any {
11+
12+
def unsafeReader: Reads[A]
13+
14+
/**
15+
* Recovers from all exceptions thrown during reading, producing an exception-safe Reads.
16+
*/
17+
def recoverTotal(recoverFn: Throwable => A): Reads[A] = build {
18+
Reads { json: JsValue =>
19+
try {
20+
unsafeReader.reads(json)
21+
} catch {
22+
case NonFatal(ex) => JsSuccess(recoverFn(ex))
23+
}
24+
}
25+
}
26+
27+
/**
28+
* Translates exceptions thrown during reading into JsError validation results.
29+
*/
30+
def recoverJsError(implicit ct: ClassTag[A]): Reads[A] = {
31+
recoverWith {
32+
case ex => RecoverOps.expectedTypeError(ct.runtimeClass, ex)
33+
}
34+
}
35+
36+
/**
37+
* Recovers from some exceptions thrown during reading into a [[JsResult]].
38+
*
39+
* @note if the recover function is undefined for an exception, the [[Reads]] produced is still unsafe.
40+
*/
41+
def recoverWith(
42+
recoverFn: PartialFunction[Throwable, JsResult[A]]
43+
): Reads[A] = build {
44+
Reads { json: JsValue =>
45+
try {
46+
unsafeReader.reads(json)
47+
} catch {
48+
case NonFatal(ex) if recoverFn isDefinedAt ex => recoverFn(ex)
49+
}
50+
}
51+
}
52+
53+
// Subclasses need to define how to build an instance of F[A] from a Reads[A]
54+
protected def build(safeReader: Reads[A]): F[A]
55+
}
56+
57+
object RecoverOps {
58+
59+
/**
60+
* Similar to the Class.getSimpleName method, except it does not throw any exceptions and
61+
* handles Scala inner classes better.
62+
*/
63+
def safeSimpleClassName(cls: Class[_]): String = {
64+
// This logic is designed to be robust without much noise
65+
// 1. use getName to avoid runtime exceptions from getSimpleName
66+
// 2. filter out '$' anonymous class / method separators
67+
// 3. start the full class name from the first upper-cased outer class name
68+
// (to avoid picking up unnecessary package names)
69+
cls.getName
70+
.split('.')
71+
.last // safe because Class names will never be empty in any realistic scenario
72+
.split('$')
73+
.mkString(".")
74+
}
75+
76+
/**
77+
* Following the style of play json's DefaultReads class. Type should be all lowercase.
78+
*
79+
* @note this method is not cross-compatible between Play 2.5 and above. Once everything
80+
* uses [[JsonValidationError]], we can move this whole file to the common project.
81+
*
82+
* e.g.
83+
* error.expected.string
84+
* error.expected.uuid
85+
*/
86+
def expectedTypeError(tpe: String, args: Any*): JsError = {
87+
val className = tpe.toLowerCase
88+
JsError(ValidationError(s"error.expected.$className", args: _*))
89+
}
90+
91+
/**
92+
* Same as [[expectedTypeError]], except safely converts the class to a string.
93+
*/
94+
def expectedTypeError(cls: Class[_], args: Any*): JsError = {
95+
expectedTypeError(safeSimpleClassName(cls), args: _*)
96+
}
97+
}
98+
99+
class ReadsRecoverOps[A](override val unsafeReader: Reads[A]) extends AnyVal with RecoverOps[Reads, A] {
100+
final override protected def build(safeReader: Reads[A]): Reads[A] = safeReader
101+
}
102+
103+
class FormatRecoverOps[A](val unsafeFormat: Format[A]) extends AnyVal with RecoverOps[Format, A] {
104+
final override def unsafeReader: Reads[A] = unsafeFormat
105+
final override protected def build(safeReader: Reads[A]): Format[A] = Format(safeReader, unsafeFormat)
106+
}
107+
108+
class OFormatRecoverOps[A](val unsafeFormat: OFormat[A]) extends AnyVal with RecoverOps[OFormat, A] {
109+
final override def unsafeReader: Reads[A] = unsafeFormat
110+
final override protected def build(safeReader: Reads[A]): OFormat[A] = OFormat(safeReader, unsafeFormat)
111+
}

play25-json-ops/src/main/scala/play/api/libs/json/ops/UTCFormats.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ import play.api.libs.json._
99
* @note this only applies for [[DateTime]] because [[org.joda.time.LocalDateTime]],
1010
* [[java.util.Date]], and [[java.sql.Date]] do not carry along the time zone.
1111
*/
12+
@deprecated(
13+
"A better approach is to explicitly convert time zones in your own code when appropriate and use the timezone " +
14+
"passed in the JSON string or fallback on one that is configured for the user. " +
15+
"If no timezone is given in the JSON, the default will be the global DateTimeZone.getDefault (which is mutable). " +
16+
"This is not available for Play >2.5 artifacts since 2.0.0 and will be removed when Play 2.5 support is removed.",
17+
"3.1.0"
18+
)
1219
trait UTCFormats {
1320

1421
/**
15-
* A good default for when you don't care about the [[DateTimeZone]] of the server
22+
* For when you don't care about the [[DateTimeZone]] of the server
1623
* that is parsing the [[DateTime]] and prefer to have all dates and times in
1724
* Universal Coordinated Time (UTC).
1825
*/
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
package play.api.libs.json
22

3-
package object ops extends JsonImplicits
3+
import scala.language.implicitConversions
4+
5+
package object ops extends JsonImplicits {
6+
7+
implicit def safeReadsOps[A](reads: Reads[A]): ReadsRecoverOps[A] = new ReadsRecoverOps(reads)
8+
9+
implicit def safeFormatOps[A](format: Format[A]): FormatRecoverOps[A] = new FormatRecoverOps(format)
10+
11+
implicit def safeOFormatOps[A](oformat: OFormat[A]): OFormatRecoverOps[A] = new OFormatRecoverOps(oformat)
12+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package play.api.libs.json.ops
2+
3+
import org.scalatest.FreeSpec
4+
import org.scalatest.Matchers._
5+
import play.api.data.validation.ValidationError
6+
import play.api.libs.json._
7+
8+
class ReadsRecoverOpsSpec extends FreeSpec {
9+
10+
private val it = classOf[ReadsRecoverOps[_]].getSimpleName
11+
12+
s"$it.recoverJsError should recover from exceptions with a JsError" in {
13+
val readStringAsInt = Reads.of[String].map(_.toInt).recoverJsError
14+
readStringAsInt.reads(JsString("not a number")) match {
15+
case JsError(Seq((JsPath, Seq(ValidationError(Seq(message), ex))))) =>
16+
assertResult("error.expected.int")(message)
17+
ex shouldBe a[NumberFormatException]
18+
case o => fail(s"Expected a single error message with a single exception, not $o")
19+
}
20+
}
21+
22+
s"$it.recoverWith should throw any exception not caught by the partial function" in {
23+
val readStringAsIntInverted = Reads.of[String].map(1 / _.toInt).recoverWith {
24+
case ex: NumberFormatException => RecoverOps.expectedTypeError(classOf[Int], ex)
25+
}
26+
// it should catch format exceptions
27+
assert(readStringAsIntInverted.reads(JsString("not a number")).isError)
28+
// but math exceptions are uncaught
29+
an[ArithmeticException] shouldBe thrownBy {
30+
readStringAsIntInverted.reads(JsString("0"))
31+
}
32+
}
33+
34+
s"$it.recoverTotal should call the recover function" in {
35+
val readStringAsIntInverted = Reads.of[String].map(_.toInt.abs).recoverTotal(_ => -1)
36+
assertResult(JsSuccess(-1))(readStringAsIntInverted.reads(JsString("not a number")))
37+
}
38+
}

play25-json-ops/src/test/scala/play/api/libs/json/ops/UTCFormatSpec.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ object UseUTC extends UTCFormats {
1616

1717
class UTCFormatSpec extends WordSpec {
1818

19+
private[this] val pacificTimeZone = DateTimeZone.forID("US/Pacific")
20+
1921
"Json.format by default" should {
2022
"deserialize with the current time zone" in {
21-
val dt = new DateTime
22-
assertResult(dt.getZone) {
23+
val dt = new DateTime(pacificTimeZone)
24+
assertResult(DateTimeZone.getDefault) {
2325
val notUTC = Json.toJson(NotUTC(dt)).as[NotUTC]
2426
notUTC.when.getZone
2527
}
@@ -29,7 +31,7 @@ class UTCFormatSpec extends WordSpec {
2931
"UTCFormats" should {
3032

3133
"override the standard Format[DateTime]" in {
32-
val dt = new DateTime
34+
val dt = new DateTime(pacificTimeZone)
3335
assertResult(DateTimeZone.UTC) {
3436
val useUTC = Json.toJson(UseUTC(dt)).as[UseUTC]
3537
useUTC.when.getZone

0 commit comments

Comments
 (0)