Quick start
ducktape is a library for boilerplate-less and configurable transformations between case classes and enums/sealed traits for Scala 3. Directly inspired by chimney.
If this project interests you, please drop a 🌟 - these things are worthless but give me a dopamine rush nonetheless.
Installation
libraryDependencies += "io.github.arainko" %% "ducktape" % "0.2.6"
// or if you're using Scala.js or Scala Native
libraryDependencies += "io.github.arainko" %%% "ducktape" % "0.2.6"
NOTE: the version scheme is set to early-semver
You're currently browsing the documentation for ducktape 0.2.x
, if you're looking for the 0.1.x
docs go here: https://github.com/arainko/ducktape/tree/series/0.1.x#-ducktape
Entrypoint of the library
The user-facing API of ducktape
is mostly a bunch of extension methods that allow us to transform between types in a variety of ways, the only import needed to get started looks like this:
import io.github.arainko.ducktape.*
Motivating example
ducktape
is all about painlessly transforming between similiarly structured case classes/enums/sealed traits. If we were to define two really, really similar sets of case class and/or enums, eg. ones like these:
import java.time.Instant
import io.github.arainko.ducktape.*
object wire:
final case class Person(
firstName: String,
lastName: String,
paymentMethods: List[wire.PaymentMethod],
status: wire.Status,
updatedAt: Option[Instant]
)
enum Status:
case Registered, PendingRegistration, Removed
enum PaymentMethod:
case Card(name: String, digits: Long, expires: Instant)
case PayPal(email: String)
case Cash
import java.time.Instant
import io.github.arainko.ducktape.*
object domain:
final case class Person( // <-- fields reshuffled
lastName: String,
firstName: String,
status: Option[domain.Status], // <-- 'status' in the domain model is optional
paymentMethods: Vector[domain.Payment], // <-- collection type changed from a List to a Vector
updatedAt: Option[Instant]
)
enum Status:
case Registered, PendingRegistration, Removed
case PendingRemoval // <-- additional enum case
enum Payment:
case Card(name: String, digits: Long, expires: Instant)
case PayPal(email: String)
case Cash
...and an input instance that we intend to transform into its domain
counterpart:
val wirePerson: wire.Person = wire.Person(
"John",
"Doe",
List(
wire.PaymentMethod.Cash,
wire.PaymentMethod.PayPal("john@doe.com"),
wire.PaymentMethod.Card("J. Doe", 12345, Instant.now)
),
wire.Status.PendingRegistration,
Some(Instant.ofEpochSecond(0))
)
...then transforming between the wire
and domain
models is just a matter of calling .to[domain.Person]
on the input:
wirePerson.to[domain.Person]
// res0: Person = Person(
// lastName = "Doe",
// firstName = "John",
// status = Some(value = PendingRegistration),
// paymentMethods = Vector(
// Cash,
// PayPal(email = "john@doe.com"),
// Card(
// name = "J. Doe",
// digits = 12345L,
// expires = 2024-11-16T09:57:08.195468774Z
// )
// ),
// updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
((new Person(
lastName = MdocApp.this.wirePerson.lastName,
firstName = MdocApp.this.wirePerson.firstName,
status = Some.apply[Status](
if MdocApp.this.wirePerson.status.isInstanceOf[Registered.type] then MdocApp.this.domain.Status.Registered
else if MdocApp.this.wirePerson.status.isInstanceOf[PendingRegistration.type] then
MdocApp.this.domain.Status.PendingRegistration
else if MdocApp.this.wirePerson.status.isInstanceOf[Removed.type] then MdocApp.this.domain.Status.Removed
else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
),
paymentMethods = MdocApp.this.wirePerson.paymentMethods
.map[Payment]((src: PaymentMethod) =>
if src.isInstanceOf[Card] then
new Card(
name = src.asInstanceOf[Card].name,
digits = src.asInstanceOf[Card].digits,
expires = src.asInstanceOf[Card].expires
)
else if src.isInstanceOf[PayPal] then new PayPal(email = src.asInstanceOf[PayPal].email)
else if src.isInstanceOf[Cash.type] then MdocApp.this.domain.Payment.Cash
else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
)
.to[Vector[Payment]](iterableFactory[Payment]),
updatedAt = MdocApp.this.wirePerson.updatedAt
): Person): Person)
But now imagine that your wire model differs ever so slightly from your domain model, maybe the wire model's PaymentMethod.Card
doesn't have the name
field for some inexplicable reason...
import java.time.Instant
import io.github.arainko.ducktape.*
object wire:
final case class Person(
firstName: String,
lastName: String,
paymentMethods: List[wire.PaymentMethod],
status: wire.Status,
updatedAt: Option[Instant]
)
enum Status:
case Registered, PendingRegistration, Removed
enum PaymentMethod:
case Card(digits: Long, expires: Instant) // <-- poof, 'name' is gone
case PayPal(email: String)
case Cash
import java.time.Instant
import io.github.arainko.ducktape.*
object domain:
final case class Person(
lastName: String,
firstName: String,
status: Option[domain.Status],
paymentMethods: Vector[domain.Payment],
updatedAt: Option[Instant]
)
enum Status:
case Registered, PendingRegistration, Removed
case PendingRemoval
enum Payment:
case Card(name: String, digits: Long, expires: Instant)
case PayPal(email: String)
case Cash
...and when you try to transform between these two representations the compiler now yells at you.
val domainPerson = wirePerson.to[domain.Person]
// error:
// No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name
// val domainPerson = wirePerson.to[domain.Person]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Now onto dealing with that, let's first examine the error message:
No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name
especially the part after @
:
Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name
the thing above is basically a path to the field/subtype under which ducktape
was not able to create a transformation, these are meant to be copy-pastable for when you're actually trying to fix the error, eg. by setting the name
field to a constant value:
val domainPerson =
wirePerson
.into[domain.Person]
.transform(Field.const(_.paymentMethods.element.at[domain.Payment.Card].name, "CONST NAME"))
// domainPerson: Person = Person(
// lastName = "Doe",
// firstName = "John",
// status = Some(value = PendingRegistration),
// paymentMethods = Vector(
// Cash,
// PayPal(email = "john@doe.com"),
// Card(
// name = "CONST NAME",
// digits = 12345L,
// expires = 2024-11-16T09:57:08.202723300Z
// )
// ),
// updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
{
val AppliedBuilder_this: AppliedBuilder[Person, Person] = into[Person](MdocApp2.this.wirePerson1)[MdocApp2.this.domain.Person]
{
val value$proxy3: Person = AppliedBuilder_this.inline$value
new Person(
lastName = value$proxy3.lastName,
firstName = value$proxy3.firstName,
status = Some.apply[Status](
if value$proxy3.status.isInstanceOf[Registered.type] then MdocApp2.this.domain.Status.Registered
else if value$proxy3.status.isInstanceOf[PendingRegistration.type] then MdocApp2.this.domain.Status.PendingRegistration
else if value$proxy3.status.isInstanceOf[Removed.type] then MdocApp2.this.domain.Status.Removed
else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
),
paymentMethods = value$proxy3.paymentMethods
.map[Payment]((src: PaymentMethod) =>
if src.isInstanceOf[Card] then
new Card(name = "CONST NAME", digits = src.asInstanceOf[Card].digits, expires = src.asInstanceOf[Card].expires)
else if src.isInstanceOf[PayPal] then new PayPal(email = src.asInstanceOf[PayPal].email)
else if src.isInstanceOf[Cash.type] then MdocApp2.this.domain.Payment.Cash
else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
)
.to[Vector[Payment]](iterableFactory[Payment]),
updatedAt = value$proxy3.updatedAt
): Person
}: Person
}
Read more in the chapter dedicated to configuring transformations.
To get an idea of what transformations are actually supported head on over to transformation rules.