Skip to content

Commit

Permalink
zio-json-yaml initial version (#217)
Browse files Browse the repository at this point in the history
* zio-json-yaml initial version

* zio-json-yaml initial version
  • Loading branch information
vigoo authored Mar 22, 2021
1 parent ed14637 commit bd8bb12
Show file tree
Hide file tree
Showing 6 changed files with 425 additions and 2 deletions.
21 changes: 19 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ addCommandAlias("fixCheck", "scalafixAll --check")
addCommandAlias("fmt", "all scalafmtSbt scalafmtAll")
addCommandAlias("fmtCheck", "all scalafmtSbtCheck scalafmtCheckAll")
addCommandAlias("prepare", "fix; fmt")
addCommandAlias("testJVM", "zioJsonJVM/test")
addCommandAlias("testJVM", "zioJsonJVM/test; zioJsonYaml/test")
addCommandAlias("testJS", "zioJsonJS/test")

val zioVersion = "1.0.5"
Expand All @@ -42,7 +42,8 @@ lazy val root = project
)
.aggregate(
zioJsonJVM,
zioJsonJS
zioJsonJS,
zioJsonYaml
)

val circeVersion = "0.13.0"
Expand Down Expand Up @@ -178,6 +179,22 @@ lazy val zioJsonJS = zioJson.js

lazy val zioJsonJVM = zioJson.jvm

lazy val zioJsonYaml = project
.in(file("zio-json-yaml"))
.settings(stdSettings("zio-json-yaml"))
.settings(buildInfoSettings("zio.json.yaml"))
.enablePlugins(NeoJmhPlugin)
.settings(
libraryDependencies ++= Seq(
"org.yaml" % "snakeyaml" % "1.28",
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-test" % zioVersion % "test",
"dev.zio" %% "zio-test-sbt" % zioVersion % "test"
),
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")
)
.dependsOn(zioJsonJVM)

lazy val docs = project
.in(file("zio-json-docs"))
.dependsOn(zioJsonJVM)
Expand Down
37 changes: 37 additions & 0 deletions zio-json-yaml/src/main/scala/zio/json/yaml/YamlOptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package zio.json.yaml

import org.yaml.snakeyaml.DumperOptions.{ FlowStyle, LineBreak, NonPrintableStyle, ScalarStyle }

import zio.json.ast.Json

case class YamlOptions(
dropNulls: Boolean,
indentation: Int,
sequenceIndentation: Int,
maxScalarWidth: Option[Int],
lineBreak: LineBreak,
flowStyle: Json => FlowStyle,
scalarStyle: Json => ScalarStyle,
keyStyle: String => ScalarStyle,
nonPrintableStyle: NonPrintableStyle
)

object YamlOptions {
private val defaultLineBreak: LineBreak = {
Set(LineBreak.MAC, LineBreak.WIN, LineBreak.UNIX)
.find(_.getString == System.lineSeparator())
.getOrElse(LineBreak.UNIX)
}

val default: YamlOptions = YamlOptions(
dropNulls = true,
indentation = 2,
sequenceIndentation = 2,
maxScalarWidth = Some(80),
lineBreak = defaultLineBreak,
flowStyle = _ => FlowStyle.AUTO,
scalarStyle = _ => ScalarStyle.PLAIN,
keyStyle = _ => ScalarStyle.PLAIN,
nonPrintableStyle = NonPrintableStyle.ESCAPE
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package zio.json.yaml.internal

import org.yaml.snakeyaml.constructor.SafeConstructor
import org.yaml.snakeyaml.nodes.{ MappingNode, Node }

private[yaml] final class YamlValueConstruction extends SafeConstructor {
def toJavaValue(node: Node): AnyRef =
getConstructor(node).construct(node)

def processMappingNode(node: MappingNode): MappingNode = {
flattenMapping(node)
node
}
}
188 changes: 188 additions & 0 deletions zio-json-yaml/src/main/scala/zio/json/yaml/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package zio.json

import java.io.{ StringReader, StringWriter }
import java.nio.charset.StandardCharsets
import java.util.Base64

import scala.jdk.CollectionConverters._
import scala.util.Try
import scala.util.matching.Regex

import org.yaml.snakeyaml.DumperOptions.{ NonPrintableStyle, ScalarStyle }
import org.yaml.snakeyaml.emitter.Emitter
import org.yaml.snakeyaml.error.YAMLException
import org.yaml.snakeyaml.nodes.{ ScalarNode, _ }
import org.yaml.snakeyaml.reader.StreamReader
import org.yaml.snakeyaml.resolver.Resolver
import org.yaml.snakeyaml.serializer._
import org.yaml.snakeyaml.{ DumperOptions, Yaml }

import zio.Chunk
import zio.json.ast.Json
import zio.json.yaml.internal.YamlValueConstruction

package object yaml {

implicit final class JsonOps(private val json: Json) extends AnyVal {
def toYaml(options: YamlOptions = YamlOptions.default): Either[YAMLException, String] = {
val yamlNode = toYamlAST(options)

try {
val dumperOptions = new DumperOptions()
dumperOptions.setIndent(options.indentation)
dumperOptions.setIndicatorIndent(options.sequenceIndentation)
options.maxScalarWidth match {
case Some(width) =>
dumperOptions.setWidth(width)
dumperOptions.setSplitLines(true)
case None =>
dumperOptions.setSplitLines(false)
}
dumperOptions.setLineBreak(options.lineBreak)

val resolver = new Resolver
val output = new StringWriter()
val serializer = new Serializer(new Emitter(output, dumperOptions), resolver, dumperOptions, yamlNode.getTag)
serializer.open()
try {
serializer.serialize(yamlNode)
} finally {
serializer.close()
}

Right(output.toString)
} catch {
case error: YAMLException => Left(error)
}
}

def toYamlAST(options: YamlOptions = YamlOptions.default): Node = jsonToYaml(json, options)
}

implicit final class EncoderYamlOps[A](private val a: A) extends AnyVal {
def toYaml(options: YamlOptions = YamlOptions.default)(implicit A: JsonEncoder[A]): Either[String, String] =
a.toJsonAST.flatMap(_.toYaml(options).left.map(_.getMessage))
def toYamlAST(options: YamlOptions = YamlOptions.default)(implicit A: JsonEncoder[A]): Either[String, Node] =
a.toJsonAST.map(_.toYamlAST(options))
}

implicit final class DecoderYamlOps(private val raw: String) extends AnyVal {
def fromYaml[A](implicit A: JsonDecoder[A]): Either[String, A] =
Try {
val yaml = new Yaml().compose(new StringReader(raw))
yamlToJson(yaml)
}.toEither.left
.map(_.getMessage)
.flatMap(A.fromJsonAST)
}

private val multiline: Regex = "[\n\u0085\u2028\u2029]".r

private final def jsonToYaml(json: Json, options: YamlOptions): Node =
json match {
case Json.Obj(fields) =>
val finalFields =
if (options.dropNulls) {
fields.filter { case (_, value) =>
value match {
case Json.Null => false
case _ => true
}
}
} else {
fields
}
new MappingNode(
Tag.MAP,
finalFields.map { case (key, value) =>
new NodeTuple(
new ScalarNode(Tag.STR, key, null, null, options.keyStyle(key)),
jsonToYaml(value, options)
)
}.toList.asJava,
options.flowStyle(json)
)
case Json.Arr(elements) =>
new SequenceNode(
Tag.SEQ,
elements.map(jsonToYaml(_, options)).toList.asJava,
options.flowStyle(json)
)
case Json.Bool(value) =>
new ScalarNode(Tag.BOOL, value.toString, null, null, options.scalarStyle(json))
case Json.Str(value) =>
if (options.nonPrintableStyle == NonPrintableStyle.BINARY && !StreamReader.isPrintable(value)) {
new ScalarNode(
Tag.BINARY,
Base64.getEncoder.encodeToString(value.getBytes(StandardCharsets.UTF_8)),
null,
null,
ScalarStyle.LITERAL
)
} else {
val isMultiLine = multiline.findFirstIn(value).isDefined
val style = options.scalarStyle(json)
val finalStyle = if (style == ScalarStyle.PLAIN && isMultiLine) ScalarStyle.LITERAL else style
new ScalarNode(Tag.STR, value, null, null, finalStyle)
}
case Json.Num(value) =>
val stripped = value.stripTrailingZeros()
if (stripped.scale() <= 0) {
new ScalarNode(Tag.INT, stripped.intValue().toString, null, null, options.scalarStyle(json))
} else {
new ScalarNode(Tag.FLOAT, stripped.toString, null, null, options.scalarStyle(json))
}
case Json.Null =>
new ScalarNode(Tag.NULL, "null", null, null, options.scalarStyle(json))
}

private final def yamlToJson(yaml: Node): Json = {
val construction = new YamlValueConstruction

def loop(node: Node): Json =
node match {
case scalar: ScalarNode =>
construction.toJavaValue(scalar) match {
case null => Json.Null
case s: String => Json.Str(s)
case b: java.lang.Boolean => Json.Bool(b)
case i: java.lang.Integer => Json.Num(i)
case l: java.lang.Long => Json.Num(l)
case bi: java.math.BigInteger => Json.Num(scala.math.BigDecimal(bi))
case f: java.lang.Float => Json.Num(f)
case d: java.lang.Double => Json.Num(d)
case arr: Array[Byte] => Json.Str(new String(arr, StandardCharsets.UTF_8))
case _ => Json.Str(scalar.getValue)
}

case sequence: SequenceNode =>
Json.Arr(
Chunk.fromIterable(
sequence.getValue.asScala
.map(yamlToJson)
)
)
case mapping: MappingNode =>
Json.Obj(
Chunk.fromIterable(
construction
.processMappingNode(mapping)
.getValue
.asScala
.map { tuple =>
val keyStr = tuple.getKeyNode match {
case scalarKey: ScalarNode => scalarKey.getValue
case _ => throw new YAMLException("Mapping key is not scalar")
}
val jsonValue = loop(tuple.getValueNode)
keyStr -> jsonValue
}
)
)
case _ =>
throw new YAMLException(s"Unsupported node type: ${node.getClass.getSimpleName}")
}

loop(yaml)
}
}
34 changes: 34 additions & 0 deletions zio-json-yaml/src/test/scala/zio/json/yaml/YamlDecoderSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package zio.json.yaml

import zio.json.yaml.YamlEncoderSpec.{ Example, ex1, ex1Yaml, ex1Yaml2 }
import zio.test.Assertion.{ equalTo, isRight }
import zio.test._
import zio.test.environment.TestEnvironment

object YamlDecoderSpec extends DefaultRunnableSpec {
override def spec: ZSpec[TestEnvironment, Any] =
suite("Decoding from YAML")(
test("object root") {
assert(ex1Yaml.fromYaml[Example])(
isRight(equalTo(ex1))
)
},
test("object root, different indentation") {
assert(ex1Yaml2.fromYaml[Example])(
isRight(equalTo(ex1))
)
},
test("scalar root") {
assert("hello".fromYaml[String])(isRight(equalTo("hello")))
},
test("bool root") {
assert("yes".fromYaml[Boolean])(isRight(equalTo(true)))
},
test("float root") {
assert("3.14".fromYaml[Double])(isRight(equalTo(3.14)))
},
test("sequence root") {
assert("- a\n- b\n- c".fromYaml[Vector[String]])(isRight(equalTo(Vector("a", "b", "c"))))
}
)
}
Loading

0 comments on commit bd8bb12

Please sign in to comment.