Skip to content

Commit

Permalink
Play-json-jsoniter (#13)
Browse files Browse the repository at this point in the history
Jsoniter integration
  • Loading branch information
haghard authored and lavrov committed Jan 10, 2020
1 parent 888cc07 commit 9b9ec4a
Show file tree
Hide file tree
Showing 12 changed files with 467 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

1. play-json-tools - Set of implicit Play-JSON `Format` helper classes. Example in [FlatFormatSpec](play-json-tools/src/test/scala/com/evolutiongaming/util/FlatFormatSpec.scala)
1. play-json-generic - provides Format derivation for enum like adt's (sealed trait/case objects'). Examples in [EnumerationDerivalSpec](play-json-generic/src/test/scala/com/evolutiongaming/util/generic/EnumerationDerivalSpec.scala)
1. play-json-jsoniter - provides the fastest way to convert an instance of `play.api.libs.json.JsValue` to byte array and read it back.

## Setup

Expand Down
20 changes: 18 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ val commonSettings = Seq(
crossScalaVersions := Seq("2.13.1", "2.12.10"),
)


lazy val root = project
.in(file("."))
.disablePlugins(MimaPlugin)
Expand All @@ -28,6 +27,7 @@ lazy val root = project
.aggregate(
playJsonTools,
playJsonGeneric,
playJsonJsoniter
)


Expand Down Expand Up @@ -55,4 +55,20 @@ lazy val playJsonTools = project
playJson,
nel,
scalaTest % Test,
).map(excludeLog4j)))
).map(excludeLog4j)))

//++ 2.12.10 or ++ 2.13.1
lazy val playJsonJsoniter = project
.in(file("play-json-jsoniter"))
.settings(commonSettings)
.settings(Seq(
moduleName := "play-json-jsoniter",
name := "play-json-jsoniter",
libraryDependencies ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, v)) if v >= 13 =>
Seq(playJson, nel, jsoniter, scalaTest % Test, jsonGenerator % Test).map(excludeLog4j)
case _ =>
Seq(playJson, nel, jsoniter, collectionCompact, scalaTest % Test).map(excludeLog4j)
}
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.evolutiongaming.jsonitertool

import com.github.plokhotnyuk.jsoniter_scala.core._
import play.api.libs.json._

import scala.collection.IndexedSeq
import scala.util.Try

object PlayJsonJsoniter {

implicit val jsValueCodec: JsonValueCodec[JsValue] =
new JsonValueCodec[JsValue] {

/**
* The implementation was borrowed from: https://github.com/plokhotnyuk/jsoniter-scala/blob/e80d51019b39efacff9e695de97dce0c23ae9135/jsoniter-scala-benchmark/src/main/scala/io/circe/CirceJsoniter.scala
* and adapted to meet PlayJson criteria.
*/
override def decodeValue(in: JsonReader, default: JsValue): JsValue = {
val b = in.nextToken()
if (b == 'n') in.readNullOrError(default, "expected `null` value")
else if (b == '"') {
in.rollbackToken()
JsString(in.readString(null))
} else if (b == 'f' || b == 't') {
in.rollbackToken()
if (in.readBoolean()) JsTrue else JsFalse
} else if ((b >= '0' && b <= '9') || b == '-') {
in.rollbackToken()
val s = JsonParserSettings.settings.bigDecimalParseSettings
JsNumber(in.readBigDecimal(null, s.mathContext, s.scaleLimit, s.digitsLimit))
} else if (b == '[') {
val array: IndexedSeq[JsValue] =
if (in.isNextToken(']')) new Array[JsValue](0)
else {
in.rollbackToken()
var i = 0
var arr = new Array[JsValue](4)
do {
if (i == arr.length) arr = java.util.Arrays.copyOf(arr, i << 1)
arr(i) = decodeValue(in, default)
i += 1
} while (in.isNextToken(','))

if (in.isCurrentToken(']'))
if (i == arr.length) arr else java.util.Arrays.copyOf(arr, i)
else in.arrayEndOrCommaError()
}
JsArray(array)
} else if (b == '{') {
/*
* Because of DoS vulnerability in Scala 2.12 HashMap https://github.com/scala/bug/issues/11203
* we use a Java LinkedHashMap because it better handles hash code collisions for Comparable keys.
*/
val kvs =
if (in.isNextToken('}')) new java.util.LinkedHashMap[String, JsValue]()
else {
val underlying = new java.util.LinkedHashMap[String, JsValue]()
in.rollbackToken()
do {
underlying.put(in.readKeyAsString(), decodeValue(in, default))
} while (in.isNextToken(','))

if (!in.isCurrentToken('}'))
in.objectEndOrCommaError()

underlying
}
import scala.jdk.CollectionConverters._
JsObject(kvs.asScala)
} else in.decodeError("expected JSON value")
}

override def encodeValue(jsValue: JsValue, out: JsonWriter): Unit =
jsValue match {
case JsBoolean(b) =>
out.writeVal(b)
case JsString(value) =>
out.writeVal(value)
case JsNumber(value) =>
out.writeVal(value)
case JsArray(items) =>
out.writeArrayStart()
items.foreach(encodeValue(_, out))
out.writeArrayEnd()
case JsObject(kvs) =>
out.writeObjectStart()
kvs.foreach {
case (k, v) =>
out.writeKey(k)
encodeValue(v, out)
}
out.writeObjectEnd()
case JsNull =>
out.writeNull()
}

override val nullValue: JsValue = JsNull
}

def serialize(payload: JsValue): Array[Byte] =
writeToArray(payload)

def deserialize(bytes: Array[Byte]): Try[JsValue] =
Try(readFromArray[JsValue](bytes))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.evolutiongaming.jsonitertool

import play.api.libs.json.Json
import com.evolutiongaming.jsonitertool.TestDataGenerators.{Address, User}

trait PlayJsonImplicits {

implicit val e = Json.reads[Address]
implicit val f = Json.writes[Address]

implicit val g = Json.reads[User]
implicit val h = Json.writes[User]

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.evolutiongaming.jsonitertool

import com.evolutiongaming.jsonitertool.TestDataGenerators.{User, genUser}
import org.scalacheck.Prop.forAll
import org.scalacheck.{Arbitrary, Gen, Test}
import play.api.libs.json.{JsSuccess, Json}

//sbt playJsonJsoniter/test:"runMain com.evolutiongaming.jsonitertool.PlayJsonWithJsoniterBackendSpec"
object PlayJsonWithJsoniterBackendSpec extends org.scalacheck.Properties("PlayJsonWithJsoniterBackend") {
val Size = 200

override def overrideParameters(p: Test.Parameters): Test.Parameters =
p.withMinSuccessfulTests(Size)

implicit def generator: Arbitrary[User] = Arbitrary(genUser)

property("Write using PlayJson -> Read using Jsoniter") = forAll { user: User =>
val jsValue = Json.toJson(user)
val bts = Json.toBytes(jsValue)
val actJsValue = PlayJsonJsoniter.deserialize(bts).map(Json.fromJson[User](_))
JsSuccess(user) == actJsValue.get
}

property("Write using PlayJson -> Read using Jsoniter. Batch") = forAll(
Gen.containerOfN[Vector, User](Size, genUser),
) { batch: Vector[User] =>
val bools = batch.map { user =>
val jsValue = Json.toJson(user)
val bts = Json.toBytes(jsValue)
val actJsValue = PlayJsonJsoniter.deserialize(bts).map(Json.fromJson[User](_)).get
JsSuccess(user) == actJsValue
}

bools.find(_ == false).isEmpty
}

property("Write using Jsoniter -> Read using Jsoniter. Batch") = forAll(
Gen.containerOfN[Vector, User](Size, genUser),
) { batch: Vector[User] =>
val bools = batch.map { user =>
val jsValue = Json.toJson(user)
val bts = PlayJsonJsoniter.serialize(jsValue)
val actJsValue = PlayJsonJsoniter.deserialize(bts).map(Json.fromJson[User](_))
JsSuccess(user) == actJsValue.get
}

bools.find(_ == false).isEmpty
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.evolutiongaming.jsonitertool

import org.scalacheck.Prop.forAll
import org.scalacheck.{Arbitrary, Gen, Test}
import play.api.libs.json.Json
import valuegen.RandomJsArrayGen

//sbt playJsonJsoniter/test:"runMain com.evolutiongaming.jsonitertool.RandomJsonArraysSpec"
object RandomJsonArraysSpec extends org.scalacheck.Properties("RandomJsonSpec") {

val Size = 5000

//produces any imaginable Json array
def randomArrayGen: Gen[value.JsArray] = RandomJsArrayGen()

implicit def generator: Arbitrary[value.JsArray] = Arbitrary(randomArrayGen)

override def overrideParameters(p: Test.Parameters): Test.Parameters =
p.withMinSuccessfulTests(Size)

property("Random json arrays") = forAll { array: value.JsArray =>
val json = array.toString
val jsValue = Json.parse(json)
val bts = PlayJsonJsoniter.serialize(jsValue)
val actJsValue = PlayJsonJsoniter.deserialize(bts)
jsValue == actJsValue.get
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.evolutiongaming.jsonitertool

import org.scalacheck.Prop.forAll
import org.scalacheck.{Arbitrary, Gen, Test}
import play.api.libs.json.Json
import valuegen.RandomJsObjGen

//sbt playJsonJsoniter/test:"runMain com.evolutiongaming.jsonitertool.RandomJsonObjectsSpec"
object RandomJsonObjectsSpec extends org.scalacheck.Properties("RandomJsonObjectsSpec") {

val Size = 5000

//produces any imaginable Json object
def randomObjGen: Gen[value.JsObj] = RandomJsObjGen()

implicit def generator: Arbitrary[value.JsObj] = Arbitrary(randomObjGen)

override def overrideParameters(p: Test.Parameters): Test.Parameters =
p.withMinSuccessfulTests(Size)

property("Random json objects") = forAll { obj: value.JsObj =>
val json = obj.toString
val jsValue = Json.parse(json)
val bts = PlayJsonJsoniter.serialize(jsValue)
val actJsValue = PlayJsonJsoniter.deserialize(bts)
jsValue == actJsValue.get
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.evolutiongaming.jsonitertool

import play.api.libs.json.Json
import valuegen.Preamble._
import valuegen.{JsArrayGen, JsObjGen}
import value.JsObj
import org.scalacheck.{Arbitrary, Gen}

object TestDataGenerators extends PlayJsonImplicits {

val ALPHABET: Vector[String] = "abcdefghijklmnopqrstuvwzyz".split("").toVector

def str(len: Int): Gen[String] = Gen.listOfN(len, Gen.choose(0, ALPHABET.size - 1).map(ALPHABET(_))).map(_.mkString(""))

def flagGen: Gen[Boolean] = Arbitrary.arbitrary[Boolean]

def nameGen: Gen[String] = Gen.choose(5, 20).flatMap(str)

def birthDateGen: Gen[String] = Gen.choose(5, 20).flatMap(str)

def latitudeGen: Gen[Double] = Arbitrary.arbitrary[Double]

def longitudeGen: Gen[Double] = Arbitrary.arbitrary[Double]

def longGen: Gen[Long] = Arbitrary.arbitrary[Long]

def ints: Gen[Int] = Arbitrary.arbitrary[Int]

def emailGen: Gen[String] = Gen.choose(5, 20).flatMap(str)

def countryGen: Gen[String] = Gen.choose(5, 20).flatMap(str)

def friendGen(depth: Int): Gen[JsObj] = JsObjGen(
"name" -> nameGen,
"email" -> emailGen,
"flag" -> flagGen,
"l" -> longGen,
"from" -> JsObjGen("country" -> countryGen, "doubles" -> JsArrayGen(latitudeGen, longitudeGen)),
"metrics" -> JsArrayGen(ints, ints),
"friends" -> (if (depth > 0) JsArrayGen(friendGen(depth - 1)) else JsArrayGen())
)

def objGen: Gen[JsObj] = JsObjGen(
"name" -> nameGen,
"email" -> emailGen,
"flag" -> flagGen,
"l" -> longGen,
"from" -> JsObjGen("country" -> countryGen, "doubles" -> JsArrayGen(latitudeGen, longitudeGen)),
"metrics" -> JsArrayGen(ints, ints, ints, ints, ints),
"friends" -> JsArrayGen(friendGen(depth = 50))
)

def genUser: Gen[User] = objGen
.map(json => Json.fromJson[User](Json.parse(json.toString)).get)

case class Address(country: String, doubles: List[Double])

case class User(name: String, email: String, flag: Boolean, from: Address, l: Long, metrics: List[Int],
friends: List[User])
}
Loading

0 comments on commit 9b9ec4a

Please sign in to comment.