Skip to content

Basic guide

Alexander Valentinov edited this page Dec 7, 2020 · 7 revisions

Phobos is a data-binding library that allows to transform case classes or sealed traits to XML documents and vice-verca.

A case class represents an XML element. Case class parameters correspond to contents of the element. There are three types of parameters:

  • parameters with @attr annotation are attributes;
  • parameter with @text annotation is the text inside the element (only one is allowed per case class);
  • parameters without these two annotations are children elements.

Attributes' and children elements' labels are defined by parameter names. Label of the element represented by the case class is provided externally, so that a case class could describe several elements with same structure, but different names.

Case class parameters are processed with several special macros.

For encoding some case class or sealed trait A to XML document encoding.XmlEncoder[A] (ru.tinkoff.phobos. prefix is omitted) instance is required. This instance is created with derivation.semiauto.deriveXmlEncoder macro. The macro takes single param - the name of the root element to encode. For case classes it traverses parameters and tries to infer implicit

  • encoding.AttributeEncoder for attributes (@attr),
  • encoding.TextEncoder for text (@text) and
  • encoding.ElementEncoder for elements (everything else).

Instances of these typeclasses are defined for simple types. Instances of encoding.ElementEncoder can be also derived for case classes or sealed traits with derivation.semiauto.deriveElementEncoder macro. It works much like derivation.semiauto.deriveXmlEncoder, but does not require element label. Instance of encoding.XmlEncoder can also be created from encoding.ElementEncoder by helper functions from encoding.XmlEncoder object.

Decoding works the same way, just replace "encoding" to "decoding" in typeclass names.

Scala classes below represent journey information, which can be encoded to (and decoded from) XML.

import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.encoding._
import ru.tinkoff.phobos.syntax._
import ru.tinkoff.phobos.derivation.semiauto._

case class TravelPoint(country: String, city: String)
object TravelPoint {
  implicit val travelPointElementEncoder: ElementEncoder[TravelPoint] = 
    deriveElementEncoder
  implicit val travelPointElementDecoder: ElementDecoder[TravelPoint] = 
    deriveElementDecoder
}

case class Price(@attr currency: String, @text value: Double)
object Price {
  implicit val priceElementEncoder: ElementEncoder[Price] = deriveElementEncoder
  implicit val priceElementDecoder: ElementDecoder[Price] = deriveElementDecoder
}

case class Journey(price: Price, departure: TravelPoint, arrival: TravelPoint)
object Journey {
  implicit val journeyXmlEncoder: XmlEncoder[Journey] = deriveXmlEncoder("journey")
  implicit val journeyXmlDecoder: XmlDecoder[Journey] = deriveXmlDecoder("journey")
}

Journey class can be encoded to XML documents like this one:

<journey>
  <departure>
    <country>France</country>
    <city>Marcelle</city>
  </departure>
  <arrival>
    <country>Germany</country>
    <city>Munich</city>
  </arrival>
  <price currency="EUR">1000</price>
</journey>

This code encodes journey class to XML and then decodes it back, checking, that initial and decoded values are equal.

val journey =
  Journey(
    price = Price("EUR", 1000.0),
    departure = TravelPoint("France", "Marcelle"),
    arrival = TravelPoint("Germany", "Munich")
  )

val xml: String = XmlEncoder[Journey].encode(journey)
println(xml)

val decodedJourney = XmlDecoder[Journey].decode(xml)
println(decodedJourney)

assert(Right(journey) == decodedJourney)

Listings

Some documents may contain several elements with same label and structure, like this one:

<shoppingList>
  <item count="2">"Sausage"</item>
  <item count="4">"Egg"</item>
  <item count="1">"Tomato"</item>
</shoppingList>

It can be processed with following code:

import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.derivation.semiauto._
import ru.tinkoff.phobos.syntax._

val xml = """
  |<shoppingList>
  |  <item count="2">"Sausage"</item>
  |  <item count="4">"Egg"</item>
  |  <item count="1">"Tomato"</item>
  |</shoppingList>
  |""".stripMargin

case class Item(@attr count: Int, @text value: String)
object Item {
  implicit val itemElementDecoder: ElementDecoder[Item] = deriveElementDecoder
}

case class ShoppingList(item: List[Item])
object ShoppingList {
  implicit val shoppingListXmlDecoder: XmlDecoder[ShoppingList] = deriveXmlDecoder("shoppingList")
}

println(XmlDecoder[ShoppingList].decode(xml))
// Right(ShoppingList(List(Item(2,"Sausage"), Item(4,"Egg"), Item(1,"Tomato"))))

Hence, it works very similar to regular elements.

Namespaces

Namespaces are usually represented with case objects, which have Namespace typeclass instance:

import ru.tinkoff.phobos.Namespace

case object foo {
  implicit val fooNamespace: Namespace[foo.type] = Namespace.mkInstance("example.com")
}

Namespace of case class parameter can be defined with @xmlns annotation:

import ru.tinkoff.phobos.syntax._

case class Bar(@xmlns(foo) baz: Int)

This design looks much like namespace prefixes in XML documents, however name of case object is ignored and name space prefix value will differ from it.

Macros derivation.semiauto.deriveXmlEncoder and derivation.semiauto.deriveXmlDecoder have overloaded versions which allow to provide namespaces for elements along with labels:

import ru.tinkoff.phobos.encoding._
import ru.tinkoff.phobos.derivation.semiauto._

implicit val barXmlEncoder: XmlEncoder[Bar] = deriveXmlEncoder("bar", foo)

println(XmlEncoder[Bar].encode(Bar(10)))
// <?xml version='1.0' encoding='UTF-8'?>
// <ans1:bar xmlns:ans1="example.com">
//   <ans1:baz>10</ans1:baz>
// </ans1:bar>

Reducing boilerplate with annotations

To activate macro annotations add -Ymacro-annotations flag to scalac options (Scala 2.13):

scalacOptions += "-Ymacro-annotations"

For Scala 2.12 enable scala macro paradise:

libaryDependencies ++= compilerPlugin("org.scalamacros" % "paradise" % "X.X.X" cross CrossVersion.patch)

All annotations are contained in ru.tinkoff.phobos.annotations package.

  • ElementCodec derives ElementEncoder and ElementDecoder for case class:
    @ElementCodec
    case class Foo()
  • XmlCodec derives ElementEncoder, ElementDecoder, XmlEncoder and XmlDecoder for provided name:
    @XmlCodec("foo")
    case class Foo()
  • XmlnsDef provides Namespace instance for an object:
    @XmlnsDef("example.com")
    case object xpl
  • XmlCodecNs works like XmlCodec, but it also requires namespace:
    @XmlCodecNs("foo", xpl)
    case class Foo()

Decoders and encoders can be derived separately with phobos-derevo module.

Transforming case class parameter names

Case class parameter names can be transformed with special configured codec:

import ru.tinkoff.phobos.annotations.XmlCodec
import ru.tinkoff.phobos.configured.ElementCodecConfig
import ru.tinkoff.phobos.configured.naming._
import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.encoding._
import ru.tinkoff.phobos.syntax._

val config = ElementCodecConfig.default
               .withElementsRenamed(camelCase)
               .withAttributesRenamed(snakeCase)

// For element codecs @XmlCodec(config) or 
// implicit val colorEncoder = deriveElementEncoderConfigured[Color](config)
@XmlCodec("Color", config)
case class Color(@attr htmlName: String, hexCode: String)

val color = Color("tomato", "#FF6347")
val encoded = XmlEncoder[Color].encode(color)
println(encoded)
// <?xml version='1.0' encoding='UTF-8'?>
// <Color html_name="tomato">
//   <HexCode>#FF6347</HexCode>
// </Color>
assert(XmlDecoder[Color].decode(encoded) == Right(color))

Parameters also can be specifically renamed with @renamed annotation:

import ru.tinkoff.phobos.annotations.XmlCodec
import ru.tinkoff.phobos.configured.ElementCodecConfig
import ru.tinkoff.phobos.configured.naming._
import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.encoding._
import ru.tinkoff.phobos.syntax._

val config = ElementCodecConfig.default
  .withElementsRenamed(camelCase)
  .withAttributesRenamed(snakeCase)

@XmlCodec("Address", config)
case class Address(
  @attr @renamed("postCode") code: String, // will be renamed to "postCode", but not "PostCode"
  city: String,                            // will be transformed to "City"
  @renamed("state") region: String         // will be renamed to "state", but not "State"
)

val address = Address("124365", "New York", "NY")
val encoded = XmlEncoder[Address].encode(address)
println(encoded)
// <?xml version='1.0' encoding='UTF-8'?>
// <Address postCode="124365">
//   <City>New York</City>
//   <state>NY</state>
// </Address>
assert(XmlDecoder[Address].decode(encoded) == Right(address))

Processing sealed traits

Sealed trait support was implemented in 0.5.0. Sealed trait instances are differentiated with special discriminator-attribute. By default it is http://www.w3.org/2001/XMLSchema-instance:type as in XSD. There are some examples below.

import ru.tinkoff.phobos.annotations.XmlCodec
import ru.tinkoff.phobos.annotations.ElementCodec
import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.encoding._

@XmlCodec("mammal")
sealed trait Mammal

@ElementCodec
case class Lion(name: String, isLeader: Boolean) extends Mammal
@ElementCodec
case class Wolf(name: String, age: Int) extends Mammal

val lion = Lion("Mickey", true)
val encodedLion = XmlEncoder[Mammal].encode(lion)
println(encodedLion)
// <?xml version='1.0' encoding='UTF-8'?>
// <mammal xmlns:ans1="http://www.w3.org/2001/XMLSchema-instance" ans1:type="Lion">
//   <name>Mickey</name>
//   <isLeader>true</isLeader>
// </mammal>

val wolf = Wolf("Bill", 33)
val encodedWolf = XmlEncoder[Mammal].encode(wolf)
println(encodedWolf)
// <?xml version='1.0' encoding='UTF-8'?>
// <mammal xmlns:ans1="http://www.w3.org/2001/XMLSchema-instance" ans1:type="Wolf">
//   <name>Bill</name>
//   <age>33</age>
// </mammal>

assert(XmlDecoder[Mammal].decode(encodedLion) == Right(lion))
assert(XmlDecoder[Mammal].decode(encodedWolf) == Right(wolf))

Discriminator-attribute can be renamed with configured codec. Discriminator values can be transformed with configured codec or overwritten with @discriminator annotation.

import ru.tinkoff.phobos.annotations.XmlCodec
import ru.tinkoff.phobos.annotations.ElementCodec
import ru.tinkoff.phobos.decoding._
import ru.tinkoff.phobos.encoding._
import ru.tinkoff.phobos.configured.ElementCodecConfig
import ru.tinkoff.phobos.configured.naming._
import ru.tinkoff.phobos.syntax.discriminator

val config = ElementCodecConfig.default
  .withConstructorsRenamed(snakeCase)
  .withDiscriminator("species", None)

// For element codecs @XmlCodec(config) or
// implicit val fishEncoder = deriveElementEncoderConfigured[Fish](config)
@XmlCodec("fish", config)
sealed trait Fish

@ElementCodec
@discriminator("ClownFish") // will not be transformed to "clown_fish"
case class ClownFish(name: String, finNumber: Int) extends Fish

@ElementCodec
case class WhiteShark(name: String, teethNumber: Long) extends Fish

val clownFish  = ClownFish("Nemo", 1)
val encodedClownFish = XmlEncoder[Fish].encode(clownFish)
println(encodedClownFish)
// <?xml version='1.0' encoding='UTF-8'?>
// <fish species="ClownFish">
//   <name>Nemo</name>
//   <finNumber>1</finNumber>
// </fish>

val whiteShark = WhiteShark("John", 200)
val encodedWhiteShark = XmlEncoder[Fish].encode(whiteShark)
println(encodedWhiteShark)
// <?xml version='1.0' encoding='UTF-8'?>
// <fish species="white_shark">
//   <name>John</name>
//   <teethNumber>200</teethNumber>
// </fish>

assert(XmlDecoder[Fish].decode(encodedClownFish) == Right(clownFish))
assert(XmlDecoder[Fish].decode(encodedWhiteShark) == Right(whiteShark))