Skip to content

Commit

Permalink
Add support for writing to an output stream
Browse files Browse the repository at this point in the history
Added JsonFacade functions writeToStream and prettyPrintToStream to support
writing to an OutputStream without first writing the full string to memory.
The JVM implementation forwards these calls to the appropriate functions in
Jackson.

For now, the non-JVM implementations will build up the full string in
memory. Ideally the strings should be written out as they are built up, but
this would require a refactoring of the fromJs() function to support an
interface to feed each string value and minimize the number of conversions
to UTF-8 (for toBytes and writing to stream) or avoid conversions (for
String). For now, this fulfills the interface guarantee while providing the
same level of functionality as before.

Fixes playframework#1126
  • Loading branch information
here-abarany committed Feb 14, 2025
1 parent 6e6314e commit 257f760
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 3 deletions.
8 changes: 8 additions & 0 deletions play-json/js-native/src/main/scala/StaticBindingNonJvm.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,17 @@ private[json] object StaticBindingNonJvm {
arraySep = ("[ ", ", ", " ]")
)

// TODO: Write to the stream when traversing JsValue without buffering the whole string.
def prettyPrintToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
stream.write(prettyPrint(jsValue).getBytes("UTF-8"))

def toBytes(jsValue: JsValue): Array[Byte] =
generateFromJsValue(jsValue, false).getBytes("UTF-8")

// TODO: Write to the stream when traversing JsValue without buffering the whole string.
def writeToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
stream.write(toBytes(jsValue))

def fromJs(
jsValue: JsValue,
escapeNonASCII: Boolean,
Expand Down
6 changes: 6 additions & 0 deletions play-json/js/src/main/scala/StaticBinding.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ object StaticBinding {

def prettyPrint(jsValue: JsValue): String = StaticBindingNonJvm.prettyPrint(jsValue)

def prettyPrintToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
StaticBindingNonJvm.prettyPrintToStream(jsValue, stream)

def toBytes(jsValue: JsValue): Array[Byte] = StaticBindingNonJvm.toBytes(jsValue)

def writeToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
StaticBindingNonJvm.writeToStream(jsValue, stream)

@inline private[json] def fromString(s: String, escapeNonASCII: Boolean): String =
if (!escapeNonASCII) JSON.stringify(s, null) else StaticBindingNonJvm.escapeStr(JSON.stringify(s, null))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package play.api.libs.json

import play.api.libs.json.jackson.JacksonJson

import java.io.OutputStream

object StaticBinding {

/** Parses a [[JsValue]] from raw data. */
Expand All @@ -25,6 +27,12 @@ object StaticBinding {

def prettyPrint(jsValue: JsValue): String = JacksonJson.get.prettyPrint(jsValue)

def prettyPrintToStream(jsValue: JsValue, stream: OutputStream): Unit =
JacksonJson.get.prettyPrintToStream(jsValue, stream)

def toBytes(jsValue: JsValue): Array[Byte] =
JacksonJson.get.jsValueToBytes(jsValue)

def writeToStream(jsValue: JsValue, stream: OutputStream): Unit =
JacksonJson.get.writeJsValueToStream(jsValue, stream)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package play.api.libs.json.jackson

import java.io.InputStream
import java.io.OutputStream
import java.io.StringWriter

import scala.annotation.switch
Expand Down Expand Up @@ -286,7 +287,10 @@ private[json] case class JacksonJson(jsonConfig: JsonConfig) {

private val jsonFactory = new JsonFactory(mapper)

private def stringJsonGenerator(out: java.io.StringWriter) =
private def stringJsonGenerator(out: StringWriter) =
jsonFactory.createGenerator(out)

private def stringJsonGenerator(out: OutputStream) =
jsonFactory.createGenerator(out)

def parseJsValue(data: Array[Byte]): JsValue =
Expand Down Expand Up @@ -338,9 +342,21 @@ private[json] case class JacksonJson(jsonConfig: JsonConfig) {
sw.getBuffer.toString
}

def prettyPrintToStream(jsValue: JsValue, stream: OutputStream): Unit = {
val gen = stringJsonGenerator(stream).setPrettyPrinter(
new DefaultPrettyPrinter()
)
val writer: ObjectWriter = mapper.writerWithDefaultPrettyPrinter()

writer.writeValue(gen, jsValue)
}

def jsValueToBytes(jsValue: JsValue): Array[Byte] =
mapper.writeValueAsBytes(jsValue)

def writeJsValueToStream(jsValue: JsValue, stream: OutputStream): Unit =
mapper.writeValue(stream, jsValue)

def jsValueToJsonNode(jsValue: JsValue): JsonNode =
mapper.valueToTree(jsValue)

Expand Down
6 changes: 6 additions & 0 deletions play-json/native/src/main/scala/StaticBinding.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,14 @@ object StaticBinding {

def prettyPrint(jsValue: JsValue): String = StaticBindingNonJvm.prettyPrint(jsValue)

def prettyPrintToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
StaticBindingNonJvm.prettyPrintToStream(jsValue, stream)

def toBytes(jsValue: JsValue): Array[Byte] = StaticBindingNonJvm.toBytes(jsValue)

def writeToStream(jsValue: JsValue, stream: java.io.OutputStream): Unit =
StaticBindingNonJvm.writeToStream(jsValue, stream)

@inline private[json] def fromString(s: String, escapeNonASCII: Boolean): String = {
def escaped(c: Char) = c match {
case '\b' => "\\b"
Expand Down
26 changes: 24 additions & 2 deletions play-json/shared/src/main/scala/play/api/libs/json/Json.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

package play.api.libs.json

import java.io.InputStream
import java.io.{ InputStream, OutputStream }

import scala.collection.mutable.{ Builder => MBuilder }

Expand Down Expand Up @@ -87,6 +87,14 @@ sealed trait JsonFacade {
*/
def toBytes(json: JsValue): Array[Byte]

/**
* Writes a [[JsValue]] to an output stream.
*
* $jsonParam
* @param stream the stream to write to.
*/
def writeToStream(json: JsValue, stream: OutputStream): Unit

/**
* Converts a [[JsValue]] to its string representation,
* escaping all non-ascii characters using `\u005CuXXXX` syntax.
Expand Down Expand Up @@ -140,6 +148,16 @@ sealed trait JsonFacade {
*/
def prettyPrint(json: JsValue): String

/**
* Converts a [[JsValue]] to its pretty string representation using default
* pretty printer (line feeds after each fields and 2-spaces indentation) and
* writes the result to an output stream.
*
* $jsonParam
* @param stream the stream to write to.
*/
def prettyPrintToStream(json: JsValue, stream: OutputStream): Unit

/**
* Converts any writeable value to a [[JsValue]].
*
Expand Down Expand Up @@ -186,7 +204,7 @@ sealed trait JsonFacade {
* Helper functions to handle JsValues.
*
* @define macroOptions @tparam Opts the compile-time options
* @define macroTypeParam @tparam A the type for which the handler must be materialized
* @define macroTypeParam @tparam The type for which the handler must be materialized
* @define macroWarning If any missing implicit is discovered, compiler will break with corresponding error.
*/
object Json extends JsonFacade with JsMacros with JsValueMacros {
Expand All @@ -207,13 +225,17 @@ object Json extends JsonFacade with JsMacros with JsValueMacros {

def toBytes(json: JsValue): Array[Byte] = StaticBinding.toBytes(json)

def writeToStream(json: JsValue, stream: OutputStream): Unit = StaticBinding.writeToStream(json, stream)

// We use unicode \u005C for a backlash in comments, because Scala will replace unicode escapes during lexing
// anywhere in the program.
def asciiStringify(json: JsValue): String =
StaticBinding.generateFromJsValue(json, true)

def prettyPrint(json: JsValue): String = StaticBinding.prettyPrint(json)

def prettyPrintToStream(json: JsValue, stream: OutputStream): Unit = StaticBinding.prettyPrintToStream(json, stream)

def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = tjs.writes(o)

def toJsObject[T](o: T)(implicit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import org.scalacheck.Gen
import org.scalatest.matchers.must.Matchers
import org.scalatest.wordspec.AnyWordSpec

import java.io.ByteArrayOutputStream

class JsonSharedSpec
extends AnyWordSpec
with Matchers
Expand Down Expand Up @@ -178,6 +180,29 @@ class JsonSharedSpec

(success \ "price").mustEqual(JsDefined(JsString("2.5 €")))
}

"write to output stream the UTF-8 representation" in json { js =>
val json = js.parse("""
|{
| "name": "coffee",
| "symbol": "☕",
| "price": "2.5 €"
|}
""".stripMargin)

val stream = new ByteArrayOutputStream()
js.writeToStream(json, stream)
val string = stream.toString("UTF-8")
val parsedJson = js.tryParse(string)

parsedJson.isSuccess.mustEqual(true)

val success = parsedJson.success.value

(success \ "symbol").mustEqual(JsDefined(JsString("")))

(success \ "price").mustEqual(JsDefined(JsString("2.5 €")))
}
}

"Complete JSON should create full object" when {
Expand Down Expand Up @@ -316,6 +341,28 @@ class JsonSharedSpec
}""")
}

"JSON pretty print to stream" in json { js =>
def jo = js.obj(
"key1" -> "toto",
"key2" -> js.obj("key21" -> "tata", "key22" -> 123),
"key3" -> js.arr(1, "tutu")
)

val stream = new ByteArrayOutputStream()
js.prettyPrintToStream(jo, stream)
stream
.toString("UTF-8")
.replace("\r\n", "\n")
.mustEqual("""{
"key1" : "toto",
"key2" : {
"key21" : "tata",
"key22" : 123
},
"key3" : [ 1, "tutu" ]
}""")
}

"asciiStringify should escape non-ascii characters" in json { js =>
def jo = js.obj(
"key1" -> "\u2028\u2029\u2030",
Expand Down

0 comments on commit 257f760

Please sign in to comment.