From 1bfeffe929732191cd9ee3fbaa6c280ba2e69892 Mon Sep 17 00:00:00 2001 From: Georgi Krastev Date: Mon, 21 Sep 2020 08:59:23 +0200 Subject: [PATCH] Flesh out Reader and Writer APIs (#56) * Flesh out Reader and Writer APIs We are missing many combinators for those type classes. * Add Reader and Writer tests --- build.sbt | 2 +- .../io/moia/protos/teleproto/Reader.scala | 53 ++++- .../io/moia/protos/teleproto/TestData.scala | 56 ++++++ .../io/moia/protos/teleproto/Writer.scala | 43 +++- .../io/moia/protos/teleproto/ReaderTest.scala | 189 +++++++++--------- .../io/moia/protos/teleproto/WriterTest.scala | 147 ++++++++------ 6 files changed, 336 insertions(+), 154 deletions(-) create mode 100644 src/main/scala/io/moia/protos/teleproto/TestData.scala diff --git a/build.sbt b/build.sbt index 4d439bd..24ad30f 100644 --- a/build.sbt +++ b/build.sbt @@ -15,7 +15,7 @@ lazy val `teleproto` = .settings(Project.inConfig(Test)(sbtprotoc.ProtocPlugin.protobufConfigSettings): _*) .settings( name := "teleproto", - version := "1.7.0", + version := "1.8.0", libraryDependencies ++= Seq( library.scalaPB % "protobuf", library.scalaPBJson % Compile, diff --git a/src/main/scala/io/moia/protos/teleproto/Reader.scala b/src/main/scala/io/moia/protos/teleproto/Reader.scala index 77ae163..1717b38 100644 --- a/src/main/scala/io/moia/protos/teleproto/Reader.scala +++ b/src/main/scala/io/moia/protos/teleproto/Reader.scala @@ -32,7 +32,7 @@ import scala.util.Try * Provides reading of a generated Protocol Buffers model into a business model. */ @implicitNotFound("No Protocol Buffers mapper from type ${P} to ${M} was found. Try to implement an implicit Reader for this type.") -trait Reader[-P, +M] { self => +trait Reader[-P, +M] { /** * Returns the read business model or an error message. @@ -43,18 +43,63 @@ trait Reader[-P, +M] { self => * Transforms successfully read results. */ def map[N](f: M => N): Reader[P, N] = - (protobuf: P) => self.read(protobuf).map(f) + read(_).map(f) + + /** + * Transforms the protobuf before reading. + */ + final def contramap[Q](f: Q => P): Reader[Q, M] = + protobuf => read(f(protobuf)) /** * Transforms successfully read results with the option to fail. */ - def flatMap[N](f: M => PbSuccess[N]): Reader[P, N] = - (protobuf: P) => self.read(protobuf).flatMap(f) + final def pbmap[N](f: M => PbResult[N]): Reader[P, N] = + read(_).flatMap(f) + + @deprecated("Use a function that returns a Reader with flatMap or one that returns a PbResult with emap", "1.8.0") + protected def flatMap[N](f: M => PbSuccess[N]): Reader[P, N] = + read(_).flatMap(f) + + /** + * Transforms successfully read results by stacking another reader on top of the original protobuf. + */ + final def flatMap[Q <: P, N](f: M => Reader[Q, N])(implicit dummy: DummyImplicit): Reader[Q, N] = + protobuf => read(protobuf).flatMap(f(_).read(protobuf)) + + /** + * Combines two readers with a specified function. + */ + final def zipWith[Q <: P, N, O](that: Reader[Q, N])(f: (M, N) => O): Reader[Q, O] = + protobuf => + for { + m <- this.read(protobuf) + n <- that.read(protobuf) + } yield f(m, n) + /** + * Combines two readers into a reader of a tuple. + */ + final def zip[Q <: P, N](that: Reader[Q, N]): Reader[Q, (M, N)] = + zipWith(that)((_, _)) + + /** + * Chain `that` reader after `this` one. + */ + final def andThen[N](that: Reader[M, N]): Reader[P, N] = + read(_).flatMap(that.read) + + /** + * Chain `this` reader after `that` one. + */ + final def compose[Q](that: Reader[Q, P]): Reader[Q, M] = + that.andThen(this) } object Reader extends LowPriorityReads { + def apply[P, M](implicit reader: Reader[P, M]): Reader[P, M] = reader + /* Combinators */ def transform[PV, MV](protobuf: PV, path: String)(implicit valueReader: Reader[PV, MV]): PbResult[MV] = diff --git a/src/main/scala/io/moia/protos/teleproto/TestData.scala b/src/main/scala/io/moia/protos/teleproto/TestData.scala new file mode 100644 index 0000000..9f312ab --- /dev/null +++ b/src/main/scala/io/moia/protos/teleproto/TestData.scala @@ -0,0 +1,56 @@ +package io.moia.protos.teleproto + +import java.time.Instant + +import com.google.protobuf.duration.{Duration => PBDuration} +import com.google.protobuf.timestamp.Timestamp + +import scala.concurrent.duration.Duration + +object TestData { + final case class Protobuf( + id: Option[String], + price: Option[String], + time: Option[Timestamp], + duration: Option[PBDuration], + pickupId: Option[String], + prices: Seq[String], + discounts: Map[String, String] + ) + + final case class Model( + id: String, + price: BigDecimal, + time: Instant, + duration: Duration, + pickupId: Option[String], + prices: List[BigDecimal], + discounts: Map[String, BigDecimal] + ) + + final case class ProtobufLight( + id: Option[String], + price: Option[String], + time: Option[Timestamp], + duration: Option[PBDuration] + ) { + def complete( + pickupId: Option[String] = None, + prices: Seq[String] = Nil, + discounts: Map[String, String] = Map.empty + ): Protobuf = Protobuf(id, price, time, duration, pickupId, prices, discounts) + } + + final case class ModelLight( + id: String, + price: BigDecimal, + time: Instant, + duration: Duration + ) { + def complete( + pickupId: Option[String] = None, + prices: List[BigDecimal] = Nil, + discounts: Map[String, BigDecimal] = Map.empty + ): Model = Model(id, price, time, duration, pickupId, prices, discounts) + } +} diff --git a/src/main/scala/io/moia/protos/teleproto/Writer.scala b/src/main/scala/io/moia/protos/teleproto/Writer.scala index b7b9ace..cfac379 100644 --- a/src/main/scala/io/moia/protos/teleproto/Writer.scala +++ b/src/main/scala/io/moia/protos/teleproto/Writer.scala @@ -32,7 +32,7 @@ import scala.concurrent.duration.{Deadline, Duration} @implicitNotFound( "No mapper from business model type ${M} to Protocol Buffers type ${P} was found. Try to implement an implicit Writer for this type." ) -trait Writer[-M, +P] { self => +trait Writer[-M, +P] { /** * Returns the written Protocol Buffer object. @@ -42,11 +42,50 @@ trait Writer[-M, +P] { self => /** * Transforms each written result. */ - def map[Q](f: P => Q): Writer[M, Q] = new Writer.Mapped(this, f) + def map[Q](f: P => Q): Writer[M, Q] = + model => f(write(model)) + + /** + * Transforms the model before writing. + */ + final def contramap[N](f: N => M): Writer[N, P] = + model => write(f(model)) + + /** + * Transforms written results by stacking another writer on top of the original model. + */ + final def flatMap[N <: M, Q](f: P => Writer[N, Q]): Writer[N, Q] = + model => f(write(model)).write(model) + + /** + * Combines two writers with a specified function. + */ + final def zipWith[N <: M, Q, R](that: Writer[N, Q])(f: (P, Q) => R): Writer[N, R] = + model => f(this.write(model), that.write(model)) + + /** + * Combines two writers into a writer of a tuple. + */ + final def zip[N <: M, Q](that: Writer[N, Q]): Writer[N, (P, Q)] = + zipWith(that)((_, _)) + + /** + * Chain `that` writer after `this` one. + */ + final def andThen[Q](that: Writer[P, Q]): Writer[M, Q] = + model => that.write(this.write(model)) + + /** + * Chain `this` writer after `that` one. + */ + final def compose[N](that: Writer[N, M]): Writer[N, P] = + that.andThen(this) } object Writer extends LowPriorityWrites { + def apply[M, P](implicit writer: Writer[M, P]): Writer[M, P] = writer + /* Combinators */ def transform[MV, PV](model: MV)(implicit valueWriter: Writer[MV, PV]): PV = diff --git a/src/test/scala/io/moia/protos/teleproto/ReaderTest.scala b/src/test/scala/io/moia/protos/teleproto/ReaderTest.scala index 4ddce2c..d85bee0 100644 --- a/src/test/scala/io/moia/protos/teleproto/ReaderTest.scala +++ b/src/test/scala/io/moia/protos/teleproto/ReaderTest.scala @@ -9,117 +9,73 @@ import com.google.protobuf.timestamp.Timestamp import scala.concurrent.duration.{Duration, DurationLong, FiniteDuration} class ReaderTest extends UnitTest { - import Reader._ - - case class Protobuf(id: Option[String], - price: Option[String], - time: Option[Timestamp], - duration: Option[PBDuration], - pickupId: Option[String], - prices: Seq[String], - discounts: Map[String, String]) - - case class Model(id: String, - price: BigDecimal, - time: Instant, - duration: Duration, - pickupId: Option[String], - prices: List[BigDecimal], - discounts: Map[String, BigDecimal]) + import TestData._ "Reader" should { - - val reader = new Reader[Protobuf, Model] { - - def read(protobuf: Protobuf): PbResult[Model] = - for { - id <- required[String, String](protobuf.id, "/id") - price <- required[String, BigDecimal](protobuf.price, "/price") - time <- required[Timestamp, Instant](protobuf.time, "/time") - duration <- required[PBDuration, Duration](protobuf.duration, "/duration") - pickupId <- optional[String, String](protobuf.pickupId, "/pickupId") - prices <- sequence[List, String, BigDecimal](protobuf.prices, "/prices") - discounts <- transform[Map[String, String], Map[String, BigDecimal]](protobuf.discounts, "/discounts") - } yield { - Model(id, price, time, duration, pickupId, prices, discounts) - } - } + val readerLight: Reader[Protobuf, ModelLight] = protobuf => + for { + id <- required[String, String](protobuf.id, "/id") + price <- required[String, BigDecimal](protobuf.price, "/price") + time <- required[Timestamp, Instant](protobuf.time, "/time") + duration <- required[PBDuration, Duration](protobuf.duration, "/duration") + } yield ModelLight(id, price, time, duration) + + val reader: Reader[Protobuf, Model] = protobuf => + for { + light <- readerLight.read(protobuf) + pickupId <- optional[String, String](protobuf.pickupId, "/pickupId") + prices <- sequence[List, String, BigDecimal](protobuf.prices, "/prices") + discounts <- transform[Map[String, String], Map[String, BigDecimal]](protobuf.discounts, "/discounts") + } yield light.complete(pickupId, prices, discounts) + + val proto = Protobuf( + id = Some("foo"), + price = Some("1.2"), + time = Some(Timestamp.defaultInstance), + duration = Some(PBDuration.defaultInstance), + pickupId = Some("pickup"), + prices = Nil, + discounts = Map.empty + ) + + val modelLight = ModelLight( + id = "foo", + price = 1.2, + time = Instant.ofEpochMilli(0), + duration = Duration.Zero, + ) + + val model = modelLight.complete(pickupId = Some("pickup")) "fail if value is missing" in { - - reader.read( - Protobuf(None, Some("bar"), Some(Timestamp.defaultInstance), Some(PBDuration.defaultInstance), None, Seq.empty, Map.empty) - ) shouldBe - PbFailure("/id", "Value is required.") + reader.read(proto.copy(id = None)) shouldBe PbFailure("/id", "Value is required.") } "fail if value is not a valid decimal" in { - - reader.read( - Protobuf(Some("foo"), Some("bar"), Some(Timestamp.defaultInstance), Some(PBDuration.defaultInstance), None, Seq.empty, Map.empty) - ) shouldBe - PbFailure("/price", "Value must be a valid decimal number.") + reader.read(proto.copy(price = Some("bar"))) shouldBe PbFailure("/price", "Value must be a valid decimal number.") } "fail if sequence entries are not a valid decimal" in { - - reader.read( - Protobuf(Some("foo"), - Some("1.2"), - Some(Timestamp.defaultInstance), - Some(PBDuration.defaultInstance), - None, - Seq("1", "Pi", "Wurzel zwei"), - Map.empty) - ) shouldBe + reader.read(proto.copy(prices = Seq("1", "Pi", "Wurzel zwei"))) shouldBe PbFailure(Seq("/prices(1)" -> "Value must be a valid decimal number.", "/prices(2)" -> "Value must be a valid decimal number.")) } "fail if map entries are not a valid decimal" in { - - reader.read( - Protobuf(Some("foo"), - Some("1.2"), - Some(Timestamp.defaultInstance), - Some(PBDuration.defaultInstance), - None, - Seq.empty, - Map("1" -> "0", "2" -> "50%", "3" -> "80%")) - ) shouldBe + reader.read(proto.copy(discounts = Map("1" -> "0", "2" -> "50%", "3" -> "80%"))) shouldBe PbFailure(Seq("/discounts/2" -> "Value must be a valid decimal number.", "/discounts/3" -> "Value must be a valid decimal number.")) } "read successfully if list is empty" in { - - reader.read( - Protobuf(Some("foo"), - Some("1.2"), - Some(Timestamp.defaultInstance), - Some(PBDuration.defaultInstance), - Some("pickup"), - Seq.empty, - Map.empty) - ) shouldBe - PbSuccess(Model("foo", 1.2, Instant.ofEpochMilli(0), Duration.Zero, Some("pickup"), Nil, Map.empty)) + reader.read(proto) shouldBe PbSuccess(model) } "read successfully if an option is undefined" in { - - reader.read( - Protobuf(Some("foo"), - Some("1.2"), - Some(Timestamp.defaultInstance), - Some(PBDuration.defaultInstance), - None, - Seq("1", "1.2", "1.23"), - Map.empty) - ) shouldBe - PbSuccess(Model("foo", 1.2, Instant.ofEpochMilli(0), Duration.Zero, None, List(1, 1.2, 1.23), Map.empty)) + reader.read(proto.copy(pickupId = None, prices = Seq("1", "1.2", "1.23"))) shouldBe + PbSuccess(model.copy(pickupId = None, prices = List(1, 1.2, 1.23))) } "read durations in coarsest unit" in { - FiniteDurationReader.read(PBDuration(3600 * 24 * 7)) shouldBe PbSuccess(FiniteDuration(7, TimeUnit.DAYS)) FiniteDurationReader.read(PBDuration(3600 * 3)) shouldBe PbSuccess(FiniteDuration(3, TimeUnit.HOURS)) FiniteDurationReader.read(PBDuration(60 * 2)) shouldBe PbSuccess(FiniteDuration(2, TimeUnit.MINUTES)) @@ -130,15 +86,66 @@ class ReaderTest extends UnitTest { } "read timestamps on nano level" in { - - reader.read(Protobuf(Some("foo"), Some("1.2"), Some(Timestamp(12, 34)), Some(PBDuration.defaultInstance), None, Seq.empty, Map.empty)) shouldBe - PbSuccess(Model("foo", 1.2, Instant.ofEpochSecond(12, 34), Duration.Zero, None, Nil, Map.empty)) + reader.read(proto.copy(time = Some(Timestamp(12, 34)))) shouldBe + PbSuccess(model.copy(time = Instant.ofEpochSecond(12, 34))) } "read durations on nano level" in { + reader.read(proto.copy(duration = Some(PBDuration(12, 34)))) shouldBe + PbSuccess(model.copy(duration = 12.seconds + 34.nanos)) + } + + "map over the result" in { + val priceReader = reader.map(_.price) + priceReader.read(proto) shouldBe PbSuccess(BigDecimal(1.2)) + } + + "contramap over the input" in { + val firstReader = reader.contramap[(Protobuf, Int)](_._1) + firstReader.read((proto, 42)) shouldBe PbSuccess(model) + } + + "pbmap over the result" in { + val pickupIdReader = reader.pbmap(model => PbResult.fromOption(model.pickupId)(PbFailure("pickupId is missing"))) + pickupIdReader.read(proto) shouldBe PbSuccess("pickup") + pickupIdReader.read(proto.copy(pickupId = None)) shouldBe PbFailure("pickupId is missing") + } + + "flatMap over the result" in { + val pickupIdReader: Reader[Protobuf, String] = proto => PbResult.fromOption(proto.pickupId)(PbFailure("pickupId is missing")) + val complexReader = pickupIdReader.flatMap { + case "pickup" => reader + case other => readerLight.map(_.complete(pickupId = Some(other))) + } + + complexReader.read(proto) shouldBe PbSuccess(model) + complexReader.read(proto.copy(pickupId = None)) shouldBe PbFailure("pickupId is missing") + complexReader.read(proto.copy(pickupId = Some("other"), prices = Seq("1", "Pi", "Wurzel zwei"))) shouldBe + PbSuccess(model.copy(pickupId = Some("other"))) + } + + "zip two readers" in { + val tupleReader = reader.zip(readerLight) + tupleReader.read(proto) shouldBe PbSuccess((model, modelLight)) + } + + "zip two readers with a function" in { + val isLightReader = reader.zipWith(readerLight)(_ == _.complete()) + isLightReader.read(proto) shouldBe PbSuccess(false) + isLightReader.read(proto.copy(pickupId = None)) shouldBe PbSuccess(true) + } + + "chain two readers one after another" in { + val cheapReader = reader.andThen(model => PbSuccess(model.price < 1)) + cheapReader.read(proto) shouldBe PbSuccess(false) + cheapReader.read(proto.copy(price = Some("0.99"))) shouldBe PbSuccess(true) + } - reader.read(Protobuf(Some("foo"), Some("1.2"), Some(Timestamp.defaultInstance), Some(PBDuration(12, 34)), None, Seq.empty, Map.empty)) shouldBe - PbSuccess(Model("foo", 1.2, Instant.ofEpochMilli(0), 12.seconds + 34.nanos, None, Nil, Map.empty)) + "compose two readers one before another" in { + val hasDiscountReader: Reader[Model, Boolean] = model => PbSuccess(model.discounts.nonEmpty) + val discountedReader = hasDiscountReader.compose(reader) + discountedReader.read(proto) shouldBe PbSuccess(false) + discountedReader.read(proto.copy(discounts = Map("2" -> "0.5", "3" -> "0.8"))) shouldBe PbSuccess(true) } } } diff --git a/src/test/scala/io/moia/protos/teleproto/WriterTest.scala b/src/test/scala/io/moia/protos/teleproto/WriterTest.scala index eca7a49..1a115e2 100644 --- a/src/test/scala/io/moia/protos/teleproto/WriterTest.scala +++ b/src/test/scala/io/moia/protos/teleproto/WriterTest.scala @@ -1,77 +1,112 @@ package io.moia.protos.teleproto import java.time.Instant + import com.google.protobuf.duration.{Duration => PBDuration} import com.google.protobuf.timestamp.Timestamp -import scala.concurrent.duration.{Duration, DurationLong} +import scala.concurrent.duration.DurationLong class WriterTest extends UnitTest { - + import TestData._ import Writer._ - case class Protobuf(id: Option[String], - price: Option[String], - time: Option[Timestamp], - duration: Option[PBDuration], - pickupId: Option[String], - prices: Seq[String], - discounts: Map[String, String]) - - case class Model(id: String, - price: BigDecimal, - time: Instant, - duration: Duration, - pickupId: Option[String], - prices: List[BigDecimal], - discounts: Map[String, BigDecimal]) - "Writer" should { + val writerLight: Writer[Model, ProtobufLight] = model => + ProtobufLight( + id = present(model.id), + price = present(model.price), + time = present(model.time), + duration = present(model.duration) + ) - val writer = new Writer[Model, Protobuf] { - - def write(model: Model): Protobuf = - Protobuf( - present(model.id), - present(model.price), - present(model.time), - present(model.duration), - transform(model.pickupId), - sequence(model.prices), - transform(model.discounts) - ) - } + val writer: Writer[Model, Protobuf] = model => + Protobuf( + id = present(model.id), + price = present(model.price), + time = present(model.time), + duration = present(model.duration), + pickupId = transform(model.pickupId), + prices = sequence(model.prices), + discounts = transform(model.discounts) + ) - "write complete model" in { + val modelLight = ModelLight( + id = "id", + price = 1.23, + time = Instant.ofEpochSecond(12, 34), + duration = 45.seconds + 67.nanos, + ) + + val model = modelLight.complete( + pickupId = Some("pickup-id"), + prices = List(1.2, 3.45), + discounts = Map("1" -> 1.2, "2" -> 2) + ) - writer.write( - Model("id", - 1.23, - Instant.ofEpochSecond(12, 34), - 45.seconds + 67.nanos, - Some("pickup-id"), - List(1.2, 3.45), - Map("1" -> 1.2, "2" -> 2)) - ) shouldBe - Protobuf(Some("id"), - Some("1.23"), - Some(Timestamp(12, 34)), - Some(PBDuration(45, 67)), - Some("pickup-id"), - Seq("1.2", "3.45"), - Map("1" -> "1.2", "2" -> "2")) + val protoLight = ProtobufLight( + id = Some("id"), + price = Some("1.23"), + time = Some(Timestamp(12, 34)), + duration = Some(PBDuration(45, 67)), + ) + + val proto = protoLight.complete( + pickupId = Some("pickup-id"), + prices = Seq("1.2", "3.45"), + discounts = Map("1" -> "1.2", "2" -> "2") + ) + + "write complete model" in { + writer.write(model) shouldBe proto } "write partial model" in { + writer.write(model.copy(pickupId = None, prices = Nil)) shouldBe proto.copy(pickupId = None, prices = Nil) + } + + "map over the result" in { + val pricesWriter = writer.map(_.prices) + pricesWriter.write(model) shouldBe proto.prices + } + + "contramap over the input" in { + val secondWriter = writer.contramap[(Int, Model)](_._2) + secondWriter.write((42, model)) shouldBe proto + } + + "flatMap over the result" in { + val pickupIdWriter: Writer[Model, Option[String]] = _.pickupId + val complexWriter = pickupIdWriter.flatMap { + case Some(_) => writer + case None => writerLight.map(_.complete()) + } + + complexWriter.write(model) shouldBe proto + complexWriter.write(model.copy(pickupId = None)) shouldBe protoLight.complete() + } + + "zip two writers" in { + val tupleWriter = writer.zip(writerLight) + val (result, resultLight) = tupleWriter.write(model) + result shouldBe proto + resultLight shouldBe protoLight + } + + "zip two writers with a function" in { + val isLightWriter = writer.zipWith(writerLight)(_ == _.complete()) + isLightWriter.write(model) shouldBe false + isLightWriter.write(model.copy(pickupId = None, prices = Nil, discounts = Map.empty)) shouldBe true + } + + "chain two writers one after another" in { + val pricesWriter = writer.andThen(_.prices) + pricesWriter.write(model) shouldBe proto.prices + } - writer.write(Model("id", 1.23, Instant.ofEpochSecond(12, 34), 45.seconds + 67.nanos, None, Nil, Map("1" -> 1.2, "2" -> 2))) shouldBe - Protobuf(Some("id"), - Some("1.23"), - Some(Timestamp(12, 34)), - Some(PBDuration(45, 67)), - None, - Seq.empty, - Map("1" -> "1.2", "2" -> "2")) + "compose two writers one before another" in { + val fromLightWriter = writer.compose[ModelLight](_.complete()) + fromLightWriter.write(modelLight) shouldBe protoLight.complete() } } }