Skip to content

Instantly share code, notes, and snippets.

@kirked
Last active June 8, 2022 07:48
Show Gist options
  • Save kirked/5d795cbef7ada9457a50 to your computer and use it in GitHub Desktop.
Save kirked/5d795cbef7ada9457a50 to your computer and use it in GitHub Desktop.
A lens wrapper over spray-json
import scala.reflect.ClassTag
import scala.util.{Either, Left, Right}
import spray.json._
object JsonLens {
class Json(val value: Option[JsValue]) {
def /(name: String): Json = Json(value.flatMap(_.asJsObject.fields.get(name)))
def -(name: String): Json = Json(value.map(obj => JsObject(obj.asJsObject.fields - name)))
def apply(index: Int): Json = {
value map {
case array: JsArray if array.elements.size > index => Json(Some(array.elements(index)))
case _ => Json.empty
} getOrElse Json.empty
}
def isDefined = value.isDefined
def isEmpty = value.isEmpty
def at(path: String, separator: Char): Json = path.split(separator).foldLeft(this) {
case (json, segment) => json / segment
}
def at(path: String): Json = at(path, '/')
def optionAt[T](path: String, separator: Char)(implicit r: JsonReader[T]): Option[T] =
at(path, separator).value flatMap {
case JsNull => None
case value => Some(value.convertTo[T])
}
def optionAt[T](path: String)(implicit r: JsonReader[T]): Option[T] = optionAt[T](path, '/')
def hasPath(path: String, separator: Char): Boolean = at(path, separator).isDefined
def hasPath(path: String): Boolean = hasPath(path, '/')
def unwrap: JsValue = value.getOrElse(JsNull)
def unwrapTo[T <: JsValue]: T = value.get.asInstanceOf[T]
def foldLeft[A](z: A)(f: (A, JsValue) => A): A = value match {
case Some(array: JsArray) => array.elements.foldLeft(z)(f)
case _ => z
}
def foreach[A](f: JsValue => A): Unit = map(f)
def map[A](f: JsValue => A): Seq[A] = value match {
case Some(array: JsArray) => array.elements.map(f)
case Some(jsvalue) => Seq(f(jsvalue))
case _ => Seq.empty
}
def flatMap(f: JsValue => Json): Json = value match {
case Some(array: JsArray) => Json(JsArray(array.map(f.andThen(_.unwrap)): _*))
case Some(jsvalue) => f(jsvalue)
case None => Json.empty
}
def filter(f: JsValue => Boolean): Json = value match {
case Some(array: JsArray) => Json(JsArray(array.elements.filter(f)))
case Some(JsObject(fields)) => Json(JsObject(fields.filter { case (_, v) => f(v) }))
case Some(jsvalue) => if (f(jsvalue)) this else Json.empty
case None => Json.empty
}
def filterNot(f: JsValue => Boolean): Json = filter(!f(_))
def filterObject(f: JsField => Boolean): Json = value match {
case Some(JsObject(fields)) => Json(JsObject(fields.filter(f)))
case _ => Json.empty
}
def getOrElse(that: JsValue): JsValue = value getOrElse that
def orElse(that: Json): Json = if (isDefined) this else that
override def equals(other: Any): Boolean = {
if (other.isInstanceOf[Json]) value == other.asInstanceOf[Json].value
else false
}
/** Add an array element. */
def :+(update: Any): Json = {
def encoded: JsValue = Json.encode(update) match {
case Left(_) => throw new IllegalArgumentException("attempt to add a JsField to a JsArray")
case Right(element) => element
}
value match {
case Some(JsArray(elements)) => Json(JsArray(elements :+ encoded))
case Some(JsNull) | None => Json(JsArray(encoded))
case _ => throw new IllegalArgumentException("attempt to add an array element to a JsObject")
}
}
//---- Add/update object fields
def <<(name: String, update: String): Json = updated( (name, JsString(update)) )
def <<(name: String, update: Boolean): Json = updated( (name, JsBoolean(update)) )
def <<(name: String, update: Int): Json = updated( (name, JsNumber(update)) )
def <<(name: String, update: Long): Json = updated( (name, JsNumber(update)) )
def <<(name: String, update: Double): Json = updated( (name, JsNumber(update)) )
def <<(name: String, update: BigInt): Json = updated( (name, JsNumber(update)) )
def <<(name: String, update: BigDecimal): Json = updated( (name, JsNumber(update)) )
def <<(name: String, update: JsValue): Json = updated( (name, update) )
def <<(name: String, update: Json): Json = updated( (name, update.unwrap) )
/**
* Return an updated copy of the JSON, removing `name` from objects if the option is empty.
*
* Use [[<<?]] to only apply a non-empty option.
*/
def <<[T: ClassTag](name: String, option: Option[T]): Json =
option map { value =>
updated( (name, Json.decode[T](value)) )
} getOrElse {
value match {
case Some(JsObject(fields)) => Json(JsObject(fields - name))
case _ => this
}
}
/**
* This will handle *uniform* iterable update values with regard to `JsField`s.
* What that means is that if `update` contains a `Tuple2[String, _]` for a JsField,
* then all elements must be field tuples, and a JsObject will be returned.
*
* Otherwise, a JsArray of values will be returned.
*/
def <<(name: String, update: Iterable[_], encodeNulls: Boolean = false): Json =
updated( (name, Json.array(update, encodeNulls).toJsArray) )
def updated(update: Tuple2[String, JsValue]): Json = {
if (value.isEmpty) Json(JsObject(Map(update)))
else Json(JsObject(value.get.asJsObject.fields + update))
}
//---- Conditionally update from an option
/**
* Return an updated copy of the JSON if the option is defined.
*
* Use [[<<]] to remove `name` with empty options.
*/
def <<?[T: ClassTag](name: String, option: Option[T]): Json = {
option map { value =>
updated( (name, Json.decode[T](value)) )
} getOrElse this
}
def toBoolean(): Boolean = value match {
case Some(JsBoolean(bool)) => bool
case _ => false
}
def toJsObject(): JsObject = value match {
case Some(jsobj: JsObject) => jsobj
case Some(JsNull) | None => JsObject.empty
case Some(jsvalue) => JsObject(("value" -> jsvalue))
}
def toJsArray(): JsArray = value match {
case Some(jsarray: JsArray) => jsarray
case Some(JsNull) | None => JsArray.empty
case Some(jsvalue) => JsArray(jsvalue)
}
override def toString(): String = value match {
case None => ""
case Some(JsString(s)) => s
case Some(JsNumber(n)) => n.toString
case Some(v) => v.prettyPrint
}
}
object Json {
def apply(value: JsValue) = new Json(Some(value))
def apply(value: Option[JsValue]) = new Json(value)
def apply(values: JsField*) = new Json(Some(JsObject(values: _*)))
val empty: Json = new Json(None)
def unapply(lens: Json): Option[JsValue] = lens.value
def array(values: Iterable[_], encodeNulls: Boolean = false): Json = {
def encodeValue(items: Iterable[_]): JsValue = {
var fields = Vector.empty[JsField]
var elements = Vector.empty[JsValue]
items foreach(encode(_) match {
case Right(JsNull) => if (encodeNulls) elements = elements :+ JsNull
case Right(element) => elements = elements :+ element
case Left(field) => if (encodeNulls || field._2 != JsNull) fields = fields :+ field
})
if (elements.nonEmpty || fields.isEmpty) JsArray(elements: _*)
else JsObject(fields: _*)
}
encodeValue(values)
}
def encode(item: Any): Either[JsField, JsValue] = item match {
case null | () => Right(JsNull)
case item: JsValue => Right(item)
case item: Json => Right(item.unwrap)
case item: Tuple2[_, _] if item._1.isInstanceOf[String] => Left( (item._1.toString, encode(item._2).right.get) )
case item: Boolean => Right(JsBoolean(item))
case item: Short => Right(JsNumber(item))
case item: Int => Right(JsNumber(item))
case item: Long => Right(JsNumber(item))
case item: Float => Right(JsNumber(item))
case item: Double => Right(JsNumber(item))
case item: BigInt => Right(JsNumber(item))
case item: BigDecimal => Right(JsNumber(item))
case item => Right(JsString(item.toString))
}
/**
* Decode a value of supported type to a JsValue.
*
* **Warning:** providing a type other than one of the accepted
* ones will throw an `IllegalArgumentException` at runtime. Meh, erasure.
*/
def decode[T: ClassTag](value: T): JsValue = {
val klass = implicitly[ClassTag[T]].runtimeClass
if (klass eq classOf[String]) JsString(value.toString)
else if (klass eq classOf[Boolean]) JsBoolean(value.asInstanceOf[Boolean])
else if (klass eq classOf[Int]) JsNumber(value.asInstanceOf[Int])
else if (klass eq classOf[Long]) JsNumber(value.asInstanceOf[Long])
else if (klass eq classOf[Double]) JsNumber(value.asInstanceOf[Double])
else if (klass eq classOf[BigInt]) JsNumber(value.asInstanceOf[BigInt])
else if (klass eq classOf[BigDecimal]) JsNumber(value.asInstanceOf[BigDecimal])
else if (classOf[JsValue] isAssignableFrom klass) value.asInstanceOf[JsValue]
else if (klass eq classOf[Json]) value.asInstanceOf[Json].unwrap
else throw new IllegalArgumentException(s"can't decode [${klass.getName}] to JsValue")
}
}
implicit def jsValue2Lens(value: JsValue): Json = Json(Option(value))
implicit def lens2jsValue(value: Json): JsValue = value.unwrap
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment