aboutsummaryrefslogtreecommitdiff
path: root/src/main/scala/xyz/driver
diff options
context:
space:
mode:
authorStewart Stewart <stewinsalot@gmail.com>2016-12-19 12:32:18 -0500
committerStewart Stewart <stewinsalot@gmail.com>2016-12-19 12:32:18 -0500
commit8515d672a9fdbb0eb9038a96cee661828cafa61a (patch)
tree3f04b773de6cea3def3566d2ca4bdff9b78ace8b /src/main/scala/xyz/driver
parent1702e1c44c45e36e2d6d289ef1b7d703f65ec422 (diff)
parent861ceb03e8faeb564dd027b13250b5604af8645f (diff)
downloaddriver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.tar.gz
driver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.tar.bz2
driver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.zip
Merge branch 'master' into implicit-companions
Diffstat (limited to 'src/main/scala/xyz/driver')
-rw-r--r--src/main/scala/xyz/driver/core/app.scala230
-rw-r--r--src/main/scala/xyz/driver/core/auth.scala120
-rw-r--r--src/main/scala/xyz/driver/core/config.scala24
-rw-r--r--src/main/scala/xyz/driver/core/core.scala52
-rw-r--r--src/main/scala/xyz/driver/core/database.scala125
-rw-r--r--src/main/scala/xyz/driver/core/file.scala154
-rw-r--r--src/main/scala/xyz/driver/core/generators.scala82
-rw-r--r--src/main/scala/xyz/driver/core/json.scala158
-rw-r--r--src/main/scala/xyz/driver/core/logging.scala169
-rw-r--r--src/main/scala/xyz/driver/core/messages.scala59
-rw-r--r--src/main/scala/xyz/driver/core/rest.scala153
-rw-r--r--src/main/scala/xyz/driver/core/stats.scala97
-rw-r--r--src/main/scala/xyz/driver/core/time.scala75
13 files changed, 1498 insertions, 0 deletions
diff --git a/src/main/scala/xyz/driver/core/app.scala b/src/main/scala/xyz/driver/core/app.scala
new file mode 100644
index 0000000..227be57
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/app.scala
@@ -0,0 +1,230 @@
+package xyz.driver.core
+
+import akka.actor.ActorSystem
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
+import akka.http.scaladsl.model.StatusCodes._
+import akka.http.scaladsl.model.headers.RawHeader
+import akka.http.scaladsl.model.{HttpResponse, StatusCodes}
+import akka.http.scaladsl.server.Directives._
+import akka.http.scaladsl.server.RouteResult._
+import akka.http.scaladsl.server.{ExceptionHandler, Route, RouteConcatenation}
+import akka.stream.ActorMaterializer
+import com.typesafe.config.Config
+import org.slf4j.LoggerFactory
+import spray.json.DefaultJsonProtocol
+import xyz.driver.core
+import xyz.driver.core.logging.{Logger, TypesafeScalaLogger}
+import xyz.driver.core.rest.{ContextHeaders, Swagger}
+import xyz.driver.core.stats.SystemStats
+import xyz.driver.core.time.Time
+import xyz.driver.core.time.provider.{SystemTimeProvider, TimeProvider}
+
+import scala.compat.Platform.ConcurrentModificationException
+import scala.concurrent.duration._
+import scala.concurrent.{Await, Future}
+
+object app {
+
+ class DriverApp(version: String,
+ gitHash: String,
+ modules: Seq[Module],
+ time: TimeProvider = new SystemTimeProvider(),
+ log: Logger = new TypesafeScalaLogger(
+ com.typesafe.scalalogging.Logger(LoggerFactory.getLogger(classOf[DriverApp]))),
+ config: Config = core.config.loadDefaultConfig,
+ interface: String = "::0",
+ baseUrl: String = "localhost:8080",
+ port: Int = 8080) {
+
+ implicit private lazy val actorSystem = ActorSystem("spray-routing", config)
+ implicit private lazy val executionContext = actorSystem.dispatcher
+ implicit private lazy val materializer = ActorMaterializer()(actorSystem)
+ private lazy val http = Http()(actorSystem)
+
+ def run(): Unit = {
+ activateServices(modules)
+ scheduleServicesDeactivation(modules)
+ bindHttp(modules)
+ Console.print(s"${this.getClass.getName} App is started\n")
+ }
+
+ def stop(): Unit = {
+ http.shutdownAllConnectionPools().onComplete { _ =>
+ val _ = actorSystem.terminate()
+ val terminated = Await.result(actorSystem.whenTerminated, 30.seconds)
+ val addressTerminated = if (terminated.addressTerminated) "is" else "is not"
+ Console.print(s"${this.getClass.getName} App $addressTerminated stopped ")
+ }
+ }
+
+ protected def bindHttp(modules: Seq[Module]): Unit = {
+ val serviceTypes = modules.flatMap(_.routeTypes)
+ val swaggerService = new Swagger(baseUrl, version, actorSystem, serviceTypes, config)
+ val swaggerRoutes = swaggerService.routes ~ swaggerService.swaggerUI
+ val versionRt = versionRoute(version, gitHash, time.currentTime())
+
+ val _ = Future {
+ http.bindAndHandle(route2HandlerFlow(handleExceptions(ExceptionHandler(exceptionHandler)) { ctx =>
+ val trackingId = rest.extractTrackingId(ctx)
+ log.audit(s"Received request ${ctx.request} with tracking id $trackingId")
+
+ val contextWithTrackingId =
+ ctx.withRequest(ctx.request.addHeader(RawHeader(ContextHeaders.TrackingIdHeader, trackingId)))
+
+ respondWithHeaders(List(RawHeader(ContextHeaders.TrackingIdHeader, trackingId))) {
+ modules.map(_.route).foldLeft(versionRt ~ healthRoute ~ swaggerRoutes)(_ ~ _)
+ }(contextWithTrackingId)
+ }), interface, port)(materializer)
+ }
+ }
+
+ protected def exceptionHandler = PartialFunction[Throwable, Route] {
+
+ case is: IllegalStateException =>
+ ctx =>
+ val trackingId = rest.extractTrackingId(ctx)
+ log.debug(s"Request is not allowed to ${ctx.request.uri} ($trackingId)", is)
+ complete(HttpResponse(BadRequest, entity = is.getMessage))(ctx)
+
+ case cm: ConcurrentModificationException =>
+ ctx =>
+ val trackingId = rest.extractTrackingId(ctx)
+ log.audit(s"Concurrent modification of the resource ${ctx.request.uri} ($trackingId)", cm)
+ complete(
+ HttpResponse(Conflict, entity = "Resource was changed concurrently, try requesting a newer version"))(ctx)
+
+ case t: Throwable =>
+ ctx =>
+ val trackingId = rest.extractTrackingId(ctx)
+ log.error(s"Request to ${ctx.request.uri} could not be handled normally ($trackingId)", t)
+ complete(HttpResponse(InternalServerError, entity = t.getMessage))(ctx)
+ }
+
+ protected def versionRoute(version: String, gitHash: String, startupTime: Time): Route = {
+ import DefaultJsonProtocol._
+ import SprayJsonSupport._
+
+ path("version") {
+ val currentTime = time.currentTime().millis
+ complete(
+ Map(
+ "version" -> version,
+ "gitHash" -> gitHash,
+ "modules" -> modules.map(_.name).mkString(", "),
+ "startupTime" -> startupTime.millis.toString,
+ "serverTime" -> currentTime.toString,
+ "uptime" -> (currentTime - startupTime.millis).toString
+ ))
+ }
+ }
+
+ protected def healthRoute: Route = {
+ import DefaultJsonProtocol._
+ import SprayJsonSupport._
+ import spray.json._
+
+ val memoryUsage = SystemStats.memoryUsage
+ val gcStats = SystemStats.garbageCollectorStats
+
+ path("health") {
+ complete(
+ Map(
+ "availableProcessors" -> SystemStats.availableProcessors.toJson,
+ "memoryUsage" -> Map(
+ "free" -> memoryUsage.free.toJson,
+ "total" -> memoryUsage.total.toJson,
+ "max" -> memoryUsage.max.toJson
+ ).toJson,
+ "gcStats" -> Map(
+ "garbageCollectionTime" -> gcStats.garbageCollectionTime.toJson,
+ "totalGarbageCollections" -> gcStats.totalGarbageCollections.toJson
+ ).toJson,
+ "fileSystemSpace" -> SystemStats.fileSystemSpace.map { f =>
+ Map("path" -> f.path.toJson,
+ "freeSpace" -> f.freeSpace.toJson,
+ "totalSpace" -> f.totalSpace.toJson,
+ "usableSpace" -> f.usableSpace.toJson)
+ }.toJson,
+ "operatingSystem" -> SystemStats.operatingSystemStats.toJson
+ ))
+ }
+ }
+
+ /**
+ * Initializes services
+ */
+ protected def activateServices(services: Seq[Module]): Unit = {
+ services.foreach { service =>
+ Console.print(s"Service ${service.name} starts ...")
+ try {
+ service.activate()
+ } catch {
+ case t: Throwable =>
+ log.fatal(s"Service ${service.name} failed to activate", t)
+ Console.print(" Failed! (check log)")
+ }
+ Console.print(" Done\n")
+ }
+ }
+
+ /**
+ * Schedules services to be deactivated on the app shutdown
+ */
+ protected def scheduleServicesDeactivation(services: Seq[Module]) = {
+ Runtime.getRuntime.addShutdownHook(new Thread() {
+ override def run(): Unit = {
+ services.foreach { service =>
+ Console.print(s"Service ${service.name} shutting down ...")
+ try {
+ service.deactivate()
+ } catch {
+ case t: Throwable =>
+ log.fatal(s"Service ${service.name} failed to deactivate", t)
+ Console.print(" Failed! (check log)")
+ }
+ Console.print(" Done\n")
+ }
+ }
+ })
+ }
+ }
+
+ import scala.reflect.runtime.universe._
+
+ trait Module {
+ val name: String
+ def route: Route
+ def routeTypes: Seq[Type]
+
+ def activate(): Unit = {}
+ def deactivate(): Unit = {}
+ }
+
+ class EmptyModule extends Module {
+ val name = "Nothing"
+ def route: Route = complete(StatusCodes.OK)
+ def routeTypes = Seq.empty[Type]
+ }
+
+ class SimpleModule(val name: String, val route: Route, routeType: Type) extends Module {
+ def routeTypes: Seq[Type] = Seq(routeType)
+ }
+
+ /**
+ * Module implementation which may be used to composed a few
+ *
+ * @param name more general name of the composite module,
+ * must be provided as there is no good way to automatically
+ * generalize the name from the composed modules' names
+ * @param modules modules to compose into a single one
+ */
+ class CompositeModule(val name: String, modules: Seq[Module]) extends Module with RouteConcatenation {
+
+ def route: Route = modules.map(_.route).reduce(_ ~ _)
+ def routeTypes = modules.flatMap(_.routeTypes)
+
+ override def activate() = modules.foreach(_.activate())
+ override def deactivate() = modules.reverse.foreach(_.deactivate())
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/auth.scala b/src/main/scala/xyz/driver/core/auth.scala
new file mode 100644
index 0000000..0b30bc0
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/auth.scala
@@ -0,0 +1,120 @@
+package xyz.driver.core
+
+import akka.http.scaladsl.model.headers.HttpChallenges
+import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected
+import xyz.driver.core.rest.ServiceRequestContext
+
+import scala.concurrent.Future
+import scala.util.{Failure, Success}
+import scalaz.OptionT
+
+object auth {
+
+ sealed trait Permission
+ case object CanSeeUser extends Permission
+ case object CanSeeAssay extends Permission
+ case object CanSeeReport extends Permission
+ case object CanCreateReport extends Permission
+ case object CanEditReport extends Permission
+ case object CanReviewReport extends Permission
+ case object CanEditReviewingReport extends Permission
+ case object CanSignOutReport extends Permission
+ case object CanAmendReport extends Permission
+ case object CanShareReportWithPatient extends Permission
+ case object CanAssignRoles extends Permission
+
+ trait Role {
+ val id: Id[Role]
+ val name: Name[Role]
+ val permissions: Set[Permission]
+
+ def hasPermission(permission: Permission): Boolean = permissions.contains(permission)
+ }
+
+ case object ObserverRole extends Role {
+ val id = Id("1")
+ val name = Name("observer")
+ val permissions = Set[Permission](CanSeeUser, CanSeeAssay, CanSeeReport)
+ }
+
+ case object PatientRole extends Role {
+ val id = Id("2")
+ val name = Name("patient")
+ val permissions = Set.empty[Permission]
+ }
+
+ case object CuratorRole extends Role {
+ val id = Id("3")
+ val name = Name("curator")
+ val permissions = ObserverRole.permissions ++ Set[Permission](CanEditReport, CanReviewReport)
+ }
+
+ case object PathologistRole extends Role {
+ val id = Id("4")
+ val name = Name("pathologist")
+ val permissions = ObserverRole.permissions ++
+ Set[Permission](CanEditReport, CanSignOutReport, CanAmendReport, CanEditReviewingReport)
+ }
+
+ case object AdministratorRole extends Role {
+ val id = Id("5")
+ val name = Name("administrator")
+ val permissions = CuratorRole.permissions ++
+ Set[Permission](CanCreateReport, CanShareReportWithPatient, CanAssignRoles)
+ }
+
+ case object PhysicianRole extends Role {
+ val id = Id("6")
+ val name = Name("physician")
+ val permissions = Set[Permission]()
+ }
+
+ case object RelativeRole extends Role {
+ val id = Id("7")
+ val name = Name("relative")
+ val permissions = Set[Permission]()
+ }
+
+ trait User {
+ def id: Id[User]
+ def roles: Set[Role]
+ def permissions: Set[Permission] = roles.flatMap(_.permissions)
+ }
+
+ final case class AuthToken(value: String)
+
+ final case class PasswordHash(value: String)
+
+ object AuthService {
+ val AuthenticationTokenHeader = rest.ContextHeaders.AuthenticationTokenHeader
+ val SetAuthenticationTokenHeader = "set-authorization"
+ }
+
+ trait AuthService[U <: User] {
+
+ import akka.http.scaladsl.server._
+ import Directives._
+
+ protected def authStatus(context: ServiceRequestContext): OptionT[Future, U]
+
+ def authorize(permissions: Permission*): Directive1[U] = {
+ rest.serviceContext flatMap { ctx =>
+ onComplete(authStatus(ctx).run).flatMap {
+ case Success(Some(user)) =>
+ if (permissions.forall(user.permissions.contains)) provide(user)
+ else {
+ val challenge =
+ HttpChallenges.basic(s"User does not have the required permissions: ${permissions.mkString(", ")}")
+ reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
+ }
+
+ case Success(None) =>
+ reject(ValidationRejection(s"Wasn't able to find authenticated user for the token provided"))
+
+ case Failure(t) =>
+ reject(ValidationRejection(s"Wasn't able to verify token for authenticated user", Some(t)))
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/config.scala b/src/main/scala/xyz/driver/core/config.scala
new file mode 100644
index 0000000..112986e
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/config.scala
@@ -0,0 +1,24 @@
+package xyz.driver.core
+
+import java.io.File
+import com.typesafe.config.{Config, ConfigFactory}
+
+object config {
+
+ def loadDefaultConfig: Config = {
+ val configDefaults = ConfigFactory.load(this.getClass.getClassLoader, "application.conf")
+
+ scala.sys.props.get("application.config") match {
+
+ case Some(filename) =>
+ val configFile = new File(filename)
+ if (configFile.exists()) {
+ ConfigFactory.parseFile(configFile).withFallback(configDefaults)
+ } else {
+ throw new IllegalStateException(s"No config found at $filename")
+ }
+
+ case None => configDefaults
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/core.scala b/src/main/scala/xyz/driver/core/core.scala
new file mode 100644
index 0000000..8ae9122
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/core.scala
@@ -0,0 +1,52 @@
+package xyz.driver
+
+import scalaz.Equal
+
+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()
+ }
+ }
+}
+
+package core {
+
+ final case class Id[+Tag](value: String) extends AnyVal {
+ @inline def length: Int = value.length
+ override def toString: String = value
+ }
+
+ 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)
+ }
+
+ 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)
+ }
+
+ object revision {
+ final case class Revision[T](id: String)
+
+ implicit def revisionEqual[T]: Equal[Revision[T]] = Equal.equal[Revision[T]](_.id == _.id)
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/database.scala b/src/main/scala/xyz/driver/core/database.scala
new file mode 100644
index 0000000..a82e345
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/database.scala
@@ -0,0 +1,125 @@
+package xyz.driver.core
+
+import slick.backend.DatabaseConfig
+import slick.dbio.{DBIOAction, NoStream}
+import slick.driver.JdbcProfile
+import xyz.driver.core.time.Time
+
+import scala.concurrent.{ExecutionContext, Future}
+import scalaz.Monad
+
+object database {
+
+ trait Database {
+ val profile: JdbcProfile
+ val database: JdbcProfile#Backend#Database
+ }
+
+ object Database {
+
+ def fromConfig(databaseName: String): Database = {
+ val dbConfig: DatabaseConfig[JdbcProfile] = DatabaseConfig.forConfig(databaseName)
+
+ new Database {
+ val profile: JdbcProfile = dbConfig.driver
+ val database: JdbcProfile#Backend#Database = dbConfig.db
+ }
+ }
+ }
+
+ type Schema = {
+ def create: DBIOAction[Unit, NoStream, slick.dbio.Effect.Schema]
+ def drop: DBIOAction[Unit, NoStream, slick.dbio.Effect.Schema]
+ }
+
+ trait ColumnTypes {
+ val profile: JdbcProfile
+ import profile.api._
+
+ implicit def `xyz.driver.core.Id.columnType`[T]: BaseColumnType[Id[T]]
+
+ implicit def `xyz.driver.core.Name.columnType`[T]: BaseColumnType[Name[T]] =
+ MappedColumnType.base[Name[T], String](_.value, Name[T](_))
+
+ implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] =
+ MappedColumnType.base[Time, Long](_.millis, Time(_))
+ }
+
+ object ColumnTypes {
+ trait UUID extends ColumnTypes {
+ import profile.api._
+
+ override implicit def `xyz.driver.core.Id.columnType`[T] =
+ MappedColumnType
+ .base[Id[T], java.util.UUID](id => java.util.UUID.fromString(id.value), uuid => Id[T](uuid.toString))
+ }
+ trait SerialId extends ColumnTypes {
+ import profile.api._
+
+ override implicit def `xyz.driver.core.Id.columnType`[T] =
+ MappedColumnType.base[Id[T], Long](_.value.toLong, serialId => Id[T](serialId.toString))
+ }
+ trait NaturalId extends ColumnTypes {
+ import profile.api._
+
+ override implicit def `xyz.driver.core.Id.columnType`[T] =
+ MappedColumnType.base[Id[T], String](_.value, Id[T](_))
+ }
+ }
+
+ trait DatabaseObject extends ColumnTypes {
+
+ def createTables(): Future[Unit]
+ def disconnect(): Unit
+ }
+
+ abstract class DatabaseObjectAdapter extends DatabaseObject {
+ def createTables(): Future[Unit] = Future.successful(())
+ def disconnect(): Unit = {}
+ }
+
+ trait Dal {
+
+ type T[_]
+ implicit val monadT: Monad[T]
+
+ def execute[D](operations: T[D]): Future[D]
+ def noAction[V](v: V): T[V]
+ def customAction[R](action: => Future[R]): T[R]
+ }
+
+ class FutureDal(executionContext: ExecutionContext) extends Dal {
+
+ implicit val exec = executionContext
+
+ override type T[_] = Future[_]
+ implicit val monadT: Monad[T] = new Monad[T] {
+ override def point[A](a: => A): T[A] = Future(a)
+ override def bind[A, B](fa: T[A])(f: A => T[B]): T[B] = fa.flatMap(a => f(a.asInstanceOf[A]))
+ }
+
+ def execute[D](operations: T[D]): Future[D] = operations.asInstanceOf[Future[D]]
+ def noAction[V](v: V): T[V] = Future.successful(v)
+ def customAction[R](action: => Future[R]): T[R] = action
+ }
+
+ class SlickDal(database: Database, executionContext: ExecutionContext) extends Dal {
+
+ import database.profile.api._
+
+ implicit val exec = executionContext
+
+ override type T[_] = slick.dbio.DBIO[_]
+ val monadT: Monad[T] = new Monad[T] {
+ override def point[A](a: => A): T[A] = DBIO.successful(a)
+ override def bind[A, B](fa: T[A])(f: A => T[B]): T[B] = fa.flatMap(a => f(a.asInstanceOf[A]))
+ }
+
+ def execute[D](readOperations: T[D]): Future[D] = {
+ database.database.run(readOperations.asInstanceOf[slick.dbio.DBIO[D]].transactionally)
+ }
+
+ def noAction[V](v: V): slick.dbio.DBIO[V] = DBIO.successful(v)
+ def customAction[R](action: => Future[R]): T[R] = DBIO.from(action)
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/file.scala b/src/main/scala/xyz/driver/core/file.scala
new file mode 100644
index 0000000..9cea9e5
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/file.scala
@@ -0,0 +1,154 @@
+package xyz.driver.core
+
+import java.io.File
+import java.nio.file.{Path, Paths}
+import java.util.UUID._
+
+import com.amazonaws.services.s3.AmazonS3
+import com.amazonaws.services.s3.model.{Bucket, GetObjectRequest, ListObjectsV2Request}
+import xyz.driver.core.revision.Revision
+import xyz.driver.core.time.Time
+
+import scala.concurrent.{ExecutionContext, Future}
+import scalaz.{ListT, OptionT}
+
+object file {
+
+ final case class FileLink(
+ name: Name[File],
+ location: Path,
+ revision: Revision[File],
+ lastModificationDate: Time,
+ fileSize: Long
+ )
+
+ trait FileService {
+
+ def getFileLink(id: Name[File]): FileLink
+
+ def getFile(fileLink: FileLink): File
+ }
+
+ trait FileStorage {
+
+ def upload(localSource: File, destination: Path): Future[Unit]
+
+ def download(filePath: Path): OptionT[Future, File]
+
+ def delete(filePath: Path): Future[Unit]
+
+ def list(path: Path): ListT[Future, FileLink]
+
+ /** List of characters to avoid in S3 (I would say file names in general)
+ *
+ * @see http://stackoverflow.com/questions/7116450/what-are-valid-s3-key-names-that-can-be-accessed-via-the-s3-rest-api
+ */
+ private val illegalChars = "\\^`><{}][#%~|&@:,$=+?; "
+
+ protected def checkSafeFileName[T](filePath: Path)(f: => T): T = {
+ filePath.toString.find(c => illegalChars.contains(c)) match {
+ case Some(illegalCharacter) =>
+ throw new IllegalArgumentException(s"File name cannot contain character `$illegalCharacter`")
+ case None => f
+ }
+ }
+ }
+
+ class S3Storage(s3: AmazonS3, bucket: Name[Bucket], executionContext: ExecutionContext) extends FileStorage {
+ implicit private val execution = executionContext
+
+ def upload(localSource: File, destination: Path): Future[Unit] = Future {
+ checkSafeFileName(destination) {
+ val _ = s3.putObject(bucket.value, destination.toString, localSource).getETag
+ }
+ }
+
+ def download(filePath: Path): OptionT[Future, File] =
+ OptionT.optionT(Future {
+ val tempDir = System.getProperty("java.io.tmpdir")
+ val randomFolderName = randomUUID().toString
+ val tempDestinationFile = new File(Paths.get(tempDir, randomFolderName, filePath.toString).toString)
+
+ if (!tempDestinationFile.getParentFile.mkdirs()) {
+ throw new Exception(s"Failed to create temp directory to download file `$tempDestinationFile`")
+ } else {
+ Option(s3.getObject(new GetObjectRequest(bucket.value, filePath.toString), tempDestinationFile)).map { _ =>
+ tempDestinationFile
+ }
+ }
+ })
+
+ def delete(filePath: Path): Future[Unit] = Future {
+ s3.deleteObject(bucket.value, filePath.toString)
+ }
+
+ def list(path: Path): ListT[Future, FileLink] =
+ ListT.listT(Future {
+ import scala.collection.JavaConverters._
+ val req = new ListObjectsV2Request().withBucketName(bucket.value).withPrefix(path.toString).withMaxKeys(2)
+
+ def isInSubFolder(path: Path)(fileLink: FileLink) =
+ fileLink.location.toString.replace(path.toString + "/", "").contains("/")
+
+ Iterator.continually(s3.listObjectsV2(req)).takeWhile { result =>
+ req.setContinuationToken(result.getNextContinuationToken)
+ result.isTruncated
+ } flatMap { result =>
+ result.getObjectSummaries.asScala.toList.map { summary =>
+ FileLink(Name[File](summary.getKey),
+ Paths.get(path.toString + "/" + summary.getKey),
+ Revision[File](summary.getETag),
+ Time(summary.getLastModified.getTime),
+ summary.getSize)
+ } filterNot isInSubFolder(path)
+ } toList
+ })
+ }
+
+ class FileSystemStorage(executionContext: ExecutionContext) extends FileStorage {
+ implicit private val execution = executionContext
+
+ def upload(localSource: File, destination: Path): Future[Unit] = Future {
+ checkSafeFileName(destination) {
+ val destinationFile = destination.toFile
+
+ if (destinationFile.getParentFile.exists() || destinationFile.getParentFile.mkdirs()) {
+ if (localSource.renameTo(destinationFile)) ()
+ else {
+ throw new Exception(
+ s"Failed to move file from `${localSource.getCanonicalPath}` to `${destinationFile.getCanonicalPath}`")
+ }
+ } else {
+ throw new Exception(s"Failed to create parent directories for file `${destinationFile.getCanonicalPath}`")
+ }
+ }
+ }
+
+ def download(filePath: Path): OptionT[Future, File] =
+ OptionT.optionT(Future {
+ Option(new File(filePath.toString)).filter(file => file.exists() && file.isFile)
+ })
+
+ def delete(filePath: Path): Future[Unit] = Future {
+ val file = new File(filePath.toString)
+ if (file.delete()) ()
+ else {
+ throw new Exception(s"Failed to delete file $file" + (if (!file.exists()) ", file does not exist." else "."))
+ }
+ }
+
+ def list(path: Path): ListT[Future, FileLink] =
+ ListT.listT(Future {
+ val file = new File(path.toString)
+ if (file.isDirectory) {
+ file.listFiles().toList.filter(_.isFile).map { file =>
+ FileLink(Name[File](file.getName),
+ Paths.get(file.getPath),
+ Revision[File](file.hashCode.toString),
+ Time(file.lastModified()),
+ file.length())
+ }
+ } else List.empty[FileLink]
+ })
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/generators.scala b/src/main/scala/xyz/driver/core/generators.scala
new file mode 100644
index 0000000..c61cb94
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/generators.scala
@@ -0,0 +1,82 @@
+package xyz.driver.core
+
+import java.math.MathContext
+
+import xyz.driver.core.revision.Revision
+import xyz.driver.core.time.{Time, TimeRange}
+
+import scala.reflect.ClassTag
+import scala.util.Random
+
+object generators {
+
+ private val random = new Random
+ import random._
+
+ private val DefaultMaxLength = 10
+ private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet
+
+ def nextInt(maxValue: Int): Int = random.nextInt(maxValue)
+
+ 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 nextUuid() = java.util.UUID.randomUUID
+
+ def nextRevision[T]() = Revision[T](nextUuid().toString)
+
+ def nextString(maxLength: Int = DefaultMaxLength): String =
+ (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString
+
+ 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 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 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 arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength): Array[T] =
+ Array.fill(nextInt(maxLength))(generator)
+
+ def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength): Seq[T] =
+ Seq.fill(nextInt(maxLength))(generator)
+
+ def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength): Vector[T] =
+ Vector.fill(nextInt(maxLength))(generator)
+
+ def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength): List[T] =
+ List.fill(nextInt(maxLength))(generator)
+
+ def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength): Set[T] =
+ seqOf(generator, maxLength).toSet
+
+ def mapOf[K, V](maxLength: Int, keyGenerator: => K, valueGenerator: => V): Map[K, V] =
+ seqOf(nextPair(keyGenerator, valueGenerator), maxLength).toMap
+}
diff --git a/src/main/scala/xyz/driver/core/json.scala b/src/main/scala/xyz/driver/core/json.scala
new file mode 100644
index 0000000..277543b
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/json.scala
@@ -0,0 +1,158 @@
+package xyz.driver.core
+
+import akka.http.scaladsl.model.Uri.Path
+import akka.http.scaladsl.server.PathMatcher.{Matched, Unmatched}
+import akka.http.scaladsl.server.{PathMatcher, _}
+import akka.http.scaladsl.unmarshalling.Unmarshaller
+import spray.json.{DeserializationException, JsNumber, _}
+import xyz.driver.core.revision.Revision
+import xyz.driver.core.time.Time
+
+import scala.reflect.runtime.universe._
+
+object json {
+
+ def IdInPath[T]: PathMatcher1[Id[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 idFormat[T] = new RootJsonFormat[Id[T]] {
+ def write(id: Id[T]) = JsString(id.value)
+
+ def read(value: JsValue) = value match {
+ case JsString(id) => Id[T](id)
+ case _ => throw DeserializationException("Id expects string")
+ }
+ }
+
+ 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")
+ }
+ }
+
+ 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] = 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")
+ }
+ }
+
+ 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)
+ }
+ }
+
+ 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)
+ }
+ }
+
+ 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)
+ })
+
+ 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)
+ }
+ }
+
+ object GadtJsonFormat {
+
+ def create[T: TypeTag](typeField: String)(typeValue: PartialFunction[T, String])(
+ jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) = {
+
+ new GadtJsonFormat[T](typeField, typeValue, jsonFormat)
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/logging.scala b/src/main/scala/xyz/driver/core/logging.scala
new file mode 100644
index 0000000..ba17131
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/logging.scala
@@ -0,0 +1,169 @@
+package xyz.driver.core
+
+import java.text.SimpleDateFormat
+import java.util.Date
+
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.LayoutBase
+import org.apache.commons.lang3.StringUtils
+import org.slf4j.Marker
+
+object logging {
+
+ trait Logger {
+
+ def fatal(message: String): Unit
+ def fatal(message: String, cause: Throwable): Unit
+ def fatal(message: String, args: AnyRef*): Unit
+ def fatal(marker: Marker, message: String): Unit
+ def fatal(marker: Marker, message: String, cause: Throwable): Unit
+ def fatal(marker: Marker, message: String, args: AnyRef*): Unit
+
+ def error(message: String): Unit
+ def error(message: String, cause: Throwable): Unit
+ def error(message: String, args: AnyRef*): Unit
+ def error(marker: Marker, message: String): Unit
+ def error(marker: Marker, message: String, cause: Throwable): Unit
+ def error(marker: Marker, message: String, args: AnyRef*): Unit
+
+ def audit(message: String): Unit
+ def audit(message: String, cause: Throwable): Unit
+ def audit(message: String, args: AnyRef*): Unit
+ def audit(marker: Marker, message: String): Unit
+ def audit(marker: Marker, message: String, cause: Throwable): Unit
+ def audit(marker: Marker, message: String, args: AnyRef*): Unit
+
+ def debug(message: String): Unit
+ def debug(message: String, cause: Throwable): Unit
+ def debug(message: String, args: AnyRef*): Unit
+ def debug(marker: Marker, message: String): Unit
+ def debug(marker: Marker, message: String, cause: Throwable): Unit
+ def debug(marker: Marker, message: String, args: AnyRef*): Unit
+ }
+
+ /**
+ * Logger implementation which uses `com.typesafe.scalalogging.Logger` on the back.
+ * It redefines the meaning of logging levels to fit to the Driver infrastructure design,
+ * and as using error and warn, debug and trace was always confusing and mostly done wrong.
+ *
+ * @param scalaLogging com.typesafe.scalalogging.Logger which logging will be delegated to
+ */
+ class TypesafeScalaLogger(scalaLogging: com.typesafe.scalalogging.Logger) extends Logger {
+
+ def fatal(message: String): Unit = scalaLogging.error("FATAL " + message)
+ def fatal(message: String, cause: Throwable): Unit = scalaLogging.error("FATAL " + message, cause)
+ def fatal(message: String, args: AnyRef*): Unit = scalaLogging.error("FATAL " + message, args)
+ def fatal(marker: Marker, message: String): Unit = scalaLogging.error(marker, "FATAL " + message)
+ def fatal(marker: Marker, message: String, cause: Throwable): Unit =
+ scalaLogging.error(marker, "FATAL " + message, cause)
+ def fatal(marker: Marker, message: String, args: AnyRef*): Unit =
+ scalaLogging.error(marker, "FATAL " + message, args)
+
+ def error(message: String): Unit = scalaLogging.warn("ERROR " + message)
+ def error(message: String, cause: Throwable): Unit = scalaLogging.warn("ERROR " + message, cause)
+ def error(message: String, args: AnyRef*): Unit = scalaLogging.warn("ERROR " + message, args)
+ def error(marker: Marker, message: String): Unit = scalaLogging.warn(marker, "ERROR " + message)
+ def error(marker: Marker, message: String, cause: Throwable): Unit =
+ scalaLogging.warn(marker, "ERROR " + message, cause)
+ def error(marker: Marker, message: String, args: AnyRef*): Unit =
+ scalaLogging.warn(marker, "ERROR " + message, args)
+
+ def audit(message: String): Unit = scalaLogging.info("AUDIT " + message)
+ def audit(message: String, cause: Throwable): Unit = scalaLogging.info("AUDIT " + message, cause)
+ def audit(message: String, args: AnyRef*): Unit = scalaLogging.info("AUDIT " + message, args)
+ def audit(marker: Marker, message: String): Unit = scalaLogging.info(marker, "AUDIT " + message)
+ def audit(marker: Marker, message: String, cause: Throwable): Unit =
+ scalaLogging.info(marker, "AUDIT " + message, cause)
+ def audit(marker: Marker, message: String, args: AnyRef*): Unit =
+ scalaLogging.info(marker, "AUDIT " + message, args)
+
+ def debug(message: String): Unit = scalaLogging.debug("DEBUG " + message)
+ def debug(message: String, cause: Throwable): Unit = scalaLogging.debug("DEBUG " + message, cause)
+ def debug(message: String, args: AnyRef*): Unit = scalaLogging.debug("DEBUG " + message, args)
+ def debug(marker: Marker, message: String): Unit = scalaLogging.debug(marker, "DEBUG " + message)
+ def debug(marker: Marker, message: String, cause: Throwable): Unit =
+ scalaLogging.debug(marker, "DEBUG " + message, cause)
+ def debug(marker: Marker, message: String, args: AnyRef*): Unit =
+ scalaLogging.debug(marker, "DEBUG " + message, args)
+ }
+
+ object NoLogger extends Logger {
+
+ def fatal(message: String): Unit = {}
+ def fatal(message: String, cause: Throwable): Unit = {}
+ def fatal(message: String, args: AnyRef*): Unit = {}
+ def fatal(marker: Marker, message: String): Unit = {}
+ def fatal(marker: Marker, message: String, cause: Throwable): Unit = {}
+ def fatal(marker: Marker, message: String, args: AnyRef*): Unit = {}
+
+ def error(message: String): Unit = {}
+ def error(message: String, cause: Throwable): Unit = {}
+ def error(message: String, args: AnyRef*): Unit = {}
+ def error(marker: Marker, message: String): Unit = {}
+ def error(marker: Marker, message: String, cause: Throwable): Unit = {}
+ def error(marker: Marker, message: String, args: AnyRef*): Unit = {}
+
+ def audit(message: String): Unit = {}
+ def audit(message: String, cause: Throwable): Unit = {}
+ def audit(message: String, args: AnyRef*): Unit = {}
+ def audit(marker: Marker, message: String): Unit = {}
+ def audit(marker: Marker, message: String, cause: Throwable): Unit = {}
+ def audit(marker: Marker, message: String, args: AnyRef*): Unit = {}
+
+ def debug(message: String): Unit = {}
+ def debug(message: String, cause: Throwable): Unit = {}
+ def debug(message: String, args: AnyRef*): Unit = {}
+ def debug(marker: Marker, message: String): Unit = {}
+ def debug(marker: Marker, message: String, cause: Throwable): Unit = {}
+ def debug(marker: Marker, message: String, args: AnyRef*): Unit = {}
+ }
+
+ class DriverLayout extends LayoutBase[ILoggingEvent] {
+ import scala.collection.JavaConverters._
+
+ private val FieldSeparator = "="
+ private val DateFormatString = "MM/dd/yyyy HH:mm:ss"
+ private val newline = System.getProperty("line.separator")
+ private val IgnoredClassesInStack = Set("org.apache.catalina", "org.apache.coyote", "sun.reflect", "javax.servlet")
+
+ override def doLayout(loggingEvent: ILoggingEvent): String = {
+
+ val date = new SimpleDateFormat(DateFormatString).format(new Date(loggingEvent.getTimeStamp))
+ val level = StringUtils.rightPad(loggingEvent.getLevel.toString, 5)
+
+ val message = new StringBuilder(s"$date [$level] - loggingEvent.getMessage$newline")
+
+ logContext(message, loggingEvent)
+
+ Option(loggingEvent.getCallerData) foreach { stacktrace =>
+ val stacktraceLength = stacktrace.length
+
+ if (stacktraceLength > 0) {
+ val location = stacktrace.head
+
+ val _ = message
+ .append(s"Location: ${location.getClassName}.${location.getMethodName}:${location.getLineNumber}$newline")
+ .append(s"Exception: ${location.toString}$newline")
+
+ if (stacktraceLength > 1) {
+ message.append(stacktrace.tail.filterNot { e =>
+ IgnoredClassesInStack.forall(ignored => !e.getClassName.startsWith(ignored))
+ } map {
+ _.toString
+ } mkString newline)
+ }
+ }
+ }
+
+ message.toString
+ }
+
+ private def logContext(message: StringBuilder, loggingEvent: ILoggingEvent) = {
+ Option(loggingEvent.getMDCPropertyMap).map(_.asScala).filter(_.nonEmpty).foreach { context =>
+ message.append(
+ context map { case (key, value) => s"$key$FieldSeparator$value" } mkString ("Context: ", " ", newline)
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/messages.scala b/src/main/scala/xyz/driver/core/messages.scala
new file mode 100644
index 0000000..94d9889
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/messages.scala
@@ -0,0 +1,59 @@
+package xyz.driver.core
+
+import java.util.Locale
+
+import com.typesafe.config.Config
+import xyz.driver.core.logging.Logger
+
+import scala.collection.JavaConverters._
+
+/**
+ * Scala internationalization (i18n) support
+ */
+object messages {
+
+ object Messages {
+ def messages(config: Config, log: Logger, locale: Locale = Locale.US): Messages = {
+ val map = config.getConfig(locale.getLanguage).root().unwrapped().asScala.mapValues(_.toString).toMap
+ Messages(map, locale, log)
+ }
+ }
+
+ final case class Messages(map: Map[String, String], locale: Locale, log: Logger) {
+
+ /**
+ * Returns message for the key
+ *
+ * @param key key
+ * @return message
+ */
+ def apply(key: String): String = {
+ map.get(key) match {
+ case Some(message) => message
+ case None =>
+ log.error(s"Message with key '$key' not found for locale '${locale.getLanguage}'")
+ key
+ }
+ }
+
+ /**
+ * Returns message for the key and formats that with parameters
+ *
+ * @example "Hello {0}!" with "Joe" will be "Hello Joe!"
+ *
+ * @param key key
+ * @param params params to be embedded
+ * @return formatted message
+ */
+ def apply(key: String, params: Any*): String = {
+
+ def format(formatString: String, params: Seq[Any]) =
+ params.zipWithIndex.foldLeft(formatString) {
+ case (res, (value, index)) => res.replace(s"{$index}", value.toString)
+ }
+
+ val template = apply(key)
+ format(template, params)
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/rest.scala b/src/main/scala/xyz/driver/core/rest.scala
new file mode 100644
index 0000000..f05a800
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/rest.scala
@@ -0,0 +1,153 @@
+package xyz.driver.core
+
+import akka.actor.ActorSystem
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.model._
+import akka.http.scaladsl.model.headers.RawHeader
+import akka.http.scaladsl.unmarshalling.Unmarshal
+import akka.stream.ActorMaterializer
+import com.github.swagger.akka.model._
+import com.github.swagger.akka.{HasActorSystem, SwaggerHttpService}
+import com.typesafe.config.Config
+import xyz.driver.core.logging.Logger
+import xyz.driver.core.stats.Stats
+import xyz.driver.core.time.TimeRange
+import xyz.driver.core.time.provider.TimeProvider
+
+import scala.concurrent.{ExecutionContext, Future}
+import scala.util.{Failure, Success}
+import scalaz.Scalaz.{Id => _, _}
+
+object rest {
+
+ object ContextHeaders {
+ val AuthenticationTokenHeader = "WWW-Authenticate"
+ val TrackingIdHeader = "X-Trace"
+
+ object LinkerD {
+ // https://linkerd.io/doc/0.7.4/linkerd/protocol-http/
+ def isLinkerD(headerName: String) = headerName.startsWith("l5d-")
+ }
+ }
+
+ final case class ServiceRequestContext(
+ trackingId: String = generators.nextUuid().toString,
+ contextHeaders: Map[String, String] = Map.empty[String, String])
+
+ import akka.http.scaladsl.server._
+ import Directives._
+
+ def serviceContext: Directive1[ServiceRequestContext] = extract(ctx => extractServiceContext(ctx))
+
+ def extractServiceContext(ctx: RequestContext): ServiceRequestContext =
+ ServiceRequestContext(extractTrackingId(ctx), extractContextHeaders(ctx))
+
+ def extractTrackingId(ctx: RequestContext): String = {
+ ctx.request.headers
+ .find(_.name == ContextHeaders.TrackingIdHeader)
+ .fold(java.util.UUID.randomUUID.toString)(_.value())
+ }
+
+ def extractContextHeaders(ctx: RequestContext): Map[String, String] = {
+ ctx.request.headers.filter { h =>
+ h.name === ContextHeaders.AuthenticationTokenHeader || h.name === ContextHeaders.TrackingIdHeader
+ // || ContextHeaders.LinkerD.isLinkerD(h.lowercaseName)
+ } map { header =>
+ header.name -> header.value
+ } toMap
+ }
+
+
+ trait Service
+
+ trait ServiceTransport {
+
+ def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]]
+ }
+
+ trait ServiceDiscovery {
+
+ def discover[T <: Service](serviceName: Name[Service]): T
+ }
+
+ class HttpRestServiceTransport(actorSystem: ActorSystem, executionContext: ExecutionContext,
+ log: Logger, stats: Stats, time: TimeProvider) extends ServiceTransport {
+
+ protected implicit val materializer = ActorMaterializer()(actorSystem)
+ protected implicit val execution = executionContext
+
+ def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]] = {
+
+ val requestTime = time.currentTime()
+
+ val request = requestStub
+ .withHeaders(RawHeader(ContextHeaders.TrackingIdHeader, context.trackingId))
+ .withHeaders(context.contextHeaders.toSeq.map { h => RawHeader(h._1, h._2): HttpHeader }: _*)
+
+ log.audit(s"Sending to ${request.uri} request $request with tracking id ${context.trackingId}")
+
+ val responseEntity = Http()(actorSystem).singleRequest(request)(materializer) map { response =>
+ if(response.status == StatusCodes.NotFound) {
+ Unmarshal(HttpEntity.Empty: ResponseEntity)
+ } else if(response.status.isFailure()) {
+ throw new Exception(s"Http status is failure ${response.status}")
+ } else {
+ Unmarshal(response.entity)
+ }
+ }
+
+ responseEntity.onComplete {
+ case Success(r) =>
+ val responseTime = time.currentTime()
+ log.audit(s"Response from ${request.uri} to request $requestStub is successful")
+ stats.recordStats(Seq("request", request.uri.toString, "success"), TimeRange(requestTime, responseTime), 1)
+
+ case Failure(t: Throwable) =>
+ val responseTime = time.currentTime()
+ log.audit(s"Failed to receive response from ${request.uri} to request $requestStub")
+ log.error(s"Failed to receive response from ${request.uri} to request $requestStub", t)
+ stats.recordStats(Seq("request", request.uri.toString, "fail"), TimeRange(requestTime, responseTime), 1)
+ } (executionContext)
+
+ responseEntity
+ }
+ }
+
+ import scala.reflect.runtime.universe._
+
+ class Swagger(override val host: String,
+ version: String,
+ override val actorSystem: ActorSystem,
+ override val apiTypes: Seq[Type],
+ val config: Config) extends SwaggerHttpService with HasActorSystem {
+
+ val materializer = ActorMaterializer()(actorSystem)
+
+ override val basePath = config.getString("swagger.basePath")
+ override val apiDocsPath = config.getString("swagger.docsPath")
+
+ override val info = Info(
+ config.getString("swagger.apiInfo.description"),
+ version,
+ config.getString("swagger.apiInfo.title"),
+ config.getString("swagger.apiInfo.termsOfServiceUrl"),
+ contact = Some(Contact(
+ config.getString("swagger.apiInfo.contact.name"),
+ config.getString("swagger.apiInfo.contact.url"),
+ config.getString("swagger.apiInfo.contact.email")
+ )),
+ license = Some(License(
+ config.getString("swagger.apiInfo.license"),
+ config.getString("swagger.apiInfo.licenseUrl")
+ )),
+ vendorExtensions = Map.empty[String, AnyRef])
+
+ def swaggerUI = get {
+ pathPrefix("") {
+ pathEndOrSingleSlash {
+ getFromResource("swagger-ui/index.html")
+ }
+ } ~ getFromResourceDirectory("swagger-ui")
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/stats.scala b/src/main/scala/xyz/driver/core/stats.scala
new file mode 100644
index 0000000..5759012
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/stats.scala
@@ -0,0 +1,97 @@
+package xyz.driver.core
+
+import java.io.File
+import java.lang.management.ManagementFactory
+import java.lang.reflect.Modifier
+
+import xyz.driver.core.logging.Logger
+import xyz.driver.core.time.{Time, TimeRange}
+
+object stats {
+
+ type StatsKey = String
+ type StatsKeys = Seq[StatsKey]
+
+ trait Stats {
+
+ def recordStats(keys: StatsKeys, interval: TimeRange, value: BigDecimal): Unit
+
+ def recordStats(keys: StatsKeys, interval: TimeRange, value: Int): Unit =
+ recordStats(keys, interval, BigDecimal(value))
+
+ def recordStats(key: StatsKey, interval: TimeRange, value: BigDecimal): Unit =
+ recordStats(Vector(key), interval, value)
+
+ def recordStats(key: StatsKey, interval: TimeRange, value: Int): Unit =
+ recordStats(Vector(key), interval, BigDecimal(value))
+
+ def recordStats(keys: StatsKeys, time: Time, value: BigDecimal): Unit =
+ recordStats(keys, TimeRange(time, time), value)
+
+ def recordStats(keys: StatsKeys, time: Time, value: Int): Unit =
+ recordStats(keys, TimeRange(time, time), BigDecimal(value))
+
+ def recordStats(key: StatsKey, time: Time, value: BigDecimal): Unit =
+ recordStats(Vector(key), TimeRange(time, time), value)
+
+ def recordStats(key: StatsKey, time: Time, value: Int): Unit =
+ recordStats(Vector(key), TimeRange(time, time), BigDecimal(value))
+ }
+
+ class LogStats(log: Logger) extends Stats {
+ def recordStats(keys: StatsKeys, interval: TimeRange, value: BigDecimal): Unit = {
+ val valueString = value.bigDecimal.toPlainString
+ log.audit(s"${keys.mkString(".")}(${interval.start.millis}-${interval.end.millis})=$valueString")
+ }
+ }
+
+ final case class MemoryStats(free: Long, total: Long, max: Long)
+
+ final case class GarbageCollectorStats(totalGarbageCollections: Long, garbageCollectionTime: Long)
+
+ final case class FileRootSpace(path: String, totalSpace: Long, freeSpace: Long, usableSpace: Long)
+
+ object SystemStats {
+
+ def memoryUsage: MemoryStats = {
+ val runtime = Runtime.getRuntime
+ MemoryStats(runtime.freeMemory, runtime.totalMemory, runtime.maxMemory)
+ }
+
+ def availableProcessors: Int = {
+ Runtime.getRuntime.availableProcessors()
+ }
+
+ def garbageCollectorStats: GarbageCollectorStats = {
+ import scala.collection.JavaConverters._
+
+ val (totalGarbageCollections, garbageCollectionTime) =
+ ManagementFactory.getGarbageCollectorMXBeans.asScala.foldLeft(0L -> 0L) {
+ case ((total, collectionTime), gc) =>
+ (total + math.max(0L, gc.getCollectionCount)) -> (collectionTime + math.max(0L, gc.getCollectionTime))
+ }
+
+ GarbageCollectorStats(totalGarbageCollections, garbageCollectionTime)
+ }
+
+ def fileSystemSpace: Array[FileRootSpace] = {
+ File.listRoots() map { root =>
+ FileRootSpace(root.getAbsolutePath, root.getTotalSpace, root.getFreeSpace, root.getUsableSpace)
+ }
+ }
+
+ def operatingSystemStats: Map[String, String] = {
+ val operatingSystemMXBean = ManagementFactory.getOperatingSystemMXBean
+ operatingSystemMXBean.getClass.getDeclaredMethods
+ .map(method => { method.setAccessible(true); method })
+ .filter(method => method.getName.startsWith("get") && Modifier.isPublic(method.getModifiers))
+ .map { method =>
+ try {
+ method.getName -> String.valueOf(method.invoke(operatingSystemMXBean))
+ } catch {
+ case t: Throwable => method.getName -> t.getMessage
+ }
+ } toMap
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/core/time.scala b/src/main/scala/xyz/driver/core/time.scala
new file mode 100644
index 0000000..6ff8209
--- /dev/null
+++ b/src/main/scala/xyz/driver/core/time.scala
@@ -0,0 +1,75 @@
+package xyz.driver.core
+
+import java.text.SimpleDateFormat
+import java.util._
+
+import scala.concurrent.duration._
+
+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)
+ }
+
+ 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 class SpecificTimeProvider(time: Time) extends TimeProvider {
+ def currentTime() = time
+ }
+ }
+}