Vulcan Vulcan

Derivation

The vulcan-generic module provides generic derivation of Codecs using Magnolia for records and unions, and reflection for enumerations and fixed types.

Getting Started

To get started with sbt, add the following line to your build.sbt file.

libraryDependencies += "com.github.fd4s" %% "vulcan-generic" % "1.14.1"

Enumerations

deriveEnum can be used to partly derive Codecs for enumeration types. Annotations like @AvroDoc can be used to customize the derivation.

import vulcan.AvroError
import vulcan.generic._

@AvroNamespace("com.example")
@AvroDoc("A selection of different fruits")
sealed trait Fruit
case object Apple extends Fruit
case object Banana extends Fruit
case object Cherry extends Fruit

deriveEnum[Fruit](
  symbols = List("apple", "banana", "cherry"),
  encode = {
    case Apple  => "apple"
    case Banana => "banana"
    case Cherry => "cherry"
  },
  decode = {
    case "apple"  => Right(Apple)
    case "banana" => Right(Banana)
    case "cherry" => Right(Cherry)
    case other    => Left(AvroError(s"$other is not a Fruit"))
  }
)
// res1: vulcan.Codec.Aux[vulcan.Avro.EnumSymbol, Fruit] = WithTypeName(
//   codec = Validated(
//     codec = Codec({
//   "type" : "enum",
//   "name" : "Fruit",
//   "namespace" : "com.example",
//   "doc" : "A selection of different fruits",
//   "symbols" : [ "apple", "banana", "cherry" ]
// }),
//     validSchema = {"type":"enum","name":"Fruit","namespace":"com.example","doc":"A selection of different fruits","symbols":["apple","banana","cherry"]}
//   ),
//   typeName = "com.example.Fruit"
// )

Fixed

deriveFixed can be used to partly derive Codecs for fixed types. Annotations like @AvroDoc can be used to customize the derivation.

import vulcan.AvroError

sealed abstract case class Pence(value: Byte)

object Pence {
  def apply(value: Byte): Either[AvroError, Pence] =
    if(0 <= value && value < 100) Right(new Pence(value) {})
    else Left(AvroError(s"Expected pence value, got $value"))
}

deriveFixed[Pence](
  size = 1,
  encode = pence => Array[Byte](pence.value),
  decode = bytes => Pence(bytes.head)
)
// res2: vulcan.Codec.Aux[vulcan.Avro.Fixed, Pence] = WithTypeName(
//   codec = Validated(
//     codec = Codec({
//   "type" : "fixed",
//   "name" : "Pence",
//   "namespace" : "repl.MdocSession.MdocApp0",
//   "size" : 1
// }),
//     validSchema = {"type":"fixed","name":"Pence","namespace":"repl.MdocSession.MdocApp0","size":1}
//   ),
//   typeName = "repl.MdocSession.MdocApp0.Pence"
// )

Records

To derive Codecs for case classes or sealed traits, we can use Codec.derive. Annotations like @AvroDoc and @AvroNamespace can be used to customize the documentation and namespace during derivation.

import vulcan.Codec
import vulcan.generic._

@AvroNamespace("com.example")
@AvroDoc("Person with a first name, last name, and optional age")
final case class Person(firstName: String, lastName: String, age: Option[Int])

Codec.derive[Person]
// res3: Codec[Person] = WithTypeName(
//   codec = Validated(
//     codec = Codec({
//   "type" : "record",
//   "name" : "Person",
//   "namespace" : "com.example",
//   "doc" : "Person with a first name, last name, and optional age",
//   "fields" : [ {
//     "name" : "firstName",
//     "type" : "string"
//   }, {
//     "name" : "lastName",
//     "type" : "string"
//   }, {
//     "name" : "age",
//     "type" : [ "null", "int" ]
//   } ]
// }),
//     validSchema = {"type":"record","name":"Person","namespace":"com.example","doc":"Person with a first name, last name, and optional age","fields":[{"name":"firstName","type":"string"},{"name":"lastName","type":"string"},{"name":"age","type":["null","int"]}]}
//   ),
//   typeName = "com.example.Person"
// )

Unions

While case classes correspond to Avro records, sealed traits correspond to unions.

sealed trait FirstOrSecond

@AvroNamespace("com.example")
final case class First(value: Int) extends FirstOrSecond

@AvroNamespace("com.example")
final case class Second(value: String) extends FirstOrSecond

Codec.derive[FirstOrSecond]
// res4: Codec.Aux[Any, FirstOrSecond] = WithTypeName(
//   codec = Validated(
//     codec = WithTypeName(
//       codec = Validated(
//         codec = UnionCodec(
//           alts = Append(
//             leftNE = Singleton(a = vulcan.Codec$Alt$$anon$6@68fc56d3),
//             rightNE = Singleton(a = vulcan.Codec$Alt$$anon$6@53ccc017)
//           )
//         ),
//         validSchema = [{"type":"record","name":"First","namespace":"com.example","fields":[{"name":"value","type":"int"}]},{"type":"record","name":"Second","namespace":"com.example","fields":[{"name":"value","type":"string"}]}]
//       ),
//       typeName = "union"
//     ),
//     validSchema = [{"type":"record","name":"First","namespace":"com.example","fields":[{"name":"value","type":"int"}]},{"type":"record","name":"Second","namespace":"com.example","fields":[{"name":"value","type":"string"}]}]
//   ),
//   typeName = "repl.MdocSession.MdocApp0.FirstOrSecond"
// )

Shapeless Coproducts are also supported and correspond to Avro unions.

import shapeless.{:+:, CNil}

Codec[Int :+: String :+: CNil]
// res5: Codec[Int :+: String :+: CNil] = WithTypeName(
//   codec = Validated(
//     codec = UnionCodec(
//       alts = Append(
//         leftNE = Singleton(a = vulcan.Codec$Alt$$anon$6@73b03467),
//         rightNE = Singleton(a = vulcan.Codec$Alt$$anon$6@5513a170)
//       )
//     ),
//     validSchema = ["int","string"]
//   ),
//   typeName = "Coproduct"
// )