Kebs
Scala library to eliminate boilerplate
A library maintained by Iterators.
Table of contents
Why?
kebs
is for eliminating some common sources of Scala boilerplate code that arise when you use
Slick (kebs-slick
), Doobie (kebs-doobie
), Spray (kebs-spray-json
), Play (kebs-play-json
), Circe (kebs-circe
), Akka HTTP (kebs-akka-http
), http4s (kebs-http4s
).
SBT
Support for slick
libraryDependencies += "pl.iterators" %% "kebs-slick" % "1.9.3"
Support for doobie
libraryDependencies += "pl.iterators" %% "kebs-doobie" % "1.9.3"
Support for spray-json
libraryDependencies += "pl.iterators" %% "kebs-spray-json" % "1.9.3"
Support for play-json
libraryDependencies += "pl.iterators" %% "kebs-play-json" % "1.9.3"
Support for circe
libraryDependencies += "pl.iterators" %% "kebs-circe" % "1.9.3"
Support for json-schema
libraryDependencies += "pl.iterators" %% "kebs-jsonschema" % "1.9.3"
Support for scalacheck
libraryDependencies += "pl.iterators" %% "kebs-scalacheck" % "1.9.3"
Support for akka-http
libraryDependencies += "pl.iterators" %% "kebs-akka-http" % "1.9.3"
Support for http4s
libraryDependencies += "pl.iterators" %% "kebs-http4s" % "1.9.3"
Support for tagged types
libraryDependencies += "pl.iterators" %% "kebs-tagged" % "1.9.3"
or for tagged-types code generation support
libraryDependencies += "pl.iterators" %% "kebs-tagged-meta" % "1.9.3"
addCompilerPlugin("org.scalameta" % "paradise" % "3.0.0-M11" cross CrossVersion.full)
Support for instances
libraryDependencies += "pl.iterators" %% "kebs-instances" % "1.9.3"
Builds for Scala 2.12
and 2.13
are provided.
Examples
Please check out examples
- kebs generates slick mappers for your case-class wrappers (kebs-slick)
If you want to model the following table
case class UserId(userId: String) extends AnyVal
case class EmailAddress(emailAddress: String) extends AnyVal
case class FullName(fullName: String) extends AnyVal
//...
class People(tag: Tag) extends Table[Person](tag, "people") {
def userId: Rep[UserId] = column[UserId]("user_id")
def emailAddress: Rep[EmailAddress] = column[EmailAddress]("email_address")
def fullName: Rep[FullName] = column[FullName]("full_name")
def mobileCountryCode: Rep[String] = column[String]("mobile_country_code")
def mobileNumber: Rep[String] = column[String]("mobile_number")
def billingAddressLine1: Rep[AddressLine] = column[AddressLine]("billing_address_line1")
def billingAddressLine2: Rep[Option[AddressLine]] = column[Option[AddressLine]]("billing_address_line2")
def billingPostalCode: Rep[PostalCode] = column[PostalCode]("billing_postal_code")
def billingCity: Rep[City] = column[City]("billing_city")
def billingCountry: Rep[Country] = column[Country]("billing_country")
def taxId: Rep[TaxId] = column[TaxId]("tax_id")
def bankName: Rep[BankName] = column[BankName]("bank_name")
def bankAccountNumber: Rep[BankAccountNumber] = column[BankAccountNumber]("bank_account_number")
def recipientName: Rep[RecipientName] = column[RecipientName]("recipient_name")
def additionalInfo: Rep[AdditionalInfo] = column[AdditionalInfo]("additional_info")
def workCity: Rep[City] = column[City]("work_city")
def workArea: Rep[Area] = column[Area]("work_area")
protected def mobile = (mobileCountryCode, mobileNumber) <> (Mobile.tupled, Mobile.unapply)
protected def billingAddress =
(billingAddressLine1, billingAddressLine2, billingPostalCode, billingCity, billingCountry) <> (Address.tupled, Address.unapply)
protected def billingInfo =
(billingAddress, taxId, bankName, bankAccountNumber, recipientName, additionalInfo) <> (BillingInfo.tupled, BillingInfo.unapply)
override def * : ProvenShape[Person] =
(userId, emailAddress, fullName, mobile, billingInfo, workCity, workArea) <> (Person.tupled, Person.unapply)
}
then you are forced to write this:
object People {
implicit val userIdColumnType: BaseColumnType[UserId] = MappedColumnType.base(_.userId, UserId.apply)
implicit val emailAddressColumnType: BaseColumnType[EmailAddress] = MappedColumnType.base(_.emailAddress, EmailAddress.apply)
implicit val fullNameColumnType: BaseColumnType[FullName] = MappedColumnType.base(_.fullName, FullName.apply)
implicit val addressLineColumnType: BaseColumnType[AddressLine] = MappedColumnType.base(_.line, AddressLine.apply)
implicit val postalCodeColumnType: BaseColumnType[PostalCode] = MappedColumnType.base(_.postalCode, PostalCode.apply)
implicit val cityColumnType: BaseColumnType[City] = MappedColumnType.base(_.city, City.apply)
implicit val areaColumnType: BaseColumnType[Area] = MappedColumnType.base(_.area, Area.apply)
implicit val countryColumnType: BaseColumnType[Country] = MappedColumnType.base(_.country, Country.apply)
implicit val taxIdColumnType: BaseColumnType[TaxId] = MappedColumnType.base(_.taxId, TaxId.apply)
implicit val bankNameColumnType: BaseColumnType[BankName] = MappedColumnType.base(_.name, BankName.apply)
implicit val recipientNameColumnType: BaseColumnType[RecipientName] = MappedColumnType.base(_.name, RecipientName.apply)
implicit val additionalInfoColumnType: BaseColumnType[AdditionalInfo] = MappedColumnType.base(_.content, AdditionalInfo.apply)
implicit val bankAccountNumberColumnType: BaseColumnType[BankAccountNumber] =
MappedColumnType.base(_.number, BankAccountNumber.apply)
}
kebs
can do it automagically for you
import pl.iterators.kebs._
class People(tag: Tag) extends Table[Person](tag, "people") {
def userId: Rep[UserId] = column[UserId]("user_id")
//...
}
If you prefer to mix in trait instead of import (for example you're using a custom driver like slick-pg
), you can do it as well:
import pl.iterators.kebs.Kebs
object MyPostgresProfile extends ExPostgresDriver with PgArraySupport {
override val api: API = new API {}
trait API extends super.API with ArrayImplicits with Kebs
}
import MyPostgresProfile.api._
kebs-slick
can also generate mappings for Postgres ARRAY type, which is a common source of boilerplate in slick-pg
.
Instead of:
object MyPostgresProfile extends ExPostgresDriver with PgArraySupport {
override val api: API = new API {}
trait API extends super.API with ArrayImplicits {
implicit val institutionListTypeWrapper =
new SimpleArrayJdbcType[Long]("int8").mapTo[Institution](Institution, _.value).to(_.toList)
implicit val marketFinancialProductWrapper =
new SimpleArrayJdbcType[String]("text").mapTo[MarketFinancialProduct](MarketFinancialProduct, _.value).to(_.toList)
}
}
import MyPostgresProfile.api._
class ArrayTestTable(tag: Tag) extends Table[(Long, List[Institution], Option[List[MarketFinancialProduct]])](tag, "ArrayTest") {
def id = column[Long]("id", O.AutoInc, O.PrimaryKey)
def institutions = column[List[Institution]]("institutions")
def mktFinancialProducts = column[Option[List[MarketFinancialProduct]]]("mktFinancialProducts")
def * = (id, institutions, mktFinancialProducts)
}
you can do just:
object MyPostgresProfile extends ExPostgresDriver with PgArraySupport {
override val api: API = new API {}
trait API extends super.API with ArrayImplicits with Kebs
}
import MyPostgresProfile.api._
class ArrayTestTable(tag: Tag) extends Table[(Long, List[Institution], Option[List[MarketFinancialProduct]])](tag, "ArrayTest") {
def id = column[Long]("id", O.AutoInc, O.PrimaryKey)
def institutions = column[List[Institution]]("institutions")
def mktFinancialProducts = column[Option[List[MarketFinancialProduct]]]("mktFinancialProducts")
def * = (id, institutions, mktFinancialProducts)
}
kebs-slick
supports Postgres HSTORE type
Instead of writing this:
object MyPostgresProfile extends ExPostgresProfile with PgHStoreSupport {
override val api: APIWithHstore = new APIWithHstore {}
trait APIWithHstore extends super.API with HStoreImplicits {
val yearMonthIso: Isomorphism[YearMonth, String] = new Isomorphism(_.toString, YearMonth.parse)
}
}
import MyPostgresProfile.api._
class HStoreTestTable(tag: Tag) extends Table[(Long, Map[YearMonth, Boolean])](tag, "HStoreTest") {
def id = column[Long]("id")
def history: Rep[Map[String, String]] = column[Map[String, String]]("history")
def historyMapped: MappedProjection[Map[YearMonth, Boolean], Map[String, String]] =
history.<>(h => h.map(kv => yearMonthIso.comap(kv._1) -> kv._2.toBoolean),
h => Option(h.map(kv => yearMonthIso.map(kv._1) -> kv._2.toString)))
def * = (id, historyMapped)
}
class HstoreRepository(implicit ec: ExecutionContext) {
def get(id: Long, yearMonth: YearMonth): DBIO[Option[Boolean]] =
byIdQuery(id)
.map(_.history +> yearMonthIso.map(yearMonth).asColumnOf[Option[String]])
.result
.map(_.headOption.flatMap(_.map(_.toBoolean)))
private def byIdQuery(id: Long) = testTable.filter(_.id === id)
private val testTable = TableQuery[HStoreTestTable]
}
you can write this:
object MyPostgresProfile extends ExPostgresProfile with PgHStoreSupport {
override val api: APIWithHstore = new APIWithHstore {}
trait APIWithHstore extends super.API with HStoreImplicits with Kebs with YearMonthString
}
import MyPostgresProfile.api._
class HStoreTestTable(tag: Tag) extends Table[(Long, Map[YearMonth, Boolean])](tag, "HStoreTest") {
def id = column[Long]("id")
def history: Rep[Map[YearMonth, Boolean]] = column[Map[YearMonth, Boolean]]("history")
def * = (id, history)
}
class HstoreRepository(implicit ec: ExecutionContext) {
def get(id: Long, yearMonth: YearMonth): DBIO[Option[Boolean]] =
byIdQuery(id)
.map(_.history +> yearMonth)
.result
.map(_.headOption.flatten)
private def byIdQuery(id: Long) = testTable.filter(_.id === id)
private val testTable = TableQuery[HStoreTestTable]
}
Make sure to mix in correct mapping from instances
, in this case YearMonthString
.
kebs
also supports Enumeratum
Let's go back to the previous example. If you wanted to add a column of type EnumEntry
, then you would have to write mapping for it:
sealed trait WorkerAccountStatus extends EnumEntry
object WorkerAccountStatus extends Enum[WorkerAccountStatus] {
case object Unapproved extends WorkerAccountStatus
case object Active extends WorkerAccountStatus
case object Blocked extends WorkerAccountStatus
override val values = findValues
}
object People {
//...
implicit val workerAccountStatusColumnType: BaseColumnType[WorkerAccountStatus] =
MappedColumnType.base(_.entryName, WorkerAccountStatus.withName)
}
class People(tag: Tag) extends Table[Person](tag, "people") {
import People._
//...
def status: Rep[WorkerAccountStatus] = column[WorkerAccountStatus]("status")
//...
override def * : ProvenShape[Person] =
(userId, emailAddress, fullName, mobile, billingInfo, workCity, workArea, status) <> (Person.tupled, Person.unapply)
}
kebs
takes care of this as well:
import pl.iterators.kebs._
import enums._
class People(tag: Tag) extends Table[Person](tag, "people") {
//...
def status: Rep[WorkerAccountStatus] = column[WorkerAccountStatus]("status")
//...
override def * : ProvenShape[Person] =
(userId, emailAddress, fullName, mobile, billingInfo, workCity, workArea, status) <> (Person.tupled, Person.unapply)
}
You can also choose between a few strategies of writing enums.
If you just import enums._
, then you'll get its entryName
in db.
If you import enums.lowercase._
or enums.uppercase._
then it'll save enum name in, respectively, lower or upper case
Of course, enums also work with traits:
import pl.iterators.kebs.Kebs
import pl.iterators.kebs.enums.KebsEnums
object MyPostgresProfile extends ExPostgresDriver {
override val api: API = new API {}
trait API extends super.API with Kebs with KebsEnums.Lowercase /* or KebsEnums, KebsEnums.Uppercase etc. */
}
import MyPostgresProfile.api._
kebs
also supports ValueEnum
s, to save something other than entry's name to db. For example, if you wanted WorkerAccountStatus
to be saved as int value, you'd write:
sealed abstract class WorkerAccountStatusInt(val value: Int) extends IntEnumEntry
object WorkerAccountStatusInt extends IntEnum[WorkerAccountStatusInt] {
case object Unapproved extends WorkerAccountStatusInt(0)
case object Active extends WorkerAccountStatusInt(1)
case object Blocked extends WorkerAccountStatusInt(2)
override val values = findValues
}
- kebs generates doobie mappers for your case-class wrappers (kebs-doobie)
kebs-doobie works similarly to kebs-slick. It provides doobie's Meta
instances for:
- Instances of
CaseClass1Rep
(value classes, tagged types, opaque types) - Instances of
InstanceConverter
- Enumeratum for Scala 2
- Native enums for Scala 3
To make the magic happen, do import pl.iterators.kebs._
and import pl.iterators.kebs.enums._
(or import pl.iterators.kebs.enums.uppercase._
or import pl.iterators.kebs.enums.lowercase._
).
- kebs eliminates spray-json induced boilerplate (kebs-spray-json)
Writing JSON formats in spray can be really unwieldy. For every case-class you want serialized, you have to count the number of fields it has. And if you want a 'flat' format for 1-element case classes, you have to wire it yourself
def jsonFlatFormat[P, T <: Product](construct: P => T)(implicit jw: JsonWriter[P], jr: JsonReader[P]): JsonFormat[T] =
new JsonFormat[T] {
override def read(json: JsValue): T = construct(jr.read(json))
override def write(obj: T): JsValue = jw.write(obj.productElement(0).asInstanceOf[P])
}
All of this can be left to kebs-spray-json
. Let's pretend we are to write an akka-http
router:
class ThingRouter(thingsService: ThingsService)(implicit ec: ExecutionContext) {
import ThingProtocol._
def createRoute = (post & pathEndOrSingleSlash & entity(as[ThingCreateRequest])) { request =>
complete {
thingsService.create(request).map[ToResponseMarshallable] {
case ThingCreateResponse.Created(thing) => Created -> thing
case ThingCreateResponse.AlreadyExists => Conflict -> Error("Already exists")
}
}
}
}
The source of boilerplate is ThingProtocol
which can grow really big
trait JsonProtocol extends DefaultJsonProtocol with SprayJsonSupport {
implicit val urlJsonFormat = new JsonFormat[URI] {
override def read(json: JsValue): URI = json match {
case JsString(uri) => Try(new URI(uri)).getOrElse(deserializationError("Invalid URI format"))
case _ => deserializationError("URI should be string")
}
override def write(obj: URI): JsValue = JsString(obj.toString)
}
implicit val uuidFormat = new JsonFormat[UUID] {
override def write(obj: UUID): JsValue = JsString(obj.toString)
override def read(json: JsValue): UUID = json match {
case JsString(uuid) => Try(UUID.fromString(uuid)).getOrElse(deserializationError("Expected UUID format"))
case _ => deserializationError("Expected UUID format")
}
}
}
object ThingProtocol extends JsonProtocol {
def jsonFlatFormat[P, T <: Product](construct: P => T)(implicit jw: JsonWriter[P], jr: JsonReader[P]): JsonFormat[T] =
new JsonFormat[T] {
override def read(json: JsValue): T = construct(jr.read(json))
override def write(obj: T): JsValue = jw.write(obj.productElement(0).asInstanceOf[P])
}
implicit val errorJsonFormat = jsonFormat1(Error.apply)
implicit val thingIdJsonFormat = jsonFlatFormat(ThingId.apply)
implicit val tagIdJsonFormat = jsonFlatFormat(TagId.apply)
implicit val thingNameJsonFormat = jsonFlatFormat(ThingName.apply)
implicit val thingDescriptionJsonFormat = jsonFlatFormat(ThingDescription.apply)
implicit val locationJsonFormat = jsonFormat2(Location.apply)
implicit val createThingRequestJsonFormat = jsonFormat5(ThingCreateRequest.apply)
implicit val thingJsonFormat = jsonFormat6(Thing.apply)
}
But all of this can be generated automatically, can't it? You only need to import KebsSpray
trait and you're done:
object ThingProtocol extends JsonProtocol with KebsSpray
If you want to further eliminate boilerplate generated by JsonProtocol
itself, you can import traits
from kebs-instances
you need and then ThingProtocol
looks like this:
object ThingProtocol extends DefaultJsonProtocol with SprayJsonSupport with KebsSpray with URIString with UUIDString
Additionally, kebs-spray-json
tries hard to be smart. It prefers 'flat' format when it comes across 1-element case-classes
In case like this:
case class ThingId(uuid: UUID)
case class ThingName(name: String)
case class Thing(id: ThingId, name: ThingName, ...)
it'll do what you probably expected - {"id": "uuid", "name": "str"}
. But it also takes into account
if you want RootJsonFormat
or not. So case class Error(message: String)
in Conflict -> Error("Already exists")
will be formatted as
{"message": "Already exists"}
in JSON.
What if you do not want to use 'flat' format by default? You have three options to choose from:
- redefine implicits for case-classes you want serialized 'non-flat'
case class Book(name: String, chapters: List[Chapter])
case class Chapter(name: String)
implicit val chapterRootFormat: RootJsonFormat[Chapter] = jsonFormatN[Chapter]
test("work with nested single field objects") {
val json =
"""
| {
| "name": "Functional Programming in Scala",
| "chapters": [{"name":"first"}, {"name":"second"}]
| }
""".stripMargin
json.parseJson.convertTo[Book] shouldBe Book(
name = "Functional Programming in Scala",
chapters = List(Chapter("first"), Chapter("second"))
)
}
- mix-in
KebsSpray.NonFlat
if you want flat format to become globally turned off for a protocol
object KebsProtocol extends DefaultJsonProtocol with KebsSpray.NoFlat
- use
noflat
annotation on selected case-classes (thanks to @dbronecki)
case class Book(name: String, chapters: List[Chapter])
@noflat case class Chapter(name: String)
Often you have to deal with convention to have snake-case
fields in JSON.
That's something kebs-spray-json
can do for you as well
object ThingProtocol extends JsonProtocol with KebsSpray.Snakified
Another advantage is that snakified names are computed during computation, so in run-time they're just string constants.
kebs-spray-json
also can deal with enumeratum
enums.
object ThingProtocol extends JsonProtocol with KebsSpray with KebsEnumFormats
As in slick's example, you have two additional enum serialization strategies:
uppercase i lowercase (KebsEnumFormats.Uppercase
, KebsEnumFormats.Lowercase
), as well as support for ValueEnumEntry
It can also generate recursive formats via jsonFormatRec
macro, as in the following example:
case class Thing(thingId: String, parent: Option[Thing])
implicit val thingFormat: RootJsonFormat[Thing] = jsonFormatRec[Thing]
kebs-spray-json
also provides JSON formats for case classes with more than 22 fields.
- kebs eliminates play-json induced boilerplate (kebs-play-json)
To be honest play-json
has never been a source of extensive boilerplate for me- thanks to Json.format[CC]
macro.
Only flat formats have had to be written over and over. And there is no support for (support for enumeratum
enumeratum
is provided by enumeratum-play-json
and has been removed from kebs
).
So if you find yourself writing lots of code similar to:
def flatFormat[P, T <: Product](construct: P => T)(implicit jf: Format[P]): Format[T] =
Format[T](jf.map(construct), Writes(a => jf.writes(a.productElement(0).asInstanceOf[P])))
implicit val thingIdJsonFormat = flatFormat(ThingId.apply)
implicit val tagIdJsonFormat = flatFormat(TagId.apply)
implicit val thingNameJsonFormat = flatFormat(ThingName.apply)
implicit val thingDescriptionJsonFormat = flatFormat(ThingDescription.apply)
implicit val errorJsonFormat = Json.format[Error]
implicit val locationJsonFormat = Json.format[Location]
implicit val createThingRequestJsonFormat = Json.format[ThingCreateRequest]
implicit val thingJsonFormat = Json.format[Thing]
, you can delegate it to kebs-play-json
import pl.iterators.kebs.json._
implicit val errorJsonFormat = Json.format[Error]
implicit val locationJsonFormat = Json.format[Location]
implicit val createThingRequestJsonFormat = Json.format[ThingCreateRequest]
implicit val thingJsonFormat = Json.format[Thing]
(or, trait-style)
object AfterKebs extends JsonProtocol with KebsPlay {
implicit val errorJsonFormat = Json.format[Error]
implicit val locationJsonFormat = Json.format[Location]
implicit val createThingRequestJsonFormat = Json.format[ThingCreateRequest]
implicit val thingJsonFormat = Json.format[Thing]
}
- kebs eliminates Circe induced boilerplate (kebs-circe)
Still in experimental stage! Circe might be a source of boilerplate depending on the type of derivation you use - if it's semi-auto derivation, you'll have to write a lot of encoders/decoders for your case classes:
object BeforeKebs {
object ThingProtocol extends CirceProtocol with CirceAkkaHttpSupport {
import io.circe._
import io.circe.generic.semiauto._
implicit val thingCreateRequestEncoder: Encoder[ThingCreateRequest] = deriveEncoder
implicit val thingCreateRequestDecoder: Decoder[ThingCreateRequest] = deriveDecoder
implicit val thingIdEncoder: Encoder[ThingId] = deriveEncoder
implicit val thingIdDecoder: Decoder[ThingId] = deriveDecoder
implicit val thingNameEncoder: Encoder[ThingName] = deriveEncoder
implicit val thingNameDecoder: Decoder[ThingName] = deriveDecoder
implicit val thingDescriptionEncoder: Encoder[ThingDescription] = deriveEncoder
implicit val thingDescriptionDecoder: Decoder[ThingDescription] = deriveDecoder
implicit val tagIdEncoder: Encoder[TagId] = deriveEncoder
implicit val tagIdDecoder: Decoder[TagId] = deriveDecoder
implicit val locationEncoder: Encoder[Location] = deriveEncoder
implicit val locationDecoder: Decoder[Location] = deriveDecoder
implicit val thingEncoder: Encoder[Thing] = deriveEncoder
implicit val thingDecoder: Decoder[Thing] = deriveDecoder
implicit val errorMessageDecoder: Decoder[ErrorMessage] = deriveDecoder
implicit val errorMessageEncoder: Encoder[ErrorMessage] = deriveEncoder
}
import ThingProtocol._
class ThingRouter(thingsService: ThingsService)(implicit ec: ExecutionContext) {
def createRoute: Route = (post & pathEndOrSingleSlash & entity(as[ThingCreateRequest])) { request =>
complete {
thingsService.create(request).map[ToResponseMarshallable] {
case ThingCreateResponse.Created(thing) => Created -> thing
case ThingCreateResponse.AlreadyExists => Conflict -> ErrorMessage("Already exists")
}
}
}
}
}
Kebs can get rid of this for you:
object AfterKebs {
object ThingProtocol extends KebsCirce with CirceProtocol with CirceAkkaHttpSupport
import ThingProtocol._
class ThingRouter(thingsService: ThingsService)(implicit ec: ExecutionContext) {
def createRoute: Route = (post & pathEndOrSingleSlash & entity(as[ThingCreateRequest])) { request =>
complete {
thingsService.create(request).map[ToResponseMarshallable] {
case ThingCreateResponse.Created(thing) => Created -> thing
case ThingCreateResponse.AlreadyExists => Conflict -> ErrorMessage("Already exists")
}
}
}
}
}
If you want to disable flat formats, you can mix-in KebsCirce.NoFlat
:
object KebsProtocol extends KebsCirce with KebsCirce.NoFlat
You can also support snake-case fields in JSON:
object KebsProtocol extends KebsCirce with KebsCirce.Snakified
And capitalized:
object KebsProtocol extends KebsCirce with KebsCirce.Capitalized
NOTE for Scala 3 version of kebs-circe:
- As of today, there is no support for the @noflat annotation - using it will have no effect.
- If you're using recursive types - due to this issue you'll have to add codecs explicitly in the following way:
case class R(a: Int, rs: Seq[R]) derives Decoder, Encoder.AsObject
- If you're using flat format or Snakified/Capitalized formats, remember to import
given
instances, e.g.:
object KebsProtocol extends KebsCirce with KebsCirce.Snakified
import KebsProtocol.{given, _}
as for NoFlat, it should stay the same:
object KebsProtocol extends KebsCirce with KebsCirce.NoFlat
import KebsProtocol._
- kebs generates akka-http Unmarshaller (kebs-akka-http)
It makes it very easy to use 1-element case-classes or enumeratum
enums/value enums in eg. parameters
directive:
sealed abstract class Column(val value: Int) extends IntEnumEntry
object Column extends IntEnum[Column] {
case object Name extends Column(1)
case object Date extends Column(2)
case object Type extends Column(3)
override val values = findValues
}
sealed trait SortOrder extends EnumEntry
object SortOrder extends Enum[SortOrder] {
case object Asc extends SortOrder
case object Desc extends SortOrder
override val values = findValues
}
case class Offset(value: Int) extends AnyVal
case class Limit(value: Int) extends AnyVal
case class PaginationQuery(sortBy: Column, sortOrder: SortOrder, offset: Offset, limit: Limit)
import pl.iterators.kebs.unmarshallers._
import enums._
val route = get {
parameters('sortBy.as[Column], 'order.as[SortOrder] ? (SortOrder.Desc: SortOrder), 'offset.as[Offset] ? Offset(0), 'limit.as[Limit])
.as(PaginationQuery) { query =>
//...
}
}
- kebs provides helpers for http4s
Kebs makes it easy to use 1-element case-classes, opaque types (Scala 3), enumeratum
or native Scala 3 enums in its DSL:
import java.util.UUID
import java.time.Year
import java.util.Currency
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import pl.iterators.kebs.opaque.Opaque
import pl.iterators.kebs.http4s.{given, _}
import pl.iterators.kebs.instances.KebsInstances._ // optional, if you want instances support, ex. java.util.Currency
opaque type Age = Int
object Age extends Opaque[Age, Int] {
override def validate(value: Int): Either[String, Age] =
if (value < 0) Left("No going back, sorry") else Right(value)
}
case class UserId(id: UUID)
enum Color {
case Red, Blue, Green
}
object AgeQueryParamDecoderMatcher extends QueryParamDecoderMatcher[Age]("age")
object OptionalYearParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Year]("year")
object ValidatingColorQueryParamDecoderMatcher extends ValidatingQueryParamDecoderMatcher[Color]("color")
val routes = HttpRoutes.of[IO] {
case GET -> Root / "WrappedInt" / WrappedInt[Age](age) => ...
case GET -> Root / "InstanceString" / InstanceString[Currency](currency) => ...
case GET -> Root / "EnumString" / EnumString[Color](color) => ...
case GET -> Root / "WrappedUUID" / WrappedUUID[UserId](userId) => ...
case GET -> Root / "WrappedIntParam" :? AgeQueryParamDecoderMatcher(age) => ...
case GET -> Root / "InstanceIntParam" :? OptionalYearParamDecoderMatcher(year) => ...
case GET -> Root / "EnumStringParam" :? ValidatingColorQueryParamDecoderMatcher(color) => ...
}
In Scala 2, some more boilerplate is required due to scala/bug#884. See tests for more details.
Tagged types
Starting with version 1.6.0, kebs contain an implementation of, so-called, tagged types
. If you want to know what a tagged type
is, please see eg.
Introduction to Tagged Types or Scalaz tagged types description.
In general, taggging of a type is a mechanism for distinguishing between various instances of the same type. For instance, you might want to use an Int
to represent an user id or purchase id.
But if you use just an Int the compiler will not protest if you use purchase id integer in place of user id integer and vice versa.
To gain additional type safety you could use 1-element case-class wrappers, or, tagged types. In short, you would create Int @@ UserId
and Int @@ PurchaseId
types,
where @@
is tag operator. Thus, you can distinguish between various usages of Int
while still retaining all Int
properties ie. Int @@ UserId
is still an Int
, but it is not Int @@ PurchaseId
.
This representation is very useful at times, but there is some boilerplate involved which kebs strives to eliminate. Let's take a look at examples.
To get only the kebs' implementation of tagged types, please add kebs-tagged
module to your build. You'll then be able to use tagging:
import pl.iterators.kebs.tagged._
trait UserId
trait PurchaseId
val userId: Int @@ UserId = 10.taggedWith[UserId]
val purchaseId: Int @@ PurchaseId = 10.@@[PurchaseId]
val userIds: List[Int @@ UserId] = List(10, 15, 20).@@@[UserId]
val purchaseIds: List[Int @@ PurchaseId] = List(10, 15, 20).taggedWithF[PurchaseId]
Additionally, if you want to use tagged types in Slick, just mix-in pl.iterators.kebs.tagged.slick.SlickSupport
(or import pl.iterators.kebs.tagged.slick._
).
import pl.iterators.kebs.tagged._
import pl.iterators.kebs.tagged.slick.SlickSupport
object SlickTaggedExample extends SlickSupport {
trait UserIdTag
type UserId = Long @@ UserIdTag
trait EmailTag
type Email = String @@ EmailTag
trait FirstNameTag
type FirstName = String @@ FirstNameTag
trait LastNameTag
type LastName = String @@ LastNameTag
final case class User(id: UserId, email: Email, firstName: Option[FirstName], lastName: Option[LastName], isAdmin: Boolean)
class Users(tag: Tag) extends Table[User](tag, "user") {
def id: Rep[UserId] = column[UserId]("id")
def email: Rep[Email] = column[Email]("email")
def firstName: Rep[Option[FirstName]] = column[Option[FirstName]]("first_name")
def lastName: Rep[Option[LastName]] = column[Option[LastName]]("last_name")
def isAdmin: Rep[Boolean] = column[Boolean]("is_admin")
override def * : ProvenShape[User] =
(id, email, firstName, lastName, isAdmin) <> (User.tupled, User.unapply)
}
}
More often than not, you want to perform some validation before tagging, or, you just want to have a smart constructor that will return tagged representation
whenever criteria are met. You do not have to write it by hand, you can just use kebs-tagged-meta
which generates all this code for you using scalameta
.
You just have to tag an object, or a trait, containing your tagged types with @tagged
annotation.
import pl.iterators.kebs.tagged._
import pl.iterators.kebs.tag.meta.tagged
@tagged object Tags {
trait NameTag
trait IdTag[+A]
trait PositiveIntTag
type Name = String @@ NameTag
type Id[A] = Int @@ IdTag[A]
type PositiveInt = Int @@ PositiveIntTag
object PositiveInt {
sealed trait Error
case object Negative extends Error
case object Zero extends Error
def validate(i: Int) = if (i == 0) Left(Zero) else if (i < 0) Left(Negative) else Right(i)
}
}
The annotation will translate your code to something like
object Tags {
trait NameTag
trait IdTag[+A]
trait PositiveIntTag
type Name = String @@ NameTag
type Id[A] = Int @@ IdTag[A]
type PositiveInt = Int @@ PositiveIntTag
object Name {
def apply(arg: String) = from(arg)
def from(arg: String) = arg.taggedWith[NameTag]
}
object Id {
def apply[A](arg: Int) = from[A](arg)
def from[A](arg: Int) = arg.taggedWith[IdTag[A]]
}
object PositiveInt {
sealed trait Error
case object Negative extends Error
case object Zero extends Error
def validate(i: Int) = if (i == 0) Left(Zero) else if (i < 0) Left(Negative) else Right(i)
def apply(arg: Int) = from(arg).getOrElse(throw new IllegalArgumentException(arg.toString))
def from(arg: Int) = validate(arg).right.map(arg1 => arg1.taggedWith[PositiveIntTag])
}
object PositiveIntTag {
implicit val PositiveIntCaseClass1Rep = new CaseClass1Rep[PositiveInt, Int](PositiveInt.apply(_), identity)
}
object IdTag {
implicit def IdCaseClass1Rep[A] = new CaseClass1Rep[Id[A], Int](Id.apply(_), identity)
}
object NameTag {
implicit val NameCaseClass1Rep = new CaseClass1Rep[Name, String](Name.apply(_), identity)
}
}
You can use generated from
and apply
methods as constructors of tagged type instance.
trait User
val someone = Name("Someone")
//someone: String @@ Tags.NameTag = Someone
val userId = Id[User](10)
//userId: Int @@ Tags.IdTag[User] = 10
val right = PositiveInt.from(10)
//right: scala.util.Either[Tags.PositiveInt.Error,Int @@ Tags.PositiveIntTag] = Right(10)
val notRight = PositiveInt.from(-10)
//notRight: scala.util.Either[Tags.PositiveInt.Error,Int @@ Tags.PositiveIntTag] = Left(Negative)
val alsoRight = PositiveInt(10)
//alsoRight: Int @@ Tags.PositiveIntTag = 10
PositiveInt(-10)
// java.lang.IllegalArgumentException: -10
There are some conventions that are assumed during generation.
- tags have to be empty traits (possibly generic)
- tagged types have to be aliases in form of
type X = SomeType @@ Tag
(possibly generic) - validation methods for tagged type X have to be defined in
object X
and have to:- be public
- be named
validate
- take no type parameters
- take a single argument
- return Either (this is not enforced though - you'll have a compilation error later)
Also, CaseClass1Rep
is generated for each tag meaning you will get a lot of kebs
machinery for free eg. spray formats etc.
Opaque types
As an alternative to tagged types, Scala 3 provides opaque types.
The principles of opaque types are similar to tagged type. The basic usage of opaque types requires the
same amount of boilerplate as tagged types - e.g. you have to write smart constructors, validations and unwrapping
mechanisms all by hand. kebs-opaque
is meant to help with that by generating a handful of methods and providing a
CaseClass1Rep
for an easy typclass derivation.
import pl.iterators.kebs.opaque._
object MyDomain {
opaque type ISBN = String
object ISBN extends Opaque[ISBN, String]
}
That's the basic usage. Inside the companion object you will get methods like from
, apply
, unsafe
and extension
method unwrap
plus an instance of CaseClass1Rep[ISBN, String]
. A more complete example below.
import pl.iterators.kebs.macros.CaseClass1Rep
import pl.iterators.kebs.opaque._
object MyDomain {
opaque type ISBN = String
object ISBN extends Opaque[ISBN, String] {
override protected def validate(unwrapped: String): Either[String, ISBN] = {
val trimmed = unwrapped.trim
val allDigits = trimmed.forall(_.isDigit)
if (allDigits && trimmed.length == 9) Right("0" + trimmed) // converting old style ISBN to a new one
else if (allDigits && trimmed.length == 10) Right(trimmed)
else Left(s"Invalid ISBN: $trimmed")
}
}
}
import MyDomain._
ISBN.from("1234567890") // Right(ISBN("1234567890"))
ISBN.from(" 123456789 ") // Right(ISBN("023456789"))
ISBN.from("foo") // Left("Invalid ISBN: foo")
val isbn = ISBN("1234567890") // ISBN("1234567890")
isbn.unwrap // "1234567890"
ISBN("foo") // throws IllegalArgumentException("Invalid ISBN: foo")
ISBN.unsafe("boom") // don't do that, unless you really need to!
trait Showable[A] {
def show(a: A): String
}
given Showable[String] = (a: String) => a
given[S, A](using showable: Showable[S], cc1Rep: CaseClass1Rep[A, S]): Showable[A] = (a: A) => showable.show(cc1Rep.unapply(a))
implicitly[Showable[ISBN]].show(ISBN("1234567890")) // "1234567890"
JsonSchema support
Still at experimental stage.
Kebs contains a macro which generates wrapped Json Schema object of https://github.com/andyglow/scala-jsonschema
.
Kebs also provides proper implicits conversions for their tagged types and common Java types.
To get your json schema you need to use import pl.iterators.kebs.jsonschema.KebsJsonSchema
(together with pl.iterators.kebs.jsonschema.KebsJsonSchemaPredefs if you need support for more Java types).
import com.github.andyglow.json.JsonFormatter
import com.github.andyglow.jsonschema.AsValue
import json.schema.Version.Draft07
import pl.iterators.kebs.jsonschema.{KebsJsonSchema, JsonSchemaWrapper}
case class WrappedInt(int: Int)
case class WrappedIntAnyVal(int: Int) extends AnyVal
case class Sample(someNumber: Int,
someText: String,
arrayOfNumbers: List[Int],
wrappedNumber: WrappedInt,
wrappedNumberAnyVal: WrappedIntAnyVal)
object Sample extends KebsJsonSchema {
object SchemaPrinter {
def printWrapper[T](id: String = "id")(implicit schemaWrapper: JsonSchemaWrapper[T]): String =
JsonFormatter.format(AsValue.schema(schemaWrapper.schema, Draft07(id)))
}
SchemaPrinter.printWrapper[Sample]()
}
Scalacheck support
Still at experimental stage.
Kebs provides support to use tagged types in your Arbitrary instances from ScalaCheck. Additionally, Kebs provides support for Java types. Kebs also introduces term of minimal and maximal generator. The minimal generator is a generator which always generates empty collection of Option, Set, Map etc. The maximum - in the opposite - always generates non-empty collections. Kebs provides an useful trait called AllGenerators which binds minimal, normal and maximal generator all together, so you can easily generate the representation you currently need for tests.
import pl.iterators.kebs.scalacheck.{KebsArbitraryPredefs, KebsScalacheckGenerators}
import java.net.{URI, URL}
import java.time.{Duration, Instant, LocalDate, LocalDateTime, LocalTime, ZonedDateTime}
case class WrappedInt(int: Int)
case class WrappedIntAnyVal(int: Int) extends AnyVal
case class BasicSample(
someNumber: Int,
someText: String,
wrappedNumber: WrappedInt,
wrappedNumberAnyVal: WrappedIntAnyVal,
)
case class CollectionsSample(
listOfNumbers: List[Int],
arrayOfNumbers: Array[Int],
setOfNumbers: Set[Int],
vectorOfNumbers: Vector[Int],
optionOfNumber: Option[Int],
mapOfNumberString: Map[Int, String],
)
case class JavaTypesSample(
instant: Instant,
zonedDateTime: ZonedDateTime,
localDateTime: LocalDateTime,
localDate: LocalDate,
localTime: LocalTime,
duration: Duration,
url: URL,
uri: URI
)
object Sample extends KebsScalacheckGenerators with KebsArbitraryPredefs {
val basic = allGenerators[BasicSample].normal.generate
val minimalCollections = allGenerators[CollectionsSample].minimal.generate
val maximalCollections = allGenerators[CollectionsSample].maximal.generate
val javaTypes = allGenerators[JavaTypesSample].normal.generate
}
Kebs for IntelliJ
The code generated by macros in kebs-tagged-meta
is not visible to IntelliJ IDEA. There is Kebs for IntelliJ
plugin that enhances experience with the library by adding support for generated code. You can install it from the IntelliJ Marketplace.
In the Settings/Preferences dialog, select "Plugins" and type "Kebs" into search input (see https://www.jetbrains.com/help/idea/managing-plugins.html for detailed instructions).
You can also use this web page: https://plugins.jetbrains.com/plugin/16069-kebs.