Encode & decode protobuf messages

Encoders and Decoders for protobuf Messages can be created in three different ways:

  • Fully Automatic derivation: encoders & decoders will automatically be derived from your models. All the fields in the proto schema must be numbered consecutively starting from one.
  • Semi-automatic derivation (recommended): you have to derive an encoder/decoder for each case class involved in protobuf serialization. You can derive with automatic field numbering, or configure a specific mapping.
  • Hand-crafted encoders/decoders: you have the freedom to compose your own encoders/decoders using existing bricks.

Semi-automatic derivation

Usage

Suppose that we need to work with the following proto3 schema:

message Student {
  int64 id = 1;
  string name = 2;
  //uint32 age = 3; -- Deprecated in favor of birthDate
  string birthDate = 4;
  int32 libraryCard = 7;
  repeated Course courses = 8;
}

message Course {
  string name = 1;
  double price = 2;
  bool external = 3;
}

Note that the Student schema has certainly evolved many times, because the numbering is not sequential, unlike the Course, which is numbered consecutively.

In your application, we want to use these types:

import cats.data.NonEmptyList

case class Course(name: String, price: Double) // we doesn't care about the `external` field
case class StudentId(value: Long) extends AnyVal
case class Student(id: StudentId, name: String, birthDate: String, courses: NonEmptyList[Course])

You can summon encoders and decoders for these models with deriveEncoder and deriveDecoder, respectively :

import io.protoless.generic.semiauto._
import shapeless.{::, HNil, Nat}

// Summon automatic decoder for course (note that we will read a partial representation of the message Course)
implicit val courseDecoder = deriveDecoder[Course]

// Summon a decoder for student with a custom mapping between fields (studentId: 1, name: 2, birthDate: 4, courses: 8)
implicit val studentDecoder = deriveDecoder[Student, Nat._1 :: Nat._2 :: Nat._4 :: Nat._8 :: HNil]

implicit val courseEncoder = deriveEncoder[Course]
implicit val studentEncoder = deriveEncoder[Student, Nat._1 :: Nat._2 :: Nat._4 :: Nat._8 :: HNil] // tips: move the fields definition in a type alias

You’re ready to encode and decode protobuf messages:

val student = Student(StudentId(4815162342L), "Kate", "1977-06-21", NonEmptyList.of(
    Course("airline pilot", 8150),
    Course("US marshall", 4912)
))
// student: Student = Student(StudentId(4815162342),Kate,1977-06-21,NonEmptyList(Course(airline pilot,8150.0), Course(US marshall,4912.0)))

val bytes = studentEncoder.encodeAsBytes(student)
// bytes: Array[Byte] = Array(8, -26, -105, -122, -8, 17, 18, 4, 75, 97, 116, 101, 34, 10, 49, 57, 55, 55, 45, 48, 54, 45, 50, 49, 66, 24, 10, 13, 97, 105, 114, 108, 105, 110, 101, 32, 112, 105, 108, 111, 116, 17, 0, 0, 0, 0, 0, -42, -65, 64, 66, 22, 10, 11, 85, 83, 32, 109, 97, 114, 115, 104, 97, 108, 108, 17, 0, 0, 0, 0, 0, 48, -77, 64)

studentDecoder.decode(bytes)
// res6: io.protoless.messages.Decoder.Result[Student] = Right(Student(StudentId(4815162342),Kate,1977-06-21,NonEmptyList(Course(airline pilot,8150.0), Course(US marshall,4912.0))))

You can also use the syntaxic sugar .as[A] and .asProtobufBytes to replace the explicit call on decoders/encoders:

import io.protoless.syntax._

student.asProtobufBytes
bytes.as[Student]

Literal types

If your project use the typelevel fork of Scala, you can define the field mapping with Literal types.

type StudentMapping = 1 :: 2 :: 4 :: 8 :: HNil

Fully Automatic derivation

Automatically derive the required decoder/encoders, using the automatic field numbering strategy.

This approach requires less code, but only works if your messages are numbered consecutively starting from one.

import io.protoless.generic.auto._

case class Cloud(name: String, nickname: Char)
case class Unicorn(color: String, canFlight: Boolean, locations: Seq[Cloud])

val unicorn = Unicorn("pink", true, Seq(Cloud("Celestial", 'c'), Cloud("Misty Cheeky", 'm')))

val bytes = unicorn.asProtobufBytes

bytes.as[Unicorn]

Hand-crafted encoders/decoders

Hand-crafted encoders and decoders give you a total control on how your objects are encoded and decoded. For each field you have to define how and where to read/write it .

It’s useful if you want to validate or transform protobuf message to fit your model.

message Meteo {
  string city = 1;
  string country = 2;
  int32 temperature = 3; // in °F
  float wind = 4;
  float humidity = 5; // optional
}


case class Location(country: String, city: String)
case class Celcius(temp: Float)

sealed trait Weather
case object Sunny extends Weather
case class Rainy(humidity: Float) extends Weather

case class Meteo(location: Location, temp: Celcius, weather: Weather)


import io.protoless.messages._, io.protoless.syntax._

implicit val meteoDecoder = Decoder.instance[Meteo](input =>
  for {
    city <- input.read[String] // if nothing is specified, index are automatically incremented
    country <- input.read[String].map(_.toUpperCase)
    temp <- input.read[Int].map(f => Celcius((f-32f)/1.8f))
    // we don't care about wind
    weather <- input.read[Option[Float]](5).map {
      case Some(humidity) => Rainy(humidity)
      case None => Sunny
    }
  } yield Meteo(Location(city, country), temp, weather)
)

implicit val meteoEncoder = Encoder.instance[Meteo]{ meteo =>
  output =>
    output.write[String](meteo.location.city)
    output.write[String](meteo.location.country)
    output.write[Int]((meteo.temp.temp * 1.8f - 32).toInt)
    output.write[Option[Float]](meteo.weather match {
      case Rainy(humidity) => Some(humidity)
      case _ => None
    }, 5)
}
val bytes = meteoEncoder.encodeAsBytes(Meteo(Location("France", "Montpellier"), Celcius(38), Sunny))
// bytes: Array[Byte] = Array(10, 11, 77, 111, 110, 116, 112, 101, 108, 108, 105, 101, 114, 18, 6, 70, 114, 97, 110, 99, 101, 24, 36)

bytes.as[Meteo]
// res4: io.protoless.messages.Decoder.Result[Meteo] = Right(Meteo(Location(Montpellier,FRANCE),Celcius(2.2222223),Sunny))