diff --git a/README.md b/README.md index baf59e6..d7c7884 100644 --- a/README.md +++ b/README.md @@ -30,22 +30,18 @@ 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: ```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,8 +50,29 @@ PBDirect serialises case classes into protobuf and there is no need for a .proto ```scala case class MyMessage( - id: Option[Int], - text: Option[String], + @pbIndex(1) id: Option[Int], + @pbIndex(3) text: Option[String], + @pbIndex(5) numbers: List[Int] +) +``` + +is equivalent to the following protobuf definition: + +```protobuf +message MyMessage { + optional int32 id = 1; + optional string text = 3; + repeated int32 numbers = 5; +} +``` + +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] ) ``` @@ -70,8 +87,6 @@ message MyMessage { } ``` -The field numbers correspond to the order of the fields inside the case class. - ### Serialization You only need to call the `toPB` method on your case class. This method is implicitly added with `import pbdirect._`. @@ -83,6 +98,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 +107,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 @@ -124,8 +141,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) ) ``` @@ -136,4 +153,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) 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/main/scala/pbdirect/FieldIndex.scala b/src/main/scala/pbdirect/FieldIndex.scala new file mode 100644 index 0000000..2b36bc7 --- /dev/null +++ b/src/main/scala/pbdirect/FieldIndex.scala @@ -0,0 +1,29 @@ +/* + * 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. + * 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(values: List[Int]) diff --git a/src/main/scala/pbdirect/PBWriter.scala b/src/main/scala/pbdirect/PBFieldWriter.scala similarity index 55% rename from src/main/scala/pbdirect/PBWriter.scala rename to src/main/scala/pbdirect/PBFieldWriter.scala index 83b50ac..a12559f 100644 --- a/src/main/scala/pbdirect/PBWriter.scala +++ b/src/main/scala/pbdirect/PBFieldWriter.scala @@ -25,140 +25,133 @@ import java.io.ByteArrayOutputStream import cats.{Contravariant, Functor} import com.google.protobuf.CodedOutputStream -import shapeless.{:+:, ::, CNil, Coproduct, Generic, HList, HNil, Inl, Inr, Lazy} import enumeratum.values.IntEnumEntry -trait PBWriter[A] { +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] { +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 val hnilWriter: PBWriter[HNil] = instance { (_: Int, _: 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 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 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 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 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 PBWriterImplicits extends LowPriorityPBWriterImplicits { - implicit object BooleanWriter extends PBWriter[Boolean] { +trait PBFieldWriterImplicits extends LowPriorityPBFieldWriterImplicits { + 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]] = - 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], + 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: 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 PBFieldWriter extends PBFieldWriterImplicits { + def apply[A: PBFieldWriter]: PBFieldWriter[A] = implicitly } 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/PBMessageWriter.scala b/src/main/scala/pbdirect/PBMessageWriter.scala new file mode 100644 index 0000000..f022a67 --- /dev/null +++ b/src/main/scala/pbdirect/PBMessageWriter.scala @@ -0,0 +1,99 @@ +/* + * 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._ +import shapeless.ops.nat._ + +trait PBMessageWriter[A] { + def writeTo(value: A, out: CodedOutputStream): Unit +} + +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) => + 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 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, ZWI <: HList, ZWFI <: HList]( + implicit gen: Generic.Aux[A, R], + annotations: Annotations.Aux[pbIndex, A, Anns], + 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 fieldsWithIndices = fields.zipWithIndex + val indexedFields = annotations.apply.zipWith(fieldsWithIndices)(zipWithFieldIndex) + 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/main/scala/pbdirect/PBReader.scala b/src/main/scala/pbdirect/PBReader.scala index fb0c62a..e366eca 100644 --- a/src/main/scala/pbdirect/PBReader.scala +++ b/src/main/scala/pbdirect/PBReader.scala @@ -25,7 +25,9 @@ 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 shapeless.ops.nat._ import enumeratum.values.{IntEnum, IntEnumEntry} import scala.util.Try @@ -33,6 +35,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.values.head, bytes) :: tail.value.read(indices.tail, bytes) + } + +} + trait LowerPriorityPBReaderImplicits { def instance[A](f: CodedInputStream => A): PBReader[A] = new PBReader[A] { @@ -53,14 +78,28 @@ 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 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, ZWI <: 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], + 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.zipWithIndex.map(collectFieldIndices) + val bytes = input.readByteArray() + gen.from(reader.value.read(fieldIndices, bytes)) } implicit def enumReader[A]( @@ -83,6 +122,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 +180,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/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..c760607 --- /dev/null +++ b/src/main/scala/pbdirect/pbIndex.scala @@ -0,0 +1,38 @@ +/* + * 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, + * @pbIndex(3, 4) c: String :+: Boolean :+: CNil // oneof field + * ) + * }}} + */ +case class pbIndex(first: Int, more: Int*) 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/PBFormatSpec.scala b/src/test/scala/pbdirect/PBFormatSpec.scala index 27a3bc8..1b8aafa 100644 --- a/src/test/scala/pbdirect/PBFormatSpec.scala +++ b/src/test/scala/pbdirect/PBFormatSpec.scala @@ -33,15 +33,14 @@ 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) bytes.pbTo[Message] shouldBe Message(instant) } "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/PBMessageWriterSpec.scala b/src/test/scala/pbdirect/PBMessageWriterSpec.scala new file mode 100644 index 0000000..be1761c --- /dev/null +++ b/src/test/scala/pbdirect/PBMessageWriterSpec.scala @@ -0,0 +1,126 @@ +/* + * 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 + +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 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 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 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 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) + message.toPB shouldBe Array[Byte](8, 1, 8, 2, 8, 3, 8, 4) + } + "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], + @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 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], + @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) + } + "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) + } + } +} diff --git a/src/test/scala/pbdirect/PBReaderSpec.scala b/src/test/scala/pbdirect/PBReaderSpec.scala index 79f7f82..f5bc5ed 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,65 @@ class PBReaderSpec extends AnyWordSpecLike with Matchers { bytes.pbTo[EmptyMessage] shouldBe EmptyMessage() } "read a multi-field message from Protobuf" in { + 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 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(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 +180,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)) + } } } diff --git a/src/test/scala/pbdirect/PBWriterSpec.scala b/src/test/scala/pbdirect/PBWriterSpec.scala deleted file mode 100644 index 12bd4be..0000000 --- a/src/test/scala/pbdirect/PBWriterSpec.scala +++ /dev/null @@ -1,192 +0,0 @@ -/* - * 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 cats.instances.option._ -import cats.instances.list._ -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpecLike - -class PBWriterSpec extends AnyWordSpecLike with Matchers { - "PBWriter" should { - "write a Boolean to Protobuf" in { - case class BooleanMessage(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]) - val message = ByteMessage(Some(32)) - message.toPB shouldBe Array[Byte](8, 32) - } - "write a Short to Protobuf" in { - case class ShortMessage(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]) - val message = IntMessage(Some(5)) - message.toPB shouldBe Array[Byte](8, 5) - } - "write a Long to Protobuf" in { - case class LongMessage(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]) - 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]) - 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]) - 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]) - 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(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(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(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) - 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 multi-field message 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 missing field to Protobuf" in { - case class MissingMessage(text: Option[String], 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]) - 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]) - 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) - } - "write a nested message to Protobuf" in { - case class InnerMessage(value: Option[Int]) - case class OuterMessage(text: Option[String], 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) - } - "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) - Message(instant).toPB shouldBe Array[Byte](8, -127, -55, -2, -34, -47, 43) - } - } -} 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] + ) + +}