From c68a583b5c67051ed45e43a7cc82813fed60e582 Mon Sep 17 00:00:00 2001 From: Aaron Barany Date: Thu, 13 Feb 2025 18:41:05 -0800 Subject: [PATCH] Add support for writing to an output stream 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 #1126 --- .../working/scalaGuide/main/json/ScalaJson.md | 12 +++++ .../main/json/code/ScalaJsonSpec.scala | 22 +++++++++ .../src/main/scala/StaticBindingNonJvm.scala | 8 +++ .../js/src/main/scala/StaticBinding.scala | 6 +++ .../play/api/libs/json/StaticBinding.scala | 8 +++ .../api/libs/json/jackson/JacksonJson.scala | 18 ++++++- .../native/src/main/scala/StaticBinding.scala | 6 +++ .../main/scala/play/api/libs/json/Json.scala | 30 +++++++++++- .../play/api/libs/json/JsonSharedSpec.scala | 49 ++++++++++++++++++- 9 files changed, 155 insertions(+), 4 deletions(-) diff --git a/docs/manual/working/scalaGuide/main/json/ScalaJson.md b/docs/manual/working/scalaGuide/main/json/ScalaJson.md index 49ad7838b..e96694801 100644 --- a/docs/manual/working/scalaGuide/main/json/ScalaJson.md +++ b/docs/manual/working/scalaGuide/main/json/ScalaJson.md @@ -163,6 +163,18 @@ Readable: } ``` +### Using OutputStream utilities + +As with the String utilities, but writing to an `OutputStream` to avoid building the full string in memory. These examples use a `ByteArrayOutputStream` for illustration, though other stream types such as `FileOutputStream` will be more common. + +Minified: + +@[convert-to-stream](code/ScalaJsonSpec.scala) + +Readable: + +@[convert-to-stream-pretty](code/ScalaJsonSpec.scala) + ### Using JsValue.as/asOpt The simplest way to convert a `JsValue` to another type is using `JsValue.as[T](implicit fjs: Reads[T]): T`. This requires an implicit converter of type [`Reads[T]`](api/scala/play/api/libs/json/Reads.html) to convert a `JsValue` to `T` (the inverse of `Writes[T]`). As with `Writes`, the JSON API provides `Reads` for basic types. diff --git a/docs/manual/working/scalaGuide/main/json/code/ScalaJsonSpec.scala b/docs/manual/working/scalaGuide/main/json/code/ScalaJsonSpec.scala index 5861e03c9..9248ae4c5 100644 --- a/docs/manual/working/scalaGuide/main/json/code/ScalaJsonSpec.scala +++ b/docs/manual/working/scalaGuide/main/json/code/ScalaJsonSpec.scala @@ -6,6 +6,8 @@ package scalaguide.json import org.specs2.mutable.Specification +import java.io.ByteArrayOutputStream + class ScalaJsonSpec extends Specification { val sampleJson = { //#convert-from-string @@ -339,6 +341,26 @@ class ScalaJsonSpec extends Specification { readableString.must(contain("Bigwig")) } + "allow writing JsValue to OutputStream" in { + import play.api.libs.json._ + val json = sampleJson + + //#convert-to-stream + val minifiedStream = new ByteArrayOutputStream() + Json.writeToStream(json, minifiedStream) + //#convert-to-stream + + //#convert-to-stream-pretty + val readableStream = new ByteArrayOutputStream() + Json.prettyPrintToStream(json, readableStream) + //#convert-to-stream-pretty + + val minifiedString: String = minifiedStream.toString("UTF-8") + minifiedString.must(contain("Fiver")) + val readableString: String = readableStream.toString("UTF-8") + readableString.must(contain("Bigwig")) + } + "allow converting JsValue using as" in { val json = sampleJson diff --git a/play-json/js-native/src/main/scala/StaticBindingNonJvm.scala b/play-json/js-native/src/main/scala/StaticBindingNonJvm.scala index 5700586be..4fcc9b52a 100644 --- a/play-json/js-native/src/main/scala/StaticBindingNonJvm.scala +++ b/play-json/js-native/src/main/scala/StaticBindingNonJvm.scala @@ -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, diff --git a/play-json/js/src/main/scala/StaticBinding.scala b/play-json/js/src/main/scala/StaticBinding.scala index c24d8862e..676c095ad 100644 --- a/play-json/js/src/main/scala/StaticBinding.scala +++ b/play-json/js/src/main/scala/StaticBinding.scala @@ -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)) diff --git a/play-json/jvm/src/main/scala/play/api/libs/json/StaticBinding.scala b/play-json/jvm/src/main/scala/play/api/libs/json/StaticBinding.scala index a11763502..8b5be8db0 100644 --- a/play-json/jvm/src/main/scala/play/api/libs/json/StaticBinding.scala +++ b/play-json/jvm/src/main/scala/play/api/libs/json/StaticBinding.scala @@ -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. */ @@ -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) } diff --git a/play-json/jvm/src/main/scala/play/api/libs/json/jackson/JacksonJson.scala b/play-json/jvm/src/main/scala/play/api/libs/json/jackson/JacksonJson.scala index cad70cdee..0745176d7 100644 --- a/play-json/jvm/src/main/scala/play/api/libs/json/jackson/JacksonJson.scala +++ b/play-json/jvm/src/main/scala/play/api/libs/json/jackson/JacksonJson.scala @@ -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 @@ -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 = @@ -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) diff --git a/play-json/native/src/main/scala/StaticBinding.scala b/play-json/native/src/main/scala/StaticBinding.scala index 109d77373..0eb0a8ed0 100644 --- a/play-json/native/src/main/scala/StaticBinding.scala +++ b/play-json/native/src/main/scala/StaticBinding.scala @@ -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" diff --git a/play-json/shared/src/main/scala/play/api/libs/json/Json.scala b/play-json/shared/src/main/scala/play/api/libs/json/Json.scala index 96c9d78bd..9157fc8cd 100644 --- a/play-json/shared/src/main/scala/play/api/libs/json/Json.scala +++ b/play-json/shared/src/main/scala/play/api/libs/json/Json.scala @@ -4,7 +4,7 @@ package play.api.libs.json -import java.io.InputStream +import java.io.{ InputStream, OutputStream } import scala.collection.mutable.{ Builder => MBuilder } @@ -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. @@ -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]]. * @@ -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 { @@ -207,6 +225,8 @@ 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 = @@ -214,6 +234,8 @@ object Json extends JsonFacade with JsMacros with JsValueMacros { 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 @@ -361,11 +383,15 @@ object Json extends JsonFacade with JsMacros with JsValueMacros { @inline def toBytes(json: JsValue): Array[Byte] = Json.toBytes(json) + @inline def writeToStream(json: JsValue, stream: OutputStream): Unit = Json.writeToStream(json, stream) + @inline def asciiStringify(json: JsValue): String = Json.asciiStringify(json) @inline def prettyPrint(json: JsValue): String = Json.prettyPrint(json) + @inline def prettyPrintToStream(json: JsValue, stream: OutputStream): Unit = Json.prettyPrintToStream(json, stream) + @inline def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = Json.toJson[T](o) diff --git a/play-json/shared/src/test/scala/play/api/libs/json/JsonSharedSpec.scala b/play-json/shared/src/test/scala/play/api/libs/json/JsonSharedSpec.scala index 939c8bdf2..82ef3592b 100644 --- a/play-json/shared/src/test/scala/play/api/libs/json/JsonSharedSpec.scala +++ b/play-json/shared/src/test/scala/play/api/libs/json/JsonSharedSpec.scala @@ -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 @@ -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 { @@ -313,7 +338,29 @@ class JsonSharedSpec "key22" : 123 }, "key3" : [ 1, "tutu" ] -}""") +}""".replace("\r\n", "\n")) + } + + "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" ] +}""".replace("\r\n", "\n")) } "asciiStringify should escape non-ascii characters" in json { js =>