Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of kebs-opaque. #196

Merged
merged 3 commits into from
Feb 16, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,62 @@ There are some conventions that are assumed during generation.

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](https://docs.scala-lang.org/scala3/reference/other-new-features/opaques.html).
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.

```scala
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.

```scala
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.**
Expand Down
15 changes: 15 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ lazy val taggedSettings = commonSettings ++ Seq(
libraryDependencies += optionalCirce
)

lazy val opaqueSettings = commonSettings

lazy val examplesSettings = commonSettings ++ Seq(
libraryDependencies += slickPg.cross(CrossVersion.for3Use2_13),
libraryDependencies += circeParser,
Expand Down Expand Up @@ -380,6 +382,18 @@ lazy val tagged = project
crossScalaVersions := supportedScalaVersions
)

lazy val opaque = project
.in(file("opaque"))
.dependsOn(macroUtils)
.settings(opaqueSettings: _*)
.settings(publishSettings: _*)
.settings(
name := "opaque",
description := "Representation of opaque types",
moduleName := "kebs-opaque",
crossScalaVersions := Seq(scala_3)
)

lazy val taggedMeta = project
.in(file("tagged-meta"))
.dependsOn(
Expand Down Expand Up @@ -440,6 +454,7 @@ lazy val kebs = project
.in(file("."))
.aggregate(
tagged,
opaque,
macroUtils,
slickSupport,
sprayJsonMacros,
Expand Down
46 changes: 46 additions & 0 deletions opaque/src/main/scala/pl/iterators/kebs/opaque/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package pl.iterators.kebs

import pl.iterators.kebs.macros.CaseClass1Rep

package object opaque {
class Opaque[OpaqueType, Unwrapped](using ev: OpaqueType =:= Unwrapped) {
/**
* Validates and transforms (ex. sanitizes) unwrapped value.
* @param unwrapped value to be validated and transformed
* @return Left(reason) if validation fails; Right(opaqueType) if validation succeeds
*/
protected def validate(unwrapped: Unwrapped): Either[String, OpaqueType] = Right(ev.flip.apply(unwrapped))

/**
* Validates and transforms (ex. sanitizes) unwrapped value. By default, there is no validation or transformation.
* @param unwrapped value to be validated and transformed
* @return Left(reason) if validation fails; Right(opaqueType) if validation succeeds
*/
def from(unwrapped: Unwrapped): Either[String, OpaqueType] = validate(unwrapped)

/**
* Creates an instance of OpaqueType from unwrapped value.
* @param unwrapped value to be validated, transformed and wrapped in OpaqueType
* @throws IllegalArgumentException with reason, if validation fails
* @return OpaqueType wrapping validated & transformed unwrapped value
*/
def apply(unwrapped: Unwrapped): OpaqueType = validate(unwrapped).fold(l => throw new IllegalArgumentException(l), identity)

/**
* Creates an instance of OpaqueType from unwrapped value in an unsafe manner - without validation or transformation.
* @param unwrapped value to be wrapped in OpaqueType
* @return OpaqueType wrapping unwrapped value
*/
def unsafe(unwrapped: Unwrapped): OpaqueType = ev.flip.apply(unwrapped)

extension (w: OpaqueType) {
/**
* Unwraps value wrapped in OpaqueType.
* @return unwrapped value
*/
def unwrap: Unwrapped = ev.apply(w)
}

given cc1Rep: CaseClass1Rep[OpaqueType, Unwrapped] = CaseClass1Rep(apply, _.unwrap)
}
}
68 changes: 68 additions & 0 deletions opaque/src/test/scala/OpaqueTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import pl.iterators.kebs.opaque.Opaque
import pl.iterators.kebs.macros.CaseClass1Rep

object OpaqueTestDomain {
opaque type TestWrappedInt = Int
object TestWrappedInt extends Opaque[TestWrappedInt, Int]

opaque type ValidatedTestWrappedString = String
object ValidatedTestWrappedString extends Opaque[ValidatedTestWrappedString, String] {
override def validate(value: String): Either[String, ValidatedTestWrappedString] =
if (value.isEmpty) Left("Empty string") else Right(value.trim)
}

extension (s: ValidatedTestWrappedString) {
def myMap(f: Char => Char): ValidatedTestWrappedString = s.map(f)
}
}

object OpaqueTestTypeclass {
trait Showable[A] {
def show(a: A): String
}

given Showable[Int] = (a: Int) => a.toString
given[S, A](using showable: Showable[S], cc1Rep: CaseClass1Rep[A, S]): Showable[A] = (a: A) => showable.show(cc1Rep.unapply(a))
}

class OpaqueTest extends AnyFunSuite with Matchers {
import OpaqueTestDomain._
test("Equality") {
TestWrappedInt(42) shouldEqual TestWrappedInt(42)
TestWrappedInt(42) shouldNot equal (TestWrappedInt(1337))
"""TestWrappedString("foo") == "foo"""" shouldNot compile
"""implicitly[=:=[TestWrappedString, String]]""" shouldNot compile
}

test("Basic ops") {
TestWrappedInt(42).unwrap shouldEqual 42
TestWrappedInt.from(42) should equal (Right(TestWrappedInt(42)))
}

test("Validation & sanitization") {
an[IllegalArgumentException] should be thrownBy ValidatedTestWrappedString("")
ValidatedTestWrappedString.unsafe("").unwrap should equal ("")
ValidatedTestWrappedString(" foo ").unwrap should equal ("foo")
ValidatedTestWrappedString.from("") should equal (Left("Empty string"))
ValidatedTestWrappedString.from(" foo ") should equal (Right(ValidatedTestWrappedString("foo")))
}

test("Extension") {
ValidatedTestWrappedString("foo").myMap(_.toUpper) shouldEqual ValidatedTestWrappedString("FOO")
}

test("Basic derivation") {
"implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]]" should compile
implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].apply("foo") shouldEqual ValidatedTestWrappedString("foo")
implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].unapply(ValidatedTestWrappedString("foo")) shouldEqual "foo"
an[IllegalArgumentException] should be thrownBy implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].apply("")
}

test("Typeclass derivation") {
import OpaqueTestTypeclass._
"implicitly[Showable[TestWrappedInt]]" should compile
implicitly[Showable[TestWrappedInt]].show(TestWrappedInt(42)) shouldEqual "42"
}
}