Skip to content

Commit

Permalink
Make parsing errors type-safe (closes #75)
Browse files Browse the repository at this point in the history
  • Loading branch information
dilyand committed Nov 4, 2019
1 parent d9b1e70 commit 9076942
Show file tree
Hide file tree
Showing 6 changed files with 73 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,56 +17,56 @@ import cats.syntax.either._
import io.circe._
import io.circe.syntax._
import com.snowplowanalytics.snowplow.analytics.scalasdk.decode.Key
import com.snowplowanalytics.snowplow.analytics.scalasdk.decode.Key._

/**
* Represents error during parsing TSV event
* Represents an error raised when parsing a TSV line.
*/
sealed trait ParsingError

object ParsingError {

/**
* Represents error which given line is not a TSV
* Represents an error indicating a non-TSV line.
*/
final case object NonTSVPayload extends ParsingError
final case object NotTSV extends ParsingError

/**
* Represents error which number of given columns is not equal
* to number of expected columns
* @param columnCount mismatched column count in the event
* Represents an error indicating the number of actual fields is not equal
* to the number of expected fields.
* @param fieldCount The number of fields in the TSV line.
*/
final case class ColumnNumberMismatch(columnCount: Int) extends ParsingError
final case class FieldNumberMismatch(fieldCount: Int) extends ParsingError

/**
* Represents error which encountered while decoding values in row
* @param errors Infos of errors which encountered during decoding
* Represents an error raised when trying to decode the values in a line.
* @param errors A non-empty list of errors encountered when trying to decode the values.
*/
final case class RowDecodingError(errors: NonEmptyList[RowDecodingErrorInfo]) extends ParsingError

/**
* Gives info about the reasons of the errors during decoding value in row
* Contains information about the reasons behind errors raised when trying to decode the values in a line.
*/
sealed trait RowDecodingErrorInfo

object RowDecodingErrorInfo {
/**
* Represents cases where value in a field is not valid
* e.g invalid timestamp, invalid UUID
* @param key key of field
* @param value value of field
* @param message error message
* Represents cases where tha value in a field is not valid,
* e.g. an invalid timestamp, an invalid UUID, etc.
* @param key The name of the field.
* @param value The value of field.
* @param message The error message.
*/
final case class InvalidValue(key: Key, value: String, message: String) extends RowDecodingErrorInfo

/**
* Represents cases which getting error is not expected while decoding row
* For example, while parsing the list of tuples to HList in the
* RowDecoder, getting more or less values than expected is impossible
* due to type check. Therefore 'UnexpectedRowDecodingError' is returned for
* these cases. These errors can be ignored since they are not possible to get
* @param error error message
* Represents unhandled errors raised when trying to decode a line.
* For example, while parsing a list of tuples to [[HList]] in
* [[RowDecoder]], type checking should make it impossible to get more or less values
* than expected.
* @param message The error message.
*/
final case class UnexpectedRowDecodingError(error: String) extends RowDecodingErrorInfo
final case class UnhandledRowDecodingError(message: String) extends RowDecodingErrorInfo

implicit val analyticsSdkRowDecodingErrorInfoCirceEncoder: Encoder[RowDecodingErrorInfo] =
Encoder.instance {
Expand All @@ -77,10 +77,10 @@ object ParsingError {
"value" := value,
"message" := message
)
case UnexpectedRowDecodingError(error: String) =>
case UnhandledRowDecodingError(message: String) =>
Json.obj(
"type" := "UnexpectedRowDecodingError",
"error" := error
"type" := "UnhandledRowDecodingError",
"message" := message
)
}

Expand All @@ -96,31 +96,24 @@ object ParsingError {
message <- cursor.downField("message").as[String]
} yield InvalidValue(key, value, message)

case "UnexpectedRowDecodingError" =>
case "UnhandledRowDecodingError" =>
cursor
.downField("error")
.downField("message")
.as[String]
.map(UnexpectedRowDecodingError)
.map(UnhandledRowDecodingError)
}
} yield result
}

implicit val analyticsSdkKeyCirceEncoder: Encoder[Key] =
Encoder.instance(_.toString.stripPrefix("'").asJson)

implicit val analyticsSdkKeyCirceDecoder: Decoder[Key] =
Decoder.instance(_.as[String].map(Symbol(_)))

}

implicit val analyticsSdkParsingErrorCirceEncoder: Encoder[ParsingError] =
Encoder.instance {
case NonTSVPayload =>
Json.obj("type" := "NonTSVPayload")
case ColumnNumberMismatch(columnCount) =>
case NotTSV =>
Json.obj("type" := "NotTSV")
case FieldNumberMismatch(fieldCount) =>
Json.obj(
"type" := "ColumnNumberMismatch",
"columnCount" := columnCount
"type" := "FieldNumberMismatch",
"fieldCount" := fieldCount
)
case RowDecodingError(errors) =>
Json.obj(
Expand All @@ -134,21 +127,21 @@ object ParsingError {
for {
error <- cursor.downField("type").as[String]
result <- error match {
case "NonTSVPayload" =>
NonTSVPayload.asRight
case "ColumnNumberMismatch" =>
case "NotTSV" =>
NotTSV.asRight
case "FieldNumberMismatch" =>
cursor
.downField("columnCount")
.downField("fieldCount")
.as[Int]
.map(ColumnNumberMismatch)
.map(FieldNumberMismatch)
case "RowDecodingError" =>
cursor
.downField("errors")
.as[NonEmptyList[RowDecodingErrorInfo]]
.map(RowDecodingError)
case _ =>
DecodingFailure(
s"Error type $error cannot be recognized as Analytics SDK Parsing Error",
s"Error type $error is not an Analytics SDK Parsing Error.",
cursor.history).asLeft
}
} yield result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import shapeless._
import shapeless.ops.record._
import shapeless.ops.hlist._
import cats.data.{NonEmptyList, Validated}
import com.snowplowanalytics.snowplow.analytics.scalasdk.ParsingError.{ColumnNumberMismatch, NonTSVPayload, RowDecodingError}
import com.snowplowanalytics.snowplow.analytics.scalasdk.ParsingError.{FieldNumberMismatch, NotTSV, RowDecodingError}

private[scalasdk] trait Parser[A] extends Serializable {
/** Heterogeneous TSV values */
Expand All @@ -34,10 +34,10 @@ private[scalasdk] trait Parser[A] extends Serializable {
def parse(row: String): DecodeResult[A] = {
val values = row.split("\t", -1)
if (values.length == 1) {
Validated.Invalid(NonTSVPayload)
Validated.Invalid(NotTSV)
}
else if (values.length != knownKeys.length) {
Validated.Invalid(ColumnNumberMismatch(values.length))
Validated.Invalid(FieldNumberMismatch(values.length))
} else {
val zipped = knownKeys.zip(values)
val decoded = decoder(zipped).leftMap(e => RowDecodingError(e))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import shapeless._
import cats.syntax.validated._
import cats.syntax.either._
import cats.syntax.apply._
import com.snowplowanalytics.snowplow.analytics.scalasdk.ParsingError.RowDecodingErrorInfo.UnexpectedRowDecodingError
import com.snowplowanalytics.snowplow.analytics.scalasdk.ParsingError.RowDecodingErrorInfo.UnhandledRowDecodingError

/**
* Type class to decode List of keys-value pairs into HList
Expand Down Expand Up @@ -44,12 +44,12 @@ private[scalasdk] object RowDecoder {
val hv: RowDecodeResult[H] = ValueDecoder[H].parse(h).toValidatedNel
val tv: RowDecodeResult[T] = RowDecoder[T].apply(t)
(hv, tv).mapN { _ :: _ }
case Nil => UnexpectedRowDecodingError("Not enough values, format is invalid").invalidNel
case Nil => UnhandledRowDecodingError("Not enough values, format is invalid").invalidNel
}

implicit def hnilFromRow: RowDecoder[HNil] = fromFunc {
case Nil => HNil.validNel
case rows => UnexpectedRowDecodingError(s"No more values expected, following provided: ${rows.map(_._2).mkString(", ")}").invalidNel
case rows => UnhandledRowDecodingError(s"No more values expected, following provided: ${rows.map(_._2).mkString(", ")}").invalidNel
}

implicit def hconsFromRow[H: ValueDecoder, T <: HList: RowDecoder]: RowDecoder[H :: T] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,21 @@ package com.snowplowanalytics.snowplow.analytics.scalasdk

import cats.data.{Validated, ValidatedNel}
import com.snowplowanalytics.snowplow.analytics.scalasdk.ParsingError.RowDecodingErrorInfo
import io.circe.{Decoder, Encoder}
import io.circe.syntax._

package object decode {
/** Expected name of the field */
type Key = Symbol

object Key {
implicit val analyticsSdkKeyCirceEncoder: Encoder[Key] =
Encoder.instance(_.toString.stripPrefix("'").asJson)

implicit val analyticsSdkKeyCirceDecoder: Decoder[Key] =
Decoder.instance(_.as[String].map(Symbol(_)))
}

/** Result of single-value parsing */
type DecodedValue[A] = Either[RowDecodingErrorInfo, A]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1857,7 +1857,7 @@ class EventSpec extends Specification {
)
}

"fail if column values are invalid (and combine errors)" in {
"fail (and combine errors) if values are invalid" in {

val input = List(
"app_id" -> "angry-birds",
Expand Down Expand Up @@ -2013,10 +2013,10 @@ class EventSpec extends Specification {

"fail if payload is not TSV" in {
val event = Event.parse("non tsv")
event mustEqual Invalid(NonTSVPayload)
event mustEqual Invalid(NotTSV)
}

"fail if there are more column than expected" in {
"fail if there are more fields than expected" in {
val input = List(
"app_id" -> "angry-birds",
"platform" -> "web",
Expand Down Expand Up @@ -2155,10 +2155,10 @@ class EventSpec extends Specification {
val eventValues = input.unzip._2.mkString("\t")
val event = Event.parse(eventValues)

event mustEqual Invalid(ColumnNumberMismatch(132))
event mustEqual Invalid(FieldNumberMismatch(132))
}

"fail if there are less column than expected" in {
"fail if there are fewer fields than expected" in {
val input = List(
"app_id" -> "angry-birds",
"platform" -> "web",
Expand All @@ -2169,7 +2169,7 @@ class EventSpec extends Specification {
val eventValues = input.unzip._2.mkString("\t")
val event = Event.parse(eventValues)

event mustEqual Invalid(ColumnNumberMismatch(4))
event mustEqual Invalid(FieldNumberMismatch(4))
}

"successfully decode encoded event which has no contexts or unstruct_event" in {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,40 @@ import org.specs2.Specification

class ParsingErrorSpec extends Specification { def is = s2"""
ParsingError encoder-decoder
works correctly with NonTSVPayload error $e1
works correctly with ColumnNumberMismatch error $e2
works correctly with NotTSV error $e1
works correctly with FieldNumberMismatch error $e2
works correctly with RowDecodingError $e3
"""

def e1 = {
val errorJson = parseJson(
"""
|{
| "type": "NonTSVPayload"
| "type": "NotTSV"
|}
""".stripMargin
)

val decoded = decodeJson[ParsingError](errorJson)
val encoded = decoded.asJson

(decoded must beEqualTo(NonTSVPayload)) and (encoded must beEqualTo(errorJson))
(decoded must beEqualTo(NotTSV)) and (encoded must beEqualTo(errorJson))
}

def e2 = {
val errorJson = parseJson(
"""
|{
| "type": "ColumnNumberMismatch",
| "columnCount": 120
| "type": "FieldNumberMismatch",
| "fieldCount": 120
|}
""".stripMargin
)

val decoded = decodeJson[ParsingError](errorJson)
val encoded = decoded.asJson

(decoded must beEqualTo(ColumnNumberMismatch(120))) and (encoded must beEqualTo(errorJson))
(decoded must beEqualTo(FieldNumberMismatch(120))) and (encoded must beEqualTo(errorJson))
}

def e3 = {
Expand All @@ -73,8 +73,8 @@ class ParsingErrorSpec extends Specification { def is = s2"""
| "message": "exampleMessage"
| },
| {
| "type": "UnexpectedRowDecodingError",
| "error": "exampleError"
| "type": "UnhandledRowDecodingError",
| "message": "exampleError"
| }
| ]
|}
Expand All @@ -87,17 +87,17 @@ class ParsingErrorSpec extends Specification { def is = s2"""
val expected = RowDecodingError(
NonEmptyList.of(
InvalidValue(Symbol("exampleKey"), "exampleValue", "exampleMessage"),
UnexpectedRowDecodingError("exampleError")
UnhandledRowDecodingError("exampleError")
)
)

(decoded must beEqualTo(expected)) and (encoded must beEqualTo(errorJson))
}

private def parseJson(jsonStr: String): Json =
parse(jsonStr).right.getOrElse(throw new RuntimeException("Failed to parse expected JSON"))
parse(jsonStr).right.getOrElse(throw new RuntimeException("Failed to parse expected JSON."))

private def decodeJson[A: Decoder](json: Json): A = {
json.as[A].right.getOrElse(throw new RuntimeException("Failed to decode to ParsingError"))
json.as[A].right.getOrElse(throw new RuntimeException("Failed to decode to ParsingError."))
}
}

0 comments on commit 9076942

Please sign in to comment.