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

Add typo with custom types #426

Closed
wants to merge 13 commits into from
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ local.env
.bloop/
.ammonite/
metals.sbt

.scala-build
20 changes: 18 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ val anorm = "2.7.0"
val enumeratum = "1.7.2"
val scalaJavaTime = "2.5.0"
val tapir = "1.5.0"
val typoVersion = "0.3.1"
val chimney = "0.8.0-RC1"

val consoleDisabledOptions = Seq("-Werror", "-Ywarn-unused", "-Ywarn-unused-import")
Expand Down Expand Up @@ -228,7 +229,9 @@ lazy val common = (crossProject(JSPlatform, JVMPlatform) in file("lib/common"))
libraryDependencies ++= Seq(
"com.typesafe.play" %% "play-json" % playJson,
"net.wiringbits" %% "webapp-common" % webappUtils,
"org.scalatest" %% "scalatest" % "3.2.16" % Test
"org.playframework.anorm" %% "anorm" % anorm,
"org.scalatest" %% "scalatest" % "3.2.16" % Test,
"com.beachape" %% "enumeratum" % enumeratum
)
)
.jsSettings(
Expand Down Expand Up @@ -443,6 +446,17 @@ lazy val web = (project in file("web"))
)
)

lazy val typo = (crossProject(JSPlatform, JVMPlatform) in file("typo"))
.dependsOn(common)
.settings(
name := "wiringbits-typo",
libraryDependencies ++= Seq(
"com.olvind.typo" %% "typo" % typoVersion,
"com.olvind.typo" %% "typo-dsl-anorm" % typoVersion,
"com.typesafe.play" %% "play-json" % playJson
)
)

lazy val root = (project in file("."))
.aggregate(
common.jvm,
Expand All @@ -451,7 +465,9 @@ lazy val root = (project in file("."))
api.js,
ui,
server,
web
web,
typo.jvm,
typo.js
)
.settings(
publish := {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package net.wiringbits.common.models
import net.wiringbits.webapp.common.models.WrappedString
import net.wiringbits.webapp.common.validators.ValidationResult

import scala.language.implicitConversions

class Email private (val string: String) extends WrappedString

object Email extends WrappedString.Companion[Email] {
Expand All @@ -19,5 +21,7 @@ object Email extends WrappedString.Companion[Email] {
}
}

override def trusted(string: String): Email = new Email(string)
implicit override def trusted(string: String): Email = new Email(string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid implicit conversions


implicit val sqlType: String = "CITEXT"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make much sense to me, can we write it somewhere else? the typo layer would be ideal

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package net.wiringbits.common.models

import play.api.libs.json.*

import java.time.*
import java.time.temporal.TemporalUnit

// Typo doesn't support correctly java Instant, so we have to do our custom Instant
// TODO: remove and replace with java Instant when Typo support it
case class InstantCustom(value: Instant) {
def plus(amountToAdd: Long, unit: TemporalUnit): InstantCustom = InstantCustom(value.plus(amountToAdd, unit))

def isBefore(other: InstantCustom): Boolean = value.isBefore(other.value)

def isAfter(other: InstantCustom): Boolean = value.isAfter(other.value)

def plusSeconds(seconds: Long): InstantCustom = InstantCustom(value.plusSeconds(seconds))

override def toString: String = value.toString
}

object InstantCustom {
def now(): InstantCustom = InstantCustom(Instant.now())

def fromClock(implicit clock: Clock): InstantCustom = InstantCustom(clock.instant())

implicit val instantCustomFormat: Format[Instant] = Format[Instant](
fjs = implicitly[Reads[String]].map(string => Instant.parse(string)),
tjs = Writes[Instant](i => JsString(i.toString))
)

implicit val instantCustomWrites: Writes[InstantCustom] = Writes[InstantCustom](i => Json.toJson(i.value))

implicit val instantCustomReads: Reads[InstantCustom] =
implicitly[Reads[String]].map(string => InstantCustom(Instant.parse(string)))
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package net.wiringbits.common.models
import net.wiringbits.webapp.common.models.WrappedString
import net.wiringbits.webapp.common.validators.ValidationResult

import scala.language.implicitConversions

class Name private (val string: String) extends WrappedString

object Name extends WrappedString.Companion[Name] {
Expand All @@ -19,5 +21,5 @@ object Name extends WrappedString.Companion[Name] {
}
}

override def trusted(string: String): Name = new Name(string)
implicit override def trusted(string: String): Name = new Name(string)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.wiringbits.common.models.enums

import enumeratum.EnumEntry
import enumeratum.EnumEntry.Uppercase

sealed trait BackgroundJobStatus extends EnumEntry with Uppercase

object BackgroundJobStatus extends Enum[BackgroundJobStatus] {
case object Success extends BackgroundJobStatus
case object Pending extends BackgroundJobStatus
case object Failed extends BackgroundJobStatus

val values: IndexedSeq[BackgroundJobStatus] = findValues
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package net.wiringbits.common.models.enums

import enumeratum.EnumEntry
import enumeratum.EnumEntry.Uppercase

sealed trait BackgroundJobType extends EnumEntry with Uppercase

/** NOTE: Updating this model can cause tasks to fail, for example, if SendEmail is removed while there are pending
* SendEmail tasks stored at the database
*/
object BackgroundJobType extends Enum[BackgroundJobType] {
case object SendEmail extends BackgroundJobType

val values: IndexedSeq[BackgroundJobType] = findValues
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package net.wiringbits.common.models.enums

import enumeratum.EnumEntry
import play.api.libs.json.{Format, JsString, Reads, Writes}

import scala.language.implicitConversions

trait Enum[T <: EnumEntry] extends enumeratum.Enum[T] {
override implicit def withNameInsensitiveOption(name: String): Option[T] = super.withNameInsensitiveOption(name)

implicit val enumFormat: Format[T] = Format[T](
fjs = implicitly[Reads[String]].map(string => withNameInsensitive(string)),
tjs = Writes[T](i => JsString(i.entryName))
)

implicit val enumReads: Reads[T] =
implicitly[Reads[String]].map(string => withNameInsensitive(string))

implicit val enumWrites: Writes[T] =
Writes[T](i => JsString(i.entryName))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.wiringbits.common.models.enums

import enumeratum.EnumEntry
import enumeratum.EnumEntry.Uppercase

sealed trait UserTokenType extends EnumEntry with Uppercase

object UserTokenType extends Enum[UserTokenType] {
case object EmailVerification extends UserTokenType
case object ResetPassword extends UserTokenType

val values: IndexedSeq[UserTokenType] = findValues
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.wiringbits.common.models.id

import java.util.UUID

case class BackgroundJobId private (id: UUID) extends Id {
override def value: UUID = id
}

object BackgroundJobId extends Id.Companion[BackgroundJobId] {
override def parse(id: UUID): BackgroundJobId = BackgroundJobId(id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package net.wiringbits.common.models.id

import play.api.libs.json.{Format, JsString, Reads, Writes}

import java.util.UUID
import scala.language.implicitConversions

trait Id {
def value: UUID

override def toString: String = value.toString
}

object Id {
trait Companion[T <: Id] {
def parse(id: UUID): T

def randomUUID: T = parse(UUID.randomUUID())

implicit def fromString(str: String): T = parse(UUID.fromString(str))

implicit val idCustomReads: Reads[T] = implicitly[Reads[String]].map(string => fromString(string))

implicit val idCustomWrites: Writes[T] = Writes[T](i => JsString(i.value.toString))

implicit val idFormat: Format[T] = Format[T](
fjs = implicitly[Reads[String]].map(string => fromString(string)),
tjs = Writes[T](i => JsString(i.value.toString))
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.wiringbits.common.models.id

import java.util.UUID

case class UserId private (id: UUID) extends Id {
override def value: UUID = id
}

object UserId extends Id.Companion[UserId] {
override def parse(id: UUID): UserId = UserId(id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.wiringbits.common.models.id

import java.util.UUID

case class UserLogId private (id: UUID) extends Id {
override def value: UUID = id
}

object UserLogId extends Id.Companion[UserLogId] {
override def parse(id: UUID): UserLogId = UserLogId(id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.wiringbits.common.models.id

import java.util.UUID

case class UserTokenId private (id: UUID) extends Id {
override def value: UUID = id
}

object UserTokenId extends Id.Companion[UserTokenId] {
override def parse(id: UUID): UserTokenId = UserTokenId(id)
}
126 changes: 126 additions & 0 deletions server/src/main/scala/net/wiringbits/repositories/TypoCodecs.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package net.wiringbits.repositories

// Do not import packages, because it is easier to move this code to typo package without imports
object TypoCodecs {
implicit def wrappedColumn[T <: net.wiringbits.webapp.common.models.WrappedString](implicit
f: String => T
): anorm.Column[T] =
anorm.Column.nonNull[T] { (value, _) =>
value match {
case string: String => Right(f(string))
case _ => Left(anorm.TypeDoesNotMatch("Error parsing the email"))
}
}

implicit def wrappedOrdering[T <: net.wiringbits.webapp.common.models.WrappedString]: scala.math.Ordering[T] =
scala.math.Ordering.by(_.string)

implicit def wrappedToStatement[T <: net.wiringbits.webapp.common.models.WrappedString]: anorm.ToStatement[T] =
anorm.ToStatement[T]((s, index, v) => s.setObject(index, v.string))

implicit def wrappedParameterMetaData[T <: net.wiringbits.webapp.common.models.WrappedString](implicit
customSqlType: String = "VARCHAR"
): anorm.ParameterMetaData[T] = new anorm.ParameterMetaData[T] {
override def sqlType: String = customSqlType

override def jdbcType: Int = java.sql.Types.OTHER
}

implicit def idColumn[T <: net.wiringbits.common.models.id.Id](implicit f: String => T): anorm.Column[T] =
anorm.Column.nonNull[T] { (value, _) =>
value match {
case string: String => Right(f(string))
case _ => Left(anorm.TypeDoesNotMatch("Error parsing the email"))
}
}

implicit def idOrdering[T <: net.wiringbits.common.models.id.Id]: Ordering[T] = Ordering.by(_.value)

implicit def idToStatement[T <: net.wiringbits.common.models.id.Id]: anorm.ToStatement[T] =
anorm.ToStatement[T]((s, index, v) => s.setObject(index, v.value))

implicit def idParameterMetaData[T <: net.wiringbits.common.models.id.Id](implicit
customSqlType: String
): anorm.ParameterMetaData[T] = new anorm.ParameterMetaData[T] {
override def sqlType: String = customSqlType

override def jdbcType: Int = java.sql.Types.OTHER
}

@SuppressWarnings(Array("org.wartremover.warts.Null"))
private def timestamp[T](ts: java.sql.Timestamp)(f: java.sql.Timestamp => T): Either[anorm.SqlRequestError, T] =
Right(
if (ts == null) null.asInstanceOf[T] else f(ts)
)

private val timestamptzParser: java.time.format.DateTimeFormatter = new java.time.format.DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd HH:mm:ss")
.appendFraction(java.time.temporal.ChronoField.MICRO_OF_SECOND, 0, 6, true)
.appendPattern("X")
.toFormatter
implicit val columnToInstant: anorm.Column[net.wiringbits.common.models.InstantCustom] =
anorm.Column.nonNull(instantValueTo(instantToInstantCustom))

private def instantToInstantCustom(instant: java.time.Instant): net.wiringbits.common.models.InstantCustom =
net.wiringbits.common.models.InstantCustom(instant)

private def instantValueTo(
epoch: java.time.Instant => net.wiringbits.common.models.InstantCustom
)(value: Any, meta: anorm.MetaDataItem): Either[anorm.SqlRequestError, net.wiringbits.common.models.InstantCustom] = {
value match {
case date: java.time.LocalDateTime => Right(epoch(date.toInstant(java.time.ZoneOffset.UTC)))
case ts: java.sql.Timestamp => timestamp(ts)(t => epoch(t.toInstant))
case date: java.util.Date =>
Right(epoch(java.time.Instant.ofEpochMilli(date.getTime)))
case time: Long =>
Right(epoch(java.time.Instant.ofEpochMilli(time)))
case anorm.TimestampWrapper1(ts) => timestamp(ts)(t => epoch(t.toInstant))
case anorm.TimestampWrapper2(ts) => timestamp(ts)(t => epoch(t.toInstant))
case string: String =>
scala.util.Try(
net.wiringbits.common.models
.InstantCustom(java.time.OffsetDateTime.parse(string, timestamptzParser).toInstant)
) match
case scala.util.Failure(_) => Left(anorm.TypeDoesNotMatch("Error parsing the instant"))
case scala.util.Success(value) => Right(value)
case _ =>
Left(anorm.TypeDoesNotMatch("Error parsing the instant"))
}
}

implicit val instantCustomOrdering: Ordering[net.wiringbits.common.models.InstantCustom] = Ordering.by(_.value)
implicit val instantCustomToStatement: anorm.ToStatement[net.wiringbits.common.models.InstantCustom] =
anorm.ToStatement[net.wiringbits.common.models.InstantCustom]((s, index, v) => s.setObject(index, v.value.toString))
implicit val instantParameterMetaData: anorm.ParameterMetaData[net.wiringbits.common.models.InstantCustom] =
new anorm.ParameterMetaData[net.wiringbits.common.models.InstantCustom] {
override def sqlType: String = "TIMESTAMPTZ"

override def jdbcType: Int = java.sql.Types.TIMESTAMP_WITH_TIMEZONE
}

implicit def enumJobTypeColumn[T <: enumeratum.EnumEntry](implicit
withNameInsensitiveOption: String => Option[T]
): anorm.Column[T] =
anorm.Column.nonNull[T] { (value, _) =>
value match {
case string: String =>
withNameInsensitiveOption(string) match
case Some(value) => Right(value)
case None => Left(anorm.TypeDoesNotMatch(s"Unknown enum: $string"))
case _ => Left(anorm.TypeDoesNotMatch("Error parsing the enum"))
}
}

implicit def enumOrdering[T <: enumeratum.EnumEntry]: scala.math.Ordering[T] =
scala.math.Ordering.by(_.entryName)

implicit def enumToStatement[T <: enumeratum.EnumEntry]: anorm.ToStatement[T] =
anorm.ToStatement[T]((s, index, v) => s.setObject(index, v.entryName))

implicit def enumParameterMetaData[T <: enumeratum.EnumEntry]: anorm.ParameterMetaData[T] =
new anorm.ParameterMetaData[T] {
override def sqlType: String = "TEXT"

override def jdbcType: Int = java.sql.Types.VARCHAR
}
}
Loading
Loading