From 7a793ffa068fda8f2146f84fa785328d928dba03 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 15:56:41 -0700 Subject: Move core types into core-types project Note that xyz.driver.core.FutureExtensions was moved to xyz.driver.core.rest as it (only) contained logic that dealt with service exceptions, something that belongs into core-rest and must not be depended upon by core-types. This is a breaking change. --- build.sbt | 46 ++++- .../src/main/scala/xyz/driver/core/core.scala | 114 +++++++++++ .../src/main/scala/xyz/driver/core/date.scala | 109 +++++++++++ .../src/main/scala/xyz/driver/core/domain.scala | 73 +++++++ .../scala/xyz/driver/core/tagging/tagging.scala | 62 ++++++ .../src/main/scala/xyz/driver/core/time.scala | 209 +++++++++++++++++++++ .../src/test/scala/xyz/driver/core/CoreTest.scala | 88 +++++++++ .../src/test/scala/xyz/driver/core/DateTest.scala | 53 ++++++ .../scala/xyz/driver/core/PhoneNumberTest.scala | 117 ++++++++++++ .../src/test/scala/xyz/driver/core/TimeTest.scala | 143 ++++++++++++++ .../xyz/driver/core/tagging/TaggingTest.scala | 63 +++++++ src/main/scala/xyz/driver/core/core.scala | 124 ------------ src/main/scala/xyz/driver/core/date.scala | 109 ----------- src/main/scala/xyz/driver/core/domain.scala | 73 ------- src/main/scala/xyz/driver/core/rest/package.scala | 12 +- .../scala/xyz/driver/core/tagging/tagging.scala | 62 ------ src/main/scala/xyz/driver/core/time.scala | 209 --------------------- src/test/scala/xyz/driver/core/CoreTest.scala | 88 --------- src/test/scala/xyz/driver/core/DateTest.scala | 53 ------ .../scala/xyz/driver/core/PhoneNumberTest.scala | 117 ------------ src/test/scala/xyz/driver/core/TimeTest.scala | 143 -------------- .../xyz/driver/core/rest/DriverRouteTest.scala | 1 - .../xyz/driver/core/tagging/TaggingTest.scala | 63 ------- 23 files changed, 1082 insertions(+), 1049 deletions(-) create mode 100644 core-types/src/main/scala/xyz/driver/core/core.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/date.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/domain.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/time.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/CoreTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/DateTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/TimeTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala delete mode 100644 src/main/scala/xyz/driver/core/core.scala delete mode 100644 src/main/scala/xyz/driver/core/date.scala delete mode 100644 src/main/scala/xyz/driver/core/domain.scala delete mode 100644 src/main/scala/xyz/driver/core/tagging/tagging.scala delete mode 100644 src/main/scala/xyz/driver/core/time.scala delete mode 100644 src/test/scala/xyz/driver/core/CoreTest.scala delete mode 100644 src/test/scala/xyz/driver/core/DateTest.scala delete mode 100644 src/test/scala/xyz/driver/core/PhoneNumberTest.scala delete mode 100644 src/test/scala/xyz/driver/core/TimeTest.scala delete mode 100644 src/test/scala/xyz/driver/core/tagging/TaggingTest.scala diff --git a/build.sbt b/build.sbt index 030b12f..576de7b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,12 @@ import sbt._ import Keys._ -lazy val core = project - .in(file(".")) +val testdeps = libraryDependencies ++= Seq( + "org.mockito" % "mockito-core" % "1.9.5" % "test", + "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", + "org.scalatest" %% "scalatest" % "3.0.5" % "test", +) +lazy val `core-util` = project .enablePlugins(LibraryPlugin) .settings( libraryDependencies ++= Seq( @@ -36,14 +40,42 @@ lazy val core = project "io.kamon" %% "kamon-statsd" % "1.0.0", "io.kamon" %% "kamon-system-metrics" % "1.0.0", "javax.xml.bind" % "jaxb-api" % "2.2.8", - "org.mockito" % "mockito-core" % "1.9.5" % "test", "org.scala-lang.modules" %% "scala-async" % "0.9.7", - "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", - "org.scalatest" %% "scalatest" % "3.0.5" % "test", "org.scalaz" %% "scalaz-core" % "7.2.24", "xyz.driver" %% "spray-json-derivation" % "0.6.0", "xyz.driver" %% "tracing" % "0.1.2" - ), + ) + ) + +lazy val `core-types` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-rest` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`, `core-types`) + .settings(testdeps) + +lazy val `core-reporting` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-cloud` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-init` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val core = project + .in(file(".")) + .enablePlugins(LibraryPlugin) + .settings( scalacOptions in (Compile, doc) ++= Seq( "-groups", // group similar methods together based on the @group annotation. "-diagrams", // show classs hierarchy diagrams (requires 'dot' to be available on path) @@ -54,3 +86,5 @@ lazy val core = project s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" ) ) + .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-cloud`, `core-init`) + .settings(testdeps) diff --git a/core-types/src/main/scala/xyz/driver/core/core.scala b/core-types/src/main/scala/xyz/driver/core/core.scala new file mode 100644 index 0000000..67ac5ee --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/core.scala @@ -0,0 +1,114 @@ +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.tagging.Tagged + +// TODO: this package seems too complex, look at all the features we need! +import scala.language.{higherKinds, implicitConversions, reflectiveCalls} + +package object core { + + 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() + } + } + + type @@[+V, +Tag] = V with 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(()))) + } + +} + +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/core-types/src/main/scala/xyz/driver/core/date.scala b/core-types/src/main/scala/xyz/driver/core/date.scala new file mode 100644 index 0000000..5454093 --- /dev/null +++ b/core-types/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/core-types/src/main/scala/xyz/driver/core/domain.scala b/core-types/src/main/scala/xyz/driver/core/domain.scala new file mode 100644 index 0000000..f3b8337 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/domain.scala @@ -0,0 +1,73 @@ +package xyz.driver.core + +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat +import scalaz.Equal +import scalaz.std.string._ +import scalaz.syntax.equal._ + +import scala.util.Try +import scala.util.control.NonFatal + +object domain { + + final case class Email(username: String, domain: String) { + + def value: String = toString + + 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, number: String, extension: Option[String] = None) { + + def hasExtension: Boolean = extension.isDefined + + /** This is a more human-friendly alias for #toE164String() */ + def toCompactString: String = s"+$countryCode$number${extension.fold("")(";ext=" + _)}" + + /** Outputs the phone number in a E.164-compliant way, e.g. +14151234567 */ + def toE164String: String = toCompactString + + /** + * Outputs the phone number in a "readable" way, e.g. "+1 415-123-45-67 ext. 1234" + * @throws IllegalStateException if the contents of this object is not a valid phone number + */ + @throws[IllegalStateException] + def toHumanReadableString: String = + try { + val phoneNumber = PhoneNumber.phoneUtil.parse(toE164String, "US") + PhoneNumber.phoneUtil.format(phoneNumber, PhoneNumberFormat.INTERNATIONAL) + } catch { + case NonFatal(e) => throw new IllegalStateException(s"$toString is not a valid number", e) + } + + override def toString: String = s"+$countryCode $number${extension.fold("")(" ext. " + _)}" + } + + object PhoneNumber { + + private[PhoneNumber] val phoneUtil = PhoneNumberUtil.getInstance() + + def parse(phoneNumber: String): Option[PhoneNumber] = { + val validated = Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption.filter(phoneUtil.isValidNumber) + validated.map { pn => + PhoneNumber( + pn.getCountryCode.toString, + pn.getNationalNumber.toString, + Option(pn.getExtension).filter(_.nonEmpty)) + } + } + } +} diff --git a/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala b/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala new file mode 100644 index 0000000..5b6599e --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala @@ -0,0 +1,62 @@ +package xyz.driver.core + +import scala.collection.generic.CanBuildFrom +import scala.language.{higherKinds, implicitConversions} + +/** + * @author sergey + * @since 9/11/18 + */ +package object tagging { + + implicit class Taggable[V <: Any](val v: V) extends AnyVal { + def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] + } + +} + +package tagging { + + sealed trait Tagged[+V, +Tag] + + object Tagged { + implicit class TaggedOps[V, Tag](val v: V @@ Tag) extends AnyVal { + def tagless: V = v + } + + implicit def orderingMagnet[V, Tag](implicit ord: Ordering[V]): Ordering[V @@ Tag] = + ord.asInstanceOf[Ordering[V @@ Tag]] + + } + + sealed trait Trimmed + + object Trimmed { + + implicit def apply[V](trimmable: V)(implicit ev: CanBeTrimmed[V]): V @@ Trimmed = { + ev.trim(trimmable).tagged[Trimmed] + } + + sealed trait CanBeTrimmed[T] { + def trim(trimmable: T): T + } + + implicit object StringCanBeTrimmed extends CanBeTrimmed[String] { + def trim(str: String): String = str.trim() + } + + implicit def nameCanBeTrimmed[T]: CanBeTrimmed[Name[T]] = new CanBeTrimmed[Name[T]] { + def trim(name: Name[T]): Name[T] = Name[T](name.value.trim()) + } + + implicit def option2Trimmed[V: CanBeTrimmed](option: Option[V]): Option[V @@ Trimmed] = + option.map(Trimmed(_)) + + implicit def coll2Trimmed[T, C[_] <: Traversable[_]](coll: C[T])( + implicit ev: C[T] <:< Traversable[T], + tr: CanBeTrimmed[T], + bf: CanBuildFrom[Nothing, T @@ Trimmed, C[T @@ Trimmed]]): C[T @@ Trimmed] = + ev(coll).map(Trimmed(_)(tr)).to[C] + } + +} diff --git a/core-types/src/main/scala/xyz/driver/core/time.scala b/core-types/src/main/scala/xyz/driver/core/time.scala new file mode 100644 index 0000000..1622068 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/time.scala @@ -0,0 +1,209 @@ +package xyz.driver.core + +import java.text.SimpleDateFormat +import java.time.{Clock, Instant, ZoneId, ZoneOffset} +import java.util._ +import java.util.concurrent.TimeUnit + +import xyz.driver.core.date.Month + +import scala.concurrent.duration._ +import scala.language.implicitConversions +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)) + } + + def toInstant: Instant = Instant.ofEpochMilli(millis) + } + + object Time { + implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) + + implicit def apply(instant: Instant): Time = Time(instant.toEpochMilli) + } + + /** + * 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") + } + } + + 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)) + + class ChangeableClock(@volatile var instant: Instant, val zone: ZoneId = ZoneOffset.UTC) extends Clock { + + def tick(duration: FiniteDuration): Unit = + instant = instant.plusNanos(duration.toNanos) + + val getZone: ZoneId = zone + + def withZone(zone: ZoneId): Clock = new ChangeableClock(instant, zone = zone) + + override def toString: String = "ChangeableClock(" + instant + "," + zone + ")" + } + + 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. + */ + @deprecated( + "Use java.time.Clock instead. Note that xyz.driver.core.Time and xyz.driver.core.date.Date will also be deprecated soon!", + "0.13.0") + trait TimeProvider { + def currentTime(): Time + def toClock: Clock + } + + final implicit class ClockTimeProvider(clock: Clock) extends TimeProvider { + def currentTime(): Time = Time(clock.instant().toEpochMilli) + + val toClock: Clock = clock + } + + final class SystemTimeProvider extends TimeProvider { + def currentTime() = Time(System.currentTimeMillis()) + + lazy val toClock: Clock = Clock.systemUTC() + } + + final val SystemTimeProvider = new SystemTimeProvider + + final class SpecificTimeProvider(time: Time) extends TimeProvider { + + def currentTime(): Time = time + + lazy val toClock: Clock = Clock.fixed(time.toInstant, ZoneOffset.UTC) + } + + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/CoreTest.scala b/core-types/src/test/scala/xyz/driver/core/CoreTest.scala new file mode 100644 index 0000000..f448d24 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/CoreTest.scala @@ -0,0 +1,88 @@ +package xyz.driver.core + +import java.io.ByteArrayOutputStream + +import org.mockito.Mockito._ +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{FlatSpec, Matchers} + +class CoreTest extends FlatSpec with Matchers with MockitoSugar { + + "'make' function" should "allow initialization for objects" in { + + val createdAndInitializedValue = make(new ByteArrayOutputStream(128)) { baos => + baos.write(Array(1.toByte, 1.toByte, 0.toByte)) + } + + createdAndInitializedValue.toByteArray should be(Array(1.toByte, 1.toByte, 0.toByte)) + } + + "'using' function" should "call close after performing action on resource" in { + + val baos = mock[ByteArrayOutputStream] + + using(baos /* usually new ByteArrayOutputStream(128) */ ) { baos => + baos.write(Array(1.toByte, 1.toByte, 0.toByte)) + } + + verify(baos).close() + } + + "Id" should "have equality and ordering working correctly" in { + + (Id[String]("1234213") === Id[String]("1234213")) should be(true) + (Id[String]("1234213") === Id[String]("213414")) should be(false) + (Id[String]("213414") === Id[String]("1234213")) should be(false) + + val ids = Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")) + val sorted = Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4")) + + ids.sorted should contain theSameElementsInOrderAs sorted + } + + it should "have type-safe conversions" in { + final case class X(id: Id[X]) + final case class Y(id: Id[Y]) + final case class Z(id: Id[Z]) + + implicit val xy = Id.Mapper[X, Y] + implicit val yz = Id.Mapper[Y, Z] + + // Test that implicit conversions work correctly + val x = X(Id("0")) + val y = Y(x.id) + val z = Z(y.id) + val y2 = Y(z.id) + val x2 = X(y2.id) + (x2 === x) should be(true) + (y2 === y) should be(true) + + // Test that type inferrence for explicit conversions work correctly + val yid = y.id + val xid = xy(yid) + val zid = yz(yid) + (xid: Id[X]) should be(zid: Id[Z]) + } + + "Name" should "have equality and ordering working correctly" in { + + (Name[String]("foo") === Name[String]("foo")) should be(true) + (Name[String]("foo") === Name[String]("bar")) should be(false) + (Name[String]("bar") === Name[String]("foo")) should be(false) + + val names = Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")) + val sorted = Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d")) + names.sorted should contain theSameElementsInOrderAs sorted + } + + "Revision" should "have equality working correctly" in { + + val bla = Revision[String]("85569dab-a3dc-401b-9f95-d6fb4162674b") + val foo = Revision[String]("f54b3558-bdcd-4646-a14b-8beb11f6b7c4") + + (bla === bla) should be(true) + (bla === foo) should be(false) + (foo === bla) should be(false) + } + +} diff --git a/core-types/src/test/scala/xyz/driver/core/DateTest.scala b/core-types/src/test/scala/xyz/driver/core/DateTest.scala new file mode 100644 index 0000000..0432040 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/DateTest.scala @@ -0,0 +1,53 @@ +package xyz.driver.core + +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.prop.Checkers +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.date.Date + +class DateTest extends FlatSpec with Matchers with Checkers { + val dateGenerator = for { + year <- Gen.choose(0, 3000) + month <- Gen.choose(0, 11) + day <- Gen.choose(1, 31) + } yield Date(year, date.Month(month), day) + implicit val arbitraryDate = Arbitrary[Date](dateGenerator) + + "Date" should "correctly convert to and from String" in { + + import xyz.driver.core.generators.nextDate + import date._ + + for (date <- 1 to 100 map (_ => nextDate())) { + Some(date) should be(Date.fromString(date.toString)) + } + } + + it should "have ordering defined correctly" in { + Seq( + Date.fromString("2013-05-10"), + Date.fromString("2020-02-15"), + Date.fromString("2017-03-05"), + Date.fromString("2013-05-12")).sorted should + contain theSameElementsInOrderAs Seq( + Date.fromString("2013-05-10"), + Date.fromString("2013-05-12"), + Date.fromString("2017-03-05"), + Date.fromString("2020-02-15")) + + check { dates: List[Date] => + dates.sorted.sliding(2).filter(_.size == 2).forall { + case Seq(a, b) => + if (a.year == b.year) { + if (a.month == b.month) { + a.day <= b.day + } else { + a.month < b.month + } + } else { + a.year < b.year + } + } + } + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala b/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala new file mode 100644 index 0000000..729302b --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala @@ -0,0 +1,117 @@ +package xyz.driver.core + +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.domain.PhoneNumber + +class PhoneNumberTest extends FlatSpec with Matchers { + + "PhoneNumber.parse" should "recognize US numbers in international format, ignoring non-digits" in { + // format: off + val numbers = List( + "+18005252225", + "+1 800 525 2225", + "+1 (800) 525-2225", + "+1.800.525.2225") + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225") + } + + it should "recognize US numbers without the plus sign" in { + PhoneNumber.parse("18005252225") shouldBe Some(PhoneNumber("1", "8005252225")) + } + + it should "recognize US numbers without country code" in { + // format: off + val numbers = List( + "8005252225", + "800 525 2225", + "(800) 525-2225", + "800.525.2225") + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225") + } + + it should "recognize CN numbers in international format" in { + PhoneNumber.parse("+868005252225") shouldBe Some(PhoneNumber("86", "8005252225")) + PhoneNumber.parse("+86 134 52 52 2256") shouldBe Some(PhoneNumber("86", "13452522256")) + } + + it should "parse numbers with extensions in different formats" in { + // format: off + val numbers = List( + "+1 800 525 22 25 x23", + "+18005252225 ext. 23", + "+18005252225,23" + ) + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225", Some("23")) + } + + it should "return None on numbers that are shorter than the minimum number of digits for the country (i.e. US - 10, AR - 11)" in { + withClue("US and CN numbers are 10 digits - 9 digit (and shorter) numbers should not fit") { + // format: off + val numbers = List( + "+1 800 525-222", + "+1 800 525-2", + "+86 800 525-222", + "+86 800 525-2") + // format: on + + numbers.flatMap(PhoneNumber.parse) shouldBe empty + } + + withClue("Argentinian numbers are 11 digits (when prefixed with 0) - 10 digit numbers shouldn't fit") { + // format: off + val numbers = List( + "+54 011 525-22256", + "+54 011 525-2225", + "+54 011 525-222") + // format: on + + numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs List(PhoneNumber("54", "1152522256")) + } + } + + it should "return None on numbers that are longer than the maximum number of digits for the country (i.e. DK - 8, CN - 11)" in { + val numbers = List("+45 27 45 25 22", "+45 135 525 223", "+86 134 525 22256", "+86 135 525 22256 7") + + numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs + List(PhoneNumber("45", "27452522"), PhoneNumber("86", "13452522256")) + } + + "PhoneNumber.toCompactString/toE164String" should "produce phone number in international format without whitespaces" in { + PhoneNumber.parse("+1 800 5252225").get.toCompactString shouldBe "+18005252225" + PhoneNumber.parse("+1 800 5252225").get.toE164String shouldBe "+18005252225" + + PhoneNumber.parse("+1 800 5252225 x23").get.toCompactString shouldBe "+18005252225;ext=23" + PhoneNumber.parse("+1 800 5252225 x23").get.toE164String shouldBe "+18005252225;ext=23" + } + + "PhoneNumber.toHumanReadableString" should "produce nice readable result for different countries" in { + PhoneNumber.parse("+14154234567").get.toHumanReadableString shouldBe "+1 415-423-4567" + PhoneNumber.parse("+14154234567,23").get.toHumanReadableString shouldBe "+1 415-423-4567 ext. 23" + + PhoneNumber.parse("+78005252225").get.toHumanReadableString shouldBe "+7 800 525-22-25" + + PhoneNumber.parse("+41219437898").get.toHumanReadableString shouldBe "+41 21 943 78 98" + } + + it should "throw an IllegalArgumentException if the PhoneNumber object is not parsable/valid" in { + intercept[IllegalStateException] { + PhoneNumber("+123", "1238123120938120938").toHumanReadableString + }.getMessage should include("+123 1238123120938120938 is not a valid number") + } + +} diff --git a/core-types/src/test/scala/xyz/driver/core/TimeTest.scala b/core-types/src/test/scala/xyz/driver/core/TimeTest.scala new file mode 100644 index 0000000..1019f60 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/TimeTest.scala @@ -0,0 +1,143 @@ +package xyz.driver.core + +import java.util.TimeZone + +import org.scalacheck.Arbitrary._ +import org.scalacheck.Prop.BooleanOperators +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.prop.Checkers +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.date.Month +import xyz.driver.core.time.{Time, _} + +import scala.concurrent.duration._ +import scala.language.postfixOps + +class TimeTest extends FlatSpec with Matchers with Checkers { + + implicit val arbDuration = Arbitrary[Duration](Gen.chooseNum(0L, 9999999999L).map(_.milliseconds)) + implicit val arbTime = Arbitrary[Time](Gen.chooseNum(0L, 9999999999L).map(millis => Time(millis))) + + "Time" should "have correct methods to compare" in { + + Time(234L).isAfter(Time(123L)) should be(true) + Time(123L).isAfter(Time(123L)) should be(false) + Time(123L).isAfter(Time(234L)) should be(false) + + check((a: Time, b: Time) => (a.millis > b.millis) ==> a.isAfter(b)) + + Time(234L).isBefore(Time(123L)) should be(false) + Time(123L).isBefore(Time(123L)) should be(false) + Time(123L).isBefore(Time(234L)) should be(true) + + check { (a: Time, b: Time) => + (a.millis < b.millis) ==> a.isBefore(b) + } + } + + it should "not modify time" in { + + Time(234L).millis should be(234L) + + check { millis: Long => + Time(millis).millis == millis + } + } + + it should "support arithmetic with scala.concurrent.duration" in { + + Time(123L).advanceBy(0 minutes).millis should be(123L) + Time(123L).advanceBy(1 second).millis should be(123L + Second) + Time(123L).advanceBy(4 days).millis should be(123L + 4 * Days) + + check { (time: Time, duration: Duration) => + time.advanceBy(duration).millis == (time.millis + duration.toMillis) + } + } + + it should "have ordering defined correctly" in { + + Seq(Time(321L), Time(123L), Time(231L)).sorted should + contain theSameElementsInOrderAs Seq(Time(123L), Time(231L), Time(321L)) + + check { times: List[Time] => + times.sorted.sliding(2).filter(_.size == 2).forall { + case Seq(a, b) => + a.millis <= b.millis + } + } + } + + it should "reset to the start of the period, e.g. month" in { + + startOfMonth(Time(1468937089834L)) should be(Time(1467381889834L)) + startOfMonth(Time(1467381889834L)) should be(Time(1467381889834L)) // idempotent + } + + it should "have correct textual representations" in { + import java.util.Locale + import java.util.Locale._ + Locale.setDefault(US) + + textualDate(TimeZone.getTimeZone("EDT"))(Time(1468937089834L)) should be("July 19, 2016") + textualTime(TimeZone.getTimeZone("PDT"))(Time(1468937089834L)) should be("Jul 19, 2016 02:04:49 PM") + } + + "TimeRange" should "have duration defined as a difference of start and end times" in { + + TimeRange(Time(321L), Time(432L)).duration should be(111.milliseconds) + TimeRange(Time(432L), Time(321L)).duration should be((-111).milliseconds) + TimeRange(Time(333L), Time(333L)).duration should be(0.milliseconds) + } + + "Time" should "use TimeZone correctly when converting to Date" in { + + val EST = java.util.TimeZone.getTimeZone("EST") + val PST = java.util.TimeZone.getTimeZone("PST") + + val timestamp = { + import java.util.Calendar + val cal = Calendar.getInstance(EST) + cal.set(Calendar.HOUR_OF_DAY, 1) + Time(cal.getTime().getTime()) + } + + textualDate(EST)(timestamp) should not be textualDate(PST)(timestamp) + timestamp.toDate(EST) should not be timestamp.toDate(PST) + } + + "TimeOfDay" should "be created from valid strings and convert to java.sql.Time" in { + val s = "07:30:45" + val defaultTimeZone = TimeZone.getDefault() + val todFactory = TimeOfDay.parseTimeString(defaultTimeZone)(_) + val tod = todFactory(s) + tod.timeString shouldBe s + tod.timeZoneString shouldBe defaultTimeZone.getID + val sqlTime = tod.toTime + sqlTime.toLocalTime shouldBe tod.localTime + a[java.time.format.DateTimeParseException] should be thrownBy { + val illegal = "7:15" + todFactory(illegal) + } + } + + "TimeOfDay" should "have correct temporal relationships" in { + val s = "07:30:45" + val t = "09:30:45" + val pst = TimeZone.getTimeZone("America/Los_Angeles") + val est = TimeZone.getTimeZone("America/New_York") + val pstTodFactory = TimeOfDay.parseTimeString(pst)(_) + val estTodFactory = TimeOfDay.parseTimeString(est)(_) + val day = 1 + val month = Month.JANUARY + val year = 2018 + val sTodPst = pstTodFactory(s) + val sTodPst2 = pstTodFactory(s) + val tTodPst = pstTodFactory(t) + val tTodEst = estTodFactory(t) + sTodPst.isBefore(tTodPst, day, month, year) shouldBe true + tTodPst.isAfter(sTodPst, day, month, year) shouldBe true + tTodEst.isBefore(sTodPst, day, month, year) shouldBe true + sTodPst.sameTimeAs(sTodPst2, day, month, year) shouldBe true + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala b/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala new file mode 100644 index 0000000..14dfaf9 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala @@ -0,0 +1,63 @@ +package xyz.driver.core.tagging + +import org.scalatest.{Matchers, WordSpec} +import xyz.driver.core.{@@, Name} + +/** + * @author sergey + * @since 9/11/18 + */ +class TaggingTest extends WordSpec with Matchers { + + "@@ Trimmed" should { + "produce values transparently from Strings and Names (by default)" in { + val s: String @@ Trimmed = " trimmed " + val n: Name[Int] @@ Trimmed = Name(" trimmed ") + + s shouldBe "trimmed" + n shouldBe Name[Int]("trimmed") + } + + "produce values transparently from values that have an implicit conversion defined" in { + import scala.language.implicitConversions + implicit def stringSeq2Trimmed(stringSeq: Seq[String]): Seq[String] @@ Trimmed = + stringSeq.map(_.trim()).tagged[Trimmed] + + val strings: Seq[String] @@ Trimmed = Seq(" trimmed1 ", " trimmed2 ") + strings shouldBe Seq("trimmed1", "trimmed2") + } + + "produce values transparently from Options of values that have Trimmed implicits" in { + val maybeStringDirect: Option[String @@ Trimmed] = Some(" trimmed ") + val maybeStringFromMap: Option[String @@ Trimmed] = Map("s" -> " trimmed ").get("s") + + val maybeNameDirect: Option[Name[Int] @@ Trimmed] = Some(Name(" trimmed ")) + val maybeNameFromMap: Option[Name[Int] @@ Trimmed] = Map("s" -> Name[Int](" trimmed ")).get("s") + + maybeStringDirect shouldBe Some("trimmed") + maybeStringFromMap shouldBe Some("trimmed") + maybeNameDirect shouldBe Some(Name[Int]("trimmed")) + maybeNameFromMap shouldBe Some(Name[Int]("trimmed")) + } + + "produce values transparently from collections of values that have Trimmed implicits" in { + val strings = Seq("s" -> " trimmed1 ", "s" -> " trimmed2 ") + val names = strings.map { + case (k, v) => k -> Name[Int](v) + } + + val trimmedStrings: Seq[String @@ Trimmed] = strings.groupBy(_._1)("s").map(_._2) + val trimmedNames: Seq[Name[Int] @@ Trimmed] = names.groupBy(_._1)("s").map(_._2) + + trimmedStrings shouldBe Seq("trimmed1", "trimmed2") + trimmedNames shouldBe Seq("trimmed1", "trimmed2").map(Name[Int]) + } + + "have Ordering" in { + val names: Seq[Name[Int] @@ Trimmed] = Seq(" 2 ", " 1 ", "3").map(Name[Int]) + + names.sorted should contain inOrderOnly (Name("1"), Name("2"), Name("3")) + } + } + +} diff --git a/src/main/scala/xyz/driver/core/core.scala b/src/main/scala/xyz/driver/core/core.scala deleted file mode 100644 index 2ab4e88..0000000 --- a/src/main/scala/xyz/driver/core/core.scala +++ /dev/null @@ -1,124 +0,0 @@ -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 xyz.driver.core.tagging.Tagged - -import scala.concurrent.{ExecutionContext, Future} - -// TODO: this package seems too complex, look at all the features we need! -import scala.language.{higherKinds, implicitConversions, reflectiveCalls} - -package object core { - - 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() - } - } - - type @@[+V, +Tag] = V with 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/src/main/scala/xyz/driver/core/date.scala b/src/main/scala/xyz/driver/core/date.scala deleted file mode 100644 index 5454093..0000000 --- a/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/src/main/scala/xyz/driver/core/domain.scala b/src/main/scala/xyz/driver/core/domain.scala deleted file mode 100644 index f3b8337..0000000 --- a/src/main/scala/xyz/driver/core/domain.scala +++ /dev/null @@ -1,73 +0,0 @@ -package xyz.driver.core - -import com.google.i18n.phonenumbers.PhoneNumberUtil -import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat -import scalaz.Equal -import scalaz.std.string._ -import scalaz.syntax.equal._ - -import scala.util.Try -import scala.util.control.NonFatal - -object domain { - - final case class Email(username: String, domain: String) { - - def value: String = toString - - 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, number: String, extension: Option[String] = None) { - - def hasExtension: Boolean = extension.isDefined - - /** This is a more human-friendly alias for #toE164String() */ - def toCompactString: String = s"+$countryCode$number${extension.fold("")(";ext=" + _)}" - - /** Outputs the phone number in a E.164-compliant way, e.g. +14151234567 */ - def toE164String: String = toCompactString - - /** - * Outputs the phone number in a "readable" way, e.g. "+1 415-123-45-67 ext. 1234" - * @throws IllegalStateException if the contents of this object is not a valid phone number - */ - @throws[IllegalStateException] - def toHumanReadableString: String = - try { - val phoneNumber = PhoneNumber.phoneUtil.parse(toE164String, "US") - PhoneNumber.phoneUtil.format(phoneNumber, PhoneNumberFormat.INTERNATIONAL) - } catch { - case NonFatal(e) => throw new IllegalStateException(s"$toString is not a valid number", e) - } - - override def toString: String = s"+$countryCode $number${extension.fold("")(" ext. " + _)}" - } - - object PhoneNumber { - - private[PhoneNumber] val phoneUtil = PhoneNumberUtil.getInstance() - - def parse(phoneNumber: String): Option[PhoneNumber] = { - val validated = Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption.filter(phoneUtil.isValidNumber) - validated.map { pn => - PhoneNumber( - pn.getCountryCode.toString, - pn.getNationalNumber.toString, - Option(pn.getExtension).filter(_.nonEmpty)) - } - } - } -} diff --git a/src/main/scala/xyz/driver/core/rest/package.scala b/src/main/scala/xyz/driver/core/rest/package.scala index 3be8f02..34a4a9d 100644 --- a/src/main/scala/xyz/driver/core/rest/package.scala +++ b/src/main/scala/xyz/driver/core/rest/package.scala @@ -15,10 +15,11 @@ import scalaz.Scalaz.{intInstance, stringInstance} import scalaz.syntax.equal._ import scalaz.{Functor, OptionT} import xyz.driver.core.rest.auth.AuthProvider +import xyz.driver.core.rest.errors.ExternalServiceException import xyz.driver.core.rest.headers.Traceparent import xyz.driver.tracing.TracingDirectives -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} import scala.util.Try trait Service @@ -72,6 +73,15 @@ object ListResponse { } object `package` { + + 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 + }) + } + implicit class OptionTRestAdditions[T](optionT: OptionT[Future, T]) { def responseOrNotFound(successCode: StatusCodes.Success = StatusCodes.OK)( implicit F: Functor[Future], diff --git a/src/main/scala/xyz/driver/core/tagging/tagging.scala b/src/main/scala/xyz/driver/core/tagging/tagging.scala deleted file mode 100644 index 5b6599e..0000000 --- a/src/main/scala/xyz/driver/core/tagging/tagging.scala +++ /dev/null @@ -1,62 +0,0 @@ -package xyz.driver.core - -import scala.collection.generic.CanBuildFrom -import scala.language.{higherKinds, implicitConversions} - -/** - * @author sergey - * @since 9/11/18 - */ -package object tagging { - - implicit class Taggable[V <: Any](val v: V) extends AnyVal { - def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] - } - -} - -package tagging { - - sealed trait Tagged[+V, +Tag] - - object Tagged { - implicit class TaggedOps[V, Tag](val v: V @@ Tag) extends AnyVal { - def tagless: V = v - } - - implicit def orderingMagnet[V, Tag](implicit ord: Ordering[V]): Ordering[V @@ Tag] = - ord.asInstanceOf[Ordering[V @@ Tag]] - - } - - sealed trait Trimmed - - object Trimmed { - - implicit def apply[V](trimmable: V)(implicit ev: CanBeTrimmed[V]): V @@ Trimmed = { - ev.trim(trimmable).tagged[Trimmed] - } - - sealed trait CanBeTrimmed[T] { - def trim(trimmable: T): T - } - - implicit object StringCanBeTrimmed extends CanBeTrimmed[String] { - def trim(str: String): String = str.trim() - } - - implicit def nameCanBeTrimmed[T]: CanBeTrimmed[Name[T]] = new CanBeTrimmed[Name[T]] { - def trim(name: Name[T]): Name[T] = Name[T](name.value.trim()) - } - - implicit def option2Trimmed[V: CanBeTrimmed](option: Option[V]): Option[V @@ Trimmed] = - option.map(Trimmed(_)) - - implicit def coll2Trimmed[T, C[_] <: Traversable[_]](coll: C[T])( - implicit ev: C[T] <:< Traversable[T], - tr: CanBeTrimmed[T], - bf: CanBuildFrom[Nothing, T @@ Trimmed, C[T @@ Trimmed]]): C[T @@ Trimmed] = - ev(coll).map(Trimmed(_)(tr)).to[C] - } - -} diff --git a/src/main/scala/xyz/driver/core/time.scala b/src/main/scala/xyz/driver/core/time.scala deleted file mode 100644 index 1622068..0000000 --- a/src/main/scala/xyz/driver/core/time.scala +++ /dev/null @@ -1,209 +0,0 @@ -package xyz.driver.core - -import java.text.SimpleDateFormat -import java.time.{Clock, Instant, ZoneId, ZoneOffset} -import java.util._ -import java.util.concurrent.TimeUnit - -import xyz.driver.core.date.Month - -import scala.concurrent.duration._ -import scala.language.implicitConversions -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)) - } - - def toInstant: Instant = Instant.ofEpochMilli(millis) - } - - object Time { - implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) - - implicit def apply(instant: Instant): Time = Time(instant.toEpochMilli) - } - - /** - * 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") - } - } - - 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)) - - class ChangeableClock(@volatile var instant: Instant, val zone: ZoneId = ZoneOffset.UTC) extends Clock { - - def tick(duration: FiniteDuration): Unit = - instant = instant.plusNanos(duration.toNanos) - - val getZone: ZoneId = zone - - def withZone(zone: ZoneId): Clock = new ChangeableClock(instant, zone = zone) - - override def toString: String = "ChangeableClock(" + instant + "," + zone + ")" - } - - 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. - */ - @deprecated( - "Use java.time.Clock instead. Note that xyz.driver.core.Time and xyz.driver.core.date.Date will also be deprecated soon!", - "0.13.0") - trait TimeProvider { - def currentTime(): Time - def toClock: Clock - } - - final implicit class ClockTimeProvider(clock: Clock) extends TimeProvider { - def currentTime(): Time = Time(clock.instant().toEpochMilli) - - val toClock: Clock = clock - } - - final class SystemTimeProvider extends TimeProvider { - def currentTime() = Time(System.currentTimeMillis()) - - lazy val toClock: Clock = Clock.systemUTC() - } - - final val SystemTimeProvider = new SystemTimeProvider - - final class SpecificTimeProvider(time: Time) extends TimeProvider { - - def currentTime(): Time = time - - lazy val toClock: Clock = Clock.fixed(time.toInstant, ZoneOffset.UTC) - } - - } -} diff --git a/src/test/scala/xyz/driver/core/CoreTest.scala b/src/test/scala/xyz/driver/core/CoreTest.scala deleted file mode 100644 index f448d24..0000000 --- a/src/test/scala/xyz/driver/core/CoreTest.scala +++ /dev/null @@ -1,88 +0,0 @@ -package xyz.driver.core - -import java.io.ByteArrayOutputStream - -import org.mockito.Mockito._ -import org.scalatest.mockito.MockitoSugar -import org.scalatest.{FlatSpec, Matchers} - -class CoreTest extends FlatSpec with Matchers with MockitoSugar { - - "'make' function" should "allow initialization for objects" in { - - val createdAndInitializedValue = make(new ByteArrayOutputStream(128)) { baos => - baos.write(Array(1.toByte, 1.toByte, 0.toByte)) - } - - createdAndInitializedValue.toByteArray should be(Array(1.toByte, 1.toByte, 0.toByte)) - } - - "'using' function" should "call close after performing action on resource" in { - - val baos = mock[ByteArrayOutputStream] - - using(baos /* usually new ByteArrayOutputStream(128) */ ) { baos => - baos.write(Array(1.toByte, 1.toByte, 0.toByte)) - } - - verify(baos).close() - } - - "Id" should "have equality and ordering working correctly" in { - - (Id[String]("1234213") === Id[String]("1234213")) should be(true) - (Id[String]("1234213") === Id[String]("213414")) should be(false) - (Id[String]("213414") === Id[String]("1234213")) should be(false) - - val ids = Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")) - val sorted = Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4")) - - ids.sorted should contain theSameElementsInOrderAs sorted - } - - it should "have type-safe conversions" in { - final case class X(id: Id[X]) - final case class Y(id: Id[Y]) - final case class Z(id: Id[Z]) - - implicit val xy = Id.Mapper[X, Y] - implicit val yz = Id.Mapper[Y, Z] - - // Test that implicit conversions work correctly - val x = X(Id("0")) - val y = Y(x.id) - val z = Z(y.id) - val y2 = Y(z.id) - val x2 = X(y2.id) - (x2 === x) should be(true) - (y2 === y) should be(true) - - // Test that type inferrence for explicit conversions work correctly - val yid = y.id - val xid = xy(yid) - val zid = yz(yid) - (xid: Id[X]) should be(zid: Id[Z]) - } - - "Name" should "have equality and ordering working correctly" in { - - (Name[String]("foo") === Name[String]("foo")) should be(true) - (Name[String]("foo") === Name[String]("bar")) should be(false) - (Name[String]("bar") === Name[String]("foo")) should be(false) - - val names = Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")) - val sorted = Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d")) - names.sorted should contain theSameElementsInOrderAs sorted - } - - "Revision" should "have equality working correctly" in { - - val bla = Revision[String]("85569dab-a3dc-401b-9f95-d6fb4162674b") - val foo = Revision[String]("f54b3558-bdcd-4646-a14b-8beb11f6b7c4") - - (bla === bla) should be(true) - (bla === foo) should be(false) - (foo === bla) should be(false) - } - -} diff --git a/src/test/scala/xyz/driver/core/DateTest.scala b/src/test/scala/xyz/driver/core/DateTest.scala deleted file mode 100644 index 0432040..0000000 --- a/src/test/scala/xyz/driver/core/DateTest.scala +++ /dev/null @@ -1,53 +0,0 @@ -package xyz.driver.core - -import org.scalacheck.{Arbitrary, Gen} -import org.scalatest.prop.Checkers -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.date.Date - -class DateTest extends FlatSpec with Matchers with Checkers { - val dateGenerator = for { - year <- Gen.choose(0, 3000) - month <- Gen.choose(0, 11) - day <- Gen.choose(1, 31) - } yield Date(year, date.Month(month), day) - implicit val arbitraryDate = Arbitrary[Date](dateGenerator) - - "Date" should "correctly convert to and from String" in { - - import xyz.driver.core.generators.nextDate - import date._ - - for (date <- 1 to 100 map (_ => nextDate())) { - Some(date) should be(Date.fromString(date.toString)) - } - } - - it should "have ordering defined correctly" in { - Seq( - Date.fromString("2013-05-10"), - Date.fromString("2020-02-15"), - Date.fromString("2017-03-05"), - Date.fromString("2013-05-12")).sorted should - contain theSameElementsInOrderAs Seq( - Date.fromString("2013-05-10"), - Date.fromString("2013-05-12"), - Date.fromString("2017-03-05"), - Date.fromString("2020-02-15")) - - check { dates: List[Date] => - dates.sorted.sliding(2).filter(_.size == 2).forall { - case Seq(a, b) => - if (a.year == b.year) { - if (a.month == b.month) { - a.day <= b.day - } else { - a.month < b.month - } - } else { - a.year < b.year - } - } - } - } -} diff --git a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala b/src/test/scala/xyz/driver/core/PhoneNumberTest.scala deleted file mode 100644 index 729302b..0000000 --- a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala +++ /dev/null @@ -1,117 +0,0 @@ -package xyz.driver.core - -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.domain.PhoneNumber - -class PhoneNumberTest extends FlatSpec with Matchers { - - "PhoneNumber.parse" should "recognize US numbers in international format, ignoring non-digits" in { - // format: off - val numbers = List( - "+18005252225", - "+1 800 525 2225", - "+1 (800) 525-2225", - "+1.800.525.2225") - // format: on - - val parsed = numbers.flatMap(PhoneNumber.parse) - - parsed should have size numbers.size - parsed should contain only PhoneNumber("1", "8005252225") - } - - it should "recognize US numbers without the plus sign" in { - PhoneNumber.parse("18005252225") shouldBe Some(PhoneNumber("1", "8005252225")) - } - - it should "recognize US numbers without country code" in { - // format: off - val numbers = List( - "8005252225", - "800 525 2225", - "(800) 525-2225", - "800.525.2225") - // format: on - - val parsed = numbers.flatMap(PhoneNumber.parse) - - parsed should have size numbers.size - parsed should contain only PhoneNumber("1", "8005252225") - } - - it should "recognize CN numbers in international format" in { - PhoneNumber.parse("+868005252225") shouldBe Some(PhoneNumber("86", "8005252225")) - PhoneNumber.parse("+86 134 52 52 2256") shouldBe Some(PhoneNumber("86", "13452522256")) - } - - it should "parse numbers with extensions in different formats" in { - // format: off - val numbers = List( - "+1 800 525 22 25 x23", - "+18005252225 ext. 23", - "+18005252225,23" - ) - // format: on - - val parsed = numbers.flatMap(PhoneNumber.parse) - - parsed should have size numbers.size - parsed should contain only PhoneNumber("1", "8005252225", Some("23")) - } - - it should "return None on numbers that are shorter than the minimum number of digits for the country (i.e. US - 10, AR - 11)" in { - withClue("US and CN numbers are 10 digits - 9 digit (and shorter) numbers should not fit") { - // format: off - val numbers = List( - "+1 800 525-222", - "+1 800 525-2", - "+86 800 525-222", - "+86 800 525-2") - // format: on - - numbers.flatMap(PhoneNumber.parse) shouldBe empty - } - - withClue("Argentinian numbers are 11 digits (when prefixed with 0) - 10 digit numbers shouldn't fit") { - // format: off - val numbers = List( - "+54 011 525-22256", - "+54 011 525-2225", - "+54 011 525-222") - // format: on - - numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs List(PhoneNumber("54", "1152522256")) - } - } - - it should "return None on numbers that are longer than the maximum number of digits for the country (i.e. DK - 8, CN - 11)" in { - val numbers = List("+45 27 45 25 22", "+45 135 525 223", "+86 134 525 22256", "+86 135 525 22256 7") - - numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs - List(PhoneNumber("45", "27452522"), PhoneNumber("86", "13452522256")) - } - - "PhoneNumber.toCompactString/toE164String" should "produce phone number in international format without whitespaces" in { - PhoneNumber.parse("+1 800 5252225").get.toCompactString shouldBe "+18005252225" - PhoneNumber.parse("+1 800 5252225").get.toE164String shouldBe "+18005252225" - - PhoneNumber.parse("+1 800 5252225 x23").get.toCompactString shouldBe "+18005252225;ext=23" - PhoneNumber.parse("+1 800 5252225 x23").get.toE164String shouldBe "+18005252225;ext=23" - } - - "PhoneNumber.toHumanReadableString" should "produce nice readable result for different countries" in { - PhoneNumber.parse("+14154234567").get.toHumanReadableString shouldBe "+1 415-423-4567" - PhoneNumber.parse("+14154234567,23").get.toHumanReadableString shouldBe "+1 415-423-4567 ext. 23" - - PhoneNumber.parse("+78005252225").get.toHumanReadableString shouldBe "+7 800 525-22-25" - - PhoneNumber.parse("+41219437898").get.toHumanReadableString shouldBe "+41 21 943 78 98" - } - - it should "throw an IllegalArgumentException if the PhoneNumber object is not parsable/valid" in { - intercept[IllegalStateException] { - PhoneNumber("+123", "1238123120938120938").toHumanReadableString - }.getMessage should include("+123 1238123120938120938 is not a valid number") - } - -} diff --git a/src/test/scala/xyz/driver/core/TimeTest.scala b/src/test/scala/xyz/driver/core/TimeTest.scala deleted file mode 100644 index 1019f60..0000000 --- a/src/test/scala/xyz/driver/core/TimeTest.scala +++ /dev/null @@ -1,143 +0,0 @@ -package xyz.driver.core - -import java.util.TimeZone - -import org.scalacheck.Arbitrary._ -import org.scalacheck.Prop.BooleanOperators -import org.scalacheck.{Arbitrary, Gen} -import org.scalatest.prop.Checkers -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.date.Month -import xyz.driver.core.time.{Time, _} - -import scala.concurrent.duration._ -import scala.language.postfixOps - -class TimeTest extends FlatSpec with Matchers with Checkers { - - implicit val arbDuration = Arbitrary[Duration](Gen.chooseNum(0L, 9999999999L).map(_.milliseconds)) - implicit val arbTime = Arbitrary[Time](Gen.chooseNum(0L, 9999999999L).map(millis => Time(millis))) - - "Time" should "have correct methods to compare" in { - - Time(234L).isAfter(Time(123L)) should be(true) - Time(123L).isAfter(Time(123L)) should be(false) - Time(123L).isAfter(Time(234L)) should be(false) - - check((a: Time, b: Time) => (a.millis > b.millis) ==> a.isAfter(b)) - - Time(234L).isBefore(Time(123L)) should be(false) - Time(123L).isBefore(Time(123L)) should be(false) - Time(123L).isBefore(Time(234L)) should be(true) - - check { (a: Time, b: Time) => - (a.millis < b.millis) ==> a.isBefore(b) - } - } - - it should "not modify time" in { - - Time(234L).millis should be(234L) - - check { millis: Long => - Time(millis).millis == millis - } - } - - it should "support arithmetic with scala.concurrent.duration" in { - - Time(123L).advanceBy(0 minutes).millis should be(123L) - Time(123L).advanceBy(1 second).millis should be(123L + Second) - Time(123L).advanceBy(4 days).millis should be(123L + 4 * Days) - - check { (time: Time, duration: Duration) => - time.advanceBy(duration).millis == (time.millis + duration.toMillis) - } - } - - it should "have ordering defined correctly" in { - - Seq(Time(321L), Time(123L), Time(231L)).sorted should - contain theSameElementsInOrderAs Seq(Time(123L), Time(231L), Time(321L)) - - check { times: List[Time] => - times.sorted.sliding(2).filter(_.size == 2).forall { - case Seq(a, b) => - a.millis <= b.millis - } - } - } - - it should "reset to the start of the period, e.g. month" in { - - startOfMonth(Time(1468937089834L)) should be(Time(1467381889834L)) - startOfMonth(Time(1467381889834L)) should be(Time(1467381889834L)) // idempotent - } - - it should "have correct textual representations" in { - import java.util.Locale - import java.util.Locale._ - Locale.setDefault(US) - - textualDate(TimeZone.getTimeZone("EDT"))(Time(1468937089834L)) should be("July 19, 2016") - textualTime(TimeZone.getTimeZone("PDT"))(Time(1468937089834L)) should be("Jul 19, 2016 02:04:49 PM") - } - - "TimeRange" should "have duration defined as a difference of start and end times" in { - - TimeRange(Time(321L), Time(432L)).duration should be(111.milliseconds) - TimeRange(Time(432L), Time(321L)).duration should be((-111).milliseconds) - TimeRange(Time(333L), Time(333L)).duration should be(0.milliseconds) - } - - "Time" should "use TimeZone correctly when converting to Date" in { - - val EST = java.util.TimeZone.getTimeZone("EST") - val PST = java.util.TimeZone.getTimeZone("PST") - - val timestamp = { - import java.util.Calendar - val cal = Calendar.getInstance(EST) - cal.set(Calendar.HOUR_OF_DAY, 1) - Time(cal.getTime().getTime()) - } - - textualDate(EST)(timestamp) should not be textualDate(PST)(timestamp) - timestamp.toDate(EST) should not be timestamp.toDate(PST) - } - - "TimeOfDay" should "be created from valid strings and convert to java.sql.Time" in { - val s = "07:30:45" - val defaultTimeZone = TimeZone.getDefault() - val todFactory = TimeOfDay.parseTimeString(defaultTimeZone)(_) - val tod = todFactory(s) - tod.timeString shouldBe s - tod.timeZoneString shouldBe defaultTimeZone.getID - val sqlTime = tod.toTime - sqlTime.toLocalTime shouldBe tod.localTime - a[java.time.format.DateTimeParseException] should be thrownBy { - val illegal = "7:15" - todFactory(illegal) - } - } - - "TimeOfDay" should "have correct temporal relationships" in { - val s = "07:30:45" - val t = "09:30:45" - val pst = TimeZone.getTimeZone("America/Los_Angeles") - val est = TimeZone.getTimeZone("America/New_York") - val pstTodFactory = TimeOfDay.parseTimeString(pst)(_) - val estTodFactory = TimeOfDay.parseTimeString(est)(_) - val day = 1 - val month = Month.JANUARY - val year = 2018 - val sTodPst = pstTodFactory(s) - val sTodPst2 = pstTodFactory(s) - val tTodPst = pstTodFactory(t) - val tTodEst = estTodFactory(t) - sTodPst.isBefore(tTodPst, day, month, year) shouldBe true - tTodPst.isAfter(sTodPst, day, month, year) shouldBe true - tTodEst.isBefore(sTodPst, day, month, year) shouldBe true - sTodPst.sameTimeAs(sTodPst2, day, month, year) shouldBe true - } -} diff --git a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala index 86cf8b5..cc0019a 100644 --- a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala +++ b/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala @@ -8,7 +8,6 @@ import akka.http.scaladsl.server.{Directives, RejectionHandler, Route} import akka.http.scaladsl.testkit.ScalatestRouteTest import com.typesafe.scalalogging.Logger import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.FutureExtensions import xyz.driver.core.json.serviceExceptionFormat import xyz.driver.core.logging.NoLogger import xyz.driver.core.rest.errors._ diff --git a/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala b/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala deleted file mode 100644 index 14dfaf9..0000000 --- a/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala +++ /dev/null @@ -1,63 +0,0 @@ -package xyz.driver.core.tagging - -import org.scalatest.{Matchers, WordSpec} -import xyz.driver.core.{@@, Name} - -/** - * @author sergey - * @since 9/11/18 - */ -class TaggingTest extends WordSpec with Matchers { - - "@@ Trimmed" should { - "produce values transparently from Strings and Names (by default)" in { - val s: String @@ Trimmed = " trimmed " - val n: Name[Int] @@ Trimmed = Name(" trimmed ") - - s shouldBe "trimmed" - n shouldBe Name[Int]("trimmed") - } - - "produce values transparently from values that have an implicit conversion defined" in { - import scala.language.implicitConversions - implicit def stringSeq2Trimmed(stringSeq: Seq[String]): Seq[String] @@ Trimmed = - stringSeq.map(_.trim()).tagged[Trimmed] - - val strings: Seq[String] @@ Trimmed = Seq(" trimmed1 ", " trimmed2 ") - strings shouldBe Seq("trimmed1", "trimmed2") - } - - "produce values transparently from Options of values that have Trimmed implicits" in { - val maybeStringDirect: Option[String @@ Trimmed] = Some(" trimmed ") - val maybeStringFromMap: Option[String @@ Trimmed] = Map("s" -> " trimmed ").get("s") - - val maybeNameDirect: Option[Name[Int] @@ Trimmed] = Some(Name(" trimmed ")) - val maybeNameFromMap: Option[Name[Int] @@ Trimmed] = Map("s" -> Name[Int](" trimmed ")).get("s") - - maybeStringDirect shouldBe Some("trimmed") - maybeStringFromMap shouldBe Some("trimmed") - maybeNameDirect shouldBe Some(Name[Int]("trimmed")) - maybeNameFromMap shouldBe Some(Name[Int]("trimmed")) - } - - "produce values transparently from collections of values that have Trimmed implicits" in { - val strings = Seq("s" -> " trimmed1 ", "s" -> " trimmed2 ") - val names = strings.map { - case (k, v) => k -> Name[Int](v) - } - - val trimmedStrings: Seq[String @@ Trimmed] = strings.groupBy(_._1)("s").map(_._2) - val trimmedNames: Seq[Name[Int] @@ Trimmed] = names.groupBy(_._1)("s").map(_._2) - - trimmedStrings shouldBe Seq("trimmed1", "trimmed2") - trimmedNames shouldBe Seq("trimmed1", "trimmed2").map(Name[Int]) - } - - "have Ordering" in { - val names: Seq[Name[Int] @@ Trimmed] = Seq(" 2 ", " 1 ", "3").map(Name[Int]) - - names.sorted should contain inOrderOnly (Name("1"), Name("2"), Name("3")) - } - } - -} -- cgit v1.2.3