Skip to content

Commit

Permalink
Safer encoding
Browse files Browse the repository at this point in the history
  • Loading branch information
a.valentinov committed Feb 19, 2023
1 parent 496ce54 commit 4600cb0
Show file tree
Hide file tree
Showing 39 changed files with 301 additions and 252 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ val journey =
arrival = TravelPoint("Germany", "Munich")
)

val xml: String = XmlEncoder[Journey].encode(journey)
val xml = XmlEncoder[Journey].encode(journey)
println(xml)

val decodedJourney = XmlDecoder[Journey].decode(xml)
val decodedJourney = xml.flatMap(XmlDecoder[Journey].decode(_))
println(decodedJourney)

assert(Right(journey) == decodedJourney)
Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

ThisBuild / name := "phobos"

ThisBuild / scalaVersion := "3.1.2"
ThisBuild / scalaVersion := "3.2.1"

lazy val commonDependencies =
libraryDependencies ++=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import ru.tinkoff.phobos.encoding.XmlEncoder
object application {
implicit def soapApplicationXmlMarshaller[T](implicit encoder: XmlEncoder[T]): ToEntityMarshaller[T] =
Marshaller.withFixedContentType(MediaTypes.`application/xml` withCharset HttpCharsets.`UTF-8`) { body =>
HttpEntity(MediaTypes.`application/xml` withCharset HttpCharsets.`UTF-8`, encoder.encode(body))
HttpEntity(MediaTypes.`application/xml` withCharset HttpCharsets.`UTF-8`, encoder.encodeUnsafe(body))
}

implicit def soapApplicationXmlUnmarshaller[T](implicit decoder: XmlDecoder[T]): FromEntityUnmarshaller[T] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ru.tinkoff.phobos.encoding.XmlEncoder
object text {
implicit def soapTextXmlMarshaller[T](implicit encoder: XmlEncoder[T]): ToEntityMarshaller[T] =
Marshaller.withFixedContentType(`text/xml(UTF-8)`) { body =>
HttpEntity(`text/xml(UTF-8)`, encoder.encode(body))
HttpEntity(`text/xml(UTF-8)`, encoder.encodeUnsafe(body))
}

implicit def soapTextXmlUnmarshaller[T](implicit decoder: XmlDecoder[T]): FromEntityUnmarshaller[T] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ private[phobos] trait AkkaStreamOps {
}
.map {
case None =>
throw DecodingError("Got an internal error while decoding byte stream", Nil)
throw DecodingError("Got an internal error while decoding byte stream", Nil, None)

case Some(SinkDecoderState(_, cursor, elementDecoder)) =>
elementDecoder.result(cursor.history)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,12 @@ class CodecProperties extends Properties("Ast codecs") {
private val decoder = XmlDecoder.fromElementDecoder[XmlEntry]("test")

property("decode(encode(ast)) === ast") = forAll { entry: XmlEntry =>
decoder.decode(
encoder.encode(entry),
) == Right(entry)
encoder.encode(entry).flatMap(decoder.decode(_)) == Right(entry)
}

property("encode(decode(xmlAst)) === xmlAst") = forAll { entry: XmlEntry =>
val encoded = encoder.encode(entry)

decoder.decode(encoded).map(encoder.encode(_)) == Right(encoded)
encoded.flatMap(decoder.decode(_)).map(encoder.encode(_)) == Right(encoded)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,9 @@ class XmlEntryElementDecoderTest extends AnyWordSpec with Matchers with DiffShou

val encoded = ru.tinkoff.phobos.encoding.XmlEncoder.fromElementEncoder[XmlEntry]("ast").encode(n)

val result = XmlDecoder
.fromElementDecoder[XmlEntry]("ast")
.decode(
encoded,
)
val result = encoded.flatMap(XmlDecoder.fromElementDecoder[XmlEntry]("ast").decode(_))

result.map(util.AstTransformer.sortNodeValues) shouldMatchTo (
util.AstTransformer.sortNodeValues(n).asRight[DecodingError],
)
assert(result.map(util.AstTransformer.sortNodeValues) == Right(util.AstTransformer.sortNodeValues(n)))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class XmlEntryElementEncoderTest extends AnyWordSpec with DiffShouldMatcher with
.fromElementEncoder[XmlEntry]("ast")
.encode(ast)

assert(result == """<?xml version='1.0' encoding='UTF-8'?><ast foo="5"><bar>bazz</bar></ast>""")
assert(result == Right("""<?xml version='1.0' encoding='UTF-8'?><ast foo="5"><bar>bazz</bar></ast>"""))
}
"encodes nested Xml ast correctly" in {
case object tinkoff {
Expand Down Expand Up @@ -46,7 +46,9 @@ class XmlEntryElementEncoderTest extends AnyWordSpec with DiffShouldMatcher with
.encode(ast)

assert(
result == """<?xml version='1.0' encoding='UTF-8'?><ans1:ast xmlns:ans1="https://tinkoff.ru" foo="5"><bar>bazz</bar><array foo2="true" foo3="false"><elem>11111111111111</elem><elem>11111111111112</elem></array><nested><scala>2.13</scala><dotty>0.13</dotty><scala-4/></nested></ans1:ast>""",
result == Right(
"""<?xml version='1.0' encoding='UTF-8'?><ans1:ast xmlns:ans1="https://tinkoff.ru" foo="5"><bar>bazz</bar><array foo2="true" foo3="false"><elem>11111111111111</elem><elem>11111111111112</elem></array><nested><scala>2.13</scala><dotty>0.13</dotty><scala-4/></nested></ans1:ast>""",
),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,25 @@ object catsInstances {
listDecoder[A].emap((history, list) =>
NonEmptyList
.fromList(list)
.fold[Either[DecodingError, NonEmptyList[A]]](Left(DecodingError("List is empty", history)))(Right.apply),
.fold[Either[DecodingError, NonEmptyList[A]]](Left(DecodingError("List is empty", history, None)))(Right.apply),
)

implicit def nonEmptyVectorElementDecoder[A](implicit decoder: ElementDecoder[A]): ElementDecoder[NonEmptyVector[A]] =
vectorDecoder[A].emap((history, vector) =>
NonEmptyVector
.fromVector(vector)
.fold[Either[DecodingError, NonEmptyVector[A]]](Left(DecodingError("Vector is empty", history)))(Right.apply),
.fold[Either[DecodingError, NonEmptyVector[A]]](Left(DecodingError("Vector is empty", history, None)))(
Right.apply,
),
)

implicit def nonEmptyChainElementDecoder[A](implicit decoder: ElementDecoder[A]): ElementDecoder[NonEmptyChain[A]] =
chainElementDecoder[A].emap((history, chain) =>
NonEmptyChain
.fromChain(chain)
.fold[Either[DecodingError, NonEmptyChain[A]]](Left(DecodingError("Chain is empty", history)))(Right.apply),
.fold[Either[DecodingError, NonEmptyChain[A]]](Left(DecodingError("Chain is empty", history, None)))(
Right.apply,
),
)

implicit class XmlDecoderCatsOps[A](val xmlDecoder: XmlDecoder[A]) extends AnyVal {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private[decoding] trait AttributeLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else
Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ private[decoding] trait ElementLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ private[decoding] trait TextLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else
Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ private[decoding] trait AttributeLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ private[decoding] trait ElementLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ private[decoding] trait TextLiteralInstances {
decoder
.emap((history, a) =>
if (a == valueOfL.value) Right(valueOfL.value)
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history)),
else Left(DecodingError(s"Failed to decode literal type. Expected: ${valueOfL.value}, actual: $a", history, None)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ object decoder {
$currentFieldStates
.getOrElse(
${Expr(field.localName)},
Left(DecodingError(s"Attribute '${${field.xmlName}}' is missing or invalid", $c.history))
Left(DecodingError(s"Attribute '${${field.xmlName}}' is missing or invalid", $c.history, None))
)
.asInstanceOf[Either[DecodingError, t]]
.flatMap { ${f.asExprOf[t => Either[DecodingError, T]]} }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ object AttributeDecoder extends AttributeLiteralInstances {
string match {
case "true" | "1" => Right(true)
case "false" | "0" => Right(false)
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history))
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history, None))
},
)

Expand All @@ -73,7 +73,7 @@ object AttributeDecoder extends AttributeLiteralInstances {
implicit val charDecoder: AttributeDecoder[Char] =
stringDecoder.emap((history, string) => {
if (string.length != 1) {
Left(DecodingError("Value too long for char", history))
Left(DecodingError("Value too long for char", history, None))
} else {
Right(string.head)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class Cursor(private val sr: XmlStreamReader) {
def hasNext: Boolean = sr.hasNext

def history: List[String] = historyStack
def error(text: String): DecodingError = DecodingError(text, historyStack)
def error(text: String): DecodingError = DecodingError(text, historyStack, None)

var scopeDefaultNamespaceStack: List[String] = Nil
def setScopeDefaultNamespace(uri: String): Unit = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ru.tinkoff.phobos.decoding

case class DecodingError(text: String, history: List[String]) extends Exception {
case class DecodingError(text: String, history: List[String], cause: Option[Throwable])
extends Exception(cause.orNull) {
override def getMessage: String = {
val trace = if (history.nonEmpty) {
history.mkString("\tIn element '", "'\n\t\tin element '", "'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ object ElementDecoder extends ElementLiteralInstances with DerivedElement {

def decodingNotCompleteError(history: List[String]): DecodingError =
history match {
case element :: others => DecodingError(s"Element '$element' is missing or invalid", others)
case Nil => DecodingError("Root element is missing or invalid", Nil)
case element :: others => DecodingError(s"Element '$element' is missing or invalid", others, None)
case Nil => DecodingError("Root element is missing or invalid", Nil, None)
}

final class MappedDecoder[A, B](fa: ElementDecoder[A], f: A => B) extends ElementDecoder[B] {
Expand Down Expand Up @@ -161,7 +161,7 @@ object ElementDecoder extends ElementLiteralInstances with DerivedElement {
string match {
case "true" | "1" => Right(true)
case "false" | "0" => Right(false)
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history))
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history, None))
},
)

Expand All @@ -170,7 +170,7 @@ object ElementDecoder extends ElementLiteralInstances with DerivedElement {
implicit val charDecoder: ElementDecoder[Char] =
stringDecoder.emap((history, string) => {
if (string.length != 1) {
Left(DecodingError("Value too long for char", history))
Left(DecodingError("Value too long for char", history, None))
} else {
Right(string.head)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ object TextDecoder extends TextLiteralInstances {
string match {
case "true" | "1" => Right(true)
case "false" | "0" => Right(false)
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history))
case str => Left(DecodingError(s"Value `$str` is not `true` or `false`", history, None))
},
)

Expand All @@ -122,7 +122,7 @@ object TextDecoder extends TextLiteralInstances {
implicit val charDecoder: TextDecoder[Char] =
stringDecoder.emap((history, string) => {
if (string.length != 1) {
Left(DecodingError("Value too long for char", history))
Left(DecodingError("Value too long for char", history, None))
} else {
Right(string.head)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package ru.tinkoff.phobos.decoding

import javax.xml.stream.XMLStreamConstants
import com.fasterxml.aalto.{AsyncByteArrayFeeder, WFCException}
import com.fasterxml.aalto.AsyncByteArrayFeeder
import com.fasterxml.aalto.async.{AsyncByteArrayScanner, AsyncStreamReaderImpl}
import com.fasterxml.aalto.stax.InputFactoryImpl
import ru.tinkoff.phobos.Namespace
Expand Down Expand Up @@ -43,7 +43,7 @@ trait XmlDecoder[A] extends XmlDecoderIterable[A] {
.result(cursor.history)
} catch {
case e: Throwable =>
Left(DecodingError(Option(e.getMessage).getOrElse("No message provided"), cursor.history))
Left(DecodingError(Option(e.getMessage).getOrElse("No message provided"), cursor.history, Some(e)))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ package object decoding {
private[decoding] def wrapException[A](f: String => A) =
(history: List[String], string: String) =>
Try(f(string)) match {
case Failure(exception) => Left(DecodingError(exception.getMessage, history))
case Success(a) => Right(a)
case Failure(exception) =>
Left(DecodingError(Option(exception.getMessage).getOrElse("No text provided"), history, Some(exception)))
case Success(a) => Right(a)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package ru.tinkoff.phobos.encoding

case class EncodingError(text: String, cause: Option[Throwable] = None) extends Exception(text, cause.orNull) {
override def getMessage: String = s"Error while decoding XML: $text"
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import javax.xml.stream.XMLStreamException
import org.codehaus.stax2.typed.Base64Variant
import org.codehaus.stax2.{XMLStreamLocation2, XMLStreamReader2, XMLStreamWriter2}
import org.codehaus.stax2.validation.{ValidationProblemHandler, XMLValidationSchema, XMLValidator}
import PhobosStreamWriter.{isValidXmlCharacter, prefixBase}
import PhobosStreamWriter.prefixBase

/** [[PhobosStreamWriter]] implements most methods of [[XMLStreamWriter2]], but it does not extends [[XMLStreamWriter2]]
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,40 @@ trait XmlEncoder[A] {
val preferredNamespacePrefix: Option[String] = None
val elementEncoder: ElementEncoder[A]

def encode(a: A, charset: String = "UTF-8"): String =
new String(encodeToBytes(a, charset), charset)
// Methods below previously returned a pure value, now these methods return Either to handle encoding errors.
// Use -Unsafe versions for the old behavior.
//
// Encoding may fail if resulting XML document has illegal characters (https://www.w3.org/TR/xml11/#charsets)
// or if attribute and element names are invalid, for example "1stElement".

def encodeToBytes(a: A, charset: String = "UTF-8"): Array[Byte] = {
def encode(a: A, charset: String = "UTF-8"): Either[EncodingError, String] =
wrapXmlException(encodeUnsafe(a, charset))

def encodeToBytes(a: A, charset: String = "UTF-8"): Either[EncodingError, Array[Byte]] =
wrapXmlException(encodeToBytesUnsafe(a, charset))

def encodeWithConfig(a: A, config: XmlEncoderConfig): Either[EncodingError, String] =
wrapXmlException(encodeWithConfigUnsafe(a, config))

def encodeToBytesWithConfig(a: A, config: XmlEncoderConfig): Either[EncodingError, Array[Byte]] =
wrapXmlException(encodeToBytesWithConfigUnsafe(a, config))

def encodePrettyWithConfig(a: A, config: XmlEncoderConfig, ident: Int = 2): Either[EncodingError, String] =
wrapXmlException(encodePrettyWithConfigUnsafe(a, config, ident))

def encodePretty(a: A, charset: String = "UTF-8", ident: Int = 2): Either[EncodingError, String] =
wrapXmlException(encodePrettyUnsafe(a, charset, ident))

// Methods below can throw exceptions in some rare cases. Use "safe" methods returning Either,
// if you are not sure about the correctness of the data being encoded.
//
// These method may throw if resulting XML document has illegal characters ([[https://www.w3.org/TR/xml11/#charsets]])
// or if attribute and element names are invalid, for example "1stElement".

def encodeUnsafe(a: A, charset: String = "UTF-8"): String =
new String(encodeToBytesUnsafe(a, charset), charset)

def encodeToBytesUnsafe(a: A, charset: String = "UTF-8"): Array[Byte] = {
val os = new ByteArrayOutputStream
val sw =
new PhobosStreamWriter(XmlEncoder.factory.createXMLStreamWriter(os, charset).asInstanceOf[XMLStreamWriter2])
Expand All @@ -39,10 +69,10 @@ trait XmlEncoder[A] {
os.toByteArray
}

def encodeWithConfig(a: A, config: XmlEncoderConfig): String =
new String(encodeToBytesWithConfig(a, config), config.encoding)
def encodeWithConfigUnsafe(a: A, config: XmlEncoderConfig): String =
new String(encodeToBytesWithConfigUnsafe(a, config), config.encoding)

def encodeToBytesWithConfig(a: A, config: XmlEncoderConfig): Array[Byte] = {
def encodeToBytesWithConfigUnsafe(a: A, config: XmlEncoderConfig): Array[Byte] = {
val os = new ByteArrayOutputStream
val sw =
new PhobosStreamWriter(
Expand All @@ -63,13 +93,17 @@ trait XmlEncoder[A] {
/** Warning: Use .encodePrettyWithConfig only for debugging, as it is less performant. For production use
* .encodeWithConfig
*/
def encodePrettyWithConfig(a: A, config: XmlEncoderConfig, ident: Int = 2): String =
beautifyXml(encodeWithConfig(a, config), ident)
def encodePrettyWithConfigUnsafe(a: A, config: XmlEncoderConfig, ident: Int = 2): String =
beautifyXml(encodeWithConfigUnsafe(a, config), ident)

/** Warning: Use .encodePretty only for debugging, as it is less performant. For production use .encode
*/
def encodePretty(a: A, charset: String = "UTF-8", ident: Int = 2): String =
beautifyXml(encode(a, charset), ident)
def encodePrettyUnsafe(a: A, charset: String = "UTF-8", ident: Int = 2): String =
beautifyXml(encodeUnsafe(a, charset), ident)

private def wrapXmlException[B](xml: => B): Either[EncodingError, B] =
try { Right(xml) }
catch { case e: Throwable => Left(EncodingError(Option(e.getMessage).getOrElse("No message provided"), Some(e))) }

private def beautifyXml(xml: String, ident: Int): String = {
val t = XmlEncoder.transformerFactory.newTransformer()
Expand Down
Loading

0 comments on commit 4600cb0

Please sign in to comment.