From b6859c8560af601d716729d29094a156c9c01503 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Fri, 29 Jun 2018 17:56:06 -0700 Subject: Move shared classes (IDs, Formats, etc) to shared source folder * The JSON format object was split into traits and akka-specific unmarshallers are moved into a separate 'Directives' trait. * The singleton object xyz.driver.core.json is now deprecated. These changes should be source compatible, although they are not binary compatible. --- build.sbt | 68 ++-- jvm/src/main/scala/xyz/driver/core/auth.scala | 43 --- jvm/src/main/scala/xyz/driver/core/core.scala | 128 ------- jvm/src/main/scala/xyz/driver/core/date.scala | 109 ------ jvm/src/main/scala/xyz/driver/core/domain.scala | 46 --- .../scala/xyz/driver/core/domain/package.scala | 24 ++ jvm/src/main/scala/xyz/driver/core/future.scala | 87 ----- .../main/scala/xyz/driver/core/generators.scala | 138 ------- jvm/src/main/scala/xyz/driver/core/json.scala | 399 +-------------------- .../scala/xyz/driver/core/rest/Directives.scala | 83 +++++ .../scala/xyz/driver/core/rest/RestService.scala | 8 +- .../xyz/driver/core/rest/auth/Authorization.scala | 11 - .../core/rest/auth/AuthorizationResult.scala | 22 -- .../driver/core/rest/errors/serviceException.scala | 23 -- .../main/scala/xyz/driver/core/rest/package.scala | 58 --- .../driver/core/rest/serviceRequestContext.scala | 74 ---- jvm/src/main/scala/xyz/driver/core/time.scala | 175 --------- jvm/src/test/scala/xyz/driver/core/JsonTest.scala | 25 +- .../xyz/driver/core/rest/DriverRouteTest.scala | 7 +- .../scala/xyz/driver/core/CoreJsonFormats.scala | 321 +++++++++++++++++ shared/src/main/scala/xyz/driver/core/auth.scala | 43 +++ shared/src/main/scala/xyz/driver/core/core.scala | 132 +++++++ shared/src/main/scala/xyz/driver/core/date.scala | 109 ++++++ shared/src/main/scala/xyz/driver/core/domain.scala | 27 ++ shared/src/main/scala/xyz/driver/core/future.scala | 84 +++++ .../main/scala/xyz/driver/core/generators.scala | 138 +++++++ .../scala/xyz/driver/core/rest/AuthProvider.scala | 8 + .../xyz/driver/core/rest/ContextHeaders.scala | 15 + .../main/scala/xyz/driver/core/rest/Service.scala | 3 + .../xyz/driver/core/rest/auth/Authorization.scala | 11 + .../core/rest/auth/AuthorizationResult.scala | 21 ++ .../driver/core/rest/errors/serviceException.scala | 23 ++ .../driver/core/rest/serviceRequestContext.scala | 73 ++++ .../main/scala/xyz/driver/core/rest/sorting.scala | 35 ++ shared/src/main/scala/xyz/driver/core/time.scala | 175 +++++++++ 35 files changed, 1384 insertions(+), 1362 deletions(-) delete mode 100644 jvm/src/main/scala/xyz/driver/core/auth.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/core.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/date.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/domain.scala create mode 100644 jvm/src/main/scala/xyz/driver/core/domain/package.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/future.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/generators.scala create mode 100644 jvm/src/main/scala/xyz/driver/core/rest/Directives.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala delete mode 100644 jvm/src/main/scala/xyz/driver/core/time.scala create mode 100644 shared/src/main/scala/xyz/driver/core/CoreJsonFormats.scala create mode 100644 shared/src/main/scala/xyz/driver/core/auth.scala create mode 100644 shared/src/main/scala/xyz/driver/core/core.scala create mode 100644 shared/src/main/scala/xyz/driver/core/date.scala create mode 100644 shared/src/main/scala/xyz/driver/core/domain.scala create mode 100644 shared/src/main/scala/xyz/driver/core/future.scala create mode 100644 shared/src/main/scala/xyz/driver/core/generators.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/AuthProvider.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/ContextHeaders.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/Service.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala create mode 100644 shared/src/main/scala/xyz/driver/core/rest/sorting.scala create mode 100644 shared/src/main/scala/xyz/driver/core/time.scala diff --git a/build.sbt b/build.sbt index 9e70bb4..b3da6dc 100644 --- a/build.sbt +++ b/build.sbt @@ -8,37 +8,53 @@ lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .in(file(".")) .enablePlugins(Library) + .disablePlugins(Linting) .settings( + scalacOptions in Compile ++= Seq( + "-language:higherKinds", + "-language:implicitConversions", + "-language:postfixOps", + "-language:reflectiveCalls", // TODO this should be discouraged + "-unchecked", + "-deprecation", + "-feature", + "-encoding", + "utf8", + "-Xlint:_,-unused,-missing-interpolator", + "-Ywarn-numeric-widen", + "-Ywarn-dead-code", + "-Ywarn-unused:_,-explicits,-implicits" + ), libraryDependencies ++= Seq( - "xyz.driver" %%% "spray-json-derivation" % "0.4.5" + "xyz.driver" %%% "spray-json-derivation" % "0.4.7", + "com.beachape" %%% "enumeratum" % "1.5.13", + "org.scalaz" %%% "scalaz-core" % "7.2.24", + "eu.timepit" %%% "refined" % "0.9.1", + "org.scalatest" %%% "scalatest" % "3.0.5" % "test" ) ) .jvmSettings(libraryDependencies ++= Seq( - "xyz.driver" %% "tracing" % "0.1.2", - "com.typesafe.akka" %% "akka-actor" % "2.5.13", - "com.typesafe.akka" %% "akka-stream" % "2.5.13", - "com.typesafe.akka" %% "akka-http-core" % akkaHttpV, - "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpV, - "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpV, - "com.pauldijou" %% "jwt-core" % "0.16.0", - "org.scalatest" %% "scalatest" % "3.0.5" % "test", - "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", - "org.scalaz" %% "scalaz-core" % "7.2.24", - "com.github.swagger-akka-http" %% "swagger-akka-http" % "0.14.0", - "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0", - "eu.timepit" %% "refined" % "0.9.0", - "com.typesafe.slick" %% "slick" % "3.2.3", - "com.beachape" %% "enumeratum" % "1.5.13", - "org.mockito" % "mockito-core" % "1.9.5" % Test, - "com.amazonaws" % "aws-java-sdk-s3" % "1.11.342", - "com.google.cloud" % "google-cloud-pubsub" % "1.31.0", - "com.google.cloud" % "google-cloud-storage" % "1.31.0", - "com.typesafe" % "config" % "1.3.3", - "ch.qos.logback" % "logback-classic" % "1.2.3", - "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5", - "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5", - "com.googlecode.libphonenumber" % "libphonenumber" % "8.9.7" + "xyz.driver" %% "tracing" % "0.1.2", + "com.typesafe.akka" %% "akka-actor" % "2.5.13", + "com.typesafe.akka" %% "akka-stream" % "2.5.13", + "com.typesafe.akka" %% "akka-http-core" % akkaHttpV, + "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpV, + "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpV, + "com.pauldijou" %% "jwt-core" % "0.16.0", + "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", + "com.github.swagger-akka-http" %% "swagger-akka-http" % "0.14.0", + "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0", + "com.typesafe.slick" %% "slick" % "3.2.3", + "org.mockito" % "mockito-core" % "1.9.5" % Test, + "com.amazonaws" % "aws-java-sdk-s3" % "1.11.342", + "com.google.cloud" % "google-cloud-pubsub" % "1.31.0", + "com.google.cloud" % "google-cloud-storage" % "1.31.0", + "com.typesafe" % "config" % "1.3.3", + "ch.qos.logback" % "logback-classic" % "1.2.3", + "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5", + "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5", + "com.googlecode.libphonenumber" % "libphonenumber" % "8.9.7" )) lazy val coreJVM = core.jvm -lazy val coreJS = core.js +lazy val coreJS = core.js diff --git a/jvm/src/main/scala/xyz/driver/core/auth.scala b/jvm/src/main/scala/xyz/driver/core/auth.scala deleted file mode 100644 index 896bd89..0000000 --- a/jvm/src/main/scala/xyz/driver/core/auth.scala +++ /dev/null @@ -1,43 +0,0 @@ -package xyz.driver.core - -import xyz.driver.core.domain.Email -import xyz.driver.core.time.Time -import scalaz.Equal - -object auth { - - trait Permission - - final case class Role(id: Id[Role], name: Name[Role]) { - - def oneOf(roles: Role*): Boolean = roles.contains(this) - - def oneOf(roles: Set[Role]): Boolean = roles.contains(this) - } - - object Role { - implicit def idEqual: Equal[Role] = Equal.equal[Role](_ == _) - } - - trait User { - def id: Id[User] - } - - final case class AuthToken(value: String) - - final case class AuthTokenUserInfo( - id: Id[User], - email: Email, - emailVerified: Boolean, - audience: String, - roles: Set[Role], - expirationTime: Time) - extends User - - final case class RefreshToken(value: String) - final case class PermissionsToken(value: String) - - final case class PasswordHash(value: String) - - final case class AuthCredentials(identifier: String, password: String) -} diff --git a/jvm/src/main/scala/xyz/driver/core/core.scala b/jvm/src/main/scala/xyz/driver/core/core.scala deleted file mode 100644 index 72237b9..0000000 --- a/jvm/src/main/scala/xyz/driver/core/core.scala +++ /dev/null @@ -1,128 +0,0 @@ -package xyz.driver - -import scalaz.{Equal, Monad, OptionT} -import eu.timepit.refined.api.{Refined, Validate} -import eu.timepit.refined.collection.NonEmpty -import xyz.driver.core.rest.errors.ExternalServiceException - -import scala.concurrent.{ExecutionContext, Future} - -package object core { - - import scala.language.reflectiveCalls - - def make[T](v: => T)(f: T => Unit): T = { - val value = v - f(value) - value - } - - def using[R <: { def close() }, P](r: => R)(f: R => P): P = { - val resource = r - try { - f(resource) - } finally { - resource.close() - } - } - - object tagging { - private[core] trait Tagged[+V, +Tag] - - implicit class Taggable[V <: Any](val v: V) extends AnyVal { - def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] - } - } - type @@[+V, +Tag] = V with tagging.Tagged[V, Tag] - - implicit class OptionTExtensions[H[_]: Monad, T](optionTValue: OptionT[H, T]) { - - def returnUnit: H[Unit] = optionTValue.fold[Unit](_ => (), ()) - - def continueIgnoringNone: OptionT[H, Unit] = - optionTValue.map(_ => ()).orElse(OptionT.some[H, Unit](())) - - def subflatMap[B](f: T => Option[B]): OptionT[H, B] = - OptionT.optionT[H](implicitly[Monad[H]].map(optionTValue.run)(_.flatMap(f))) - } - - implicit class MonadicExtensions[H[_]: Monad, T](monadicValue: H[T]) { - private implicit val monadT = implicitly[Monad[H]] - - def returnUnit: H[Unit] = monadT(monadicValue)(_ => ()) - - def toOptionT: OptionT[H, T] = - OptionT.optionT[H](monadT(monadicValue)(value => Option(value))) - - def toUnitOptionT: OptionT[H, Unit] = - OptionT.optionT[H](monadT(monadicValue)(_ => Option(()))) - } - - implicit class FutureExtensions[T](future: Future[T]) { - def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = - future.transform(identity, { - case ExternalServiceException(_, _, Some(e)) => e - case t: Throwable => t - }) - } -} - -package core { - - final case class Id[+Tag](value: String) extends AnyVal { - @inline def length: Int = value.length - override def toString: String = value - } - - @SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion")) - object Id { - implicit def idEqual[T]: Equal[Id[T]] = Equal.equal[Id[T]](_ == _) - implicit def idOrdering[T]: Ordering[Id[T]] = Ordering.by[Id[T], String](_.value) - - sealed class Mapper[E, R] { - def apply[T >: E](id: Id[R]): Id[T] = Id[E](id.value) - def apply[T >: R](id: Id[E])(implicit dummy: DummyImplicit): Id[T] = Id[R](id.value) - } - object Mapper { - def apply[E, R] = new Mapper[E, R] - } - implicit def convertRE[R, E](id: Id[R])(implicit mapper: Mapper[E, R]): Id[E] = mapper[E](id) - implicit def convertER[E, R](id: Id[E])(implicit mapper: Mapper[E, R]): Id[R] = mapper[R](id) - } - - final case class Name[+Tag](value: String) extends AnyVal { - @inline def length: Int = value.length - override def toString: String = value - } - - object Name { - implicit def nameEqual[T]: Equal[Name[T]] = Equal.equal[Name[T]](_ == _) - implicit def nameOrdering[T]: Ordering[Name[T]] = Ordering.by(_.value) - - implicit def nameValidator[T, P](implicit stringValidate: Validate[String, P]): Validate[Name[T], P] = { - Validate.instance[Name[T], P, stringValidate.R]( - name => stringValidate.validate(name.value), - name => stringValidate.showExpr(name.value)) - } - } - - final case class NonEmptyName[+Tag](value: String Refined NonEmpty) { - @inline def length: Int = value.value.length - override def toString: String = value.value - } - - object NonEmptyName { - implicit def nonEmptyNameEqual[T]: Equal[NonEmptyName[T]] = - Equal.equal[NonEmptyName[T]](_.value.value == _.value.value) - - implicit def nonEmptyNameOrdering[T]: Ordering[NonEmptyName[T]] = Ordering.by(_.value.value) - } - - final case class Revision[T](id: String) - - object Revision { - implicit def revisionEqual[T]: Equal[Revision[T]] = Equal.equal[Revision[T]](_.id == _.id) - } - - final case class Base64(value: String) -} diff --git a/jvm/src/main/scala/xyz/driver/core/date.scala b/jvm/src/main/scala/xyz/driver/core/date.scala deleted file mode 100644 index 5454093..0000000 --- a/jvm/src/main/scala/xyz/driver/core/date.scala +++ /dev/null @@ -1,109 +0,0 @@ -package xyz.driver.core - -import java.util.Calendar - -import enumeratum._ -import scalaz.std.anyVal._ -import scalaz.syntax.equal._ - -import scala.collection.immutable.IndexedSeq -import scala.util.Try - -/** - * Driver Date type and related validators/extractors. - * Day, Month, and Year extractors are from ISO 8601 strings => driver...Date integers. - * TODO: Decouple extractors from ISO 8601, as we might want to parse other formats. - */ -object date { - - sealed trait DayOfWeek extends EnumEntry - object DayOfWeek extends Enum[DayOfWeek] { - case object Monday extends DayOfWeek - case object Tuesday extends DayOfWeek - case object Wednesday extends DayOfWeek - case object Thursday extends DayOfWeek - case object Friday extends DayOfWeek - case object Saturday extends DayOfWeek - case object Sunday extends DayOfWeek - - val values: IndexedSeq[DayOfWeek] = findValues - - val All: Set[DayOfWeek] = values.toSet - - def fromString(day: String): Option[DayOfWeek] = withNameInsensitiveOption(day) - } - - type Day = Int @@ Day.type - - object Day { - def apply(value: Int): Day = { - require(1 to 31 contains value, "Day must be in range 1 <= value <= 31") - value.asInstanceOf[Day] - } - - def unapply(dayString: String): Option[Int] = { - require(dayString.length === 2, s"ISO 8601 day string, DD, must have length 2: $dayString") - Try(dayString.toInt).toOption.map(apply) - } - } - - type Month = Int @@ Month.type - - object Month { - def apply(value: Int): Month = { - require(0 to 11 contains value, "Month is zero-indexed: 0 <= value <= 11") - value.asInstanceOf[Month] - } - val JANUARY = Month(Calendar.JANUARY) - val FEBRUARY = Month(Calendar.FEBRUARY) - val MARCH = Month(Calendar.MARCH) - val APRIL = Month(Calendar.APRIL) - val MAY = Month(Calendar.MAY) - val JUNE = Month(Calendar.JUNE) - val JULY = Month(Calendar.JULY) - val AUGUST = Month(Calendar.AUGUST) - val SEPTEMBER = Month(Calendar.SEPTEMBER) - val OCTOBER = Month(Calendar.OCTOBER) - val NOVEMBER = Month(Calendar.NOVEMBER) - val DECEMBER = Month(Calendar.DECEMBER) - - def unapply(monthString: String): Option[Month] = { - require(monthString.length === 2, s"ISO 8601 month string, MM, must have length 2: $monthString") - Try(monthString.toInt).toOption.map(isoM => apply(isoM - 1)) - } - } - - type Year = Int @@ Year.type - - object Year { - def apply(value: Int): Year = value.asInstanceOf[Year] - - def unapply(yearString: String): Option[Int] = { - require(yearString.length === 4, s"ISO 8601 year string, YYYY, must have length 4: $yearString") - Try(yearString.toInt).toOption.map(apply) - } - } - - final case class Date(year: Int, month: Month, day: Int) { - override def toString = f"$year%04d-${month + 1}%02d-$day%02d" - } - - object Date { - implicit def dateOrdering: Ordering[Date] = Ordering.fromLessThan { (date1, date2) => - if (date1.year != date2.year) { - date1.year < date2.year - } else if (date1.month != date2.month) { - date1.month < date2.month - } else { - date1.day < date2.day - } - } - - def fromString(dateString: String): Option[Date] = { - dateString.split('-') match { - case Array(Year(year), Month(month), Day(day)) => Some(Date(year, month, day)) - case _ => None - } - } - } -} diff --git a/jvm/src/main/scala/xyz/driver/core/domain.scala b/jvm/src/main/scala/xyz/driver/core/domain.scala deleted file mode 100644 index fa3b5c4..0000000 --- a/jvm/src/main/scala/xyz/driver/core/domain.scala +++ /dev/null @@ -1,46 +0,0 @@ -package xyz.driver.core - -import com.google.i18n.phonenumbers.PhoneNumberUtil -import scalaz.Equal -import scalaz.std.string._ -import scalaz.syntax.equal._ - -object domain { - - final case class Email(username: String, domain: String) { - override def toString: String = username + "@" + domain - } - - object Email { - implicit val emailEqual: Equal[Email] = Equal.equal { - case (left, right) => left.toString.toLowerCase === right.toString.toLowerCase - } - - def parse(emailString: String): Option[Email] = { - Some(emailString.split("@")) collect { - case Array(username, domain) => Email(username, domain) - } - } - } - - final case class PhoneNumber(countryCode: String = "1", number: String) { - override def toString: String = s"+$countryCode $number" - } - - object PhoneNumber { - - private val phoneUtil = PhoneNumberUtil.getInstance() - - def parse(phoneNumber: String): Option[PhoneNumber] = { - val phone = scala.util.Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption - - val validated = phone match { - case None => None - case Some(pn) => - if (!phoneUtil.isValidNumber(pn)) None - else Some(pn) - } - validated.map(pn => PhoneNumber(pn.getCountryCode.toString, pn.getNationalNumber.toString)) - } - } -} diff --git a/jvm/src/main/scala/xyz/driver/core/domain/package.scala b/jvm/src/main/scala/xyz/driver/core/domain/package.scala new file mode 100644 index 0000000..a76e83c --- /dev/null +++ b/jvm/src/main/scala/xyz/driver/core/domain/package.scala @@ -0,0 +1,24 @@ +package xyz.driver.core + +import com.google.i18n.phonenumbers.PhoneNumberUtil + +package object domain { + + private val phoneUtil = PhoneNumberUtil.getInstance() + + /** Enhances the PhoneNumber companion object with methods only available on the JVM. */ + implicit class JvmPhoneNumber(val number: PhoneNumber.type) extends AnyVal { + def parse(phoneNumber: String): Option[PhoneNumber] = { + val phone = scala.util.Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption + + val validated = phone match { + case None => None + case Some(pn) => + if (!phoneUtil.isValidNumber(pn)) None + else Some(pn) + } + validated.map(pn => PhoneNumber(pn.getCountryCode.toString, pn.getNationalNumber.toString)) + } + } + +} diff --git a/jvm/src/main/scala/xyz/driver/core/future.scala b/jvm/src/main/scala/xyz/driver/core/future.scala deleted file mode 100644 index 1ee3576..0000000 --- a/jvm/src/main/scala/xyz/driver/core/future.scala +++ /dev/null @@ -1,87 +0,0 @@ -package xyz.driver.core - -import com.typesafe.scalalogging.Logger - -import scala.concurrent.{ExecutionContext, Future, Promise} -import scala.util.{Failure, Success, Try} - -object future { - val log = Logger("Driver.Future") - - implicit class RichFuture[T](f: Future[T]) { - def mapAll[U](pf: PartialFunction[Try[T], U])(implicit executionContext: ExecutionContext): Future[U] = { - val p = Promise[U]() - f.onComplete(r => p.complete(Try(pf(r)))) - p.future - } - - def failFastZip[U](that: Future[U])(implicit executionContext: ExecutionContext): Future[(T, U)] = { - future.failFastZip(f, that) - } - } - - def failFastSequence[T](t: Iterable[Future[T]])(implicit ec: ExecutionContext): Future[Seq[T]] = { - t.foldLeft(Future.successful(Nil: List[T])) { (f, i) => - failFastZip(f, i).map { case (tail, h) => h :: tail } - } - .map(_.reverse) - } - - /** - * Standard scala zip waits forever on the left side, even if the right side fails - */ - def failFastZip[T, U](ft: Future[T], fu: Future[U])(implicit ec: ExecutionContext): Future[(T, U)] = { - type State = Either[(T, Promise[U]), (U, Promise[T])] - val middleState = Promise[State]() - - ft.onComplete { - case f @ Failure(err) => - if (!middleState.tryFailure(err)) { - // the right has already succeeded - middleState.future.foreach { - case Right((_, pt)) => pt.complete(f) - case Left((t1, _)) => // This should never happen - log.error(s"Logic error: tried to set Failure($err) but Left($t1) already set") - } - } - case Success(t) => - // Create the next promise: - val pu = Promise[U]() - if (!middleState.trySuccess(Left((t, pu)))) { - // we can't set, so the other promise beat us here. - middleState.future.foreach { - case Right((_, pt)) => pt.success(t) - case Left((t1, _)) => // This should never happen - log.error(s"Logic error: tried to set Left($t) but Left($t1) already set") - } - } - } - fu.onComplete { - case f @ Failure(err) => - if (!middleState.tryFailure(err)) { - // we can't set, so the other promise beat us here. - middleState.future.foreach { - case Left((_, pu)) => pu.complete(f) - case Right((u1, _)) => // This should never happen - log.error(s"Logic error: tried to set Failure($err) but Right($u1) already set") - } - } - case Success(u) => - // Create the next promise: - val pt = Promise[T]() - if (!middleState.trySuccess(Right((u, pt)))) { - // we can't set, so the other promise beat us here. - middleState.future.foreach { - case Left((_, pu)) => pu.success(u) - case Right((u1, _)) => // This should never happen - log.error(s"Logic error: tried to set Right($u) but Right($u1) already set") - } - } - } - - middleState.future.flatMap { - case Left((t, pu)) => pu.future.map((t, _)) - case Right((u, pt)) => pt.future.map((_, u)) - } - } -} diff --git a/jvm/src/main/scala/xyz/driver/core/generators.scala b/jvm/src/main/scala/xyz/driver/core/generators.scala deleted file mode 100644 index d57980e..0000000 --- a/jvm/src/main/scala/xyz/driver/core/generators.scala +++ /dev/null @@ -1,138 +0,0 @@ -package xyz.driver.core - -import enumeratum._ -import java.math.MathContext -import java.util.UUID - -import xyz.driver.core.time.{Time, TimeOfDay, TimeRange} -import xyz.driver.core.date.{Date, DayOfWeek} - -import scala.reflect.ClassTag -import scala.util.Random -import eu.timepit.refined.refineV -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection._ - -object generators { - - private val random = new Random - import random._ - private val secureRandom = new java.security.SecureRandom() - - private val DefaultMaxLength = 10 - private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet - private val NonAmbigiousCharacters = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" - private val Numbers = "0123456789" - - private def nextTokenString(length: Int, chars: IndexedSeq[Char]): String = { - val builder = new StringBuilder - for (_ <- 0 until length) { - builder += chars(secureRandom.nextInt(chars.length)) - } - builder.result() - } - - /** Creates a random invitation token. - * - * This token is meant fo human input and avoids using ambiguous characters such as 'O' and '0'. It - * therefore contains less entropy and is not meant to be used as a cryptographic secret. */ - @deprecated( - "The term 'token' is too generic and security and readability conventions are not well defined. " + - "Services should implement their own version that suits their security requirements.", - "1.11.0" - ) - def nextToken(length: Int): String = nextTokenString(length, NonAmbigiousCharacters) - - @deprecated( - "The term 'token' is too generic and security and readability conventions are not well defined. " + - "Services should implement their own version that suits their security requirements.", - "1.11.0" - ) - def nextNumericToken(length: Int): String = nextTokenString(length, Numbers) - - def nextInt(maxValue: Int, minValue: Int = 0): Int = random.nextInt(maxValue - minValue) + minValue - - def nextBoolean(): Boolean = random.nextBoolean() - - def nextDouble(): Double = random.nextDouble() - - def nextId[T](): Id[T] = Id[T](nextUuid().toString) - - def nextId[T](maxLength: Int): Id[T] = Id[T](nextString(maxLength)) - - def nextNumericId[T](): Id[T] = Id[T](nextLong.abs.toString) - - def nextNumericId[T](maxValue: Int): Id[T] = Id[T](nextInt(maxValue).toString) - - def nextName[T](maxLength: Int = DefaultMaxLength): Name[T] = Name[T](nextString(maxLength)) - - def nextNonEmptyName[T](maxLength: Int = DefaultMaxLength): NonEmptyName[T] = - NonEmptyName[T](nextNonEmptyString(maxLength)) - - def nextUuid(): UUID = java.util.UUID.randomUUID - - def nextRevision[T](): Revision[T] = Revision[T](nextUuid().toString) - - def nextString(maxLength: Int = DefaultMaxLength): String = - (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString - - def nextNonEmptyString(maxLength: Int = DefaultMaxLength): String Refined NonEmpty = { - refineV[NonEmpty]( - (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString - ).right.get - } - - def nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None - - def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) - - def nextTriad[F, S, T](first: => F, second: => S, third: => T): (F, S, T) = (first, second, third) - - def nextTime(): Time = Time(math.abs(nextLong() % System.currentTimeMillis)) - - def nextTimeOfDay: TimeOfDay = TimeOfDay(java.time.LocalTime.MIN.plusSeconds(nextLong), java.util.TimeZone.getDefault) - - def nextTimeRange(): TimeRange = { - val oneTime = nextTime() - val anotherTime = nextTime() - - TimeRange( - Time(scala.math.min(oneTime.millis, anotherTime.millis)), - Time(scala.math.max(oneTime.millis, anotherTime.millis))) - } - - def nextDate(): Date = nextTime().toDate(java.util.TimeZone.getTimeZone("UTC")) - - def nextDayOfWeek(): DayOfWeek = oneOf(DayOfWeek.All) - - def nextBigDecimal(multiplier: Double = 1000000.00, precision: Int = 2): BigDecimal = - BigDecimal(multiplier * nextDouble, new MathContext(precision)) - - def oneOf[T](items: T*): T = oneOf(items.toSet) - - def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) - - def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) - - def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = - Array.fill(nextInt(maxLength, minLength))(generator) - - def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Seq[T] = - Seq.fill(nextInt(maxLength, minLength))(generator) - - def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Vector[T] = - Vector.fill(nextInt(maxLength, minLength))(generator) - - def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): List[T] = - List.fill(nextInt(maxLength, minLength))(generator) - - def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Set[T] = - seqOf(generator, maxLength, minLength).toSet - - def mapOf[K, V]( - keyGenerator: => K, - valueGenerator: => V, - maxLength: Int = DefaultMaxLength, - minLength: Int = 0): Map[K, V] = - seqOf(nextPair(keyGenerator, valueGenerator), maxLength, minLength).toMap -} diff --git a/jvm/src/main/scala/xyz/driver/core/json.scala b/jvm/src/main/scala/xyz/driver/core/json.scala index de1df31..ca5a47b 100644 --- a/jvm/src/main/scala/xyz/driver/core/json.scala +++ b/jvm/src/main/scala/xyz/driver/core/json.scala @@ -1,401 +1,20 @@ package xyz.driver.core -import java.net.InetAddress -import java.util.{TimeZone, UUID} - -import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} -import akka.http.scaladsl.model.Uri.Path -import akka.http.scaladsl.server.PathMatcher.{Matched, Unmatched} -import akka.http.scaladsl.server._ import akka.http.scaladsl.unmarshalling.Unmarshaller import enumeratum._ -import eu.timepit.refined.api.{Refined, Validate} -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.refineV -import spray.json._ -import xyz.driver.core.auth.AuthCredentials -import xyz.driver.core.date.{Date, DayOfWeek, Month} -import xyz.driver.core.domain.{Email, PhoneNumber} -import xyz.driver.core.rest.errors._ -import xyz.driver.core.time.{Time, TimeOfDay} - -import scala.reflect.runtime.universe._ -import scala.util.Try - -object json { - import DefaultJsonProtocol._ - - private def UuidInPath[T]: PathMatcher1[Id[T]] = - PathMatchers.JavaUUID.map((id: UUID) => Id[T](id.toString.toLowerCase)) - - def IdInPath[T]: PathMatcher1[Id[T]] = UuidInPath[T] | new PathMatcher1[Id[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => Matched(tail, Tuple1(Id[T](segment))) - case _ => Unmatched - } - } - - implicit def paramUnmarshaller[T](implicit reader: JsonReader[T]): Unmarshaller[String, T] = - Unmarshaller.firstOf( - Unmarshaller.strict((JsString(_: String)) andThen reader.read), - stringToValueUnmarshaller[T] - ) - - implicit def idFormat[T]: RootJsonFormat[Id[T]] = new RootJsonFormat[Id[T]] { - def write(id: Id[T]) = JsString(id.value) - - def read(value: JsValue): Id[T] = value match { - case JsString(id) if Try(UUID.fromString(id)).isSuccess => Id[T](id.toLowerCase) - case JsString(id) => Id[T](id) - case _ => throw DeserializationException("Id expects string") - } - } - - implicit def taggedFormat[F, T](implicit underlying: JsonFormat[F]): JsonFormat[F @@ T] = new JsonFormat[F @@ T] { - import tagging._ - - override def write(obj: F @@ T): JsValue = underlying.write(obj) - - override def read(json: JsValue): F @@ T = underlying.read(json).tagged[T] - } - - def NameInPath[T]: PathMatcher1[Name[T]] = new PathMatcher1[Name[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => Matched(tail, Tuple1(Name[T](segment))) - case _ => Unmatched - } - } - - implicit def nameFormat[T] = new RootJsonFormat[Name[T]] { - def write(name: Name[T]) = JsString(name.value) - - def read(value: JsValue): Name[T] = value match { - case JsString(name) => Name[T](name) - case _ => throw DeserializationException("Name expects string") - } - } - - def TimeInPath: PathMatcher1[Time] = - PathMatcher("""[+-]?\d*""".r) flatMap { string => - try Some(Time(string.toLong)) - catch { case _: IllegalArgumentException => None } - } - - implicit val timeFormat = new RootJsonFormat[Time] { - def write(time: Time) = JsObject("timestamp" -> JsNumber(time.millis)) - - def read(value: JsValue): Time = value match { - case JsObject(fields) => - fields - .get("timestamp") - .flatMap { - case JsNumber(millis) => Some(Time(millis.toLong)) - case _ => None - } - .getOrElse(throw DeserializationException("Time expects number")) - case _ => throw DeserializationException("Time expects number") - } - } - - implicit object localTimeFormat extends JsonFormat[java.time.LocalTime] { - private val formatter = TimeOfDay.getFormatter - def read(json: JsValue): java.time.LocalTime = json match { - case JsString(chars) => - java.time.LocalTime.parse(chars) - case _ => deserializationError(s"Expected time string got ${json.toString}") - } - - def write(obj: java.time.LocalTime): JsValue = { - JsString(obj.format(formatter)) - } - } - - implicit object timeZoneFormat extends JsonFormat[java.util.TimeZone] { - override def write(obj: TimeZone): JsValue = { - JsString(obj.getID()) - } - - override def read(json: JsValue): TimeZone = json match { - case JsString(chars) => - java.util.TimeZone.getTimeZone(chars) - case _ => deserializationError(s"Expected time zone string got ${json.toString}") - } - } - - implicit val timeOfDayFormat: RootJsonFormat[TimeOfDay] = jsonFormat2(TimeOfDay.apply) - - implicit val dayOfWeekFormat: JsonFormat[DayOfWeek] = new enumeratum.EnumJsonFormat(DayOfWeek) - - implicit val dateFormat = new RootJsonFormat[Date] { - def write(date: Date) = JsString(date.toString) - def read(value: JsValue): Date = value match { - case JsString(dateString) => - Date - .fromString(dateString) - .getOrElse( - throw DeserializationException(s"Misformated ISO 8601 Date. Expected YYYY-MM-DD, but got $dateString.")) - case _ => throw DeserializationException(s"Date expects a string, but got $value.") - } - } - - implicit val monthFormat = new RootJsonFormat[Month] { - def write(month: Month) = JsNumber(month) - def read(value: JsValue): Month = value match { - case JsNumber(month) if 0 <= month && month <= 11 => Month(month.toInt) - case _ => throw DeserializationException("Expected a number from 0 to 11") - } - } - - def RevisionInPath[T]: PathMatcher1[Revision[T]] = - PathMatcher("""[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}""".r) flatMap { string => - Some(Revision[T](string)) - } - - implicit def revisionFromStringUnmarshaller[T]: Unmarshaller[String, Revision[T]] = - Unmarshaller.strict[String, Revision[T]](Revision[T]) - - implicit def revisionFormat[T]: RootJsonFormat[Revision[T]] = new RootJsonFormat[Revision[T]] { - def write(revision: Revision[T]) = JsString(revision.id.toString) - - def read(value: JsValue): Revision[T] = value match { - case JsString(revision) => Revision[T](revision) - case _ => throw DeserializationException("Revision expects uuid string") - } - } - - implicit val base64Format = new RootJsonFormat[Base64] { - def write(base64Value: Base64) = JsString(base64Value.value) - - def read(value: JsValue): Base64 = value match { - case JsString(base64Value) => Base64(base64Value) - case _ => throw DeserializationException("Base64 format expects string") - } - } - - implicit val emailFormat = new RootJsonFormat[Email] { - def write(email: Email) = JsString(email.username + "@" + email.domain) - def read(json: JsValue): Email = json match { - case JsString(value) => - Email.parse(value).getOrElse { - deserializationError("Expected '@' symbol in email string as Email, but got " + json.toString) - } - - case _ => - deserializationError("Expected string as Email, but got " + json.toString) - } - } - - implicit val phoneNumberFormat = jsonFormat2(PhoneNumber.apply) - - implicit val authCredentialsFormat = new RootJsonFormat[AuthCredentials] { - override def read(json: JsValue): AuthCredentials = { - json match { - case JsObject(fields) => - val emailField = fields.get("email") - val identifierField = fields.get("identifier") - val passwordField = fields.get("password") - - (emailField, identifierField, passwordField) match { - case (_, _, None) => - deserializationError("password field must be set") - case (Some(JsString(em)), _, Some(JsString(pw))) => - val email = Email.parse(em).getOrElse(throw deserializationError(s"failed to parse email $em")) - AuthCredentials(email.toString, pw) - case (_, Some(JsString(id)), Some(JsString(pw))) => AuthCredentials(id.toString, pw.toString) - case (None, None, _) => deserializationError("identifier must be provided") - case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") - } - case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") - } - } - - override def write(obj: AuthCredentials): JsValue = JsObject( - "identifier" -> JsString(obj.identifier), - "password" -> JsString(obj.password) - ) - } - - implicit object inetAddressFormat extends JsonFormat[InetAddress] { - override def read(json: JsValue): InetAddress = json match { - case JsString(ipString) => - Try(InetAddress.getByName(ipString)) - .getOrElse(deserializationError(s"Invalid IP Address: $ipString")) - case _ => deserializationError(s"Expected string for IP Address, got $json") - } - - override def write(obj: InetAddress): JsValue = - JsString(obj.getHostAddress) - } +@deprecated( + "Using static JSON formats from singleton objects can require to many wildcard imports. It is " + + "recommended to stack format traits into a single protocol.", + "driver-core 1.11.5" +) +object json extends CoreJsonFormats with rest.Unmarshallers with rest.PathMatchers { self => object enumeratum { - def enumUnmarshaller[T <: EnumEntry](enum: Enum[T]): Unmarshaller[String, T] = - Unmarshaller.strict { value => - enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) - } - - trait HasJsonFormat[T <: EnumEntry] { enum: Enum[T] => - - implicit val format: JsonFormat[T] = new EnumJsonFormat(enum) - - implicit val unmarshaller: Unmarshaller[String, T] = - Unmarshaller.strict { value => - enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) - } - } - - class EnumJsonFormat[T <: EnumEntry](enum: Enum[T]) extends JsonFormat[T] { - override def read(json: JsValue): T = json match { - case JsString(name) => enum.withNameOption(name).getOrElse(unrecognizedValue(name, enum.values)) - case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) - } - - override def write(obj: T): JsValue = JsString(obj.entryName) - } - - private def unrecognizedValue(value: String, possibleValues: Seq[Any]): Nothing = - deserializationError(s"Unexpected value $value. Expected one of: ${possibleValues.mkString("[", ", ", "]")}") + rest.Directives.enumUnmarshaller(enum) + type HasJsonFormat[T <: EnumEntry] = self.HasJsonFormat[T] + type EnumJsonFormat[T <: EnumEntry] = self.EnumJsonFormat[T] } - class EnumJsonFormat[T](mapping: (String, T)*) extends RootJsonFormat[T] { - private val map = mapping.toMap - - override def write(value: T): JsValue = { - map.find(_._2 == value).map(_._1) match { - case Some(name) => JsString(name) - case _ => serializationError(s"Value $value is not found in the mapping $map") - } - } - - override def read(json: JsValue): T = json match { - case JsString(name) => - map.getOrElse(name, throw DeserializationException(s"Value $name is not found in the mapping $map")) - case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) - } - } - - class ValueClassFormat[T: TypeTag](writeValue: T => BigDecimal, create: BigDecimal => T) extends JsonFormat[T] { - def write(valueClass: T) = JsNumber(writeValue(valueClass)) - def read(json: JsValue): T = json match { - case JsNumber(value) => create(value) - case _ => deserializationError(s"Expected number as ${typeOf[T].getClass.getName}, but got " + json.toString) - } - } - - class GadtJsonFormat[T: TypeTag]( - typeField: String, - typeValue: PartialFunction[T, String], - jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) - extends RootJsonFormat[T] { - - def write(value: T): JsValue = { - - val valueType = typeValue.applyOrElse(value, { v: T => - deserializationError(s"No Value type for this type of ${typeOf[T].getClass.getName}: " + v.toString) - }) - - val valueFormat = - jsonFormat.applyOrElse(valueType, { f: String => - deserializationError(s"No Json format for this type of $valueType") - }) - - valueFormat.asInstanceOf[JsonFormat[T]].write(value) match { - case JsObject(fields) => JsObject(fields ++ Map(typeField -> JsString(valueType))) - case _ => serializationError(s"${typeOf[T].getClass.getName} serialized not to a JSON object") - } - } - - def read(json: JsValue): T = json match { - case JsObject(fields) => - val valueJson = JsObject(fields.filterNot(_._1 == typeField)) - fields(typeField) match { - case JsString(valueType) => - val valueFormat = jsonFormat.applyOrElse(valueType, { t: String => - deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") - }) - valueFormat.read(valueJson) - case _ => - deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") - } - case _ => - deserializationError(s"Expected Json Object as ${typeOf[T].getClass.getName}, but got " + json.toString) - } - } - - object GadtJsonFormat { - - def create[T: TypeTag](typeField: String)(typeValue: PartialFunction[T, String])( - jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) = { - - new GadtJsonFormat[T](typeField, typeValue, jsonFormat) - } - } - - /** - * Provides the JsonFormat for the Refined types provided by the Refined library. - * - * @see https://github.com/fthomas/refined - */ - implicit def refinedJsonFormat[T, Predicate]( - implicit valueFormat: JsonFormat[T], - validate: Validate[T, Predicate]): JsonFormat[Refined[T, Predicate]] = - new JsonFormat[Refined[T, Predicate]] { - def write(x: T Refined Predicate): JsValue = valueFormat.write(x.value) - def read(value: JsValue): T Refined Predicate = { - refineV[Predicate](valueFormat.read(value))(validate) match { - case Right(refinedValue) => refinedValue - case Left(refinementError) => deserializationError(refinementError) - } - } - } - - def NonEmptyNameInPath[T]: PathMatcher1[NonEmptyName[T]] = new PathMatcher1[NonEmptyName[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => - refineV[NonEmpty](segment) match { - case Left(_) => Unmatched - case Right(nonEmptyString) => Matched(tail, Tuple1(NonEmptyName[T](nonEmptyString))) - } - case _ => Unmatched - } - } - - implicit def nonEmptyNameFormat[T](implicit nonEmptyStringFormat: JsonFormat[Refined[String, NonEmpty]]) = - new RootJsonFormat[NonEmptyName[T]] { - def write(name: NonEmptyName[T]) = JsString(name.value.value) - - def read(value: JsValue): NonEmptyName[T] = - NonEmptyName[T](nonEmptyStringFormat.read(value)) - } - - implicit val serviceExceptionFormat: RootJsonFormat[ServiceException] = - GadtJsonFormat.create[ServiceException]("type") { - case _: InvalidInputException => "InvalidInputException" - case _: InvalidActionException => "InvalidActionException" - case _: ResourceNotFoundException => "ResourceNotFoundException" - case _: ExternalServiceException => "ExternalServiceException" - case _: ExternalServiceTimeoutException => "ExternalServiceTimeoutException" - case _: DatabaseException => "DatabaseException" - } { - case "InvalidInputException" => jsonFormat(InvalidInputException, "message") - case "InvalidActionException" => jsonFormat(InvalidActionException, "message") - case "ResourceNotFoundException" => jsonFormat(ResourceNotFoundException, "message") - case "ExternalServiceException" => - jsonFormat(ExternalServiceException, "serviceName", "serviceMessage", "serviceException") - case "ExternalServiceTimeoutException" => jsonFormat(ExternalServiceTimeoutException, "message") - case "DatabaseException" => jsonFormat(DatabaseException, "message") - } - - val jsValueToStringMarshaller: Marshaller[JsValue, String] = - Marshaller.strict[JsValue, String](value => Marshalling.Opaque[String](() => value.compactPrint)) - - def valueToStringMarshaller[T](implicit jsonFormat: JsonWriter[T]): Marshaller[T, String] = - jsValueToStringMarshaller.compose[T](jsonFormat.write) - - val stringToJsValueUnmarshaller: Unmarshaller[String, JsValue] = - Unmarshaller.strict[String, JsValue](value => value.parseJson) - - def stringToValueUnmarshaller[T](implicit jsonFormat: JsonReader[T]): Unmarshaller[String, T] = - stringToJsValueUnmarshaller.map[T](jsonFormat.read) } diff --git a/jvm/src/main/scala/xyz/driver/core/rest/Directives.scala b/jvm/src/main/scala/xyz/driver/core/rest/Directives.scala new file mode 100644 index 0000000..8e5983b --- /dev/null +++ b/jvm/src/main/scala/xyz/driver/core/rest/Directives.scala @@ -0,0 +1,83 @@ +package xyz.driver.core.rest + +import java.util.UUID + +import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} +import akka.http.scaladsl.model.Uri.Path +import akka.http.scaladsl.server.PathMatcher.{Matched, Unmatched} +import akka.http.scaladsl.server.{PathMatchers => AkkaPathMatchers, Directives => AkkaDirectives, _} +import akka.http.scaladsl.unmarshalling.Unmarshaller +import enumeratum._ +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV +import spray.json._ +import xyz.driver.core.time.Time +import xyz.driver.core.{CoreJsonFormats, Id, Name, NonEmptyName} + +trait Directives extends AkkaDirectives with Unmarshallers with PathMatchers +object Directives extends Directives + +trait PathMatchers { + + private def UuidInPath[T]: PathMatcher1[Id[T]] = + AkkaPathMatchers.JavaUUID.map((id: UUID) => Id[T](id.toString.toLowerCase)) + + def IdInPath[T]: PathMatcher1[Id[T]] = UuidInPath[T] | new PathMatcher1[Id[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => Matched(tail, Tuple1(Id[T](segment))) + case _ => Unmatched + } + } + + def NameInPath[T]: PathMatcher1[Name[T]] = new PathMatcher1[Name[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => Matched(tail, Tuple1(Name[T](segment))) + case _ => Unmatched + } + } + + def TimeInPath: PathMatcher1[Time] = + PathMatcher("""[+-]?\d*""".r) flatMap { string => + try Some(Time(string.toLong)) + catch { case _: IllegalArgumentException => None } + } + + def NonEmptyNameInPath[T]: PathMatcher1[NonEmptyName[T]] = new PathMatcher1[NonEmptyName[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => + refineV[NonEmpty](segment) match { + case Left(_) => Unmatched + case Right(nonEmptyString) => Matched(tail, Tuple1(NonEmptyName[T](nonEmptyString))) + } + case _ => Unmatched + } + } + +} + +trait Unmarshallers { + + implicit def paramUnmarshaller[T](implicit reader: JsonReader[T]): Unmarshaller[String, T] = + Unmarshaller.firstOf( + Unmarshaller.strict((JsString(_: String)) andThen reader.read), + stringToValueUnmarshaller[T] + ) + + def enumUnmarshaller[T <: EnumEntry](enum: Enum[T]): Unmarshaller[String, T] = + Unmarshaller.strict { value => + enum.withNameOption(value).getOrElse(CoreJsonFormats.unrecognizedValue(value, enum.values)) + } + + val jsValueToStringMarshaller: Marshaller[JsValue, String] = + Marshaller.strict[JsValue, String](value => Marshalling.Opaque[String](() => value.compactPrint)) + + def valueToStringMarshaller[T](implicit jsonFormat: JsonWriter[T]): Marshaller[T, String] = + jsValueToStringMarshaller.compose[T](jsonFormat.write) + + val stringToJsValueUnmarshaller: Unmarshaller[String, JsValue] = + Unmarshaller.strict[String, JsValue](value => value.parseJson) + + def stringToValueUnmarshaller[T](implicit jsonFormat: JsonReader[T]): Unmarshaller[String, T] = + stringToJsValueUnmarshaller.map[T](jsonFormat.read) + +} diff --git a/jvm/src/main/scala/xyz/driver/core/rest/RestService.scala b/jvm/src/main/scala/xyz/driver/core/rest/RestService.scala index 8d46d72..ca10c08 100644 --- a/jvm/src/main/scala/xyz/driver/core/rest/RestService.scala +++ b/jvm/src/main/scala/xyz/driver/core/rest/RestService.scala @@ -1,16 +1,16 @@ package xyz.driver.core.rest +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model._ import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} import akka.stream.Materializer import scala.concurrent.{ExecutionContext, Future} import scalaz.{ListT, OptionT} +import spray.json._ +import xyz.driver.core.CoreJsonFormats -trait RestService extends Service { - - import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ - import spray.json._ +trait RestService extends Service with CoreJsonFormats with Unmarshallers with SprayJsonSupport { protected implicit val exec: ExecutionContext protected implicit val materializer: Materializer diff --git a/jvm/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala b/jvm/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala deleted file mode 100644 index 1a5e9be..0000000 --- a/jvm/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.ServiceRequestContext - -import scala.concurrent.Future - -trait Authorization[U <: User] { - def userHasPermissions(user: U, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] -} diff --git a/jvm/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala b/jvm/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala deleted file mode 100644 index efe28c9..0000000 --- a/jvm/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala +++ /dev/null @@ -1,22 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, PermissionsToken} - -import scalaz.Scalaz.mapMonoid -import scalaz.Semigroup -import scalaz.syntax.semigroup._ - -final case class AuthorizationResult(authorized: Map[Permission, Boolean], token: Option[PermissionsToken]) -object AuthorizationResult { - val unauthorized: AuthorizationResult = AuthorizationResult(authorized = Map.empty, None) - - implicit val authorizationSemigroup: Semigroup[AuthorizationResult] = new Semigroup[AuthorizationResult] { - private implicit val authorizedBooleanSemigroup = Semigroup.instance[Boolean](_ || _) - private implicit val permissionsTokenSemigroup = - Semigroup.instance[Option[PermissionsToken]]((a, b) => b.orElse(a)) - - override def append(a: AuthorizationResult, b: => AuthorizationResult): AuthorizationResult = { - AuthorizationResult(a.authorized |+| b.authorized, a.token |+| b.token) - } - } -} diff --git a/jvm/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala b/jvm/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala deleted file mode 100644 index db289de..0000000 --- a/jvm/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala +++ /dev/null @@ -1,23 +0,0 @@ -package xyz.driver.core.rest.errors - -sealed abstract class ServiceException(val message: String) extends Exception(message) - -final case class InvalidInputException(override val message: String = "Invalid input") extends ServiceException(message) - -final case class InvalidActionException(override val message: String = "This action is not allowed") - extends ServiceException(message) - -final case class ResourceNotFoundException(override val message: String = "Resource not found") - extends ServiceException(message) - -final case class ExternalServiceException( - serviceName: String, - serviceMessage: String, - serviceException: Option[ServiceException]) - extends ServiceException(s"Error while calling '$serviceName': $serviceMessage") - -final case class ExternalServiceTimeoutException(serviceName: String) - extends ServiceException(s"$serviceName took too long to respond") - -final case class DatabaseException(override val message: String = "Database access error") - extends ServiceException(message) diff --git a/jvm/src/main/scala/xyz/driver/core/rest/package.scala b/jvm/src/main/scala/xyz/driver/core/rest/package.scala index f85c39a..550a71f 100644 --- a/jvm/src/main/scala/xyz/driver/core/rest/package.scala +++ b/jvm/src/main/scala/xyz/driver/core/rest/package.scala @@ -11,7 +11,6 @@ import akka.http.scaladsl.unmarshalling.Unmarshal import akka.stream.Materializer import akka.stream.scaladsl.Flow import akka.util.ByteString -import xyz.driver.tracing.TracingDirectives import scala.concurrent.Future import scala.util.Try @@ -19,8 +18,6 @@ import scalaz.{Functor, OptionT} import scalaz.Scalaz.{intInstance, stringInstance} import scalaz.syntax.equal._ -trait Service - trait HttpClient { def makeRequest(request: HttpRequest): Future[HttpResponse] } @@ -33,40 +30,6 @@ trait ServiceTransport { implicit mat: Materializer): Future[Unmarshal[ResponseEntity]] } -sealed trait SortingOrder -object SortingOrder { - case object Asc extends SortingOrder - case object Desc extends SortingOrder -} - -final case class SortingField(name: String, sortingOrder: SortingOrder) -final case class Sorting(sortingFields: Seq[SortingField]) - -final case class Pagination(pageSize: Int, pageNumber: Int) { - require(pageSize > 0, "Page size must be greater than zero") - require(pageNumber > 0, "Page number must be greater than zero") - - def offset: Int = pageSize * (pageNumber - 1) -} - -final case class ListResponse[+T](items: Seq[T], meta: ListResponse.Meta) - -object ListResponse { - - def apply[T](items: Seq[T], size: Int, pagination: Option[Pagination]): ListResponse[T] = - ListResponse( - items = items, - meta = ListResponse.Meta(size, pagination.fold(1)(_.pageNumber), pagination.fold(size)(_.pageSize))) - - final case class Meta(itemsCount: Int, pageNumber: Int, pageSize: Int) - - object Meta { - def apply(itemsCount: Int, pagination: Pagination): Meta = - Meta(itemsCount, pagination.pageNumber, pagination.pageSize) - } - -} - object `package` { implicit class OptionTRestAdditions[T](optionT: OptionT[Future, T]) { def responseOrNotFound(successCode: StatusCodes.Success = StatusCodes.OK)( @@ -76,27 +39,6 @@ object `package` { } } - object ContextHeaders { - val AuthenticationTokenHeader: String = "Authorization" - val PermissionsTokenHeader: String = "Permissions" - val AuthenticationHeaderPrefix: String = "Bearer" - val ClientFingerprintHeader: String = "X-Client-Fingerprint" - val TrackingIdHeader: String = "X-Trace" - val StacktraceHeader: String = "X-Stacktrace" - val OriginatingIpHeader: String = "X-Forwarded-For" - val ResourceCount: String = "X-Resource-Count" - val PageCount: String = "X-Page-Count" - val TraceHeaderName: String = TracingDirectives.TraceHeaderName - val SpanHeaderName: String = TracingDirectives.SpanHeaderName - } - - object AuthProvider { - val AuthenticationTokenHeader: String = ContextHeaders.AuthenticationTokenHeader - val PermissionsTokenHeader: String = ContextHeaders.PermissionsTokenHeader - val SetAuthenticationTokenHeader: String = "set-authorization" - val SetPermissionsTokenHeader: String = "set-permissions" - } - val AllowedHeaders: Seq[String] = Seq( "Origin", diff --git a/jvm/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala b/jvm/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala deleted file mode 100644 index 775106e..0000000 --- a/jvm/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala +++ /dev/null @@ -1,74 +0,0 @@ -package xyz.driver.core.rest - -import java.net.InetAddress - -import xyz.driver.core.auth.{AuthToken, PermissionsToken, User} -import xyz.driver.core.generators - -import scalaz.Scalaz.{mapEqual, stringInstance} -import scalaz.syntax.equal._ - -class ServiceRequestContext( - val trackingId: String = generators.nextUuid().toString, - val originatingIp: Option[InetAddress] = None, - val contextHeaders: Map[String, String] = Map.empty[String, String]) { - def authToken: Option[AuthToken] = - contextHeaders.get(AuthProvider.AuthenticationTokenHeader).map(AuthToken.apply) - - def permissionsToken: Option[PermissionsToken] = - contextHeaders.get(AuthProvider.PermissionsTokenHeader).map(PermissionsToken.apply) - - def withAuthToken(authToken: AuthToken): ServiceRequestContext = - new ServiceRequestContext( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value) - ) - - def withAuthenticatedUser[U <: User](authToken: AuthToken, user: U): AuthorizedServiceRequestContext[U] = - new AuthorizedServiceRequestContext( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value), - user - ) - - override def hashCode(): Int = - Seq[Any](trackingId, originatingIp, contextHeaders) - .foldLeft(31)((result, obj) => 31 * result + obj.hashCode()) - - override def equals(obj: Any): Boolean = obj match { - case ctx: ServiceRequestContext => - trackingId === ctx.trackingId && - originatingIp == originatingIp && - contextHeaders === ctx.contextHeaders - case _ => false - } - - override def toString: String = s"ServiceRequestContext($trackingId, $contextHeaders)" -} - -class AuthorizedServiceRequestContext[U <: User]( - override val trackingId: String = generators.nextUuid().toString, - override val originatingIp: Option[InetAddress] = None, - override val contextHeaders: Map[String, String] = Map.empty[String, String], - val authenticatedUser: U) - extends ServiceRequestContext { - - def withPermissionsToken(permissionsToken: PermissionsToken): AuthorizedServiceRequestContext[U] = - new AuthorizedServiceRequestContext[U]( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.PermissionsTokenHeader, permissionsToken.value), - authenticatedUser) - - override def hashCode(): Int = 31 * super.hashCode() + authenticatedUser.hashCode() - - override def equals(obj: Any): Boolean = obj match { - case ctx: AuthorizedServiceRequestContext[U] => super.equals(ctx) && ctx.authenticatedUser == authenticatedUser - case _ => false - } - - override def toString: String = - s"AuthorizedServiceRequestContext($trackingId, $contextHeaders, $authenticatedUser)" -} diff --git a/jvm/src/main/scala/xyz/driver/core/time.scala b/jvm/src/main/scala/xyz/driver/core/time.scala deleted file mode 100644 index 6dbd173..0000000 --- a/jvm/src/main/scala/xyz/driver/core/time.scala +++ /dev/null @@ -1,175 +0,0 @@ -package xyz.driver.core - -import java.text.SimpleDateFormat -import java.util._ -import java.util.concurrent.TimeUnit - -import xyz.driver.core.date.Month - -import scala.concurrent.duration._ -import scala.util.Try - -object time { - - // The most useful time units - val Second = 1000L - val Seconds = Second - val Minute = 60 * Seconds - val Minutes = Minute - val Hour = 60 * Minutes - val Hours = Hour - val Day = 24 * Hours - val Days = Day - val Week = 7 * Days - val Weeks = Week - - final case class Time(millis: Long) extends AnyVal { - - def isBefore(anotherTime: Time): Boolean = implicitly[Ordering[Time]].lt(this, anotherTime) - - def isAfter(anotherTime: Time): Boolean = implicitly[Ordering[Time]].gt(this, anotherTime) - - def advanceBy(duration: Duration): Time = Time(millis + duration.toMillis) - - def durationTo(anotherTime: Time): Duration = Duration.apply(anotherTime.millis - millis, TimeUnit.MILLISECONDS) - - def durationFrom(anotherTime: Time): Duration = Duration.apply(millis - anotherTime.millis, TimeUnit.MILLISECONDS) - - def toDate(timezone: TimeZone): date.Date = { - val cal = Calendar.getInstance(timezone) - cal.setTimeInMillis(millis) - date.Date(cal.get(Calendar.YEAR), date.Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) - } - } - - /** - * Encapsulates a time and timezone without a specific date. - */ - final case class TimeOfDay(localTime: java.time.LocalTime, timeZone: TimeZone) { - - /** - * Is this time before another time on a specific day. Day light savings safe. These are zero-indexed - * for month/day. - */ - def isBefore(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).before(other.toCalendar(day, month, year)) - } - - /** - * Is this time after another time on a specific day. Day light savings safe. - */ - def isAfter(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).after(other.toCalendar(day, month, year)) - } - - def sameTimeAs(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).equals(other.toCalendar(day, month, year)) - } - - /** - * Enforces the same formatting as expected by [[java.sql.Time]] - * @return string formatted for `java.sql.Time` - */ - def timeString: String = { - localTime.format(TimeOfDay.getFormatter) - } - - /** - * @return a string parsable by [[java.util.TimeZone]] - */ - def timeZoneString: String = { - timeZone.getID - } - - /** - * @return this [[TimeOfDay]] as [[java.sql.Time]] object, [[java.sql.Time.valueOf]] will - * throw when the string is not valid, but this is protected by [[timeString]] method. - */ - def toTime: java.sql.Time = { - java.sql.Time.valueOf(timeString) - } - - private def toCalendar(day: Int, month: Int, year: Int): Calendar = { - val cal = Calendar.getInstance(timeZone) - cal.set(year, month, day, localTime.getHour, localTime.getMinute, localTime.getSecond) - cal.clear(Calendar.MILLISECOND) - cal - } - } - - object TimeOfDay { - def now(): TimeOfDay = { - TimeOfDay(java.time.LocalTime.now(), TimeZone.getDefault) - } - - /** - * Throws when [s] is not parsable by [[java.time.LocalTime.parse]], uses default [[java.util.TimeZone]] - */ - def parseTimeString(tz: TimeZone = TimeZone.getDefault)(s: String): TimeOfDay = { - TimeOfDay(java.time.LocalTime.parse(s), tz) - } - - def fromString(tz: TimeZone)(s: String): Option[TimeOfDay] = { - val op = Try(java.time.LocalTime.parse(s)).toOption - op.map(lt => TimeOfDay(lt, tz)) - } - - def fromStrings(zoneId: String)(s: String): Option[TimeOfDay] = { - val op = Try(TimeZone.getTimeZone(zoneId)).toOption - op.map(tz => TimeOfDay.parseTimeString(tz)(s)) - } - - /** - * Formatter that enforces `HH:mm:ss` which is expected by [[java.sql.Time]] - */ - def getFormatter: java.time.format.DateTimeFormatter = { - java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss") - } - } - - object Time { - - implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) - } - - final case class TimeRange(start: Time, end: Time) { - def duration: Duration = FiniteDuration(end.millis - start.millis, MILLISECONDS) - } - - def startOfMonth(time: Time) = { - Time(make(new GregorianCalendar()) { cal => - cal.setTime(new Date(time.millis)) - cal.set(Calendar.DAY_OF_MONTH, cal.getActualMinimum(Calendar.DAY_OF_MONTH)) - }.getTime.getTime) - } - - def textualDate(timezone: TimeZone)(time: Time): String = - make(new SimpleDateFormat("MMMM d, yyyy"))(_.setTimeZone(timezone)).format(new Date(time.millis)) - - def textualTime(timezone: TimeZone)(time: Time): String = - make(new SimpleDateFormat("MMM dd, yyyy hh:mm:ss a"))(_.setTimeZone(timezone)).format(new Date(time.millis)) - - object provider { - - /** - * Time providers are supplying code with current times - * and are extremely useful for testing to check how system is going - * to behave at specific moments in time. - * - * All the calls to receive current time must be made using time - * provider injected to the caller. - */ - trait TimeProvider { - def currentTime(): Time - } - - final class SystemTimeProvider extends TimeProvider { - def currentTime() = Time(System.currentTimeMillis()) - } - final val SystemTimeProvider = new SystemTimeProvider - - final class SpecificTimeProvider(time: Time) extends TimeProvider { - def currentTime() = time - } - } -} diff --git a/jvm/src/test/scala/xyz/driver/core/JsonTest.scala b/jvm/src/test/scala/xyz/driver/core/JsonTest.scala index fed2a9d..b8922ae 100644 --- a/jvm/src/test/scala/xyz/driver/core/JsonTest.scala +++ b/jvm/src/test/scala/xyz/driver/core/JsonTest.scala @@ -142,7 +142,7 @@ class JsonTest extends FlatSpec with Matchers { case object Val2 extends EnumVal case object Val3 extends EnumVal - val format = new EnumJsonFormat[EnumVal]("a" -> Val1, "b" -> Val2, "c" -> Val3) + val format = new EnumJsonFormat2[EnumVal]("a" -> Val1, "b" -> Val2, "c" -> Val3) val referenceEnumValue1 = Val2 val referenceEnumValue2 = Val3 @@ -226,29 +226,6 @@ class JsonTest extends FlatSpec with Matchers { }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" } - // Should be defined outside of case to have a TypeTag - case class CustomWrapperClass(value: Int) - - "Json format for Value classes" should "read and write correct JSON" in { - - val format = new ValueClassFormat[CustomWrapperClass](v => BigDecimal(v.value), d => CustomWrapperClass(d.toInt)) - - val referenceValue1 = CustomWrapperClass(-2) - val referenceValue2 = CustomWrapperClass(10) - - val writtenJson1 = format.write(referenceValue1) - writtenJson1.prettyPrint should be("-2") - - val writtenJson2 = format.write(referenceValue2) - writtenJson2.prettyPrint should be("10") - - val parsedValue1 = format.read(writtenJson1) - val parsedValue2 = format.read(writtenJson2) - - parsedValue1 should be(referenceValue1) - parsedValue2 should be(referenceValue2) - } - "Json format for classes GADT" should "read and write correct JSON" in { import CustomGADT._ diff --git a/jvm/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/jvm/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala index d32fefd..247dc5a 100644 --- a/jvm/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala +++ b/jvm/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala @@ -4,16 +4,15 @@ import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.headers.Connection import akka.http.scaladsl.server.Directives.{complete => akkaComplete} -import akka.http.scaladsl.server.{Directives, Rejection, RejectionHandler, Route} +import akka.http.scaladsl.server.{RejectionHandler, Route} import akka.http.scaladsl.testkit.ScalatestRouteTest import com.typesafe.scalalogging.Logger import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.logging.NoLogger -import xyz.driver.core.json.serviceExceptionFormat import xyz.driver.core.FutureExtensions +import xyz.driver.core.json.serviceExceptionFormat +import xyz.driver.core.logging.NoLogger import xyz.driver.core.rest.errors._ -import scala.collection.immutable import scala.concurrent.Future class DriverRouteTest diff --git a/shared/src/main/scala/xyz/driver/core/CoreJsonFormats.scala b/shared/src/main/scala/xyz/driver/core/CoreJsonFormats.scala new file mode 100644 index 0000000..68e741c --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/CoreJsonFormats.scala @@ -0,0 +1,321 @@ +package xyz.driver.core + +import java.net.InetAddress +import java.util.{TimeZone, UUID} + +import enumeratum._ +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV +import spray.json._ +import xyz.driver.core.auth.AuthCredentials +import xyz.driver.core.date.{Date, DayOfWeek, Month} +import xyz.driver.core.domain.{Email, PhoneNumber} +import xyz.driver.core.rest.errors._ +import xyz.driver.core.time.{Time, TimeOfDay} +import scala.reflect.runtime.universe._ +import scala.util.Try + +trait CoreJsonFormats extends DerivedJsonProtocol { + + implicit def idFormat[T]: RootJsonFormat[Id[T]] = new RootJsonFormat[Id[T]] { + def write(id: Id[T]) = JsString(id.value) + + def read(value: JsValue): Id[T] = value match { + case JsString(id) if Try(UUID.fromString(id)).isSuccess => Id[T](id.toLowerCase) + case JsString(id) => Id[T](id) + case _ => throw DeserializationException("Id expects string") + } + } + + @deprecated( + "Tagged types will be removed. Please open an issue in case they are needed for your use-case.", + "driver-core 1.11.5") + implicit def taggedFormat[F, T](implicit underlying: JsonFormat[F]): JsonFormat[F @@ T] = new JsonFormat[F @@ T] { + import tagging._ + override def write(obj: F @@ T): JsValue = underlying.write(obj) + override def read(json: JsValue): F @@ T = underlying.read(json).tagged[T] + } + + implicit def nameFormat[T]: RootJsonFormat[Name[T]] = new RootJsonFormat[Name[T]] { + def write(name: Name[T]) = JsString(name.value) + def read(value: JsValue): Name[T] = value match { + case JsString(name) => Name[T](name) + case _ => throw DeserializationException("Name expects string") + } + } + + implicit val timeFormat: RootJsonFormat[Time] = new RootJsonFormat[Time] { + def write(time: Time) = JsObject("timestamp" -> JsNumber(time.millis)) + + def read(value: JsValue): Time = value match { + case JsObject(fields) => + fields + .get("timestamp") + .flatMap { + case JsNumber(millis) => Some(Time(millis.toLong)) + case _ => None + } + .getOrElse(throw DeserializationException("Time expects number")) + case _ => throw DeserializationException("Time expects number") + } + } + + implicit object localTimeFormat extends JsonFormat[java.time.LocalTime] { + private val formatter = TimeOfDay.getFormatter + def read(json: JsValue): java.time.LocalTime = json match { + case JsString(chars) => + java.time.LocalTime.parse(chars) + case _ => deserializationError(s"Expected time string got ${json.toString}") + } + def write(obj: java.time.LocalTime): JsValue = { + JsString(obj.format(formatter)) + } + } + + implicit object timeZoneFormat extends JsonFormat[java.util.TimeZone] { + override def write(obj: TimeZone): JsValue = { + JsString(obj.getID()) + } + override def read(json: JsValue): TimeZone = json match { + case JsString(chars) => + java.util.TimeZone.getTimeZone(chars) + case _ => deserializationError(s"Expected time zone string got ${json.toString}") + } + } + + implicit val timeOfDayFormat: RootJsonFormat[TimeOfDay] = jsonFormat[TimeOfDay] + + implicit val dayOfWeekFormat: JsonFormat[DayOfWeek] = new EnumJsonFormat(DayOfWeek) + + implicit val dateFormat: RootJsonFormat[Date] = new RootJsonFormat[Date] { + def write(date: Date) = JsString(date.toString) + def read(value: JsValue): Date = value match { + case JsString(dateString) => + Date + .fromString(dateString) + .getOrElse( + throw DeserializationException(s"Misformated ISO 8601 Date. Expected YYYY-MM-DD, but got $dateString.")) + case _ => throw DeserializationException(s"Date expects a string, but got $value.") + } + } + + implicit val monthFormat: RootJsonFormat[Month] = new RootJsonFormat[Month] { + def write(month: Month) = JsNumber(month) + def read(value: JsValue): Month = value match { + case JsNumber(month) if 0 <= month && month <= 11 => Month(month.toInt) + case _ => throw DeserializationException("Expected a number from 0 to 11") + } + } + + implicit def revisionFormat[T]: RootJsonFormat[Revision[T]] = new RootJsonFormat[Revision[T]] { + def write(revision: Revision[T]) = JsString(revision.id.toString) + + def read(value: JsValue): Revision[T] = value match { + case JsString(revision) => Revision[T](revision) + case _ => throw DeserializationException("Revision expects uuid string") + } + } + + implicit val base64Format: RootJsonFormat[Base64] = new RootJsonFormat[Base64] { + def write(base64Value: Base64) = JsString(base64Value.value) + + def read(value: JsValue): Base64 = value match { + case JsString(base64Value) => Base64(base64Value) + case _ => throw DeserializationException("Base64 format expects string") + } + } + + implicit val emailFormat: RootJsonFormat[Email] = new RootJsonFormat[Email] { + def write(email: Email) = JsString(email.username + "@" + email.domain) + def read(json: JsValue): Email = json match { + + case JsString(value) => + Email.parse(value).getOrElse { + deserializationError("Expected '@' symbol in email string as Email, but got " + json.toString) + } + + case _ => + deserializationError("Expected string as Email, but got " + json.toString) + } + } + + implicit val phoneNumberFormat: RootJsonFormat[PhoneNumber] = jsonFormat[PhoneNumber] + + implicit val authCredentialsFormat: RootJsonFormat[AuthCredentials] = new RootJsonFormat[AuthCredentials] { + override def read(json: JsValue): AuthCredentials = { + json match { + case JsObject(fields) => + val emailField = fields.get("email") + val identifierField = fields.get("identifier") + val passwordField = fields.get("password") + + (emailField, identifierField, passwordField) match { + case (_, _, None) => + deserializationError("password field must be set") + case (Some(JsString(em)), _, Some(JsString(pw))) => + val email = Email.parse(em).getOrElse(throw deserializationError(s"failed to parse email $em")) + AuthCredentials(email.toString, pw) + case (_, Some(JsString(id)), Some(JsString(pw))) => AuthCredentials(id.toString, pw.toString) + case (None, None, _) => deserializationError("identifier must be provided") + case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") + } + case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") + } + } + + override def write(obj: AuthCredentials): JsValue = JsObject( + "identifier" -> JsString(obj.identifier), + "password" -> JsString(obj.password) + ) + } + + trait HasJsonFormat[T <: EnumEntry] { enum: Enum[T] => + implicit val format: JsonFormat[T] = new EnumJsonFormat(enum) + } + + class EnumJsonFormat[T <: EnumEntry](enum: Enum[T]) extends JsonFormat[T] { + override def read(json: JsValue): T = json match { + case JsString(name) => enum.withNameOption(name).getOrElse(CoreJsonFormats.unrecognizedValue(name, enum.values)) + case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) + } + + override def write(obj: T): JsValue = JsString(obj.entryName) + } + + class EnumJsonFormat2[T](mapping: (String, T)*) extends RootJsonFormat[T] { + private val map = mapping.toMap + + override def write(value: T): JsValue = { + map.find(_._2 == value).map(_._1) match { + case Some(name) => JsString(name) + case _ => serializationError(s"Value $value is not found in the mapping $map") + } + } + + override def read(json: JsValue): T = json match { + case JsString(name) => + map.getOrElse(name, throw DeserializationException(s"Value $name is not found in the mapping $map")) + case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) + } + } + + class ValueClassFormat[T: TypeTag](writeValue: T => BigDecimal, create: BigDecimal => T) extends JsonFormat[T] { + def write(valueClass: T) = JsNumber(writeValue(valueClass)) + + def read(json: JsValue): T = json match { + case JsNumber(value) => create(value) + case _ => deserializationError(s"Expected number as ${typeOf[T].getClass.getName}, but got " + json.toString) + } + } + + implicit object inetAddressFormat extends JsonFormat[InetAddress] { + override def read(json: JsValue): InetAddress = json match { + case JsString(ipString) => + Try(InetAddress.getByName(ipString)) + .getOrElse(deserializationError(s"Invalid IP Address: $ipString")) + case _ => deserializationError(s"Expected string for IP Address, got $json") + } + + override def write(obj: InetAddress): JsValue = + JsString(obj.getHostAddress) + } + + class GadtJsonFormat[T: TypeTag]( + typeField: String, + typeValue: PartialFunction[T, String], + jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) + extends RootJsonFormat[T] { + + def write(value: T): JsValue = { + + val valueType = typeValue.applyOrElse(value, { v: T => + deserializationError(s"No Value type for this type of ${typeOf[T].getClass.getName}: " + v.toString) + }) + + val valueFormat = + jsonFormat.applyOrElse(valueType, { f: String => + deserializationError(s"No Json format for this type of $valueType") + }) + + valueFormat.asInstanceOf[JsonFormat[T]].write(value) match { + case JsObject(fields) => JsObject(fields ++ Map(typeField -> JsString(valueType))) + case _ => serializationError(s"${typeOf[T].getClass.getName} serialized not to a JSON object") + } + } + + def read(json: JsValue): T = json match { + case JsObject(fields) => + val valueJson = JsObject(fields.filterNot(_._1 == typeField)) + fields(typeField) match { + case JsString(valueType) => + val valueFormat = jsonFormat.applyOrElse(valueType, { t: String => + deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") + }) + valueFormat.read(valueJson) + case _ => + deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") + } + case _ => + deserializationError(s"Expected Json Object as ${typeOf[T].getClass.getName}, but got " + json.toString) + } + } + + object GadtJsonFormat { + + def create[T: TypeTag](typeField: String)(typeValue: PartialFunction[T, String])( + jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) = { + + new GadtJsonFormat[T](typeField, typeValue, jsonFormat) + } + } + + /** + * Provides the JsonFormat for the Refined types provided by the Refined library. + * + * @see https://github.com/fthomas/refined + */ + implicit def refinedJsonFormat[T, Predicate]( + implicit valueFormat: JsonFormat[T], + validate: Validate[T, Predicate]): JsonFormat[Refined[T, Predicate]] = + new JsonFormat[Refined[T, Predicate]] { + def write(x: T Refined Predicate): JsValue = valueFormat.write(x.value) + def read(value: JsValue): T Refined Predicate = { + refineV[Predicate](valueFormat.read(value))(validate) match { + case Right(refinedValue) => refinedValue + case Left(refinementError) => deserializationError(refinementError) + } + } + } + + implicit def nonEmptyNameFormat[T](implicit nonEmptyStringFormat: JsonFormat[Refined[String, NonEmpty]]): RootJsonFormat[NonEmptyName[T]] = + new RootJsonFormat[NonEmptyName[T]] { + def write(name: NonEmptyName[T]) = JsString(name.value.value) + + def read(value: JsValue): NonEmptyName[T] = + NonEmptyName[T](nonEmptyStringFormat.read(value)) + } + + implicit val serviceExceptionFormat: RootJsonFormat[ServiceException] = + GadtJsonFormat.create[ServiceException]("type") { + case _: InvalidInputException => "InvalidInputException" + case _: InvalidActionException => "InvalidActionException" + case _: ResourceNotFoundException => "ResourceNotFoundException" + case _: ExternalServiceException => "ExternalServiceException" + case _: ExternalServiceTimeoutException => "ExternalServiceTimeoutException" + case _: DatabaseException => "DatabaseException" + } { + case "InvalidInputException" => jsonFormat(InvalidInputException, "message") + case "InvalidActionException" => jsonFormat(InvalidActionException, "message") + case "ResourceNotFoundException" => jsonFormat(ResourceNotFoundException, "message") + case "ExternalServiceException" => + jsonFormat(ExternalServiceException, "serviceName", "serviceMessage", "serviceException") + case "ExternalServiceTimeoutException" => jsonFormat(ExternalServiceTimeoutException, "message") + case "DatabaseException" => jsonFormat(DatabaseException, "message") + } +} + +private[core] object CoreJsonFormats { + def unrecognizedValue(value: String, possibleValues: Seq[Any]): Nothing = + deserializationError(s"Unexpected value $value. Expected one of: ${possibleValues.mkString("[", ", ", "]")}") +} \ No newline at end of file diff --git a/shared/src/main/scala/xyz/driver/core/auth.scala b/shared/src/main/scala/xyz/driver/core/auth.scala new file mode 100644 index 0000000..896bd89 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/auth.scala @@ -0,0 +1,43 @@ +package xyz.driver.core + +import xyz.driver.core.domain.Email +import xyz.driver.core.time.Time +import scalaz.Equal + +object auth { + + trait Permission + + final case class Role(id: Id[Role], name: Name[Role]) { + + def oneOf(roles: Role*): Boolean = roles.contains(this) + + def oneOf(roles: Set[Role]): Boolean = roles.contains(this) + } + + object Role { + implicit def idEqual: Equal[Role] = Equal.equal[Role](_ == _) + } + + trait User { + def id: Id[User] + } + + final case class AuthToken(value: String) + + final case class AuthTokenUserInfo( + id: Id[User], + email: Email, + emailVerified: Boolean, + audience: String, + roles: Set[Role], + expirationTime: Time) + extends User + + final case class RefreshToken(value: String) + final case class PermissionsToken(value: String) + + final case class PasswordHash(value: String) + + final case class AuthCredentials(identifier: String, password: String) +} diff --git a/shared/src/main/scala/xyz/driver/core/core.scala b/shared/src/main/scala/xyz/driver/core/core.scala new file mode 100644 index 0000000..ea05829 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/core.scala @@ -0,0 +1,132 @@ +package xyz.driver + +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import scalaz.{Equal, Monad, OptionT} +import xyz.driver.core.rest.errors.ExternalServiceException + +import scala.concurrent.{ExecutionContext, Future} + +package object core { + + import scala.language.reflectiveCalls + + def make[T](v: => T)(f: T => Unit): T = { + val value = v + f(value) + value + } + + def using[R <: { def close() }, P](r: => R)(f: R => P): P = { + val resource = r + try { + f(resource) + } finally { + resource.close() + } + } + + object tagging { + private[core] trait Tagged[+V, +Tag] + + implicit class Taggable[V <: Any](val v: V) extends AnyVal { + def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] + } + } + type @@[+V, +Tag] = V with tagging.Tagged[V, Tag] + + implicit class OptionTExtensions[H[_]: Monad, T](optionTValue: OptionT[H, T]) { + + def returnUnit: H[Unit] = optionTValue.fold[Unit](_ => (), ()) + + def continueIgnoringNone: OptionT[H, Unit] = + optionTValue.map(_ => ()).orElse(OptionT.some[H, Unit](())) + + def subflatMap[B](f: T => Option[B]): OptionT[H, B] = + OptionT.optionT[H](implicitly[Monad[H]].map(optionTValue.run)(_.flatMap(f))) + } + + implicit class MonadicExtensions[H[_]: Monad, T](monadicValue: H[T]) { + private implicit val monadT = implicitly[Monad[H]] + + def returnUnit: H[Unit] = monadT(monadicValue)(_ => ()) + + def toOptionT: OptionT[H, T] = + OptionT.optionT[H](monadT(monadicValue)(value => Option(value))) + + def toUnitOptionT: OptionT[H, Unit] = + OptionT.optionT[H](monadT(monadicValue)(_ => Option(()))) + } + + implicit class FutureExtensions[T](future: Future[T]) { + def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = + future.transform(identity, { + case ExternalServiceException(_, _, Some(e)) => e + case t: Throwable => t + }) + } +} + +package core { + + final case class Id[+Tag](value: String) extends AnyVal { + @inline def length: Int = value.length + override def toString: String = value + } + + @SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion")) + object Id { + implicit def idEqual[T]: Equal[Id[T]] = Equal.equal[Id[T]](_ == _) + implicit def idOrdering[T]: Ordering[Id[T]] = Ordering.by[Id[T], String](_.value) + + sealed class Mapper[E, R] { + def apply[T >: E](id: Id[R]): Id[T] = Id[E](id.value) + def apply[T >: R](id: Id[E])(implicit dummy: DummyImplicit): Id[T] = Id[R](id.value) + } + object Mapper { + def apply[E, R] = new Mapper[E, R] + } + implicit def convertRE[R, E](id: Id[R])(implicit mapper: Mapper[E, R]): Id[E] = mapper[E](id) + implicit def convertER[E, R](id: Id[E])(implicit mapper: Mapper[E, R]): Id[R] = mapper[R](id) + } + + final case class Name[+Tag](value: String) extends AnyVal { + @inline def length: Int = value.length + override def toString: String = value + } + + object Name { + implicit def nameEqual[T]: Equal[Name[T]] = Equal.equal[Name[T]](_ == _) + implicit def nameOrdering[T]: Ordering[Name[T]] = Ordering.by(_.value) + + implicit def nameValidator[T, P](implicit stringValidate: Validate[String, P]): Validate[Name[T], P] = { + Validate.instance[Name[T], P, stringValidate.R]( + name => stringValidate.validate(name.value), + name => stringValidate.showExpr(name.value)) + } + } + + final case class NonEmptyName[+Tag](value: String Refined NonEmpty) { + @inline def length: Int = value.value.length + override def toString: String = value.value + } + + object NonEmptyName { + implicit def nonEmptyNameEqual[T]: Equal[NonEmptyName[T]] = + Equal.equal[NonEmptyName[T]](_.value.value == _.value.value) + + implicit def nonEmptyNameOrdering[T]: Ordering[NonEmptyName[T]] = Ordering.by(_.value.value) + } + + @deprecated("Base64 formats are rarely used in core and will be removed. Please implement the wrapper type in " + + "services, or open an issue if you think it should stay in core.", "driver-core 1.11.5") + final case class Revision[T](id: String) + + object Revision { + implicit def revisionEqual[T]: Equal[Revision[T]] = Equal.equal[Revision[T]](_.id == _.id) + } + + @deprecated("Base64 formats are rarely used in core and will be removed. Please implement the wrapper type in " + + "services, or open an issue if you think it should stay in core.", "driver-core 1.11.5") + final case class Base64(value: String) +} diff --git a/shared/src/main/scala/xyz/driver/core/date.scala b/shared/src/main/scala/xyz/driver/core/date.scala new file mode 100644 index 0000000..5454093 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/date.scala @@ -0,0 +1,109 @@ +package xyz.driver.core + +import java.util.Calendar + +import enumeratum._ +import scalaz.std.anyVal._ +import scalaz.syntax.equal._ + +import scala.collection.immutable.IndexedSeq +import scala.util.Try + +/** + * Driver Date type and related validators/extractors. + * Day, Month, and Year extractors are from ISO 8601 strings => driver...Date integers. + * TODO: Decouple extractors from ISO 8601, as we might want to parse other formats. + */ +object date { + + sealed trait DayOfWeek extends EnumEntry + object DayOfWeek extends Enum[DayOfWeek] { + case object Monday extends DayOfWeek + case object Tuesday extends DayOfWeek + case object Wednesday extends DayOfWeek + case object Thursday extends DayOfWeek + case object Friday extends DayOfWeek + case object Saturday extends DayOfWeek + case object Sunday extends DayOfWeek + + val values: IndexedSeq[DayOfWeek] = findValues + + val All: Set[DayOfWeek] = values.toSet + + def fromString(day: String): Option[DayOfWeek] = withNameInsensitiveOption(day) + } + + type Day = Int @@ Day.type + + object Day { + def apply(value: Int): Day = { + require(1 to 31 contains value, "Day must be in range 1 <= value <= 31") + value.asInstanceOf[Day] + } + + def unapply(dayString: String): Option[Int] = { + require(dayString.length === 2, s"ISO 8601 day string, DD, must have length 2: $dayString") + Try(dayString.toInt).toOption.map(apply) + } + } + + type Month = Int @@ Month.type + + object Month { + def apply(value: Int): Month = { + require(0 to 11 contains value, "Month is zero-indexed: 0 <= value <= 11") + value.asInstanceOf[Month] + } + val JANUARY = Month(Calendar.JANUARY) + val FEBRUARY = Month(Calendar.FEBRUARY) + val MARCH = Month(Calendar.MARCH) + val APRIL = Month(Calendar.APRIL) + val MAY = Month(Calendar.MAY) + val JUNE = Month(Calendar.JUNE) + val JULY = Month(Calendar.JULY) + val AUGUST = Month(Calendar.AUGUST) + val SEPTEMBER = Month(Calendar.SEPTEMBER) + val OCTOBER = Month(Calendar.OCTOBER) + val NOVEMBER = Month(Calendar.NOVEMBER) + val DECEMBER = Month(Calendar.DECEMBER) + + def unapply(monthString: String): Option[Month] = { + require(monthString.length === 2, s"ISO 8601 month string, MM, must have length 2: $monthString") + Try(monthString.toInt).toOption.map(isoM => apply(isoM - 1)) + } + } + + type Year = Int @@ Year.type + + object Year { + def apply(value: Int): Year = value.asInstanceOf[Year] + + def unapply(yearString: String): Option[Int] = { + require(yearString.length === 4, s"ISO 8601 year string, YYYY, must have length 4: $yearString") + Try(yearString.toInt).toOption.map(apply) + } + } + + final case class Date(year: Int, month: Month, day: Int) { + override def toString = f"$year%04d-${month + 1}%02d-$day%02d" + } + + object Date { + implicit def dateOrdering: Ordering[Date] = Ordering.fromLessThan { (date1, date2) => + if (date1.year != date2.year) { + date1.year < date2.year + } else if (date1.month != date2.month) { + date1.month < date2.month + } else { + date1.day < date2.day + } + } + + def fromString(dateString: String): Option[Date] = { + dateString.split('-') match { + case Array(Year(year), Month(month), Day(day)) => Some(Date(year, month, day)) + case _ => None + } + } + } +} diff --git a/shared/src/main/scala/xyz/driver/core/domain.scala b/shared/src/main/scala/xyz/driver/core/domain.scala new file mode 100644 index 0000000..7b22a6e --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/domain.scala @@ -0,0 +1,27 @@ +package xyz.driver.core + +import scalaz.Equal + +package domain { + + final case class Email(username: String, domain: String) { + override def toString: String = username + "@" + domain + } + + object Email { + implicit val emailEqual: Equal[Email] = Equal.equal { + case (left, right) => left.toString.toLowerCase == right.toString.toLowerCase + } + + def parse(emailString: String): Option[Email] = { + Some(emailString.split("@")) collect { + case Array(username, domain) => Email(username, domain) + } + } + } + + final case class PhoneNumber(countryCode: String = "1", number: String) { + override def toString: String = s"+$countryCode $number" + } + +} diff --git a/shared/src/main/scala/xyz/driver/core/future.scala b/shared/src/main/scala/xyz/driver/core/future.scala new file mode 100644 index 0000000..07b6ea4 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/future.scala @@ -0,0 +1,84 @@ +package xyz.driver.core + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.util.{Failure, Success, Try} + +object future { + + implicit class RichFuture[T](f: Future[T]) { + def mapAll[U](pf: PartialFunction[Try[T], U])(implicit executionContext: ExecutionContext): Future[U] = { + val p = Promise[U]() + f.onComplete(r => p.complete(Try(pf(r)))) + p.future + } + + def failFastZip[U](that: Future[U])(implicit executionContext: ExecutionContext): Future[(T, U)] = { + future.failFastZip(f, that) + } + } + + def failFastSequence[T](t: Iterable[Future[T]])(implicit ec: ExecutionContext): Future[Seq[T]] = { + t.foldLeft(Future.successful(Nil: List[T])) { (f, i) => + failFastZip(f, i).map { case (tail, h) => h :: tail } + } + .map(_.reverse) + } + + /** + * Standard scala zip waits forever on the left side, even if the right side fails + */ + def failFastZip[T, U](ft: Future[T], fu: Future[U])(implicit ec: ExecutionContext): Future[(T, U)] = { + type State = Either[(T, Promise[U]), (U, Promise[T])] + val middleState = Promise[State]() + + ft.onComplete { + case f @ Failure(err) => + if (!middleState.tryFailure(err)) { + // the right has already succeeded + middleState.future.foreach { + case Right((_, pt)) => pt.complete(f) + case Left((t1, _)) => // This should never happen + sys.error(s"Logic error: tried to set Failure($err) but Left($t1) already set") + } + } + case Success(t) => + // Create the next promise: + val pu = Promise[U]() + if (!middleState.trySuccess(Left((t, pu)))) { + // we can't set, so the other promise beat us here. + middleState.future.foreach { + case Right((_, pt)) => pt.success(t) + case Left((t1, _)) => // This should never happen + sys.error(s"Logic error: tried to set Left($t) but Left($t1) already set") + } + } + } + fu.onComplete { + case f @ Failure(err) => + if (!middleState.tryFailure(err)) { + // we can't set, so the other promise beat us here. + middleState.future.foreach { + case Left((_, pu)) => pu.complete(f) + case Right((u1, _)) => // This should never happen + sys.error(s"Logic error: tried to set Failure($err) but Right($u1) already set") + } + } + case Success(u) => + // Create the next promise: + val pt = Promise[T]() + if (!middleState.trySuccess(Right((u, pt)))) { + // we can't set, so the other promise beat us here. + middleState.future.foreach { + case Left((_, pu)) => pu.success(u) + case Right((u1, _)) => // This should never happen + sys.error(s"Logic error: tried to set Right($u) but Right($u1) already set") + } + } + } + + middleState.future.flatMap { + case Left((t, pu)) => pu.future.map((t, _)) + case Right((u, pt)) => pt.future.map((_, u)) + } + } +} diff --git a/shared/src/main/scala/xyz/driver/core/generators.scala b/shared/src/main/scala/xyz/driver/core/generators.scala new file mode 100644 index 0000000..d57980e --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/generators.scala @@ -0,0 +1,138 @@ +package xyz.driver.core + +import enumeratum._ +import java.math.MathContext +import java.util.UUID + +import xyz.driver.core.time.{Time, TimeOfDay, TimeRange} +import xyz.driver.core.date.{Date, DayOfWeek} + +import scala.reflect.ClassTag +import scala.util.Random +import eu.timepit.refined.refineV +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection._ + +object generators { + + private val random = new Random + import random._ + private val secureRandom = new java.security.SecureRandom() + + private val DefaultMaxLength = 10 + private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet + private val NonAmbigiousCharacters = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private val Numbers = "0123456789" + + private def nextTokenString(length: Int, chars: IndexedSeq[Char]): String = { + val builder = new StringBuilder + for (_ <- 0 until length) { + builder += chars(secureRandom.nextInt(chars.length)) + } + builder.result() + } + + /** Creates a random invitation token. + * + * This token is meant fo human input and avoids using ambiguous characters such as 'O' and '0'. It + * therefore contains less entropy and is not meant to be used as a cryptographic secret. */ + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextToken(length: Int): String = nextTokenString(length, NonAmbigiousCharacters) + + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextNumericToken(length: Int): String = nextTokenString(length, Numbers) + + def nextInt(maxValue: Int, minValue: Int = 0): Int = random.nextInt(maxValue - minValue) + minValue + + def nextBoolean(): Boolean = random.nextBoolean() + + def nextDouble(): Double = random.nextDouble() + + def nextId[T](): Id[T] = Id[T](nextUuid().toString) + + def nextId[T](maxLength: Int): Id[T] = Id[T](nextString(maxLength)) + + def nextNumericId[T](): Id[T] = Id[T](nextLong.abs.toString) + + def nextNumericId[T](maxValue: Int): Id[T] = Id[T](nextInt(maxValue).toString) + + def nextName[T](maxLength: Int = DefaultMaxLength): Name[T] = Name[T](nextString(maxLength)) + + def nextNonEmptyName[T](maxLength: Int = DefaultMaxLength): NonEmptyName[T] = + NonEmptyName[T](nextNonEmptyString(maxLength)) + + def nextUuid(): UUID = java.util.UUID.randomUUID + + def nextRevision[T](): Revision[T] = Revision[T](nextUuid().toString) + + def nextString(maxLength: Int = DefaultMaxLength): String = + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + + def nextNonEmptyString(maxLength: Int = DefaultMaxLength): String Refined NonEmpty = { + refineV[NonEmpty]( + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + ).right.get + } + + def nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None + + def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) + + def nextTriad[F, S, T](first: => F, second: => S, third: => T): (F, S, T) = (first, second, third) + + def nextTime(): Time = Time(math.abs(nextLong() % System.currentTimeMillis)) + + def nextTimeOfDay: TimeOfDay = TimeOfDay(java.time.LocalTime.MIN.plusSeconds(nextLong), java.util.TimeZone.getDefault) + + def nextTimeRange(): TimeRange = { + val oneTime = nextTime() + val anotherTime = nextTime() + + TimeRange( + Time(scala.math.min(oneTime.millis, anotherTime.millis)), + Time(scala.math.max(oneTime.millis, anotherTime.millis))) + } + + def nextDate(): Date = nextTime().toDate(java.util.TimeZone.getTimeZone("UTC")) + + def nextDayOfWeek(): DayOfWeek = oneOf(DayOfWeek.All) + + def nextBigDecimal(multiplier: Double = 1000000.00, precision: Int = 2): BigDecimal = + BigDecimal(multiplier * nextDouble, new MathContext(precision)) + + def oneOf[T](items: T*): T = oneOf(items.toSet) + + def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) + + def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) + + def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = + Array.fill(nextInt(maxLength, minLength))(generator) + + def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Seq[T] = + Seq.fill(nextInt(maxLength, minLength))(generator) + + def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Vector[T] = + Vector.fill(nextInt(maxLength, minLength))(generator) + + def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): List[T] = + List.fill(nextInt(maxLength, minLength))(generator) + + def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Set[T] = + seqOf(generator, maxLength, minLength).toSet + + def mapOf[K, V]( + keyGenerator: => K, + valueGenerator: => V, + maxLength: Int = DefaultMaxLength, + minLength: Int = 0): Map[K, V] = + seqOf(nextPair(keyGenerator, valueGenerator), maxLength, minLength).toMap +} diff --git a/shared/src/main/scala/xyz/driver/core/rest/AuthProvider.scala b/shared/src/main/scala/xyz/driver/core/rest/AuthProvider.scala new file mode 100644 index 0000000..19716bb --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/AuthProvider.scala @@ -0,0 +1,8 @@ +package xyz.driver.core.rest + +object AuthProvider { + val AuthenticationTokenHeader: String = ContextHeaders.AuthenticationTokenHeader + val PermissionsTokenHeader: String = ContextHeaders.PermissionsTokenHeader + val SetAuthenticationTokenHeader: String = "set-authorization" + val SetPermissionsTokenHeader: String = "set-permissions" +} diff --git a/shared/src/main/scala/xyz/driver/core/rest/ContextHeaders.scala b/shared/src/main/scala/xyz/driver/core/rest/ContextHeaders.scala new file mode 100644 index 0000000..7efe84c --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/ContextHeaders.scala @@ -0,0 +1,15 @@ +package xyz.driver.core.rest + +object ContextHeaders { + val AuthenticationTokenHeader: String = "Authorization" + val PermissionsTokenHeader: String = "Permissions" + val AuthenticationHeaderPrefix: String = "Bearer" + val ClientFingerprintHeader: String = "X-Client-Fingerprint" + val TrackingIdHeader: String = "X-Trace" + val StacktraceHeader: String = "X-Stacktrace" + val OriginatingIpHeader: String = "X-Forwarded-For" + val ResourceCount: String = "X-Resource-Count" + val PageCount: String = "X-Page-Count" + val TraceHeaderName = "Tracing-Trace-Id" + val SpanHeaderName = "Tracing-Span-Id" + } \ No newline at end of file diff --git a/shared/src/main/scala/xyz/driver/core/rest/Service.scala b/shared/src/main/scala/xyz/driver/core/rest/Service.scala new file mode 100644 index 0000000..8216ab7 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/Service.scala @@ -0,0 +1,3 @@ +package xyz.driver.core.rest + +trait Service diff --git a/shared/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala b/shared/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala new file mode 100644 index 0000000..1a5e9be --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala @@ -0,0 +1,11 @@ +package xyz.driver.core.rest.auth + +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.Future + +trait Authorization[U <: User] { + def userHasPermissions(user: U, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] +} diff --git a/shared/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala b/shared/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala new file mode 100644 index 0000000..3602082 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala @@ -0,0 +1,21 @@ +package xyz.driver.core.rest.auth + +import scalaz.Scalaz.mapMonoid +import scalaz.Semigroup +import scalaz.syntax.semigroup._ +import xyz.driver.core.auth.{Permission, PermissionsToken} + +final case class AuthorizationResult(authorized: Map[Permission, Boolean], token: Option[PermissionsToken]) +object AuthorizationResult { + val unauthorized: AuthorizationResult = AuthorizationResult(authorized = Map.empty, None) + + implicit val authorizationSemigroup: Semigroup[AuthorizationResult] = new Semigroup[AuthorizationResult] { + private implicit val authorizedBooleanSemigroup = Semigroup.instance[Boolean](_ || _) + private implicit val permissionsTokenSemigroup = + Semigroup.instance[Option[PermissionsToken]]((a, b) => b.orElse(a)) + + override def append(a: AuthorizationResult, b: => AuthorizationResult): AuthorizationResult = { + AuthorizationResult(a.authorized |+| b.authorized, a.token |+| b.token) + } + } +} diff --git a/shared/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala b/shared/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala new file mode 100644 index 0000000..db289de --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala @@ -0,0 +1,23 @@ +package xyz.driver.core.rest.errors + +sealed abstract class ServiceException(val message: String) extends Exception(message) + +final case class InvalidInputException(override val message: String = "Invalid input") extends ServiceException(message) + +final case class InvalidActionException(override val message: String = "This action is not allowed") + extends ServiceException(message) + +final case class ResourceNotFoundException(override val message: String = "Resource not found") + extends ServiceException(message) + +final case class ExternalServiceException( + serviceName: String, + serviceMessage: String, + serviceException: Option[ServiceException]) + extends ServiceException(s"Error while calling '$serviceName': $serviceMessage") + +final case class ExternalServiceTimeoutException(serviceName: String) + extends ServiceException(s"$serviceName took too long to respond") + +final case class DatabaseException(override val message: String = "Database access error") + extends ServiceException(message) diff --git a/shared/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala b/shared/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala new file mode 100644 index 0000000..102c97b --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala @@ -0,0 +1,73 @@ +package xyz.driver.core.rest + +import java.net.InetAddress + +import scalaz.Scalaz.{mapEqual, stringInstance} +import scalaz.syntax.equal._ +import xyz.driver.core.auth.{AuthToken, PermissionsToken, User} +import xyz.driver.core.generators + +class ServiceRequestContext( + val trackingId: String = generators.nextUuid().toString, + val originatingIp: Option[InetAddress] = None, + val contextHeaders: Map[String, String] = Map.empty[String, String]) { + def authToken: Option[AuthToken] = + contextHeaders.get(AuthProvider.AuthenticationTokenHeader).map(AuthToken.apply) + + def permissionsToken: Option[PermissionsToken] = + contextHeaders.get(AuthProvider.PermissionsTokenHeader).map(PermissionsToken.apply) + + def withAuthToken(authToken: AuthToken): ServiceRequestContext = + new ServiceRequestContext( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value) + ) + + def withAuthenticatedUser[U <: User](authToken: AuthToken, user: U): AuthorizedServiceRequestContext[U] = + new AuthorizedServiceRequestContext( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value), + user + ) + + override def hashCode(): Int = + Seq[Any](trackingId, originatingIp, contextHeaders) + .foldLeft(31)((result, obj) => 31 * result + obj.hashCode()) + + override def equals(obj: Any): Boolean = obj match { + case ctx: ServiceRequestContext => + trackingId === ctx.trackingId && + originatingIp == originatingIp && + contextHeaders === ctx.contextHeaders + case _ => false + } + + override def toString: String = s"ServiceRequestContext($trackingId, $contextHeaders)" +} + +class AuthorizedServiceRequestContext[U <: User]( + override val trackingId: String = generators.nextUuid().toString, + override val originatingIp: Option[InetAddress] = None, + override val contextHeaders: Map[String, String] = Map.empty[String, String], + val authenticatedUser: U) + extends ServiceRequestContext { + + def withPermissionsToken(permissionsToken: PermissionsToken): AuthorizedServiceRequestContext[U] = + new AuthorizedServiceRequestContext[U]( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.PermissionsTokenHeader, permissionsToken.value), + authenticatedUser) + + override def hashCode(): Int = 31 * super.hashCode() + authenticatedUser.hashCode() + + override def equals(obj: Any): Boolean = obj match { + case ctx: AuthorizedServiceRequestContext[U] => super.equals(ctx) && ctx.authenticatedUser == authenticatedUser + case _ => false + } + + override def toString: String = + s"AuthorizedServiceRequestContext($trackingId, $contextHeaders, $authenticatedUser)" +} diff --git a/shared/src/main/scala/xyz/driver/core/rest/sorting.scala b/shared/src/main/scala/xyz/driver/core/rest/sorting.scala new file mode 100644 index 0000000..ccdf150 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/rest/sorting.scala @@ -0,0 +1,35 @@ +package xyz.driver.core.rest + +sealed trait SortingOrder +object SortingOrder { + case object Asc extends SortingOrder + case object Desc extends SortingOrder +} + +final case class SortingField(name: String, sortingOrder: SortingOrder) +final case class Sorting(sortingFields: Seq[SortingField]) + +final case class Pagination(pageSize: Int, pageNumber: Int) { + require(pageSize > 0, "Page size must be greater than zero") + require(pageNumber > 0, "Page number must be greater than zero") + + def offset: Int = pageSize * (pageNumber - 1) +} + +final case class ListResponse[+T](items: Seq[T], meta: ListResponse.Meta) + +object ListResponse { + + def apply[T](items: Seq[T], size: Int, pagination: Option[Pagination]): ListResponse[T] = + ListResponse( + items = items, + meta = ListResponse.Meta(size, pagination.fold(1)(_.pageNumber), pagination.fold(size)(_.pageSize))) + + final case class Meta(itemsCount: Int, pageNumber: Int, pageSize: Int) + + object Meta { + def apply(itemsCount: Int, pagination: Pagination): Meta = + Meta(itemsCount, pagination.pageNumber, pagination.pageSize) + } + +} \ No newline at end of file diff --git a/shared/src/main/scala/xyz/driver/core/time.scala b/shared/src/main/scala/xyz/driver/core/time.scala new file mode 100644 index 0000000..6dbd173 --- /dev/null +++ b/shared/src/main/scala/xyz/driver/core/time.scala @@ -0,0 +1,175 @@ +package xyz.driver.core + +import java.text.SimpleDateFormat +import java.util._ +import java.util.concurrent.TimeUnit + +import xyz.driver.core.date.Month + +import scala.concurrent.duration._ +import scala.util.Try + +object time { + + // The most useful time units + val Second = 1000L + val Seconds = Second + val Minute = 60 * Seconds + val Minutes = Minute + val Hour = 60 * Minutes + val Hours = Hour + val Day = 24 * Hours + val Days = Day + val Week = 7 * Days + val Weeks = Week + + final case class Time(millis: Long) extends AnyVal { + + def isBefore(anotherTime: Time): Boolean = implicitly[Ordering[Time]].lt(this, anotherTime) + + def isAfter(anotherTime: Time): Boolean = implicitly[Ordering[Time]].gt(this, anotherTime) + + def advanceBy(duration: Duration): Time = Time(millis + duration.toMillis) + + def durationTo(anotherTime: Time): Duration = Duration.apply(anotherTime.millis - millis, TimeUnit.MILLISECONDS) + + def durationFrom(anotherTime: Time): Duration = Duration.apply(millis - anotherTime.millis, TimeUnit.MILLISECONDS) + + def toDate(timezone: TimeZone): date.Date = { + val cal = Calendar.getInstance(timezone) + cal.setTimeInMillis(millis) + date.Date(cal.get(Calendar.YEAR), date.Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) + } + } + + /** + * Encapsulates a time and timezone without a specific date. + */ + final case class TimeOfDay(localTime: java.time.LocalTime, timeZone: TimeZone) { + + /** + * Is this time before another time on a specific day. Day light savings safe. These are zero-indexed + * for month/day. + */ + def isBefore(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).before(other.toCalendar(day, month, year)) + } + + /** + * Is this time after another time on a specific day. Day light savings safe. + */ + def isAfter(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).after(other.toCalendar(day, month, year)) + } + + def sameTimeAs(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).equals(other.toCalendar(day, month, year)) + } + + /** + * Enforces the same formatting as expected by [[java.sql.Time]] + * @return string formatted for `java.sql.Time` + */ + def timeString: String = { + localTime.format(TimeOfDay.getFormatter) + } + + /** + * @return a string parsable by [[java.util.TimeZone]] + */ + def timeZoneString: String = { + timeZone.getID + } + + /** + * @return this [[TimeOfDay]] as [[java.sql.Time]] object, [[java.sql.Time.valueOf]] will + * throw when the string is not valid, but this is protected by [[timeString]] method. + */ + def toTime: java.sql.Time = { + java.sql.Time.valueOf(timeString) + } + + private def toCalendar(day: Int, month: Int, year: Int): Calendar = { + val cal = Calendar.getInstance(timeZone) + cal.set(year, month, day, localTime.getHour, localTime.getMinute, localTime.getSecond) + cal.clear(Calendar.MILLISECOND) + cal + } + } + + object TimeOfDay { + def now(): TimeOfDay = { + TimeOfDay(java.time.LocalTime.now(), TimeZone.getDefault) + } + + /** + * Throws when [s] is not parsable by [[java.time.LocalTime.parse]], uses default [[java.util.TimeZone]] + */ + def parseTimeString(tz: TimeZone = TimeZone.getDefault)(s: String): TimeOfDay = { + TimeOfDay(java.time.LocalTime.parse(s), tz) + } + + def fromString(tz: TimeZone)(s: String): Option[TimeOfDay] = { + val op = Try(java.time.LocalTime.parse(s)).toOption + op.map(lt => TimeOfDay(lt, tz)) + } + + def fromStrings(zoneId: String)(s: String): Option[TimeOfDay] = { + val op = Try(TimeZone.getTimeZone(zoneId)).toOption + op.map(tz => TimeOfDay.parseTimeString(tz)(s)) + } + + /** + * Formatter that enforces `HH:mm:ss` which is expected by [[java.sql.Time]] + */ + def getFormatter: java.time.format.DateTimeFormatter = { + java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss") + } + } + + object Time { + + implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) + } + + final case class TimeRange(start: Time, end: Time) { + def duration: Duration = FiniteDuration(end.millis - start.millis, MILLISECONDS) + } + + def startOfMonth(time: Time) = { + Time(make(new GregorianCalendar()) { cal => + cal.setTime(new Date(time.millis)) + cal.set(Calendar.DAY_OF_MONTH, cal.getActualMinimum(Calendar.DAY_OF_MONTH)) + }.getTime.getTime) + } + + def textualDate(timezone: TimeZone)(time: Time): String = + make(new SimpleDateFormat("MMMM d, yyyy"))(_.setTimeZone(timezone)).format(new Date(time.millis)) + + def textualTime(timezone: TimeZone)(time: Time): String = + make(new SimpleDateFormat("MMM dd, yyyy hh:mm:ss a"))(_.setTimeZone(timezone)).format(new Date(time.millis)) + + object provider { + + /** + * Time providers are supplying code with current times + * and are extremely useful for testing to check how system is going + * to behave at specific moments in time. + * + * All the calls to receive current time must be made using time + * provider injected to the caller. + */ + trait TimeProvider { + def currentTime(): Time + } + + final class SystemTimeProvider extends TimeProvider { + def currentTime() = Time(System.currentTimeMillis()) + } + final val SystemTimeProvider = new SystemTimeProvider + + final class SpecificTimeProvider(time: Time) extends TimeProvider { + def currentTime() = time + } + } +} -- cgit v1.2.3