From 49a5bfb2fb285c3394c82ce6790704de4707d30d Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Fri, 13 Dec 2019 15:16:44 +0000 Subject: [PATCH 01/11] [WIP] Add a `@pbIndex` annotation and use it for writing Add an annotation called `@pbIndex` for annotating message fields with their protobuf indices (field numbers). The idea is that skeuomorph will generate these annotations when it generates code for Mu. Started work on making use of these annotations to write fields with the correct field numbers when encoding messages to protobuf. Note: This is a breaking change in behaviour for pbdirect, as you now *have to* annotate every field of your message. (We use some shapeless voodoo to verify at compile time that every field is annotated.) This is the first step towards making pbdirect a "proper" spec-compliant protobuf serializer, with the ability to handle default scalar values, `oneof` fields, decoding messages without relying on field order, etc. Still to do: * finish implementation of protobuf encoding * decide what to do about annotating `oneof` fields (coproducts), as they have multiple field numbers * implement protobuf decoding (a bit more complicated) * write more tests --- src/main/scala/pbdirect/PBFormat.scala | 6 +- src/main/scala/pbdirect/PBWriter.scala | 160 +++++++++++++-------- src/main/scala/pbdirect/package.scala | 12 +- src/main/scala/pbdirect/pbIndex.scala | 37 +++++ src/test/scala/pbdirect/PBFormatSpec.scala | 4 +- src/test/scala/pbdirect/PBWriterSpec.scala | 143 ++++++++++-------- 6 files changed, 231 insertions(+), 131 deletions(-) create mode 100644 src/main/scala/pbdirect/pbIndex.scala diff --git a/src/main/scala/pbdirect/PBFormat.scala b/src/main/scala/pbdirect/PBFormat.scala index 33a908e..eaaa639 100644 --- a/src/main/scala/pbdirect/PBFormat.scala +++ b/src/main/scala/pbdirect/PBFormat.scala @@ -25,20 +25,20 @@ import cats.Functor import cats.{Contravariant, Invariant} import com.google.protobuf.{CodedInputStream, CodedOutputStream} -sealed trait PBFormat[A] extends PBReader[A] with PBWriter[A] +sealed trait PBFormat[A] extends PBReader[A] with PBFieldWriter[A] trait PBFormatImplicits { implicit object InvariantFormat extends Invariant[PBFormat] { override def imap[A, B](format: PBFormat[A])(f: A => B)(g: B => A): PBFormat[B] = PBFormat[B]( Functor[PBReader].map(format)(f), - Contravariant[PBWriter].contramap(format)(g) + Contravariant[PBFieldWriter].contramap(format)(g) ) } } object PBFormat extends PBFormatImplicits { - def apply[A](implicit reader: PBReader[A], writer: PBWriter[A]): PBFormat[A] = + def apply[A](implicit reader: PBReader[A], writer: PBFieldWriter[A]): PBFormat[A] = new PBFormat[A] { override def read(input: CodedInputStream): A = reader.read(input) override def writeTo(index: Int, value: A, out: CodedOutputStream): Unit = diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBWriter.scala index 83b50ac..199991e 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBWriter.scala @@ -25,140 +25,178 @@ import java.io.ByteArrayOutputStream import cats.{Contravariant, Functor} import com.google.protobuf.CodedOutputStream -import shapeless.{:+:, ::, CNil, Coproduct, Generic, HList, HNil, Inl, Inr, Lazy} +import shapeless._ +import shapeless.ops.hlist._ import enumeratum.values.IntEnumEntry -trait PBWriter[A] { +trait PBMessageWriter[A] { + def writeTo(value: A, out: CodedOutputStream): Unit +} + +trait PBFieldWriter[A] { def writeTo(index: Int, value: A, out: CodedOutputStream): Unit } -trait LowPriorityPBWriterImplicits { - def instance[A](f: (Int, A, CodedOutputStream) => Unit): PBWriter[A] = - new PBWriter[A] { - override def writeTo(index: Int, value: A, out: CodedOutputStream): Unit = - f(index, value, out) +case class FieldIndex(value: Int) + +trait PBMessageWriterImplicits { + def instance[A](f: (A, CodedOutputStream) => Unit): PBMessageWriter[A] = + new PBMessageWriter[A] { + override def writeTo(value: A, out: CodedOutputStream): Unit = + f(value, out) } - implicit val hnilWriter: PBWriter[HNil] = instance { (_: Int, _: HNil, _: CodedOutputStream) => + implicit val hnilWriter: PBMessageWriter[HNil] = instance { (_: HNil, _: CodedOutputStream) => () } implicit def consWriter[H, T <: HList]( - implicit head: PBWriter[H], - tail: Lazy[PBWriter[T]]): PBWriter[H :: T] = - instance { (index: Int, value: H :: T, out: CodedOutputStream) => - head.writeTo(index, value.head, out) - tail.value.writeTo(index + 1, value.tail, out) - } - implicit def prodWriter[A, R <: HList]( - implicit gen: Generic.Aux[A, R], - writer: Lazy[PBWriter[R]]): PBWriter[A] = - instance { (index: Int, value: A, out: CodedOutputStream) => - val buffer = new ByteArrayOutputStream() - val pbOut = CodedOutputStream.newInstance(buffer) - writer.value.writeTo(1, gen.to(value), pbOut) - pbOut.flush() - out.writeByteArray(index, buffer.toByteArray) + implicit head: PBFieldWriter[H], + tail: Lazy[PBMessageWriter[T]]): PBMessageWriter[(FieldIndex, H) :: T] = + instance { (value: (FieldIndex, H) :: T, out: CodedOutputStream) => + head.writeTo(value.head._1.value, value.head._2, out) + tail.value.writeTo(value.tail, out) } - implicit val cnilWriter: PBWriter[CNil] = instance { (_: Int, _: CNil, _: CodedOutputStream) => - throw new Exception("Can't write CNil") - } - implicit def cconsWriter[H, T <: Coproduct]( - implicit head: PBWriter[H], - tail: PBWriter[T]): PBWriter[H :+: T] = - instance { (index: Int, value: H :+: T, out: CodedOutputStream) => - value match { - case Inl(h) => head.writeTo(index, h, out) - case Inr(t) => tail.writeTo(index, t, out) - } + object zipWithIndex extends Poly2 { + implicit def defaultCase[T] = at[Some[pbIndex], T] { + case (Some(annotation), value) => (FieldIndex(annotation.value), value) } - implicit def coprodWriter[A, R <: Coproduct]( + } + + implicit def prodWriter[A, R <: HList, Anns <: HList, Z <: HList]( implicit gen: Generic.Aux[A, R], - writer: PBWriter[R]): PBWriter[A] = - instance { (index: Int, value: A, out: CodedOutputStream) => - writer.writeTo(index, gen.to(value), out) + annotations: Annotations.Aux[pbIndex, A, Anns], + zw: ZipWith.Aux[Anns, R, zipWithIndex.type, Z], + writer: Lazy[PBMessageWriter[Z]]): PBMessageWriter[A] = + instance { (value: A, out: CodedOutputStream) => + val fields = gen.to(value) + val indexedFields = annotations.apply.zipWith(fields)(zipWithIndex) + writer.value.writeTo(indexedFields, out) } + + //implicit val cnilWriter: PBWriter[CNil] = instance { (_: Int, _: CNil, _: CodedOutputStream) => + //throw new Exception("Can't write CNil") + //} + //implicit def cconsWriter[H, T <: Coproduct]( + //implicit head: PBWriter[H], + //tail: PBWriter[T]): PBWriter[H :+: T] = + //instance { (index: Int, value: H :+: T, out: CodedOutputStream) => + //value match { + //case Inl(h) => head.writeTo(index, h, out) + //case Inr(t) => tail.writeTo(index, t, out) + //} + //} + //implicit def coprodWriter[A, R <: Coproduct]( + //implicit gen: Generic.Aux[A, R], + //writer: PBWriter[R]): PBWriter[A] = + //instance { (index: Int, value: A, out: CodedOutputStream) => + //writer.writeTo(index, gen.to(value), out) + //} + } -trait PBWriterImplicits extends LowPriorityPBWriterImplicits { - implicit object BooleanWriter extends PBWriter[Boolean] { +trait PBFieldWriterImplicits { + def instance[A](f: (Int, A, CodedOutputStream) => Unit): PBFieldWriter[A] = + new PBFieldWriter[A] { + override def writeTo(index: Int, value: A, out: CodedOutputStream): Unit = + f(index, value, out) + } + implicit def embeddedMessageFieldWriter[A]( + implicit messageWriter: PBMessageWriter[A]): PBFieldWriter[A] = instance { + (index, message, out) => + { + val buffer = new ByteArrayOutputStream() + val bufferOut = CodedOutputStream.newInstance(buffer) + messageWriter.writeTo(message, bufferOut) + bufferOut.flush() + out.writeByteArray(index, buffer.toByteArray) + } + } + implicit object BooleanWriter extends PBFieldWriter[Boolean] { override def writeTo(index: Int, value: Boolean, out: CodedOutputStream): Unit = out.writeBool(index, value) } - implicit object ByteWriter extends PBWriter[Byte] { + implicit object ByteWriter extends PBFieldWriter[Byte] { override def writeTo(index: Int, value: Byte, out: CodedOutputStream): Unit = - out.writeInt32(index, value) + out.writeInt32(index, value.toInt) } - implicit object ShortWriter extends PBWriter[Short] { + implicit object ShortWriter extends PBFieldWriter[Short] { override def writeTo(index: Int, value: Short, out: CodedOutputStream): Unit = - out.writeInt32(index, value) + out.writeInt32(index, value.toInt) } - implicit object IntWriter extends PBWriter[Int] { + implicit object IntWriter extends PBFieldWriter[Int] { override def writeTo(index: Int, value: Int, out: CodedOutputStream): Unit = out.writeInt32(index, value) } - implicit object LongWriter extends PBWriter[Long] { + implicit object LongWriter extends PBFieldWriter[Long] { override def writeTo(index: Int, value: Long, out: CodedOutputStream): Unit = out.writeInt64(index, value) } - implicit object FloatWriter extends PBWriter[Float] { + implicit object FloatWriter extends PBFieldWriter[Float] { override def writeTo(index: Int, value: Float, out: CodedOutputStream): Unit = out.writeFloat(index, value) } - implicit object DoubleWriter extends PBWriter[Double] { + implicit object DoubleWriter extends PBFieldWriter[Double] { override def writeTo(index: Int, value: Double, out: CodedOutputStream): Unit = out.writeDouble(index, value) } - implicit object StringWriter extends PBWriter[String] { + implicit object StringWriter extends PBFieldWriter[String] { override def writeTo(index: Int, value: String, out: CodedOutputStream): Unit = out.writeString(index, value) } - implicit object BytesWriter extends PBWriter[Array[Byte]] { + implicit object BytesWriter extends PBFieldWriter[Array[Byte]] { override def writeTo(index: Int, value: Array[Byte], out: CodedOutputStream): Unit = out.writeByteArray(index, value) } implicit def functorWriter[F[_], A]( implicit functor: Functor[F], - writer: PBWriter[A]): PBWriter[F[A]] = + writer: PBFieldWriter[A]): PBFieldWriter[F[A]] = instance { (index: Int, value: F[A], out: CodedOutputStream) => functor.map(value) { v => writer.writeTo(index, v, out) } () } - implicit def mapWriter[K, V](implicit writer: PBWriter[List[(K, V)]]): PBWriter[Map[K, V]] = + implicit def mapWriter[K, V]( + implicit writer: PBFieldWriter[List[(K, V)]]): PBFieldWriter[Map[K, V]] = instance { (index: Int, value: Map[K, V], out: CodedOutputStream) => writer.writeTo(index, value.toList, out) } implicit def collectionMapWriter[K, V]( - implicit writer: PBWriter[List[(K, V)]]): PBWriter[collection.Map[K, V]] = + implicit writer: PBFieldWriter[List[(K, V)]]): PBFieldWriter[collection.Map[K, V]] = instance { (index: Int, value: collection.Map[K, V], out: CodedOutputStream) => writer.writeTo(index, value.toList, out) } - implicit def seqWriter[A](implicit writer: PBWriter[List[A]]): PBWriter[Seq[A]] = + implicit def seqWriter[A](implicit writer: PBFieldWriter[List[A]]): PBFieldWriter[Seq[A]] = instance { (index: Int, value: Seq[A], out: CodedOutputStream) => writer.writeTo(index, value.toList, out) } - implicit def enumWriter[E](implicit values: Enum.Values[E], ordering: Ordering[E]): PBWriter[E] = + implicit def enumWriter[E]( + implicit values: Enum.Values[E], + ordering: Ordering[E]): PBFieldWriter[E] = instance { (index: Int, value: E, out: CodedOutputStream) => out.writeInt32(index, Enum.toInt(value)) } - implicit def enumerationWriter[E <: Enumeration#Value]: PBWriter[E] = + implicit def enumerationWriter[E <: Enumeration#Value]: PBFieldWriter[E] = instance { (index: Int, value: E, out: CodedOutputStream) => out.writeInt32(index, value.id) } - implicit def enumeratumIntEnumEntryWriter[E <: IntEnumEntry]: PBWriter[E] = + implicit def enumeratumIntEnumEntryWriter[E <: IntEnumEntry]: PBFieldWriter[E] = instance { (index: Int, entry: E, out: CodedOutputStream) => out.writeInt32(index, entry.value) } - implicit object ContravariantWriter extends Contravariant[PBWriter] { - override def contramap[A, B](writer: PBWriter[A])(f: B => A) = + implicit object ContravariantWriter extends Contravariant[PBFieldWriter] { + override def contramap[A, B](writer: PBFieldWriter[A])(f: B => A) = instance { (index: Int, b: B, out: CodedOutputStream) => writer.writeTo(index, f(b), out) } } } -object PBWriter extends PBWriterImplicits { - def apply[A: PBWriter]: PBWriter[A] = implicitly +object PBMessageWriter extends PBMessageWriterImplicits { + def apply[A: PBMessageWriter]: PBMessageWriter[A] = implicitly +} + +object PBFieldWriter extends PBFieldWriterImplicits { + def apply[A: PBFieldWriter]: PBFieldWriter[A] = implicitly } diff --git a/src/main/scala/pbdirect/package.scala b/src/main/scala/pbdirect/package.scala index f929167..a4efe6c 100644 --- a/src/main/scala/pbdirect/package.scala +++ b/src/main/scala/pbdirect/package.scala @@ -21,20 +21,16 @@ import java.io.ByteArrayOutputStream -import com.google.protobuf.{CodedInputStream, CodedOutputStream} +import com.google.protobuf.CodedOutputStream package object pbdirect { implicit class PBWriterOps[A](private val a: A) extends AnyVal { - def toPB(implicit writer: PBWriter[A]): Array[Byte] = { + def toPB(implicit writer: PBMessageWriter[A]): Array[Byte] = { val out = new ByteArrayOutputStream() val pbOut = CodedOutputStream.newInstance(out) - writer.writeTo(1, a, pbOut) + writer.writeTo(a, pbOut) pbOut.flush() - val bytes = out.toByteArray - // remove the tag and return the content - val input = CodedInputStream.newInstance(bytes) - input.readTag() - input.readByteArray() + out.toByteArray } } implicit class PBParserOps(private val bytes: Array[Byte]) extends AnyVal { diff --git a/src/main/scala/pbdirect/pbIndex.scala b/src/main/scala/pbdirect/pbIndex.scala new file mode 100644 index 0000000..c80f475 --- /dev/null +++ b/src/main/scala/pbdirect/pbIndex.scala @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2019 Beyond the lines + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package pbdirect + +/** + * An annotation that must be applied to all fields in messages + * to tell pbdirect their index (a.k.a. field number) + * + * e.g. + * + * {{{ + * case class MyMessage( + * @pbIndex(1) a: Int, + * @pbIndex(2) b: String + * ) + * }}} + */ +case class pbIndex(value: Int) diff --git a/src/test/scala/pbdirect/PBFormatSpec.scala b/src/test/scala/pbdirect/PBFormatSpec.scala index 27a3bc8..8cdf7a3 100644 --- a/src/test/scala/pbdirect/PBFormatSpec.scala +++ b/src/test/scala/pbdirect/PBFormatSpec.scala @@ -33,7 +33,7 @@ class PBFormatSpec extends AnyWordSpec with Matchers { "PBFormat" should { "derived new instances using imap" in { - case class Message(instant: Instant) + case class Message(@pbIndex(1) instant: Instant) val instant = Instant.ofEpochMilli(1499411227777L) val bytes = Message(instant).toPB bytes shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) @@ -41,7 +41,7 @@ class PBFormatSpec extends AnyWordSpec with Matchers { } "derived optional instances using imap" in { import cats.instances.option._ - case class Message(instant: Option[Instant]) + case class Message(@pbIndex(1) instant: Option[Instant]) val instant = Instant.ofEpochMilli(1499411227777L) val bytes = Message(Some(instant)).toPB bytes shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBWriterSpec.scala index 12bd4be..0d082f3 100644 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ b/src/test/scala/pbdirect/PBWriterSpec.scala @@ -28,48 +28,49 @@ import org.scalatest.wordspec.AnyWordSpecLike class PBWriterSpec extends AnyWordSpecLike with Matchers { "PBWriter" should { + // TODO rewrite these tests to test only the field writer "write a Boolean to Protobuf" in { - case class BooleanMessage(value: Option[Boolean]) + case class BooleanMessage(@pbIndex(1) value: Option[Boolean]) val message = BooleanMessage(Some(true)) message.toPB shouldBe Array[Byte](8, 1) } "write a Byte to Protobuf" in { - case class ByteMessage(value: Option[Byte]) + case class ByteMessage(@pbIndex(1) value: Option[Byte]) val message = ByteMessage(Some(32)) message.toPB shouldBe Array[Byte](8, 32) } "write a Short to Protobuf" in { - case class ShortMessage(value: Option[Short]) + case class ShortMessage(@pbIndex(1) value: Option[Short]) val message = ShortMessage(Some(8191)) message.toPB shouldBe Array[Byte](8, -1, 63) } "write an Int to Protobuf" in { - case class IntMessage(value: Option[Int]) + case class IntMessage(@pbIndex(1) value: Option[Int]) val message = IntMessage(Some(5)) message.toPB shouldBe Array[Byte](8, 5) } "write a Long to Protobuf" in { - case class LongMessage(value: Option[Long]) + case class LongMessage(@pbIndex(1) value: Option[Long]) val message = LongMessage(Some(Int.MaxValue.toLong + 1)) message.toPB shouldBe Array[Byte](8, -128, -128, -128, -128, 8) } "write a Float to Protobuf" in { - case class FloatMessage(value: Option[Float]) + case class FloatMessage(@pbIndex(1) value: Option[Float]) val message = FloatMessage(Some(0.2F)) message.toPB shouldBe Array[Byte](13, -51, -52, 76, 62) } "write a Double to Protobuf" in { - case class DoubleMessage(value: Option[Double]) + case class DoubleMessage(@pbIndex(1) value: Option[Double]) val message = DoubleMessage(Some(0.00000000002D)) message.toPB shouldBe Array[Byte](9, -107, 100, 121, -31, 127, -3, -75, 61) } "write a String to Protobuf" in { - case class StringMessage(value: Option[String]) + case class StringMessage(@pbIndex(1) value: Option[String]) val message = StringMessage(Some("Hello")) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) } "write bytes to Protobuf" in { - case class BytesMessage(value: Array[Byte]) + case class BytesMessage(@pbIndex(1) value: Array[Byte]) val message = BytesMessage(Array[Byte](8, 4)) message.toPB shouldBe Array[Byte](10, 2, 8, 4) } @@ -77,7 +78,7 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { object Grade extends Enumeration { val GradeA, GradeB = Value } - case class EnumMessage(value: Grade.Value) + case class EnumMessage(@pbIndex(1) value: Grade.Value) val message = EnumMessage(Grade.GradeB) message.toPB shouldBe Array[Byte](8, 1) } @@ -85,7 +86,7 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { sealed trait Grade extends Pos case object GradeA extends Grade with Pos._0 case object GradeB extends Grade with Pos._1 - case class GradeMessage(value: Option[Grade]) + case class GradeMessage(@pbIndex(1) value: Option[Grade]) val messageA = GradeMessage(Some(GradeA)) val messageB = GradeMessage(Some(GradeB)) messageA.toPB shouldBe Array[Byte](8, 0) @@ -104,12 +105,12 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { val values = findValues } - case class QualityMessage(quality: Quality) + case class QualityMessage(@pbIndex(1) quality: Quality) val message = QualityMessage(Quality.OK) message.toPB shouldBe Array[Byte](8, 3) } "write a required field to Protobuf" in { - case class RequiredMessage(value: Int) + case class RequiredMessage(@pbIndex(1) value: Int) val message = RequiredMessage(5) message.toPB shouldBe Array[Byte](8, 5) } @@ -119,74 +120,102 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { message.toPB shouldBe Array[Byte]() } "write a multi-field message to Protobuf" in { - case class MultiMessage(text: Option[String], number: Option[Int]) + case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val message = MultiMessage(Some("Hello"), Some(3)) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) } "write a message with missing field to Protobuf" in { - case class MissingMessage(text: Option[String], number: Option[Int]) + case class MissingMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val message = MissingMessage(Some("Hello"), None) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) } "write a message with repeated field to Protobuf" in { - case class RepeatedMessage(values: List[Int]) + case class RepeatedMessage(@pbIndex(1) values: List[Int]) val message = RepeatedMessage(1 :: 2 :: 3 :: 4 :: Nil) message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) } "write a message with Seq to Protobuf" in { - case class RepeatedMessage(values: Seq[Int]) + case class RepeatedMessage(@pbIndex(1) values: Seq[Int]) val message = RepeatedMessage(Seq(1, 2, 3, 4)) message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) } - "write a Map to Protobuf" in { - case class MapMessage(values: Map[Int, String]) - val message = MapMessage(Map(1 -> "one", 2 -> "two")) - message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - 119, 111) - } - "write a scala.collection.Map to Protobuf" in { - case class MapMessage(values: collection.Map[Int, String]) - val message = MapMessage(collection.Map(1 -> "one", 2 -> "two")) - message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - 119, 111) - } + // TODO investigate + //"write a Map to Protobuf" in { + //case class MapMessage(@pbIndex(1) values: Map[Int, String]) + //val message = MapMessage(Map(1 -> "one", 2 -> "two")) + //message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, + //119, 111) + //} + //"write a scala.collection.Map to Protobuf" in { + //case class MapMessage(@pbIndex(1) values: collection.Map[Int, String]) + //val message = MapMessage(collection.Map(1 -> "one", 2 -> "two")) + //message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, + //119, 111) + //} "write a nested message to Protobuf" in { - case class InnerMessage(value: Option[Int]) - case class OuterMessage(text: Option[String], inner: Option[InnerMessage]) + case class InnerMessage(@pbIndex(1) value: Option[Int]) + case class OuterMessage( + @pbIndex(1) text: Option[String], + @pbIndex(2) inner: Option[InnerMessage]) val message = OuterMessage(Some("Hello"), Some(InnerMessage(Some(11)))) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 18, 2, 8, 11) } - "write a sealed trait to Protobuf" in { - sealed trait Message - case class IntMessage(value: Option[Int]) extends Message - case class StringMessage(value: Option[String]) extends Message - val intMessage: Message = IntMessage(Some(5)) - val stringMessage: Message = StringMessage(Some("Hello")) - intMessage.toPB shouldBe Array[Byte](8, 5) - stringMessage.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) - } - "write a message with repeated nested message in Protobuf" in { - case class Metric( - metric: String, - microservice: String, - node: String, - value: Float, - count: Int) - case class Metrics(metrics: List[Metric]) - val message = Metrics( - Metric("metric", "microservices", "node", 12F, 12345) :: Nil - ) - message.toPB shouldBe Array[Byte](10, 37, 10, 6, 109, 101, 116, 114, 105, 99, 18, 13, 109, - 105, 99, 114, 111, 115, 101, 114, 118, 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, - 0, 64, 65, 40, -71, 96) + "write a nested message with all inner fields missing to Protobuf" in { + case class InnerMessage(@pbIndex(1) value: Option[Int]) + case class OuterMessage( + @pbIndex(1) text: Option[String], + @pbIndex(2) inner: Option[InnerMessage]) + val message = OuterMessage(Some("Hello"), Some(InnerMessage(None))) + message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 18, 0) + } + "write a nested message with the whole inner message missing to Protobuf" in { + case class InnerMessage(@pbIndex(1) value: Option[Int]) + case class OuterMessage( + @pbIndex(1) text: Option[String], + @pbIndex(2) inner: Option[InnerMessage]) + val message = OuterMessage(Some("Hello"), None) + message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) } + //"write a sealed trait to Protobuf" in { + //sealed trait Message + //case class IntMessage(@pbIndex(1) value: Option[Int]) extends Message + //case class StringMessage(@pbIndex(1) value: Option[String]) extends Message + //val intMessage: Message = IntMessage(Some(5)) + //val stringMessage: Message = StringMessage(Some("Hello")) + //intMessage.toPB shouldBe Array[Byte](8, 5) + //stringMessage.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) + //} + //"write a message with repeated nested message in Protobuf" in { + //case class Metric( + //@pbIndex(1) metric: String, + //@pbIndex(2) microservice: String, + //@pbIndex(3) node: String, + //@pbIndex(4) value: Float, + //@pbIndex(5) count: Int) + //case class Metrics(@pbIndex(1) metrics: List[Metric]) + //val message = Metrics( + //Metric("metric", "microservices", "node", 12F, 12345) :: Nil + //) + //message.toPB shouldBe Array[Byte](10, 37, 10, 6, 109, 101, 116, 114, 105, 99, 18, 13, 109, + //105, 99, 114, 111, 115, 101, 114, 118, 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, + //0, 64, 65, 40, -71, 96) + //} "derive new instance using contramap" in { import java.time.Instant import cats.syntax.contravariant._ - case class Message(instant: Instant) - implicit val instantWriter: PBWriter[Instant] = PBWriter[Long].contramap(_.toEpochMilli) - val instant = Instant.ofEpochMilli(1499411227777L) + case class Message(@pbIndex(1) instant: Instant) + implicit val instantWriter: PBFieldWriter[Instant] = + PBFieldWriter[Long].contramap(_.toEpochMilli) + val instant = Instant.ofEpochMilli(1499411227777L) Message(instant).toPB shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) } + "write a message with non-sequential field numbers to Protobuf" in { + case class AnnotatedMessage( + @pbIndex(1) a: String, + @pbIndex(3) b: Int + ) + val message = AnnotatedMessage("Hello", 3) + message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 24, 3) + } } } From a8ee5e59116a14c55be02ed434c0ae320335bb17 Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Fri, 13 Dec 2019 20:52:09 +0000 Subject: [PATCH 02/11] Fix serialization of maps --- src/main/scala/pbdirect/PBWriter.scala | 14 ++++++++++++ src/test/scala/pbdirect/PBWriterSpec.scala | 25 +++++++++++----------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBWriter.scala index 199991e..cd6d975 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBWriter.scala @@ -147,6 +147,9 @@ trait PBFieldWriterImplicits { override def writeTo(index: Int, value: Array[Byte], out: CodedOutputStream): Unit = out.writeByteArray(index, value) } + // TODO this is cute but it means users need to import cats.instances.list._ + // if they want to use lists or maps. Could be quite confusing. + // Maybe add a specialised instance for List[A] as well? implicit def functorWriter[F[_], A]( implicit functor: Functor[F], writer: PBFieldWriter[A]): PBFieldWriter[F[A]] = @@ -156,6 +159,17 @@ trait PBFieldWriterImplicits { } () } + implicit def keyValuePairWriter[K, V]( + implicit keyWriter: PBFieldWriter[K], + valueWriter: PBFieldWriter[V]): PBFieldWriter[(K, V)] = + instance { (index: Int, pair: (K, V), out: CodedOutputStream) => + val buffer = new ByteArrayOutputStream() + val bufferOut = CodedOutputStream.newInstance(buffer) + keyWriter.writeTo(1, pair._1, bufferOut) + valueWriter.writeTo(2, pair._2, bufferOut) + bufferOut.flush() + out.writeByteArray(index, buffer.toByteArray) + } implicit def mapWriter[K, V]( implicit writer: PBFieldWriter[List[(K, V)]]): PBFieldWriter[Map[K, V]] = instance { (index: Int, value: Map[K, V], out: CodedOutputStream) => diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBWriterSpec.scala index 0d082f3..f7baaa1 100644 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ b/src/test/scala/pbdirect/PBWriterSpec.scala @@ -139,19 +139,18 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { val message = RepeatedMessage(Seq(1, 2, 3, 4)) message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) } - // TODO investigate - //"write a Map to Protobuf" in { - //case class MapMessage(@pbIndex(1) values: Map[Int, String]) - //val message = MapMessage(Map(1 -> "one", 2 -> "two")) - //message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - //119, 111) - //} - //"write a scala.collection.Map to Protobuf" in { - //case class MapMessage(@pbIndex(1) values: collection.Map[Int, String]) - //val message = MapMessage(collection.Map(1 -> "one", 2 -> "two")) - //message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - //119, 111) - //} + "write a Map to Protobuf" in { + case class MapMessage(@pbIndex(1) values: Map[Int, String]) + val message = MapMessage(Map(1 -> "one", 2 -> "two")) + message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, + 119, 111) + } + "write a scala.collection.Map to Protobuf" in { + case class MapMessage(@pbIndex(1) values: collection.Map[Int, String]) + val message = MapMessage(collection.Map(1 -> "one", 2 -> "two")) + message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, + 119, 111) + } "write a nested message to Protobuf" in { case class InnerMessage(@pbIndex(1) value: Option[Int]) case class OuterMessage( From 6139ddaad2111e5751f05c9083594478c8dc9c0e Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Mon, 16 Dec 2019 14:13:57 +0000 Subject: [PATCH 03/11] Restore support for writing of sealed traits --- src/main/scala/pbdirect/PBWriter.scala | 49 ++++++++++++---------- src/test/scala/pbdirect/PBWriterSpec.scala | 48 ++++++++++----------- 2 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBWriter.scala index cd6d975..b749af5 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBWriter.scala @@ -73,36 +73,36 @@ trait PBMessageWriterImplicits { writer.value.writeTo(indexedFields, out) } - //implicit val cnilWriter: PBWriter[CNil] = instance { (_: Int, _: CNil, _: CodedOutputStream) => - //throw new Exception("Can't write CNil") - //} - //implicit def cconsWriter[H, T <: Coproduct]( - //implicit head: PBWriter[H], - //tail: PBWriter[T]): PBWriter[H :+: T] = - //instance { (index: Int, value: H :+: T, out: CodedOutputStream) => - //value match { - //case Inl(h) => head.writeTo(index, h, out) - //case Inr(t) => tail.writeTo(index, t, out) - //} - //} - //implicit def coprodWriter[A, R <: Coproduct]( - //implicit gen: Generic.Aux[A, R], - //writer: PBWriter[R]): PBWriter[A] = - //instance { (index: Int, value: A, out: CodedOutputStream) => - //writer.writeTo(index, gen.to(value), out) - //} + implicit val cnilWriter: PBMessageWriter[CNil] = instance { (_: CNil, _: CodedOutputStream) => + throw new Exception("Can't write CNil") + } + implicit def cconsWriter[H, T <: Coproduct]( + implicit head: PBMessageWriter[H], + tail: PBMessageWriter[T]): PBMessageWriter[H :+: T] = + instance { (value: H :+: T, out: CodedOutputStream) => + value match { + case Inl(h) => head.writeTo(h, out) + case Inr(t) => tail.writeTo(t, out) + } + } + implicit def coprodWriter[A, R <: Coproduct]( + implicit gen: Generic.Aux[A, R], + writer: PBMessageWriter[R]): PBMessageWriter[A] = + instance { (value: A, out: CodedOutputStream) => + writer.writeTo(gen.to(value), out) + } } -trait PBFieldWriterImplicits { +trait LowPriorityPBFieldWriterImplicits { def instance[A](f: (Int, A, CodedOutputStream) => Unit): PBFieldWriter[A] = new PBFieldWriter[A] { override def writeTo(index: Int, value: A, out: CodedOutputStream): Unit = f(index, value, out) } implicit def embeddedMessageFieldWriter[A]( - implicit messageWriter: PBMessageWriter[A]): PBFieldWriter[A] = instance { - (index, message, out) => + implicit messageWriter: PBMessageWriter[A]): PBFieldWriter[A] = + instance { (index, message, out) => { val buffer = new ByteArrayOutputStream() val bufferOut = CodedOutputStream.newInstance(buffer) @@ -110,7 +110,10 @@ trait PBFieldWriterImplicits { bufferOut.flush() out.writeByteArray(index, buffer.toByteArray) } - } + } +} + +trait PBFieldWriterImplicits extends LowPriorityPBFieldWriterImplicits { implicit object BooleanWriter extends PBFieldWriter[Boolean] { override def writeTo(index: Int, value: Boolean, out: CodedOutputStream): Unit = out.writeBool(index, value) @@ -149,7 +152,7 @@ trait PBFieldWriterImplicits { } // TODO this is cute but it means users need to import cats.instances.list._ // if they want to use lists or maps. Could be quite confusing. - // Maybe add a specialised instance for List[A] as well? + // Maybe add specialised instances for List[A] and Option[A] as well? implicit def functorWriter[F[_], A]( implicit functor: Functor[F], writer: PBFieldWriter[A]): PBFieldWriter[F[A]] = diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBWriterSpec.scala index f7baaa1..bd23a4d 100644 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ b/src/test/scala/pbdirect/PBWriterSpec.scala @@ -175,30 +175,30 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { val message = OuterMessage(Some("Hello"), None) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) } - //"write a sealed trait to Protobuf" in { - //sealed trait Message - //case class IntMessage(@pbIndex(1) value: Option[Int]) extends Message - //case class StringMessage(@pbIndex(1) value: Option[String]) extends Message - //val intMessage: Message = IntMessage(Some(5)) - //val stringMessage: Message = StringMessage(Some("Hello")) - //intMessage.toPB shouldBe Array[Byte](8, 5) - //stringMessage.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) - //} - //"write a message with repeated nested message in Protobuf" in { - //case class Metric( - //@pbIndex(1) metric: String, - //@pbIndex(2) microservice: String, - //@pbIndex(3) node: String, - //@pbIndex(4) value: Float, - //@pbIndex(5) count: Int) - //case class Metrics(@pbIndex(1) metrics: List[Metric]) - //val message = Metrics( - //Metric("metric", "microservices", "node", 12F, 12345) :: Nil - //) - //message.toPB shouldBe Array[Byte](10, 37, 10, 6, 109, 101, 116, 114, 105, 99, 18, 13, 109, - //105, 99, 114, 111, 115, 101, 114, 118, 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, - //0, 64, 65, 40, -71, 96) - //} + "write a sealed trait to Protobuf" in { + sealed trait Message + case class IntMessage(@pbIndex(1) value: Option[Int]) extends Message + case class StringMessage(@pbIndex(1) value: Option[String]) extends Message + val intMessage: Message = IntMessage(Some(5)) + val stringMessage: Message = StringMessage(Some("Hello")) + intMessage.toPB shouldBe Array[Byte](8, 5) + stringMessage.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) + } + "write a message with repeated nested message in Protobuf" in { + case class Metric( + @pbIndex(1) metric: String, + @pbIndex(2) microservice: String, + @pbIndex(3) node: String, + @pbIndex(4) value: Float, + @pbIndex(5) count: Int) + case class Metrics(@pbIndex(1) metrics: List[Metric]) + val message = Metrics( + Metric("metric", "microservices", "node", 12F, 12345) :: Nil + ) + message.toPB shouldBe Array[Byte](10, 37, 10, 6, 109, 101, 116, 114, 105, 99, 18, 13, 109, + 105, 99, 114, 111, 115, 101, 114, 118, 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, + 0, 64, 65, 40, -71, 96) + } "derive new instance using contramap" in { import java.time.Instant import cats.syntax.contravariant._ From 5d2d79e79684e3d1e011e1e7732715f81cf0410a Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Mon, 16 Dec 2019 14:27:05 +0000 Subject: [PATCH 04/11] Provide PBFieldWriter instances for Option and List The existing instance for Functor is still there, but these are added for convenience so people don't have to remember to add the appropriate cats imports. --- src/main/scala/pbdirect/PBWriter.scala | 27 +++++++++++++--------- src/test/scala/pbdirect/PBFormatSpec.scala | 1 - src/test/scala/pbdirect/PBWriterSpec.scala | 2 -- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBWriter.scala index b749af5..31240d5 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBWriter.scala @@ -111,6 +111,15 @@ trait LowPriorityPBFieldWriterImplicits { out.writeByteArray(index, buffer.toByteArray) } } + implicit def functorWriter[F[_], A]( + implicit functor: Functor[F], + writer: PBFieldWriter[A]): PBFieldWriter[F[A]] = + instance { (index: Int, value: F[A], out: CodedOutputStream) => + functor.map(value) { v => + writer.writeTo(index, v, out) + } + () + } } trait PBFieldWriterImplicits extends LowPriorityPBFieldWriterImplicits { @@ -150,17 +159,13 @@ trait PBFieldWriterImplicits extends LowPriorityPBFieldWriterImplicits { override def writeTo(index: Int, value: Array[Byte], out: CodedOutputStream): Unit = out.writeByteArray(index, value) } - // TODO this is cute but it means users need to import cats.instances.list._ - // if they want to use lists or maps. Could be quite confusing. - // Maybe add specialised instances for List[A] and Option[A] as well? - implicit def functorWriter[F[_], A]( - implicit functor: Functor[F], - writer: PBFieldWriter[A]): PBFieldWriter[F[A]] = - instance { (index: Int, value: F[A], out: CodedOutputStream) => - functor.map(value) { v => - writer.writeTo(index, v, out) - } - () + implicit def optionWriter[A](implicit writer: PBFieldWriter[A]): PBFieldWriter[Option[A]] = + instance { (index: Int, option: Option[A], out: CodedOutputStream) => + option.foreach(v => writer.writeTo(index, v, out)) + } + implicit def listWriter[A](implicit writer: PBFieldWriter[A]): PBFieldWriter[List[A]] = + instance { (index: Int, list: List[A], out: CodedOutputStream) => + list.foreach(v => writer.writeTo(index, v, out)) } implicit def keyValuePairWriter[K, V]( implicit keyWriter: PBFieldWriter[K], diff --git a/src/test/scala/pbdirect/PBFormatSpec.scala b/src/test/scala/pbdirect/PBFormatSpec.scala index 8cdf7a3..1b8aafa 100644 --- a/src/test/scala/pbdirect/PBFormatSpec.scala +++ b/src/test/scala/pbdirect/PBFormatSpec.scala @@ -40,7 +40,6 @@ class PBFormatSpec extends AnyWordSpec with Matchers { bytes.pbTo[Message] shouldBe Message(instant) } "derived optional instances using imap" in { - import cats.instances.option._ case class Message(@pbIndex(1) instant: Option[Instant]) val instant = Instant.ofEpochMilli(1499411227777L) val bytes = Message(Some(instant)).toPB diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBWriterSpec.scala index bd23a4d..962d292 100644 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ b/src/test/scala/pbdirect/PBWriterSpec.scala @@ -21,8 +21,6 @@ package pbdirect -import cats.instances.option._ -import cats.instances.list._ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike From fd405e1378f1474ec5f829f13460c075939f7935 Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Mon, 16 Dec 2019 16:35:42 +0000 Subject: [PATCH 05/11] Separate tests for PBMessageWriter and PBFieldWriter --- .../{PBWriter.scala => PBFieldWriter.scala} | 67 -------- src/main/scala/pbdirect/PBMessageWriter.scala | 91 +++++++++++ .../scala/pbdirect/PBFieldWriterSpec.scala | 145 ++++++++++++++++++ ...erSpec.scala => PBMessageWriterSpec.scala} | 141 +++-------------- 4 files changed, 258 insertions(+), 186 deletions(-) rename src/main/scala/pbdirect/{PBWriter.scala => PBFieldWriter.scala} (74%) create mode 100644 src/main/scala/pbdirect/PBMessageWriter.scala create mode 100644 src/test/scala/pbdirect/PBFieldWriterSpec.scala rename src/test/scala/pbdirect/{PBWriterSpec.scala => PBMessageWriterSpec.scala} (51%) diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBFieldWriter.scala similarity index 74% rename from src/main/scala/pbdirect/PBWriter.scala rename to src/main/scala/pbdirect/PBFieldWriter.scala index 31240d5..a12559f 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBFieldWriter.scala @@ -25,75 +25,12 @@ import java.io.ByteArrayOutputStream import cats.{Contravariant, Functor} import com.google.protobuf.CodedOutputStream -import shapeless._ -import shapeless.ops.hlist._ import enumeratum.values.IntEnumEntry -trait PBMessageWriter[A] { - def writeTo(value: A, out: CodedOutputStream): Unit -} - trait PBFieldWriter[A] { def writeTo(index: Int, value: A, out: CodedOutputStream): Unit } -case class FieldIndex(value: Int) - -trait PBMessageWriterImplicits { - def instance[A](f: (A, CodedOutputStream) => Unit): PBMessageWriter[A] = - new PBMessageWriter[A] { - override def writeTo(value: A, out: CodedOutputStream): Unit = - f(value, out) - } - implicit val hnilWriter: PBMessageWriter[HNil] = instance { (_: HNil, _: CodedOutputStream) => - () - } - implicit def consWriter[H, T <: HList]( - implicit head: PBFieldWriter[H], - tail: Lazy[PBMessageWriter[T]]): PBMessageWriter[(FieldIndex, H) :: T] = - instance { (value: (FieldIndex, H) :: T, out: CodedOutputStream) => - head.writeTo(value.head._1.value, value.head._2, out) - tail.value.writeTo(value.tail, out) - } - - object zipWithIndex extends Poly2 { - implicit def defaultCase[T] = at[Some[pbIndex], T] { - case (Some(annotation), value) => (FieldIndex(annotation.value), value) - } - } - - implicit def prodWriter[A, R <: HList, Anns <: HList, Z <: HList]( - implicit gen: Generic.Aux[A, R], - annotations: Annotations.Aux[pbIndex, A, Anns], - zw: ZipWith.Aux[Anns, R, zipWithIndex.type, Z], - writer: Lazy[PBMessageWriter[Z]]): PBMessageWriter[A] = - instance { (value: A, out: CodedOutputStream) => - val fields = gen.to(value) - val indexedFields = annotations.apply.zipWith(fields)(zipWithIndex) - writer.value.writeTo(indexedFields, out) - } - - implicit val cnilWriter: PBMessageWriter[CNil] = instance { (_: CNil, _: CodedOutputStream) => - throw new Exception("Can't write CNil") - } - implicit def cconsWriter[H, T <: Coproduct]( - implicit head: PBMessageWriter[H], - tail: PBMessageWriter[T]): PBMessageWriter[H :+: T] = - instance { (value: H :+: T, out: CodedOutputStream) => - value match { - case Inl(h) => head.writeTo(h, out) - case Inr(t) => tail.writeTo(t, out) - } - } - implicit def coprodWriter[A, R <: Coproduct]( - implicit gen: Generic.Aux[A, R], - writer: PBMessageWriter[R]): PBMessageWriter[A] = - instance { (value: A, out: CodedOutputStream) => - writer.writeTo(gen.to(value), out) - } - -} - trait LowPriorityPBFieldWriterImplicits { def instance[A](f: (Int, A, CodedOutputStream) => Unit): PBFieldWriter[A] = new PBFieldWriter[A] { @@ -215,10 +152,6 @@ trait PBFieldWriterImplicits extends LowPriorityPBFieldWriterImplicits { } } -object PBMessageWriter extends PBMessageWriterImplicits { - def apply[A: PBMessageWriter]: PBMessageWriter[A] = implicitly -} - object PBFieldWriter extends PBFieldWriterImplicits { def apply[A: PBFieldWriter]: PBFieldWriter[A] = implicitly } diff --git a/src/main/scala/pbdirect/PBMessageWriter.scala b/src/main/scala/pbdirect/PBMessageWriter.scala new file mode 100644 index 0000000..a6d879f --- /dev/null +++ b/src/main/scala/pbdirect/PBMessageWriter.scala @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2019 Beyond the lines + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package pbdirect + +import com.google.protobuf.CodedOutputStream +import shapeless._ +import shapeless.ops.hlist._ + +trait PBMessageWriter[A] { + def writeTo(value: A, out: CodedOutputStream): Unit +} + +case class FieldIndex(value: Int) + +trait PBMessageWriterImplicits { + def instance[A](f: (A, CodedOutputStream) => Unit): PBMessageWriter[A] = + new PBMessageWriter[A] { + override def writeTo(value: A, out: CodedOutputStream): Unit = + f(value, out) + } + implicit val hnilWriter: PBMessageWriter[HNil] = instance { (_: HNil, _: CodedOutputStream) => + () + } + implicit def consWriter[H, T <: HList]( + implicit head: PBFieldWriter[H], + tail: Lazy[PBMessageWriter[T]]): PBMessageWriter[(FieldIndex, H) :: T] = + instance { (value: (FieldIndex, H) :: T, out: CodedOutputStream) => + head.writeTo(value.head._1.value, value.head._2, out) + tail.value.writeTo(value.tail, out) + } + + object zipWithIndex extends Poly2 { + implicit def defaultCase[T] = at[Some[pbIndex], T] { + case (Some(annotation), value) => (FieldIndex(annotation.value), value) + } + } + + implicit def prodWriter[A, R <: HList, Anns <: HList, Z <: HList]( + implicit gen: Generic.Aux[A, R], + annotations: Annotations.Aux[pbIndex, A, Anns], + zw: ZipWith.Aux[Anns, R, zipWithIndex.type, Z], + writer: Lazy[PBMessageWriter[Z]]): PBMessageWriter[A] = + instance { (value: A, out: CodedOutputStream) => + val fields = gen.to(value) + val indexedFields = annotations.apply.zipWith(fields)(zipWithIndex) + writer.value.writeTo(indexedFields, out) + } + + implicit val cnilWriter: PBMessageWriter[CNil] = instance { (_: CNil, _: CodedOutputStream) => + throw new Exception("Can't write CNil") + } + implicit def cconsWriter[H, T <: Coproduct]( + implicit head: PBMessageWriter[H], + tail: PBMessageWriter[T]): PBMessageWriter[H :+: T] = + instance { (value: H :+: T, out: CodedOutputStream) => + value match { + case Inl(h) => head.writeTo(h, out) + case Inr(t) => tail.writeTo(t, out) + } + } + implicit def coprodWriter[A, R <: Coproduct]( + implicit gen: Generic.Aux[A, R], + writer: PBMessageWriter[R]): PBMessageWriter[A] = + instance { (value: A, out: CodedOutputStream) => + writer.writeTo(gen.to(value), out) + } + +} + +object PBMessageWriter extends PBMessageWriterImplicits { + def apply[A: PBMessageWriter]: PBMessageWriter[A] = implicitly +} diff --git a/src/test/scala/pbdirect/PBFieldWriterSpec.scala b/src/test/scala/pbdirect/PBFieldWriterSpec.scala new file mode 100644 index 0000000..e54b2aa --- /dev/null +++ b/src/test/scala/pbdirect/PBFieldWriterSpec.scala @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2019 Beyond the lines + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package pbdirect + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpecLike +import java.io.ByteArrayOutputStream +import com.google.protobuf.CodedOutputStream + +class PBFieldWriterSpec extends AnyWordSpecLike with Matchers { + + def write[A](value: A)(implicit writer: PBFieldWriter[A]): Array[Byte] = { + val buffer = new ByteArrayOutputStream() + val out = CodedOutputStream.newInstance(buffer) + writer.writeTo(1, value, out) + out.flush() + buffer.toByteArray() + } + + "PBFieldWriter" should { + "write a Boolean to Protobuf" in { + write(true) shouldBe Array[Byte](8, 1) + } + "write a Byte to Protobuf" in { + write(32: Byte) shouldBe Array[Byte](8, 32) + } + "write a Short to Protobuf" in { + write(8191: Short) shouldBe Array[Byte](8, -1, 63) + } + "write an Int to Protobuf" in { + write(5) shouldBe Array[Byte](8, 5) + } + "write a Long to Protobuf" in { + write(Int.MaxValue.toLong + 1) shouldBe Array[Byte](8, -128, -128, -128, -128, 8) + } + "write a Float to Protobuf" in { + write(0.2F) shouldBe Array[Byte](13, -51, -52, 76, 62) + } + "write a Double to Protobuf" in { + write(0.00000000002D) shouldBe Array[Byte](9, -107, 100, 121, -31, 127, -3, -75, 61) + } + "write a String to Protobuf" in { + write("Hello") shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) + } + "write bytes to Protobuf" in { + write(Array[Byte](8, 4)) shouldBe Array[Byte](10, 2, 8, 4) + } + "write an enumeration to Protobuf" in { + object Grade extends Enumeration { + val GradeA, GradeB = Value + } + write(Grade.GradeB) shouldBe Array[Byte](8, 1) + } + "write an enum to Protobuf" in { + sealed trait Grade extends Pos + case object GradeA extends Grade with Pos._0 + case object GradeB extends Grade with Pos._1 + write(GradeA: Grade) shouldBe Array[Byte](8, 0) + write(GradeB: Grade) shouldBe Array[Byte](8, 1) + } + "write an enumeratum IntEnumEntry to Protobuf" in { + import enumeratum.values._ + sealed abstract class Quality(val value: Int) extends IntEnumEntry + object Quality extends IntEnum[Quality] { + case object Good extends Quality(0) + case object OK extends Quality(3) + case object Bad extends Quality(5) + + val values = findValues + } + write(Quality.OK: Quality) shouldBe Array[Byte](8, 3) + } + "write an Option[Int] to Protobuf" in { + write(Option(5)) shouldBe Array[Byte](8, 5) + } + "write an empty Option[Int] to Protobuf" in { + write(None: Option[Int]) shouldBe Array[Byte]() + } + "write a List[Int] to Protobuf as an unpacked repeated field" in { + write(1 :: 2 :: 3 :: 4 :: Nil) shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) + } + "write an empty List[Int] to Protobuf as an unpacked repeated field" in { + write(Nil: List[Int]) shouldBe Array[Byte]() + } + "write a Seq to Protobuf" in { + write(Seq(1, 2, 3, 4)) shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) + } + "write a Map to Protobuf" in { + write(Map(1 -> "one", 2 -> "two")) shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, + 7, 8, 2, 18, 3, 116, 119, 111) + } + "write a scala.collection.Map to Protobuf" in { + write(collection.Map(1 -> "one", 2 -> "two")) shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, + 110, 101, 10, 7, 8, 2, 18, 3, 116, 119, 111) + } + "write an embedded message to Protobuf" in { + case class EmbeddedMessage(@pbIndex(1) value: Int) + write(EmbeddedMessage(11)) shouldBe Array[Byte](10, 2, 8, 11) + } + "write an embedded message with all fields missing to Protobuf" in { + case class EmbeddedMessage(@pbIndex(1) value: Option[Int]) + write(EmbeddedMessage(None)) shouldBe Array[Byte](10, 0) + } + "write a repeated embedded message in Protobuf" in { + case class Metric( + @pbIndex(1) metric: String, + @pbIndex(2) microservice: String, + @pbIndex(3) node: String, + @pbIndex(4) value: Float, + @pbIndex(5) count: Int + ) + write(Metric("metric", "microservices", "node", 12F, 12345) :: Nil) shouldBe Array[Byte](10, + 37, 10, 6, 109, 101, 116, 114, 105, 99, 18, 13, 109, 105, 99, 114, 111, 115, 101, 114, 118, + 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, 0, 64, 65, 40, -71, 96) + } + "derive new instance using contramap" in { + import java.time.Instant + import cats.syntax.contravariant._ + case class Message(@pbIndex(1) instant: Instant) + implicit val instantWriter: PBFieldWriter[Instant] = + PBFieldWriter[Long].contramap(_.toEpochMilli) + val instant = Instant.ofEpochMilli(1499411227777L) + write(instant) shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) + } + } +} diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBMessageWriterSpec.scala similarity index 51% rename from src/test/scala/pbdirect/PBWriterSpec.scala rename to src/test/scala/pbdirect/PBMessageWriterSpec.scala index 962d292..2a7f3fd 100644 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ b/src/test/scala/pbdirect/PBMessageWriterSpec.scala @@ -24,132 +24,44 @@ package pbdirect import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike -class PBWriterSpec extends AnyWordSpecLike with Matchers { - "PBWriter" should { - // TODO rewrite these tests to test only the field writer - "write a Boolean to Protobuf" in { - case class BooleanMessage(@pbIndex(1) value: Option[Boolean]) - val message = BooleanMessage(Some(true)) - message.toPB shouldBe Array[Byte](8, 1) - } - "write a Byte to Protobuf" in { - case class ByteMessage(@pbIndex(1) value: Option[Byte]) - val message = ByteMessage(Some(32)) - message.toPB shouldBe Array[Byte](8, 32) - } - "write a Short to Protobuf" in { - case class ShortMessage(@pbIndex(1) value: Option[Short]) - val message = ShortMessage(Some(8191)) - message.toPB shouldBe Array[Byte](8, -1, 63) - } - "write an Int to Protobuf" in { - case class IntMessage(@pbIndex(1) value: Option[Int]) - val message = IntMessage(Some(5)) - message.toPB shouldBe Array[Byte](8, 5) - } - "write a Long to Protobuf" in { - case class LongMessage(@pbIndex(1) value: Option[Long]) - val message = LongMessage(Some(Int.MaxValue.toLong + 1)) - message.toPB shouldBe Array[Byte](8, -128, -128, -128, -128, 8) - } - "write a Float to Protobuf" in { - case class FloatMessage(@pbIndex(1) value: Option[Float]) - val message = FloatMessage(Some(0.2F)) - message.toPB shouldBe Array[Byte](13, -51, -52, 76, 62) - } - "write a Double to Protobuf" in { - case class DoubleMessage(@pbIndex(1) value: Option[Double]) - val message = DoubleMessage(Some(0.00000000002D)) - message.toPB shouldBe Array[Byte](9, -107, 100, 121, -31, 127, -3, -75, 61) - } - "write a String to Protobuf" in { - case class StringMessage(@pbIndex(1) value: Option[String]) - val message = StringMessage(Some("Hello")) - message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) - } - "write bytes to Protobuf" in { - case class BytesMessage(@pbIndex(1) value: Array[Byte]) - val message = BytesMessage(Array[Byte](8, 4)) - message.toPB shouldBe Array[Byte](10, 2, 8, 4) - } - "write an enumeration to Protobuf" in { - object Grade extends Enumeration { - val GradeA, GradeB = Value - } - case class EnumMessage(@pbIndex(1) value: Grade.Value) - val message = EnumMessage(Grade.GradeB) - message.toPB shouldBe Array[Byte](8, 1) - } - "write an enum to Protobuf" in { - sealed trait Grade extends Pos - case object GradeA extends Grade with Pos._0 - case object GradeB extends Grade with Pos._1 - case class GradeMessage(@pbIndex(1) value: Option[Grade]) - val messageA = GradeMessage(Some(GradeA)) - val messageB = GradeMessage(Some(GradeB)) - messageA.toPB shouldBe Array[Byte](8, 0) - messageB.toPB shouldBe Array[Byte](8, 1) - } - "write an enumeratum IntEnumEntry to Protobuf" in { - import enumeratum.values._ - - sealed abstract class Quality(val value: Int) extends IntEnumEntry - - object Quality extends IntEnum[Quality] { - case object Good extends Quality(0) - case object OK extends Quality(3) - case object Bad extends Quality(5) - - val values = findValues - } - - case class QualityMessage(@pbIndex(1) quality: Quality) - val message = QualityMessage(Quality.OK) - message.toPB shouldBe Array[Byte](8, 3) +class PBMessageWriterSpec extends AnyWordSpecLike with Matchers { + "PBMessageWriter" should { + "write an empty message to Protobuf" in { + case class EmptyMessage() + val message = EmptyMessage() + message.toPB shouldBe Array[Byte]() } - "write a required field to Protobuf" in { + "write a message with a required field to Protobuf" in { case class RequiredMessage(@pbIndex(1) value: Int) val message = RequiredMessage(5) message.toPB shouldBe Array[Byte](8, 5) } - "write an empty message to Protobuf" in { - case class EmptyMessage() - val message = EmptyMessage() - message.toPB shouldBe Array[Byte]() + "write a message with an optional field to Protobuf" in { + case class OptionalMessage(@pbIndex(1) value: Option[Int]) + val message = OptionalMessage(Some(5)) + message.toPB shouldBe Array[Byte](8, 5) } - "write a multi-field message to Protobuf" in { - case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) - val message = MultiMessage(Some("Hello"), Some(3)) - message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) + "write a message with an empty optional field to Protobuf" in { + case class OptionalMessage(@pbIndex(1) value: Option[Int]) + val message = OptionalMessage(None) + message.toPB shouldBe Array[Byte]() } "write a message with missing field to Protobuf" in { case class MissingMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val message = MissingMessage(Some("Hello"), None) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111) } + "write a multi-field message to Protobuf" in { + case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) + val message = MultiMessage(Some("Hello"), Some(3)) + message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) + } "write a message with repeated field to Protobuf" in { case class RepeatedMessage(@pbIndex(1) values: List[Int]) val message = RepeatedMessage(1 :: 2 :: 3 :: 4 :: Nil) message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) } - "write a message with Seq to Protobuf" in { - case class RepeatedMessage(@pbIndex(1) values: Seq[Int]) - val message = RepeatedMessage(Seq(1, 2, 3, 4)) - message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) - } - "write a Map to Protobuf" in { - case class MapMessage(@pbIndex(1) values: Map[Int, String]) - val message = MapMessage(Map(1 -> "one", 2 -> "two")) - message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - 119, 111) - } - "write a scala.collection.Map to Protobuf" in { - case class MapMessage(@pbIndex(1) values: collection.Map[Int, String]) - val message = MapMessage(collection.Map(1 -> "one", 2 -> "two")) - message.toPB shouldBe Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, - 119, 111) - } - "write a nested message to Protobuf" in { + "write a message with an embedded message to Protobuf" in { case class InnerMessage(@pbIndex(1) value: Option[Int]) case class OuterMessage( @pbIndex(1) text: Option[String], @@ -157,7 +69,7 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { val message = OuterMessage(Some("Hello"), Some(InnerMessage(Some(11)))) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 18, 2, 8, 11) } - "write a nested message with all inner fields missing to Protobuf" in { + "write a nested message with the inner message's fields all missing to Protobuf" in { case class InnerMessage(@pbIndex(1) value: Option[Int]) case class OuterMessage( @pbIndex(1) text: Option[String], @@ -197,15 +109,6 @@ class PBWriterSpec extends AnyWordSpecLike with Matchers { 105, 99, 114, 111, 115, 101, 114, 118, 105, 99, 101, 115, 26, 4, 110, 111, 100, 101, 37, 0, 0, 64, 65, 40, -71, 96) } - "derive new instance using contramap" in { - import java.time.Instant - import cats.syntax.contravariant._ - case class Message(@pbIndex(1) instant: Instant) - implicit val instantWriter: PBFieldWriter[Instant] = - PBFieldWriter[Long].contramap(_.toEpochMilli) - val instant = Instant.ofEpochMilli(1499411227777L) - Message(instant).toPB shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) - } "write a message with non-sequential field numbers to Protobuf" in { case class AnnotatedMessage( @pbIndex(1) a: String, From b66fc5eb2d4b50673ad2abdf3e52d6b5fd7c1308 Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Tue, 17 Dec 2019 12:18:27 +0000 Subject: [PATCH 06/11] Use pbIndex annotations when reading from protobuf --- src/main/scala/pbdirect/FieldIndex.scala | 27 ++++++++ src/main/scala/pbdirect/PBMessageWriter.scala | 8 +-- src/main/scala/pbdirect/PBReader.scala | 61 ++++++++++++---- src/test/scala/pbdirect/PBReaderSpec.scala | 69 ++++++++++++------- 4 files changed, 120 insertions(+), 45 deletions(-) create mode 100644 src/main/scala/pbdirect/FieldIndex.scala diff --git a/src/main/scala/pbdirect/FieldIndex.scala b/src/main/scala/pbdirect/FieldIndex.scala new file mode 100644 index 0000000..ab44ffb --- /dev/null +++ b/src/main/scala/pbdirect/FieldIndex.scala @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2019 Beyond the lines + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package pbdirect + +/** + * A protobuf index, also known as the field number + */ +private[pbdirect] final case class FieldIndex(value: Int) diff --git a/src/main/scala/pbdirect/PBMessageWriter.scala b/src/main/scala/pbdirect/PBMessageWriter.scala index a6d879f..b7b6598 100644 --- a/src/main/scala/pbdirect/PBMessageWriter.scala +++ b/src/main/scala/pbdirect/PBMessageWriter.scala @@ -29,8 +29,6 @@ trait PBMessageWriter[A] { def writeTo(value: A, out: CodedOutputStream): Unit } -case class FieldIndex(value: Int) - trait PBMessageWriterImplicits { def instance[A](f: (A, CodedOutputStream) => Unit): PBMessageWriter[A] = new PBMessageWriter[A] { @@ -48,7 +46,7 @@ trait PBMessageWriterImplicits { tail.value.writeTo(value.tail, out) } - object zipWithIndex extends Poly2 { + object zipWithFieldIndex extends Poly2 { implicit def defaultCase[T] = at[Some[pbIndex], T] { case (Some(annotation), value) => (FieldIndex(annotation.value), value) } @@ -57,11 +55,11 @@ trait PBMessageWriterImplicits { implicit def prodWriter[A, R <: HList, Anns <: HList, Z <: HList]( implicit gen: Generic.Aux[A, R], annotations: Annotations.Aux[pbIndex, A, Anns], - zw: ZipWith.Aux[Anns, R, zipWithIndex.type, Z], + zw: ZipWith.Aux[Anns, R, zipWithFieldIndex.type, Z], writer: Lazy[PBMessageWriter[Z]]): PBMessageWriter[A] = instance { (value: A, out: CodedOutputStream) => val fields = gen.to(value) - val indexedFields = annotations.apply.zipWith(fields)(zipWithIndex) + val indexedFields = annotations.apply.zipWith(fields)(zipWithFieldIndex) writer.value.writeTo(indexedFields, out) } diff --git a/src/main/scala/pbdirect/PBReader.scala b/src/main/scala/pbdirect/PBReader.scala index fb0c62a..4dda57e 100644 --- a/src/main/scala/pbdirect/PBReader.scala +++ b/src/main/scala/pbdirect/PBReader.scala @@ -25,7 +25,8 @@ import java.io.ByteArrayOutputStream import cats.Functor import com.google.protobuf.{CodedInputStream, CodedOutputStream} -import shapeless.{:+:, ::, CNil, Coproduct, Generic, HList, HNil, Inl, Inr, Lazy} +import shapeless._ +import shapeless.ops.hlist._ import enumeratum.values.{IntEnum, IntEnumEntry} import scala.util.Try @@ -33,6 +34,29 @@ import scala.util.Try trait PBReader[A] { def read(input: CodedInputStream): A } + +trait PBProductReader[R <: HList, I <: HList] { + def read(indices: I, bytes: Array[Byte]): R +} +object PBProductReader { + def instance[R <: HList, I <: HList](f: (I, Array[Byte]) => R): PBProductReader[R, I] = + new PBProductReader[R, I] { + def read(indices: I, bytes: Array[Byte]): R = f(indices, bytes) + } + implicit val hnilProductReader: PBProductReader[HNil, HNil] = PBProductReader.instance { + (indices: HNil, bytes: Array[Byte]) => + HNil + } + implicit def consProductReader[H, T <: HList, IT <: HList]( + implicit + headParser: PBParser[H], + tail: Lazy[PBProductReader[T, IT]]): PBProductReader[H :: T, FieldIndex :: IT] = + PBProductReader.instance { (indices: FieldIndex :: IT, bytes: Array[Byte]) => + headParser.parse(indices.head.value, bytes) :: tail.value.read(indices.tail, bytes) + } + +} + trait LowerPriorityPBReaderImplicits { def instance[A](f: CodedInputStream => A): PBReader[A] = new PBReader[A] { @@ -53,14 +77,24 @@ trait LowerPriorityPBReaderImplicits { gen.from(repr.value.parse(1, out.toByteArray)) } } + trait PBReaderImplicits extends LowerPriorityPBReaderImplicits { - implicit def prodReader[A, R <: HList]( + object collectFieldIndices extends Poly1 { + implicit val defaultCase = at[Some[pbIndex]] { + case Some(annotation) => FieldIndex(annotation.value) + } + } + + implicit def prodReader[A, R <: HList, Anns <: HList, I <: HList]( implicit gen: Generic.Aux[A, R], - repr: Lazy[PBParser[R]]): PBReader[A] = instance { (input: CodedInputStream) => - val bytes = input.readByteArray() - gen.from(repr.value.parse(1, bytes)) + annotations: Annotations.Aux[pbIndex, A, Anns], + indices: Mapper.Aux[collectFieldIndices.type, Anns, I], + reader: Lazy[PBProductReader[R, I]]): PBReader[A] = instance { (input: CodedInputStream) => + val fieldIndices = annotations.apply.map(collectFieldIndices) + val bytes = input.readByteArray() + gen.from(reader.value.read(fieldIndices, bytes)) } implicit def enumReader[A]( @@ -83,6 +117,14 @@ trait PBReaderImplicits extends LowerPriorityPBReaderImplicits { enum: IntEnum[E]): PBReader[E] = instance { (input: CodedInputStream) => enum.withValue(reader.read(input)) } + implicit def keyValuePairReader[K, V]( + implicit keyParser: PBParser[K], + valueParser: PBParser[V]): PBReader[(K, V)] = instance { (input: CodedInputStream) => + val bytes = input.readByteArray() + val key = keyParser.parse(1, bytes) + val value = valueParser.parse(2, bytes) + (key, value) + } } object PBReader extends PBReaderImplicits { implicit object BooleanReader$ extends PBReader[Boolean] { @@ -133,15 +175,6 @@ trait LowPriorityPBParserImplicits { def instance[A](f: (Int, Array[Byte]) => A): PBParser[A] = new PBParser[A] { override def parse(index: Int, bytes: Array[Byte]): A = f(index, bytes) } - implicit val hnilParser: PBParser[HNil] = instance { (index: Int, bytes: Array[Byte]) => - HNil - } - implicit def consParser[H, T <: HList]( - implicit - head: PBParser[H], - tail: Lazy[PBParser[T]]): PBParser[H :: T] = instance { (index: Int, bytes: Array[Byte]) => - head.parse(index, bytes) :: tail.value.parse(index + 1, bytes) - } implicit val cnilParser: PBParser[CNil] = instance { (index: Int, bytes: Array[Byte]) => throw new UnsupportedOperationException("Can't read CNil") diff --git a/src/test/scala/pbdirect/PBReaderSpec.scala b/src/test/scala/pbdirect/PBReaderSpec.scala index 79f7f82..360fd85 100644 --- a/src/test/scala/pbdirect/PBReaderSpec.scala +++ b/src/test/scala/pbdirect/PBReaderSpec.scala @@ -29,47 +29,47 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { "PBReader" should { "read a Boolean from Protobuf" in { - case class BooleanMessage(value: Option[Boolean]) + case class BooleanMessage(@pbIndex(1) value: Option[Boolean]) val bytes = Array[Byte](8, 1) bytes.pbTo[BooleanMessage] shouldBe BooleanMessage(Some(true)) } "read a Byte from Protobuf" in { - case class ByteMessage(value: Option[Byte]) + case class ByteMessage(@pbIndex(1) value: Option[Byte]) val bytes = Array[Byte](8, 32) bytes.pbTo[ByteMessage] shouldBe ByteMessage(Some(32)) } "read a Short from Protobuf" in { - case class ShortMessage(value: Option[Short]) + case class ShortMessage(@pbIndex(1) value: Option[Short]) val bytes = Array[Byte](8, -1, 63) bytes.pbTo[ShortMessage] shouldBe ShortMessage(Some(8191)) } "read an Int from Protobuf" in { - case class IntMessage(value: Option[Int]) + case class IntMessage(@pbIndex(1) value: Option[Int]) val bytes = Array[Byte](8, 5) bytes.pbTo[IntMessage] shouldBe IntMessage(Some(5)) } "read a Long from Protobuf" in { - case class LongMessage(value: Option[Long]) + case class LongMessage(@pbIndex(1) value: Option[Long]) val bytes = Array[Byte](8, -128, -128, -128, -128, 8) bytes.pbTo[LongMessage] shouldBe LongMessage(Some(Int.MaxValue.toLong + 1)) } "read a Float from Protobuf" in { - case class FloatMessage(value: Option[Float]) + case class FloatMessage(@pbIndex(1) value: Option[Float]) val bytes = Array[Byte](13, -51, -52, 76, 62) bytes.pbTo[FloatMessage] shouldBe FloatMessage(Some(0.2F)) } "read a Double from Protobuf" in { - case class DoubleMessage(value: Option[Double]) + case class DoubleMessage(@pbIndex(1) value: Option[Double]) val bytes = Array[Byte](9, -107, 100, 121, -31, 127, -3, -75, 61) bytes.pbTo[DoubleMessage] shouldBe DoubleMessage(Some(0.00000000002D)) } "read a String from Protobuf" in { - case class StringMessage(value: Option[String]) + case class StringMessage(@pbIndex(1) value: Option[String]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111) bytes.pbTo[StringMessage] shouldBe StringMessage(Some("Hello")) } "read bytes from Protobuf" in { - case class BytesMessage(value: Option[Array[Byte]]) + case class BytesMessage(@pbIndex(1) value: Option[Array[Byte]]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111) bytes.pbTo[BytesMessage].value.get shouldBe Array[Byte](72, 101, 108, 108, 111) } @@ -79,7 +79,7 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { } val bytesA = Array[Byte](8, 0) val bytesB = Array[Byte](8, 1) - case class GradeMessage(value: Option[Grade.Value]) + case class GradeMessage(@pbIndex(1) value: Option[Grade.Value]) bytesA.pbTo[GradeMessage] shouldBe GradeMessage(Some(Grade.GradeA)) bytesB.pbTo[GradeMessage] shouldBe GradeMessage(Some(Grade.GradeB)) } @@ -87,19 +87,19 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { sealed trait Grade extends Pos case object GradeA extends Grade with Pos._0 case object GradeB extends Grade with Pos._1 - case class GradeMessage(value: Option[Grade]) + case class GradeMessage(@pbIndex(1) value: Option[Grade]) val bytesA = Array[Byte](8, 0) val bytesB = Array[Byte](8, 1) bytesA.pbTo[GradeMessage] shouldBe GradeMessage(Some(GradeA)) bytesB.pbTo[GradeMessage] shouldBe GradeMessage(Some(GradeB)) } "read an enumeratum IntEnumEntry from Protobuf" in { - case class QualityMessage(quality: Quality) + case class QualityMessage(@pbIndex(1) quality: Quality) val bytes = Array[Byte](8, 3) bytes.pbTo[QualityMessage] shouldBe QualityMessage(Quality.OK) } "read a required field from Protobuf" in { - case class RequiredMessage(value: Int) + case class RequiredMessage(@pbIndex(1) value: Int) val bytes = Array[Byte](8, 5) bytes.pbTo[RequiredMessage] shouldBe RequiredMessage(5) } @@ -109,53 +109,60 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { bytes.pbTo[EmptyMessage] shouldBe EmptyMessage() } "read a multi-field message from Protobuf" in { - case class MultiMessage(text: Option[String], number: Option[Int]) + case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) bytes.pbTo[MultiMessage] shouldBe MultiMessage(Some("Hello"), Some(3)) } "read a message with missing field from Protobuf" in { - case class MissingMessage(text: Option[String], number: Option[Int]) + case class MissingMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111) bytes.pbTo[MissingMessage] shouldBe MissingMessage(Some("Hello"), None) } "read a message with repeated field from Protobuf" in { - case class RepeatedMessage(values: List[Int]) + case class RepeatedMessage(@pbIndex(1) values: List[Int]) val bytes = Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) bytes.pbTo[RepeatedMessage] shouldBe RepeatedMessage(1 :: 2 :: 3 :: 4 :: Nil) } "read a message with Seq from Protobuf" in { - case class RepeatedMessage(values: Seq[Int]) + case class RepeatedMessage(@pbIndex(1) values: Seq[Int]) val bytes = Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) bytes.pbTo[RepeatedMessage] shouldBe RepeatedMessage(Seq(1, 2, 3, 4)) } "read a Map from Protobuf" in { - case class MapMessage(values: Map[Int, String]) + case class MapMessage(@pbIndex(1) values: Map[Int, String]) val bytes = Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, 119, 111) bytes.pbTo[MapMessage] shouldBe MapMessage(Map(1 -> "one", 2 -> "two")) } "read a scala.collection.Map from Protobuf" in { - case class MapMessage(values: collection.Map[Int, String]) + case class MapMessage(@pbIndex(1) values: collection.Map[Int, String]) val bytes = Array[Byte](10, 7, 8, 1, 18, 3, 111, 110, 101, 10, 7, 8, 2, 18, 3, 116, 119, 111) bytes.pbTo[MapMessage] shouldBe MapMessage(collection.Map(1 -> "one", 2 -> "two")) } "read a nested message from Protobuf" in { - case class InnerMessage(value: Option[Int]) - case class OuterMessage(text: Option[String], inner: Option[InnerMessage]) + case class InnerMessage(@pbIndex(1) value: Option[Int]) + case class OuterMessage( + @pbIndex(1) text: Option[String], + @pbIndex(2) inner: Option[InnerMessage]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111, 18, 2, 8, 11) bytes.pbTo[OuterMessage] shouldBe OuterMessage(Some("Hello"), Some(InnerMessage(Some(11)))) } "read a sealed trait from Protobuf" in { sealed trait Message - case class IntMessage(value: Option[Int]) extends Message - case class StringMessage(value: Option[String]) extends Message + case class IntMessage(@pbIndex(1) value: Option[Int]) extends Message + case class StringMessage(@pbIndex(1) value: Option[String]) extends Message val intBytes = Array[Byte](8, 5) val stringBytes = Array[Byte](10, 5, 72, 101, 108, 108, 111) intBytes.pbTo[Message] shouldBe IntMessage(Some(5)) stringBytes.pbTo[Message] shouldBe StringMessage(Some("Hello")) } "read a message with repeated nested message from Protobuf" in { - case class Metric(name: String, service: String, node: String, value: Float, count: Int) - case class Metrics(metrics: List[Metric]) + case class Metric( + @pbIndex(1) name: String, + @pbIndex(2) service: String, + @pbIndex(3) node: String, + @pbIndex(4) value: Float, + @pbIndex(5) count: Int) + case class Metrics(@pbIndex(1) metrics: List[Metric]) val message = Metrics( Metric("metric", "microservices", "node", 12F, 12345) :: Nil ) @@ -168,10 +175,20 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { import java.time.Instant import cats.syntax.functor._ implicit val instantReader: PBReader[Instant] = PBReader[Long].map(Instant.ofEpochMilli) - case class Message(instant: Instant) + case class Message(@pbIndex(1) instant: Instant) val instant = Instant.ofEpochMilli(1499411227777L) Array[Byte](8, -127, -55, -2, -34, -47, 43).pbTo[Message] shouldBe Message(instant) } + "read a message with fields out-of-order from Protobuf" in { + case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) + val bytes = Array[Byte](16, 3, 10, 5, 72, 101, 108, 108, 111) + bytes.pbTo[MultiMessage] shouldBe MultiMessage(Some("Hello"), Some(3)) + } + "read a message with non-sequential field indices from Protobuf" in { + case class MultiMessage(@pbIndex(1) text: Option[String], @pbIndex(3) number: Option[Int]) + val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111, 24, 3) + bytes.pbTo[MultiMessage] shouldBe MultiMessage(Some("Hello"), Some(3)) + } } } From e1e7c6b28c039352cd8f3752fb83e7519f28224a Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Tue, 17 Dec 2019 15:56:19 +0000 Subject: [PATCH 07/11] Make pbIndex annotation support multiple indices This is to support encoding of shapeless Coproducts as protobuf 'oneof' fields in future. (That functionality is not implemented yet.) --- src/main/scala/pbdirect/FieldIndex.scala | 6 ++++-- src/main/scala/pbdirect/PBMessageWriter.scala | 7 +++++-- src/main/scala/pbdirect/PBReader.scala | 4 ++-- src/main/scala/pbdirect/pbIndex.scala | 5 +++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/scala/pbdirect/FieldIndex.scala b/src/main/scala/pbdirect/FieldIndex.scala index ab44ffb..2b36bc7 100644 --- a/src/main/scala/pbdirect/FieldIndex.scala +++ b/src/main/scala/pbdirect/FieldIndex.scala @@ -22,6 +22,8 @@ package pbdirect /** - * A protobuf index, also known as the field number + * A protobuf index, also known as the field number. + * It holds a list of indices in order to support 'oneof' fields, + * which are encoded as shapeless Coproducts and have a different index for each branch. */ -private[pbdirect] final case class FieldIndex(value: Int) +private[pbdirect] final case class FieldIndex(values: List[Int]) diff --git a/src/main/scala/pbdirect/PBMessageWriter.scala b/src/main/scala/pbdirect/PBMessageWriter.scala index b7b6598..89fe0e9 100644 --- a/src/main/scala/pbdirect/PBMessageWriter.scala +++ b/src/main/scala/pbdirect/PBMessageWriter.scala @@ -42,13 +42,16 @@ trait PBMessageWriterImplicits { implicit head: PBFieldWriter[H], tail: Lazy[PBMessageWriter[T]]): PBMessageWriter[(FieldIndex, H) :: T] = instance { (value: (FieldIndex, H) :: T, out: CodedOutputStream) => - head.writeTo(value.head._1.value, value.head._2, out) + val headIndex = value.head._1.values.head + val headValue = value.head._2 + head.writeTo(headIndex, headValue, out) tail.value.writeTo(value.tail, out) } object zipWithFieldIndex extends Poly2 { implicit def defaultCase[T] = at[Some[pbIndex], T] { - case (Some(annotation), value) => (FieldIndex(annotation.value), value) + case (Some(annotation), value) => + (FieldIndex(annotation.first :: annotation.more.toList), value) } } diff --git a/src/main/scala/pbdirect/PBReader.scala b/src/main/scala/pbdirect/PBReader.scala index 4dda57e..215253b 100644 --- a/src/main/scala/pbdirect/PBReader.scala +++ b/src/main/scala/pbdirect/PBReader.scala @@ -52,7 +52,7 @@ object PBProductReader { headParser: PBParser[H], tail: Lazy[PBProductReader[T, IT]]): PBProductReader[H :: T, FieldIndex :: IT] = PBProductReader.instance { (indices: FieldIndex :: IT, bytes: Array[Byte]) => - headParser.parse(indices.head.value, bytes) :: tail.value.read(indices.tail, bytes) + headParser.parse(indices.head.values.head, bytes) :: tail.value.read(indices.tail, bytes) } } @@ -82,7 +82,7 @@ trait PBReaderImplicits extends LowerPriorityPBReaderImplicits { object collectFieldIndices extends Poly1 { implicit val defaultCase = at[Some[pbIndex]] { - case Some(annotation) => FieldIndex(annotation.value) + case Some(annotation) => FieldIndex(annotation.first :: annotation.more.toList) } } diff --git a/src/main/scala/pbdirect/pbIndex.scala b/src/main/scala/pbdirect/pbIndex.scala index c80f475..c760607 100644 --- a/src/main/scala/pbdirect/pbIndex.scala +++ b/src/main/scala/pbdirect/pbIndex.scala @@ -30,8 +30,9 @@ package pbdirect * {{{ * case class MyMessage( * @pbIndex(1) a: Int, - * @pbIndex(2) b: String + * @pbIndex(2) b: String, + * @pbIndex(3, 4) c: String :+: Boolean :+: CNil // oneof field * ) * }}} */ -case class pbIndex(value: Int) +case class pbIndex(first: Int, more: Int*) From fca8e480c2ea501f0fe18fce5a36064d273c8b2a Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Tue, 17 Dec 2019 17:32:01 +0000 Subject: [PATCH 08/11] Add property-based tests for protobuf round-trip This at least gives us some confidence that the library is consistent with itself, even if it's not yet compliant with the protobuf spec. --- project/ProjectPlugin.scala | 28 ++++--- src/test/scala/pbdirect/RoundTripSpec.scala | 93 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 src/test/scala/pbdirect/RoundTripSpec.scala diff --git a/project/ProjectPlugin.scala b/project/ProjectPlugin.scala index 372ecc0..ab2a25c 100644 --- a/project/ProjectPlugin.scala +++ b/project/ProjectPlugin.scala @@ -18,13 +18,15 @@ object ProjectPlugin extends AutoPlugin { object autoImport { lazy val V = new { - val cats: String = "2.0.0" - val protobuf: String = "3.11.1" - val scala211: String = "2.11.12" - val scala212: String = "2.12.10" - val scalaTest: String = "3.1.0" - val shapeless: String = "2.3.3" - val enumeratum: String = "1.5.14" + val cats: String = "2.0.0" + val protobuf: String = "3.11.1" + val scala211: String = "2.11.12" + val scala212: String = "2.12.10" + val shapeless: String = "2.3.3" + val enumeratum: String = "1.5.14" + val scalaTest: String = "3.1.0" + val scalatestScalacheck: String = "3.1.0.0" + val scalacheckShapeless: String = "1.2.3" } } @@ -58,11 +60,13 @@ object ProjectPlugin extends AutoPlugin { scalaVersion := V.scala212, crossScalaVersions := Seq(scalaVersion.value, V.scala211), libraryDependencies ++= Seq( - "com.chuusai" %% "shapeless" % V.shapeless, - "org.typelevel" %% "cats-core" % V.cats, - "com.google.protobuf" % "protobuf-java" % V.protobuf, - "com.beachape" %% "enumeratum" % V.enumeratum, - "org.scalatest" %% "scalatest" % V.scalaTest % Test + "com.chuusai" %% "shapeless" % V.shapeless, + "org.typelevel" %% "cats-core" % V.cats, + "com.google.protobuf" % "protobuf-java" % V.protobuf, + "com.beachape" %% "enumeratum" % V.enumeratum, + "org.scalatest" %% "scalatest" % V.scalaTest % Test, + "org.scalatestplus" %% "scalacheck-1-14" % V.scalatestScalacheck % Test, + "com.github.alexarchambault" %% "scalacheck-shapeless_1.14" % V.scalacheckShapeless % Test ), orgScriptTaskListSetting := List( (clean in Global).asRunnableItemFull, diff --git a/src/test/scala/pbdirect/RoundTripSpec.scala b/src/test/scala/pbdirect/RoundTripSpec.scala new file mode 100644 index 0000000..f8f65e2 --- /dev/null +++ b/src/test/scala/pbdirect/RoundTripSpec.scala @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2019 Beyond the lines + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package pbdirect + +import org.scalatest.flatspec._ +import org.scalatestplus.scalacheck.Checkers +import org.scalacheck.ScalacheckShapeless._ +import org.scalacheck.Prop._ +import enumeratum.values.IntEnumEntry +import enumeratum.values.IntEnum + +sealed abstract class Status(val value: Int) extends IntEnumEntry +object Status extends IntEnum[Status] { + case object Running extends Status(0) + case object Stopped extends Status(5) + val values = findValues +} + +class RoundTripSpec extends AnyFlatSpec with Checkers { + + implicit override val generatorDrivenConfig = + PropertyCheckConfiguration(minSuccessful = 500) + + "round trip to protobuf and back" should "be an identity" in check { + forAllNoShrink { (message: MessageThree) => + val roundtripped = message.toPB.pbTo[MessageThree] + + // arrays use reference equality :( + s"message after roundtrip = $roundtripped" |: all( + "byte array" |: roundtripped.bytes.toList === message.bytes.toList, + "rest of message" |: roundtripped.copy(bytes = message.bytes) === message + ) + } + } + + case class EmptyMessage() + + case class MessageOne( + @pbIndex(1) optionalEmpty: Option[EmptyMessage], + @pbIndex(2) boolean: Boolean, + @pbIndex(3) repeatedFloat: List[Float] + ) + + case class MessageTwo( + @pbIndex(5) int: Int, + @pbIndex(10) string: String, + @pbIndex(15) emptyMessage: EmptyMessage, + @pbIndex(20) nestedMessage: MessageOne + ) + + case class MessageThree( + @pbIndex(2) int: Int, + @pbIndex(4) optionalInt: Option[Int], + @pbIndex(6) boolean: Boolean, + @pbIndex(8) optionalBoolean: Option[Boolean], + @pbIndex(10) double: Double, + @pbIndex(12) float: Float, + @pbIndex(14) long: Long, + @pbIndex(16) string: String, + @pbIndex(18) repeatedString: List[String], + @pbIndex(20) enum: Status, + @pbIndex(22) repeatedEnum: List[Status], + @pbIndex(24) optionalEnum: Option[Status], + @pbIndex(26) byte: Byte, + @pbIndex(28) short: Short, + @pbIndex(30) bytes: Array[Byte], + @pbIndex(32) intStringMap: Map[Int, String], + @pbIndex(34) stringBoolListMap: Map[String, List[Boolean]], + @pbIndex(36) nestedMessage: MessageTwo, + @pbIndex(38) repeatedNestedMessage: List[MessageTwo], + @pbIndex(40) intMessageMap: Map[Int, MessageTwo] + ) + +} From c08e3b844f983b13159c498ec97e8f963cfb7ea0 Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Wed, 18 Dec 2019 15:03:14 +0000 Subject: [PATCH 09/11] Update readme Also fixed incorrect byte array contents in example --- README.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index baf59e6..6cdfb1a 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,9 @@ PBDirect depends on: In order to use PBDirect you need to import the following: ```scala -import cats.instances.list._ -import cats.instances.option._ import pbdirect._ ``` -*Note*: It's not recommended to use `import cats.instances.all._` as it may cause issues with implicit resolution. - ## Example ### Schema definition @@ -54,9 +50,9 @@ PBDirect serialises case classes into protobuf and there is no need for a .proto ```scala case class MyMessage( - id: Option[Int], - text: Option[String], - numbers: List[Int] + @pbIndex(1) id: Option[Int], + @pbIndex(3) text: Option[String], + @pbIndex(5) numbers: List[Int] ) ``` @@ -65,12 +61,12 @@ is equivalent to the following protobuf definition: ```protobuf message MyMessage { optional int32 id = 1; - optional string text = 2; - repeated int32 numbers = 3; + optional string text = 3; + repeated int32 numbers = 5; } ``` -The field numbers correspond to the order of the fields inside the case class. +Note that the `@pbIndex` annotation is mandatory on all fields of a message. ### Serialization @@ -83,6 +79,7 @@ val message = MyMessage( numbers = List(1, 2, 3, 4) ) val bytes = message.toPB +// bytes: Array(8, 123, 26, 5, 72, 101, 108, 108, 111, 40, 1, 40, 2, 40, 3, 40, 4) ``` ### Deserialization @@ -91,8 +88,9 @@ Deserializing bytes into a case class is also straight forward. You only need to This method is added implicitly on all `Array[Byte]` by importing `pbdirect._`. ```scala -val bytes: Array[Byte] = Array[Byte](8, 123, 18, 5, 72, 101, 108, 108, 111, 24, 1, 32, 2, 40, 3, 48, 4) +val bytes: Array[Byte] = Array[Byte](8, 123, 26, 5, 72, 101, 108, 108, 111, 40, 1, 40, 2, 40, 3, 40, 4) val message = bytes.pbTo[MyMessage] +// message: MyMessage(Some(123),Some(hello),List(1, 2, 3, 4)) ``` ## Extension @@ -136,4 +134,4 @@ pbdirect is designed and developed by 47 Degrees Copyright (C) 2019 47 Degrees. -[comment]: # (End Copyright) \ No newline at end of file +[comment]: # (End Copyright) From f51539fd5348c142c500f74b083cabebb917b823 Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Wed, 18 Dec 2019 15:17:18 +0000 Subject: [PATCH 10/11] More readme updates --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6cdfb1a..6bd5456 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ And for a writer you simply contramap over it: import java.time.Instant import cats.syntax.contravariant._ -implicit val instantWriter: PBWriter[Instant] = - PBWriter[Long].contramap(_.toEpochMilli) +implicit val instantWriter: PBFieldWriter[Instant] = + PBFieldWriter[Long].contramap(_.toEpochMilli) ) ``` From 22380f38b595109d60589e44f963219b008271cb Mon Sep 17 00:00:00 2001 From: Chris Birchall Date: Thu, 19 Dec 2019 11:09:28 +0000 Subject: [PATCH 11/11] Make the pbIndex annotation optional --- README.md | 25 ++++++++++++++++--- src/main/scala/pbdirect/PBMessageWriter.scala | 21 ++++++++++------ src/main/scala/pbdirect/PBReader.scala | 15 +++++++---- .../scala/pbdirect/PBMessageWriterSpec.scala | 5 ++++ src/test/scala/pbdirect/PBReaderSpec.scala | 5 ++++ 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6bd5456..d7c7884 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ libraryDependencies += "com.47deg" %% "pbdirect" % "0.3.1" ## Dependencies PBDirect depends on: - - [protobuf-java](https://developers.google.com/protocol-buffers/docs/javatutorial) the Protobuf java library (maintained by Google) + - [protobuf-java](https://developers.google.com/protocol-buffers/docs/javatutorial) the Protobuf java library (maintained by Google) - [shapeless](https://github.com/milessabin/shapeless) for the generation of type-class instances - [cats](https://github.com/typelevel/cats) to deal with optional and repeated fields - + ## Usage In order to use PBDirect you need to import the following: @@ -66,7 +66,26 @@ message MyMessage { } ``` -Note that the `@pbIndex` annotation is mandatory on all fields of a message. +Note that the `@pbIndex` annotation is optional. If it is not present, the field's position in the case class is used +as its index. For example, an unannotated case class like: + +```scala +case class MyMessage( + id: Option[Int], + text: Option[String], + numbers: List[Int] +) +``` + +is equivalent to the following protobuf definition: + +```protobuf +message MyMessage { + optional int32 id = 1; + optional string text = 2; + repeated int32 numbers = 3; +} +``` ### Serialization diff --git a/src/main/scala/pbdirect/PBMessageWriter.scala b/src/main/scala/pbdirect/PBMessageWriter.scala index 89fe0e9..f022a67 100644 --- a/src/main/scala/pbdirect/PBMessageWriter.scala +++ b/src/main/scala/pbdirect/PBMessageWriter.scala @@ -24,6 +24,7 @@ package pbdirect import com.google.protobuf.CodedOutputStream import shapeless._ import shapeless.ops.hlist._ +import shapeless.ops.nat._ trait PBMessageWriter[A] { def writeTo(value: A, out: CodedOutputStream): Unit @@ -49,20 +50,26 @@ trait PBMessageWriterImplicits { } object zipWithFieldIndex extends Poly2 { - implicit def defaultCase[T] = at[Some[pbIndex], T] { - case (Some(annotation), value) => + implicit def annotatedCase[T, N <: Nat] = at[Some[pbIndex], (T, N)] { + case (Some(annotation), (value, n)) => (FieldIndex(annotation.first :: annotation.more.toList), value) } + implicit def unannotatedCase[T, N <: Nat](implicit toInt: ToInt[N]) = at[None.type, (T, N)] { + case (None, (value, n)) => + (FieldIndex(List(toInt() + 1)), value) + } } - implicit def prodWriter[A, R <: HList, Anns <: HList, Z <: HList]( + implicit def prodWriter[A, R <: HList, Anns <: HList, ZWI <: HList, ZWFI <: HList]( implicit gen: Generic.Aux[A, R], annotations: Annotations.Aux[pbIndex, A, Anns], - zw: ZipWith.Aux[Anns, R, zipWithFieldIndex.type, Z], - writer: Lazy[PBMessageWriter[Z]]): PBMessageWriter[A] = + zwi: ZipWithIndex.Aux[R, ZWI], + zw: ZipWith.Aux[Anns, ZWI, zipWithFieldIndex.type, ZWFI], + writer: Lazy[PBMessageWriter[ZWFI]]): PBMessageWriter[A] = instance { (value: A, out: CodedOutputStream) => - val fields = gen.to(value) - val indexedFields = annotations.apply.zipWith(fields)(zipWithFieldIndex) + val fields = gen.to(value) + val fieldsWithIndices = fields.zipWithIndex + val indexedFields = annotations.apply.zipWith(fieldsWithIndices)(zipWithFieldIndex) writer.value.writeTo(indexedFields, out) } diff --git a/src/main/scala/pbdirect/PBReader.scala b/src/main/scala/pbdirect/PBReader.scala index 215253b..e366eca 100644 --- a/src/main/scala/pbdirect/PBReader.scala +++ b/src/main/scala/pbdirect/PBReader.scala @@ -27,6 +27,7 @@ import cats.Functor import com.google.protobuf.{CodedInputStream, CodedOutputStream} import shapeless._ import shapeless.ops.hlist._ +import shapeless.ops.nat._ import enumeratum.values.{IntEnum, IntEnumEntry} import scala.util.Try @@ -81,18 +82,22 @@ trait LowerPriorityPBReaderImplicits { trait PBReaderImplicits extends LowerPriorityPBReaderImplicits { object collectFieldIndices extends Poly1 { - implicit val defaultCase = at[Some[pbIndex]] { - case Some(annotation) => FieldIndex(annotation.first :: annotation.more.toList) + implicit def annotatedCase[N <: Nat] = at[(Some[pbIndex], N)] { + case (Some(annotation), _) => FieldIndex(annotation.first :: annotation.more.toList) + } + implicit def unannotatedCase[N <: Nat](implicit toInt: ToInt[N]) = at[(None.type, N)] { + case (None, n) => FieldIndex(List(toInt() + 1)) } } - implicit def prodReader[A, R <: HList, Anns <: HList, I <: HList]( + implicit def prodReader[A, R <: HList, Anns <: HList, ZWI <: HList, I <: HList]( implicit gen: Generic.Aux[A, R], annotations: Annotations.Aux[pbIndex, A, Anns], - indices: Mapper.Aux[collectFieldIndices.type, Anns, I], + zwi: ZipWithIndex.Aux[Anns, ZWI], + indices: Mapper.Aux[collectFieldIndices.type, ZWI, I], reader: Lazy[PBProductReader[R, I]]): PBReader[A] = instance { (input: CodedInputStream) => - val fieldIndices = annotations.apply.map(collectFieldIndices) + val fieldIndices = annotations.apply.zipWithIndex.map(collectFieldIndices) val bytes = input.readByteArray() gen.from(reader.value.read(fieldIndices, bytes)) } diff --git a/src/test/scala/pbdirect/PBMessageWriterSpec.scala b/src/test/scala/pbdirect/PBMessageWriterSpec.scala index 2a7f3fd..be1761c 100644 --- a/src/test/scala/pbdirect/PBMessageWriterSpec.scala +++ b/src/test/scala/pbdirect/PBMessageWriterSpec.scala @@ -56,6 +56,11 @@ class PBMessageWriterSpec extends AnyWordSpecLike with Matchers { val message = MultiMessage(Some("Hello"), Some(3)) message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) } + "write a multi-field message without pbIndex annotations to Protobuf" in { + case class MultiMessage(text: Option[String], number: Option[Int]) + val message = MultiMessage(Some("Hello"), Some(3)) + message.toPB shouldBe Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) + } "write a message with repeated field to Protobuf" in { case class RepeatedMessage(@pbIndex(1) values: List[Int]) val message = RepeatedMessage(1 :: 2 :: 3 :: 4 :: Nil) diff --git a/src/test/scala/pbdirect/PBReaderSpec.scala b/src/test/scala/pbdirect/PBReaderSpec.scala index 360fd85..f5bc5ed 100644 --- a/src/test/scala/pbdirect/PBReaderSpec.scala +++ b/src/test/scala/pbdirect/PBReaderSpec.scala @@ -113,6 +113,11 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) bytes.pbTo[MultiMessage] shouldBe MultiMessage(Some("Hello"), Some(3)) } + "read a multi-field message without pbIndex annotations from Protobuf" in { + case class MultiMessage(text: Option[String], number: Option[Int]) + val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111, 16, 3) + bytes.pbTo[MultiMessage] shouldBe MultiMessage(Some("Hello"), Some(3)) + } "read a message with missing field from Protobuf" in { case class MissingMessage(@pbIndex(1) text: Option[String], @pbIndex(2) number: Option[Int]) val bytes = Array[Byte](10, 5, 72, 101, 108, 108, 111)