From 901adbdef04c64765b98cd1551826dcda69fa7a9 Mon Sep 17 00:00:00 2001 From: Cristian Vrabie Date: Tue, 6 Aug 2013 01:00:54 +0100 Subject: [PATCH] Added rich wrapper for JsField with specialised field getters --- src/main/scala/spray/json/RichJsObject.scala | 123 ++++++++++++++++++ .../scala/spray/json/RichJsObjectSpec.scala | 98 ++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/main/scala/spray/json/RichJsObject.scala create mode 100644 src/test/scala/spray/json/RichJsObjectSpec.scala diff --git a/src/main/scala/spray/json/RichJsObject.scala b/src/main/scala/spray/json/RichJsObject.scala new file mode 100644 index 00000000..2b6cd179 --- /dev/null +++ b/src/main/scala/spray/json/RichJsObject.scala @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2011 Mathias Doenitz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package spray.json + +import scala.reflect.ClassTag + +/** + * User: cvrabie + * Date: 05/08/2013 + * Wrapper for [[spray.json.JsObject]] class that offers specialised getters for json fields.
+ * All getters return Either[String, T] which allows writing a [[spray.json.JsonReader]] with a for comprehension by + * using [[scala.util.Right]] projections. At the same time if the conversion fails the error message + * will be stored in [[scala.util.Left]].
+ * This is especially important in case of JSON objects with optional fields. See RichJsObject for examples. + */ +class RichJsObject(val obj:JsObject){ + val fields = obj.fields + + def field(fieldName: String):Either[String, JsValue] = + fields.get(fieldName).toRight("Field '%s' is missing".format(fieldName)) + + def field[T](fieldName: String, f:JsValue=>T)(implicit ct:ClassTag[T]):Either[String, T] = + field(fieldName).right.flatMap(value => try{ Right(f(value)) }catch { + case e:DeserializationException => Left("Error while converting field '%s' to %ct: \nCause: %s - %s".format( + fieldName, ct.getClass.getName, e.getClass.getName, e.getMessage)) + }) + + def field[T](fieldName: String, f:JsValue=>T, default:T)(implicit ct:ClassTag[T]):Right[String,T] = + Right(field(fieldName, f).right.getOrElse(default)) + + def t[T](fieldName: String)(implicit ct:ClassTag[T], reader:JsonReader[T]):Either[String, T] = + field(fieldName, reader.read(_)) + + def t[T](fieldName: String, default: T)(implicit ct:ClassTag[T], reader:JsonReader[T]):Right[String, T] = + field(fieldName, reader.read(_), default) + + def string(fieldName: String):Either[String, String] = field(fieldName).right.flatMap{ + case JsString(str) => Right(str) + case x => Left("Expecting JsString in field '%s' but got %s".format(fieldName, x)) + } + + def string(fieldName: String, default: String):Right[String,String] = + Right(string(fieldName).right.getOrElse(default)) + + def int(fieldName: String):Either[String,Int] = field(fieldName).right.flatMap{ + case JsNumber(num) => try { Right(num.toIntExact) }catch { + case e:ArithmeticException => Left("The value in field '%s' is too big to fit an Int".format(fieldName)) + } + case x => Left("Expecting JsNumber in field '%s' but got %s".format(fieldName, x)) + } + + def int(fieldName: String, default: Int):Right[String,Int] = + Right(int(fieldName).right.getOrElse(default)) + + def long(fieldName: String):Either[String,Long] = field(fieldName).right.flatMap{ + case JsNumber(num) => try { Right(num.toLongExact) } catch { + case e:ArithmeticException => Left("The value in field '%s' is too big to fit a Long".format(fieldName)) + } + case x => Left("Expecting JsNumber in field '%s' but got %s".format(fieldName, x)) + } + + def long(fieldName: String, default: Long):Right[String,Long] = + Right(long(fieldName).right.getOrElse(default)) + + def float(fieldName: String):Either[String,Float] = field(fieldName).right.flatMap{ + case JsNumber(num) => Right(num.toFloat) + case x => Left("Expecting JsNumber in field '%s' but got %s".format(fieldName, x)) + } + + def float(fieldName: String, default: Float):Right[String,Float] = + Right(float(fieldName).right.getOrElse(default)) + + def double(fieldName: String):Either[String,Double] = field(fieldName).right.flatMap{ + case JsNumber(num) => Right(num.toDouble) + case x => Left("Expecting JsNumber in field '%s' but got %s".format(fieldName, x)) + } + + def double(fieldName: String, default: Double):Right[String,Double] = + Right(double(fieldName).right.getOrElse(default)) + + def number(fieldName: String):Either[String,BigDecimal] = field(fieldName).right.flatMap{ + case JsNumber(num) => Right(num) + case x => Left("Expecting JsNumber in field '%s' but got %s".format(fieldName, x)) + } + + def number(fieldName: String, default: BigDecimal):Right[String,BigDecimal] = + Right(number(fieldName).right.getOrElse(default)) + + def boolean(fieldName: String):Either[String,Boolean] = field(fieldName).right.flatMap({ + case JsBoolean(bool) => Right(bool) + case x => Left("Expecting JsBoolean in field '%s' but got %s".format(fieldName, x)) + }) + + def boolean(fieldName: String, default: Boolean):Right[String,Boolean] = + Right(boolean(fieldName).right.getOrElse(default)) +} + +object RichJsObject{ + def apply(obj:JsObject):RichJsObject = new RichJsObject(obj) + def unapply(jval:JsValue):Option[RichJsObject] = jval match { + case obj:JsObject => Some(obj) + case _ => None + } + implicit def jsValueAsRich(obj:JsValue):RichJsObject = new RichJsObject( + obj.asJsObject("Expecting JsObject but got %s: %s".format(obj.getClass.getSimpleName,obj)) + ) + implicit def richAsJsValue(obj:RichJsObject):JsObject = obj.obj + implicit def jsObjectAsRich(obj:JsObject):RichJsObject = new RichJsObject(obj) + implicit def richAsJsObject(obj:RichJsObject):JsObject = obj.obj +} \ No newline at end of file diff --git a/src/test/scala/spray/json/RichJsObjectSpec.scala b/src/test/scala/spray/json/RichJsObjectSpec.scala new file mode 100644 index 00000000..94f76fa8 --- /dev/null +++ b/src/test/scala/spray/json/RichJsObjectSpec.scala @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2011 Mathias Doenitz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package spray.json + +import org.specs2.mutable.Specification +import java.util.NoSuchElementException + +/** + * User: cvrabie + * Date: 05/08/2013 + */ +class RichJsObjectSpec extends Specification with DefaultJsonProtocol{ + + object Currency extends Enumeration{ + val GBP = Value("GBP") + val USD = Value("USD") + //we could write a custom reader/format but it's here to show RichJsObjectSpec.field(fieldName, func) + def fromJson(json:JsValue) = json match { + case JsString(str) => try{ withName(str) }catch{ + case e: NoSuchElementException => throw new DeserializationException("No currency "+str) + } + case x => throw new DeserializationException("Expecting a Currency as string but got "+x) + } + } + case class User(name: String, age: Int, plan:BillingPlan, language:String = "en_GB") + case class BillingPlan(name: String, cost: Double, currency:Currency.Value = Currency.GBP) + + implicit val PlanReader = new JsonReader[BillingPlan]{ + import RichJsObject._ + def read(json: JsValue) = readRich(json) + def readRich(obj: RichJsObject) = (for{ + name <- obj.string("name").right + cost <- obj.double("cost").right + currency <- obj.field("currency",Currency.fromJson _,Currency.GBP).right + }yield BillingPlan(name, cost, currency)) match{ + case Right(billingPlan) => billingPlan + case Left(error) => throw new DeserializationException(error) + } + } + + implicit val UserReader = new JsonReader[User] { + import RichJsObject._ + def read(json: JsValue) = readRich(json) + def readRich(obj: RichJsObject) = (for{ + name <- obj.string("name").right + age <- obj.int("age").right + plan <- obj.t("plan").right + language <- obj.string("language","en_GB").right + }yield User(name, age, plan, language)) match{ + case Right(user) => user + case Left(error) => throw new DeserializationException(error) + } + } + + "A custom JsonReader" should { + + "correctly deserialize a valid json" in { + val json = """{"name":"Bob","age":24,"plan":{"name":"free","cost":12.34,"currency":"USD"},"language":"en_US"}""".asJson + val expected = User("Bob",24,BillingPlan("free",12.34,Currency.USD),"en_US") + json.convertTo[User] must_==(expected) + } + + "provide defaults of optional json fields" in { + val json = """{"name":"Bob","age":24,"plan":{"name":"free","cost":12.34}}""".asJson + val expected = User("Bob",24,BillingPlan("free",12.34,Currency.GBP),"en_GB") + json.convertTo[User] must_==(expected) + } + + "throw an exception if a required field is missing" in { + val json = """{"age":24,"plan":{"name":"free","cost":12.34}}""".asJson + json.convertTo[User] must throwA(new DeserializationException("Field 'name' is missing")) + } + + "throw an exception if a field is not of the expected type" in{ + val json = """{"name":"Bob","age":"abc","plan":{"name":"free","cost":12.34}}""".asJson + json.convertTo[User] must throwA(new DeserializationException("Expecting JsNumber in field 'age' but got \"abc\"")) + } + + "throw an exception if a lossy conversion is done in a field" in{ + val json = """{"name":"Bob","age":999999999999999,"plan":{"name":"free","cost":12.34}}""".asJson + json.convertTo[User] must throwA(new DeserializationException("The value in field 'age' is too big to fit an Int")) + } + } + +}