diff options
author | Stewart Stewart <stewinsalot@gmail.com> | 2016-12-19 12:32:18 -0500 |
---|---|---|
committer | Stewart Stewart <stewinsalot@gmail.com> | 2016-12-19 12:32:18 -0500 |
commit | 8515d672a9fdbb0eb9038a96cee661828cafa61a (patch) | |
tree | 3f04b773de6cea3def3566d2ca4bdff9b78ace8b /src | |
parent | 1702e1c44c45e36e2d6d289ef1b7d703f65ec422 (diff) | |
parent | 861ceb03e8faeb564dd027b13250b5604af8645f (diff) | |
download | driver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.tar.gz driver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.tar.bz2 driver-core-8515d672a9fdbb0eb9038a96cee661828cafa61a.zip |
Merge branch 'master' into implicit-companions
Diffstat (limited to 'src')
-rw-r--r-- | src/main/resources/logback.xml | 22 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/auth.scala | 137 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/core.scala | 46 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/crypto.scala | 19 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/database.scala | 52 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/json.scala | 106 | ||||
-rw-r--r-- | src/main/scala/com/drivergrp/core/stats.scala | 43 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/app.scala (renamed from src/main/scala/com/drivergrp/core/app.scala) | 135 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/auth.scala | 120 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/config.scala (renamed from src/main/scala/com/drivergrp/core/config.scala) | 2 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/core.scala | 52 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/database.scala | 125 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/file.scala (renamed from src/main/scala/com/drivergrp/core/file.scala) | 31 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/generators.scala (renamed from src/main/scala/com/drivergrp/core/generators.scala) | 24 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/json.scala | 158 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/logging.scala (renamed from src/main/scala/com/drivergrp/core/logging.scala) | 121 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/messages.scala (renamed from src/main/scala/com/drivergrp/core/messages.scala) | 4 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/rest.scala (renamed from src/main/scala/com/drivergrp/core/rest.scala) | 77 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/stats.scala | 97 | ||||
-rw-r--r-- | src/main/scala/xyz/driver/core/time.scala (renamed from src/main/scala/com/drivergrp/core/time.scala) | 2 | ||||
-rw-r--r-- | src/test/scala/com/drivergrp/core/AuthTest.scala | 77 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/AuthTest.scala | 79 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/CoreTest.scala (renamed from src/test/scala/com/drivergrp/core/CoreTest.scala) | 16 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/FileTest.scala (renamed from src/test/scala/com/drivergrp/core/FileTest.scala) | 16 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/GeneratorsTest.scala (renamed from src/test/scala/com/drivergrp/core/GeneratorsTest.scala) | 38 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/JsonTest.scala (renamed from src/test/scala/com/drivergrp/core/JsonTest.scala) | 64 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/MessagesTest.scala (renamed from src/test/scala/com/drivergrp/core/MessagesTest.scala) | 20 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/StatsTest.scala (renamed from src/test/scala/com/drivergrp/core/StatsTest.scala) | 10 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/TestTypes.scala | 14 | ||||
-rw-r--r-- | src/test/scala/xyz/driver/core/TimeTest.scala (renamed from src/test/scala/com/drivergrp/core/TimeTest.scala) | 12 |
30 files changed, 1015 insertions, 704 deletions
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index 1b96003..0000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<configuration> - - <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> - <target>System.out</target> - <encoder> - <pattern>%date{MM/dd HH:mm:ss} %-5level[%.15thread] %logger{1} - %msg%n</pattern> - </encoder> - </appender> - <logger name="slick.backend.DatabaseComponent.actio" level="debug"/> - <logger name="slick.jdbc" level="error" /> - <logger name="slick.ast" level="error" /> - <logger name="slick.memory" level="error" /> - <logger name="slick.relational" level="error" /> - <logger name="slick.compiler" level="error" /> - <logger name="com.wordnik" level="error" /> - <logger name="com.github" level="error" /> - <root level="debug"> - <appender-ref ref="CONSOLE"/> - </root> - -</configuration> diff --git a/src/main/scala/com/drivergrp/core/auth.scala b/src/main/scala/com/drivergrp/core/auth.scala deleted file mode 100644 index 6b8cdaa..0000000 --- a/src/main/scala/com/drivergrp/core/auth.scala +++ /dev/null @@ -1,137 +0,0 @@ -package com.drivergrp.core - -import akka.http.scaladsl.model.headers.HttpChallenges -import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected - -import scala.concurrent.Future -import scala.util.{Failure, Success, Try} -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 CanEditReviewingReport extends Permission - case object CanSignOutReport 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(1L) - val name = Name("observer") - val permissions = Set[Permission](CanSeeUser, CanSeeAssay, CanSeeReport) - } - - case object PatientRole extends Role { - val id = Id(2L) - val name = Name("patient") - val permissions = Set.empty[Permission] - } - - case object CuratorRole extends Role { - val id = Id(3L) - val name = Name("curator") - val permissions = Set[Permission](CanSeeUser, CanSeeAssay, CanSeeReport, CanEditReport) - } - - case object PathologistRole extends Role { - val id = Id(4L) - val name = Name("pathologist") - val permissions = - Set[Permission](CanSeeUser, CanSeeAssay, CanSeeReport, CanEditReport, CanSignOutReport, CanEditReviewingReport) - } - - case object AdministratorRole extends Role { - val id = Id(5L) - val name = Name("administrator") - val permissions = Set[Permission]( - CanSeeUser, - CanSeeAssay, - CanSeeReport, - CanCreateReport, - CanEditReport, - CanEditReviewingReport, - CanSignOutReport, - CanShareReportWithPatient, - CanAssignRoles - ) - } - - trait User { - def id: Id[User] - def roles: Set[Role] - def permissions: Set[Permission] = roles.flatMap(_.permissions) - } - - final case class Macaroon(value: String) - - final case class Base64[T](value: String) - - final case class AuthToken(value: Base64[Macaroon]) - - final case class PasswordHash(value: String) - - object AuthService { - val AuthenticationTokenHeader = "WWW-Authenticate" - } - - trait AuthService[U <: User] { - - import akka.http.scaladsl.server._ - import Directives._ - - protected def authStatus(authToken: AuthToken): OptionT[Future, U] - - def authorize(permission: Permission): Directive1[(AuthToken, U)] = { - parameters('authToken.?).flatMap { parameterTokenValue => - optionalHeaderValueByName(AuthService.AuthenticationTokenHeader).flatMap { headerTokenValue => - verifyAuthToken(headerTokenValue.orElse(parameterTokenValue), permission) - } - } - } - - private def verifyAuthToken(tokenOption: Option[String], permission: Permission): Directive1[(AuthToken, U)] = - tokenOption match { - case Some(tokenValue) => - val token = AuthToken(Base64[Macaroon](tokenValue)) - - onComplete(authStatus(token).run).flatMap { tokenUserResult => - checkPermissions(tokenUserResult, permission, token) - } - - case None => - reject(MissingHeaderRejection(AuthService.AuthenticationTokenHeader)) - } - - private def checkPermissions(userResult: Try[Option[U]], - permission: Permission, - token: AuthToken): Directive1[(AuthToken, U)] = { - userResult match { - case Success(Some(user)) => - if (user.roles.exists(_.hasPermission(permission))) provide(token -> user) - else { - val challenge = HttpChallenges.basic(s"User does not have the required permission $permission") - 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/com/drivergrp/core/core.scala b/src/main/scala/com/drivergrp/core/core.scala deleted file mode 100644 index 158447f..0000000 --- a/src/main/scala/com/drivergrp/core/core.scala +++ /dev/null @@ -1,46 +0,0 @@ -package com.drivergrp - -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() - } - } - - object tagging { - private[core] trait Tagged[+V, +Tag] - } - type @@[+V, +Tag] = V with tagging.Tagged[V, Tag] - - type Id[+Tag] = Long @@ Tag - object Id { - def apply[Tag](value: Long) = value.asInstanceOf[Id[Tag]] - } - implicit def idEqual[T]: Equal[Id[T]] = Equal.equal[Id[T]](_ == _) - implicit def idOrdering[T]: Ordering[Id[T]] = Ordering.by(i => i: Long) - - type Name[+Tag] = String @@ Tag - object Name { - def apply[Tag](value: String) = value.asInstanceOf[Name[Tag]] - } - - implicit def nameEqual[T]: Equal[Name[T]] = Equal.equal[Name[T]](_ == _) - implicit def nameOrdering[T]: Ordering[Name[T]] = Ordering.by(n => n: String) - - 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/com/drivergrp/core/crypto.scala b/src/main/scala/com/drivergrp/core/crypto.scala deleted file mode 100644 index f693fa3..0000000 --- a/src/main/scala/com/drivergrp/core/crypto.scala +++ /dev/null @@ -1,19 +0,0 @@ -package com.drivergrp.core - -import com.drivergrp.core.auth.AuthToken - -object crypto { - - final case class EncryptionKey(value: String) - - final case class DecryptionKey(value: String) - - trait Crypto { - - def keyForToken(authToken: AuthToken): EncryptionKey - - def encrypt(encryptionKey: EncryptionKey)(message: Array[Byte]): Array[Byte] - - def decrypt(decryptionKey: EncryptionKey)(message: Array[Byte]): Array[Byte] - } -} diff --git a/src/main/scala/com/drivergrp/core/database.scala b/src/main/scala/com/drivergrp/core/database.scala deleted file mode 100644 index 581c5de..0000000 --- a/src/main/scala/com/drivergrp/core/database.scala +++ /dev/null @@ -1,52 +0,0 @@ -package com.drivergrp.core - -import com.drivergrp.core.time.Time - -import scala.concurrent.Future -import slick.backend.DatabaseConfig -import slick.driver.JdbcProfile - -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 - } - } - } - - trait IdColumnTypes { - val database: Database - - import database.profile.api._ - - implicit def idColumnType[T] = - MappedColumnType.base[Id[T], Long](id => id: Long, Id[T](_)) - - implicit def nameColumnType[T] = - MappedColumnType.base[Name[T], String](name => name: String, Name[T](_)) - - implicit val timeColumnType = MappedColumnType.base[Time, Long](time => time.millis, Time(_)) - } - - trait DatabaseObject extends IdColumnTypes { - - def createTables(): Future[Unit] - def disconnect(): Unit - } - - abstract class DatabaseObjectAdapter extends DatabaseObject { - def createTables(): Future[Unit] = Future.successful(()) - def disconnect(): Unit = {} - } -} diff --git a/src/main/scala/com/drivergrp/core/json.scala b/src/main/scala/com/drivergrp/core/json.scala deleted file mode 100644 index 9a30161..0000000 --- a/src/main/scala/com/drivergrp/core/json.scala +++ /dev/null @@ -1,106 +0,0 @@ -package com.drivergrp.core - -import akka.http.scaladsl.model.Uri.Path -import akka.http.scaladsl.server.PathMatcher.Matched -import akka.http.scaladsl.server.{PathMatcher, _} -import akka.http.scaladsl.unmarshalling.Unmarshaller -import com.drivergrp.core.revision.Revision -import com.drivergrp.core.time.Time -import spray.json.{DeserializationException, JsNumber, _} - -import scala.reflect.runtime.universe._ - -object json { - - def IdInPath[T]: PathMatcher1[Id[T]] = - PathMatcher("""[+-]?\d*""".r) flatMap { string => - try Some(Id[T](string.toLong)) - catch { case _: IllegalArgumentException => None } - } - - implicit def idFormat[T] = new RootJsonFormat[Id[T]] { - def write(id: Id[T]) = JsNumber(id) - - def read(value: JsValue) = value match { - case JsNumber(id) => Id[T](id.toLong) - case _ => throw new DeserializationException("Id expects number") - } - } - - def NameInPath[T]: PathMatcher1[Name[T]] = new PathMatcher1[Name[T]] { - def apply(path: Path) = Matched(Path.Empty, Tuple1(Name[T](path.toString))) - } - - implicit def nameFormat[T] = new RootJsonFormat[Name[T]] { - def write(name: Name[T]) = JsString(name) - - def read(value: JsValue): Name[T] = value match { - case JsString(name) => Name[T](name) - case _ => throw new 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 new DeserializationException("Time expects number")) - case _ => throw new 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 new DeserializationException("Revision expects uuid string") - } - } - - class EnumJsonFormat[T](mapping: (String, T)*) extends JsonFormat[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 new 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) - } - } -} diff --git a/src/main/scala/com/drivergrp/core/stats.scala b/src/main/scala/com/drivergrp/core/stats.scala deleted file mode 100644 index cd77f7a..0000000 --- a/src/main/scala/com/drivergrp/core/stats.scala +++ /dev/null @@ -1,43 +0,0 @@ -package com.drivergrp.core - -import com.drivergrp.core.logging.Logger -import com.drivergrp.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") - } - } -} diff --git a/src/main/scala/com/drivergrp/core/app.scala b/src/main/scala/xyz/driver/core/app.scala index a1e1082..227be57 100644 --- a/src/main/scala/com/drivergrp/core/app.scala +++ b/src/main/scala/xyz/driver/core/app.scala @@ -1,21 +1,24 @@ -package com.drivergrp.core +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.drivergrp.core.logging.{Logger, TypesafeScalaLogger} -import com.drivergrp.core.rest.Swagger -import com.drivergrp.core.time.Time -import com.drivergrp.core.time.provider.{SystemTimeProvider, TimeProvider} 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._ @@ -28,8 +31,8 @@ object app { modules: Seq[Module], time: TimeProvider = new SystemTimeProvider(), log: Logger = new TypesafeScalaLogger( - com.typesafe.scalalogging.Logger(LoggerFactory.getLogger(classOf[DriverApp]))), - config: Config = com.drivergrp.core.config.loadDefaultConfig, + com.typesafe.scalalogging.Logger(LoggerFactory.getLogger(classOf[DriverApp]))), + config: Config = core.config.loadDefaultConfig, interface: String = "::0", baseUrl: String = "localhost:8080", port: Int = 8080) { @@ -61,49 +64,43 @@ object app { val swaggerRoutes = swaggerService.routes ~ swaggerService.swaggerUI val versionRt = versionRoute(version, gitHash, time.currentTime()) - val generalExceptionHandler = ExceptionHandler { - - case is: IllegalStateException => - extractUri { uri => - // TODO: extract `requestUuid` from request or thread, provided by linkerd/zipkin - def requestUuid = java.util.UUID.randomUUID.toString - - log.debug(s"Request is not allowed to $uri ($requestUuid)", is) - complete( - HttpResponse(BadRequest, - entity = s"""{ "requestUuid": "$requestUuid", "message": "${is.getMessage}" }""")) - } - - case cm: ConcurrentModificationException => - extractUri { uri => - // TODO: extract `requestUuid` from request or thread, provided by linkerd/zipkin - def requestUuid = java.util.UUID.randomUUID.toString - - log.debug(s"Concurrent modification of the resource $uri ($requestUuid)", cm) - complete( - HttpResponse(Conflict, - entity = s"""{ "requestUuid": "$requestUuid", "message": "${cm.getMessage}" }""")) - } + 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") - case t: Throwable => - extractUri { uri => - // TODO: extract `requestUuid` from request or thread, provided by linkerd/zipkin - def requestUuid = java.util.UUID.randomUUID.toString + val contextWithTrackingId = + ctx.withRequest(ctx.request.addHeader(RawHeader(ContextHeaders.TrackingIdHeader, trackingId))) - log.error(s"Request to $uri could not be handled normally ($requestUuid)", t) - complete( - HttpResponse(InternalServerError, - entity = s"""{ "requestUuid": "$requestUuid", "message": "${t.getMessage}" }""")) - } - } - - val _ = Future { - http.bindAndHandle(route2HandlerFlow(handleExceptions(generalExceptionHandler) { - logRequestResult("log")(modules.map(_.route).foldLeft(versionRt ~ swaggerRoutes)(_ ~ _)) + 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._ @@ -111,14 +108,46 @@ object app { 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 - )) + 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 + )) } } @@ -173,7 +202,7 @@ object app { } class EmptyModule extends Module { - val name = "Nothing" + val name = "Nothing" def route: Route = complete(StatusCodes.OK) def routeTypes = Seq.empty[Type] } 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/com/drivergrp/core/config.scala b/src/main/scala/xyz/driver/core/config.scala index 29cd9ed..112986e 100644 --- a/src/main/scala/com/drivergrp/core/config.scala +++ b/src/main/scala/xyz/driver/core/config.scala @@ -1,4 +1,4 @@ -package com.drivergrp.core +package xyz.driver.core import java.io.File import com.typesafe.config.{Config, ConfigFactory} 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/com/drivergrp/core/file.scala b/src/main/scala/xyz/driver/core/file.scala index 20bd36e..9cea9e5 100644 --- a/src/main/scala/com/drivergrp/core/file.scala +++ b/src/main/scala/xyz/driver/core/file.scala @@ -1,4 +1,4 @@ -package com.drivergrp.core +package xyz.driver.core import java.io.File import java.nio.file.{Path, Paths} @@ -6,8 +6,8 @@ import java.util.UUID._ import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.model.{Bucket, GetObjectRequest, ListObjectsV2Request} -import com.drivergrp.core.revision.Revision -import com.drivergrp.core.time.Time +import xyz.driver.core.revision.Revision +import xyz.driver.core.time.Time import scala.concurrent.{ExecutionContext, Future} import scalaz.{ListT, OptionT} @@ -15,10 +15,11 @@ import scalaz.{ListT, OptionT} object file { final case class FileLink( - name: Name[File], - location: Path, - revision: Revision[File], - lastModificationDate: Time + name: Name[File], + location: Path, + revision: Revision[File], + lastModificationDate: Time, + fileSize: Long ) trait FileService { @@ -58,7 +59,7 @@ object file { def upload(localSource: File, destination: Path): Future[Unit] = Future { checkSafeFileName(destination) { - val _ = s3.putObject(bucket, destination.toString, localSource).getETag + val _ = s3.putObject(bucket.value, destination.toString, localSource).getETag } } @@ -71,20 +72,20 @@ object file { if (!tempDestinationFile.getParentFile.mkdirs()) { throw new Exception(s"Failed to create temp directory to download file `$tempDestinationFile`") } else { - Option(s3.getObject(new GetObjectRequest(bucket, filePath.toString), tempDestinationFile)).map { _ => + Option(s3.getObject(new GetObjectRequest(bucket.value, filePath.toString), tempDestinationFile)).map { _ => tempDestinationFile } } }) def delete(filePath: Path): Future[Unit] = Future { - s3.deleteObject(bucket, filePath.toString) + 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).withPrefix(path.toString).withMaxKeys(2) + 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("/") @@ -97,7 +98,8 @@ object file { FileLink(Name[File](summary.getKey), Paths.get(path.toString + "/" + summary.getKey), Revision[File](summary.getETag), - Time(summary.getLastModified.getTime)) + Time(summary.getLastModified.getTime), + summary.getSize) } filterNot isInSubFolder(path) } toList }) @@ -114,7 +116,7 @@ object file { if (localSource.renameTo(destinationFile)) () else { throw new Exception( - s"Failed to move file from `${localSource.getCanonicalPath}` to `${destinationFile.getCanonicalPath}`") + 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}`") @@ -143,7 +145,8 @@ object file { FileLink(Name[File](file.getName), Paths.get(file.getPath), Revision[File](file.hashCode.toString), - Time(file.lastModified())) + Time(file.lastModified()), + file.length()) } } else List.empty[FileLink] }) diff --git a/src/main/scala/com/drivergrp/core/generators.scala b/src/main/scala/xyz/driver/core/generators.scala index 10df7db..c61cb94 100644 --- a/src/main/scala/com/drivergrp/core/generators.scala +++ b/src/main/scala/xyz/driver/core/generators.scala @@ -1,9 +1,9 @@ -package com.drivergrp.core +package xyz.driver.core import java.math.MathContext -import com.drivergrp.core.revision.Revision -import com.drivergrp.core.time.{Time, TimeRange} +import xyz.driver.core.revision.Revision +import xyz.driver.core.time.{Time, TimeRange} import scala.reflect.ClassTag import scala.util.Random @@ -13,12 +13,22 @@ object generators { private val random = new Random import random._ - private val DefaultMaxLength = 100 + private val DefaultMaxLength = 10 private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet - def nextId[T](): Id[T] = Id[T](scala.math.abs(nextLong())) + def nextInt(maxValue: Int): Int = random.nextInt(maxValue) - def nextId[T](maxValue: Int): Id[T] = Id[T](scala.math.abs(nextInt(maxValue).toLong)) + 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)) @@ -29,7 +39,7 @@ object generators { 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 nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) 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/com/drivergrp/core/logging.scala b/src/main/scala/xyz/driver/core/logging.scala index 126c670..ba17131 100644 --- a/src/main/scala/com/drivergrp/core/logging.scala +++ b/src/main/scala/xyz/driver/core/logging.scala @@ -1,5 +1,11 @@ -package com.drivergrp.core +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 { @@ -44,36 +50,44 @@ object logging { */ class TypesafeScalaLogger(scalaLogging: com.typesafe.scalalogging.Logger) extends Logger { - def fatal(message: String): Unit = scalaLogging.error(message) - def fatal(message: String, cause: Throwable): Unit = scalaLogging.error(message, cause) - def fatal(message: String, args: AnyRef*): Unit = scalaLogging.error(message, args) - def fatal(marker: Marker, message: String): Unit = scalaLogging.error(marker, message) - def fatal(marker: Marker, message: String, cause: Throwable): Unit = scalaLogging.error(marker, message, cause) - def fatal(marker: Marker, message: String, args: AnyRef*): Unit = scalaLogging.error(marker, message, args) - - def error(message: String): Unit = scalaLogging.warn(message) - def error(message: String, cause: Throwable): Unit = scalaLogging.warn(message, cause) - def error(message: String, args: AnyRef*): Unit = scalaLogging.warn(message, args) - def error(marker: Marker, message: String): Unit = scalaLogging.warn(marker, message) - def error(marker: Marker, message: String, cause: Throwable): Unit = scalaLogging.warn(marker, message, cause) - def error(marker: Marker, message: String, args: AnyRef*): Unit = scalaLogging.warn(marker, message, args) - - def audit(message: String): Unit = scalaLogging.info(message) - def audit(message: String, cause: Throwable): Unit = scalaLogging.info(message, cause) - def audit(message: String, args: AnyRef*): Unit = scalaLogging.info(message, args) - def audit(marker: Marker, message: String): Unit = scalaLogging.info(marker, message) - def audit(marker: Marker, message: String, cause: Throwable): Unit = scalaLogging.info(marker, message, cause) - def audit(marker: Marker, message: String, args: AnyRef*): Unit = scalaLogging.info(marker, message, args) - - def debug(message: String): Unit = scalaLogging.debug(message) - def debug(message: String, cause: Throwable): Unit = scalaLogging.debug(message, cause) - def debug(message: String, args: AnyRef*): Unit = scalaLogging.debug(message, args) - def debug(marker: Marker, message: String): Unit = scalaLogging.debug(marker, message) - def debug(marker: Marker, message: String, cause: Throwable): Unit = scalaLogging.debug(marker, message, cause) - def debug(marker: Marker, message: String, args: AnyRef*): Unit = scalaLogging.debug(marker, message, args) + 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) } - class NoLogger() extends Logger { + object NoLogger extends Logger { def fatal(message: String): Unit = {} def fatal(message: String, cause: Throwable): Unit = {} @@ -103,4 +117,53 @@ object logging { 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/com/drivergrp/core/messages.scala b/src/main/scala/xyz/driver/core/messages.scala index 3a97401..94d9889 100644 --- a/src/main/scala/com/drivergrp/core/messages.scala +++ b/src/main/scala/xyz/driver/core/messages.scala @@ -1,9 +1,9 @@ -package com.drivergrp.core +package xyz.driver.core import java.util.Locale -import com.drivergrp.core.logging.Logger import com.typesafe.config.Config +import xyz.driver.core.logging.Logger import scala.collection.JavaConverters._ diff --git a/src/main/scala/com/drivergrp/core/rest.scala b/src/main/scala/xyz/driver/core/rest.scala index d97e13e..f05a800 100644 --- a/src/main/scala/com/drivergrp/core/rest.scala +++ b/src/main/scala/xyz/driver/core/rest.scala @@ -1,4 +1,4 @@ -package com.drivergrp.core +package xyz.driver.core import akka.actor.ActorSystem import akka.http.scaladsl.Http @@ -6,29 +6,63 @@ import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.RawHeader import akka.http.scaladsl.unmarshalling.Unmarshal import akka.stream.ActorMaterializer -import akka.stream.scaladsl.Flow -import akka.util.ByteString -import com.drivergrp.core.auth.{AuthService, AuthToken} -import com.drivergrp.core.crypto.Crypto -import com.drivergrp.core.logging.Logger -import com.drivergrp.core.stats.Stats -import com.drivergrp.core.time.TimeRange -import com.drivergrp.core.time.provider.TimeProvider 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.{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(authToken: AuthToken)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]] + def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]] } trait ServiceDiscovery { @@ -37,35 +71,28 @@ object rest { } class HttpRestServiceTransport(actorSystem: ActorSystem, executionContext: ExecutionContext, - crypto: Crypto, log: Logger, stats: Stats, time: TimeProvider) extends ServiceTransport { + log: Logger, stats: Stats, time: TimeProvider) extends ServiceTransport { protected implicit val materializer = ActorMaterializer()(actorSystem) protected implicit val execution = executionContext - def sendRequest(authToken: AuthToken)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]] = { + def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest): Future[Unmarshal[ResponseEntity]] = { val requestTime = time.currentTime() - val encryptionFlow = Flow[ByteString] map { bytes => - ByteString(crypto.encrypt(crypto.keyForToken(authToken))(bytes.toArray)) - } - val decryptionFlow = Flow[ByteString] map { bytes => - ByteString(crypto.decrypt(crypto.keyForToken(authToken))(bytes.toArray)) - } val request = requestStub - .withEntity(requestStub.entity.transformDataBytes(encryptionFlow)) - .withHeaders( - RawHeader(AuthService.AuthenticationTokenHeader, authToken.value.value)) + .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") + 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("Http status is failure " + response.status) + throw new Exception(s"Http status is failure ${response.status}") } else { - Unmarshal(response.entity.transformDataBytes(decryptionFlow)) + Unmarshal(response.entity) } } 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/com/drivergrp/core/time.scala b/src/main/scala/xyz/driver/core/time.scala index b935713..6ff8209 100644 --- a/src/main/scala/com/drivergrp/core/time.scala +++ b/src/main/scala/xyz/driver/core/time.scala @@ -1,4 +1,4 @@ -package com.drivergrp.core +package xyz.driver.core import java.text.SimpleDateFormat import java.util._ diff --git a/src/test/scala/com/drivergrp/core/AuthTest.scala b/src/test/scala/com/drivergrp/core/AuthTest.scala deleted file mode 100644 index 42f9155..0000000 --- a/src/test/scala/com/drivergrp/core/AuthTest.scala +++ /dev/null @@ -1,77 +0,0 @@ -package com.drivergrp.core - -import com.drivergrp.core.auth._ -import akka.http.scaladsl.testkit.ScalatestRouteTest -import akka.http.scaladsl.server._ -import Directives._ -import akka.http.scaladsl.model.headers.{HttpChallenges, RawHeader} -import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected -import org.scalatest.mock.MockitoSugar -import org.scalatest.{FlatSpec, Matchers} - -import scala.concurrent.Future -import scalaz.OptionT - -class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRouteTest { - - val authStatusService: AuthService[User] = new AuthService[User] { - override def authStatus(authToken: AuthToken): OptionT[Future, User] = OptionT.optionT[Future] { - Future.successful(Some(new User() { - override def id: Id[User] = Id[User](1L) - override def roles: Set[Role] = Set(PathologistRole) - })) - } - } - - import authStatusService._ - - "'authorize' directive" should "throw error is auth token is not in the request" in { - - Get("/naive/attempt") ~> - authorize(CanSignOutReport) { - case (authToken, user) => - complete("Never going to be here") - } ~> - check { - handled shouldBe false - rejections should contain(MissingHeaderRejection("WWW-Authenticate")) - } - } - - it should "throw error is authorized user is not having the requested permission" in { - - val referenceAuthToken = AuthToken(Base64("I am a pathologist's token")) - - Post("/administration/attempt").addHeader( - RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value.value) - ) ~> - authorize(CanAssignRoles) { - case (authToken, user) => - complete("Never going to get here") - } ~> - check { - handled shouldBe false - rejections should contain( - AuthenticationFailedRejection( - CredentialsRejected, - HttpChallenges.basic("User does not have the required permission CanAssignRoles"))) - } - } - - it should "pass and retrieve the token to client code, if token is in request and user has permission" in { - - val referenceAuthToken = AuthToken(Base64("I am token")) - - Get("/valid/attempt/?a=2&b=5").addHeader( - RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value.value) - ) ~> - authorize(CanSignOutReport) { - case (authToken, user) => - complete("Alright, \"" + authToken.value.value + "\" is handled") - } ~> - check { - handled shouldBe true - responseAs[String] shouldBe "Alright, \"I am token\" is handled" - } - } -} diff --git a/src/test/scala/xyz/driver/core/AuthTest.scala b/src/test/scala/xyz/driver/core/AuthTest.scala new file mode 100644 index 0000000..f4d4d2a --- /dev/null +++ b/src/test/scala/xyz/driver/core/AuthTest.scala @@ -0,0 +1,79 @@ +package xyz.driver.core + +import akka.http.scaladsl.testkit.ScalatestRouteTest +import akka.http.scaladsl.server._ +import Directives._ +import akka.http.scaladsl.model.headers.{HttpChallenges, RawHeader} +import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected +import org.scalatest.mock.MockitoSugar +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.auth._ +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.Future +import scalaz.OptionT + +class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRouteTest { + + val authStatusService: AuthService[User] = new AuthService[User] { + override def authStatus(context: ServiceRequestContext): OptionT[Future, User] = OptionT.optionT[Future] { + if (context.contextHeaders.keySet.contains(AuthService.AuthenticationTokenHeader)) { + Future.successful(Some(new User { + override def id: Id[User] = Id[User]("1") + override def roles: Set[Role] = Set(PathologistRole) + }: User)) + } else { + Future.successful(Option.empty[User]) + } + } + } + + import authStatusService._ + + "'authorize' directive" should "throw error is auth token is not in the request" in { + + Get("/naive/attempt") ~> + authorize(CanSignOutReport) { user => + complete("Never going to be here") + } ~> + check { + // handled shouldBe false + rejections should contain(ValidationRejection("Wasn't able to find authenticated user for the token provided")) + } + } + + it should "throw error is authorized user is not having the requested permission" in { + + val referenceAuthToken = AuthToken("I am a pathologist's token") + + Post("/administration/attempt").addHeader( + RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value) + ) ~> + authorize(CanAssignRoles) { user => + complete("Never going to get here") + } ~> + check { + handled shouldBe false + rejections should contain( + AuthenticationFailedRejection( + CredentialsRejected, + HttpChallenges.basic("User does not have the required permissions: CanAssignRoles"))) + } + } + + it should "pass and retrieve the token to client code, if token is in request and user has permission" in { + + val referenceAuthToken = AuthToken("I am token") + + Get("/valid/attempt/?a=2&b=5").addHeader( + RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value) + ) ~> + authorize(CanSignOutReport) { user => + complete("Alright, user \"" + user.id + "\" is authorized") + } ~> + check { + handled shouldBe true + responseAs[String] shouldBe "Alright, user \"1\" is authorized" + } + } +} diff --git a/src/test/scala/com/drivergrp/core/CoreTest.scala b/src/test/scala/xyz/driver/core/CoreTest.scala index 19e685c..3eb9eaa 100644 --- a/src/test/scala/com/drivergrp/core/CoreTest.scala +++ b/src/test/scala/xyz/driver/core/CoreTest.scala @@ -1,11 +1,11 @@ -package com.drivergrp.core +package xyz.driver.core import java.io.ByteArrayOutputStream -import com.drivergrp.core.revision.Revision +import org.mockito.Mockito._ import org.scalatest.mock.MockitoSugar import org.scalatest.{FlatSpec, Matchers} -import org.mockito.Mockito._ +import xyz.driver.core.revision.Revision class CoreTest extends FlatSpec with Matchers with MockitoSugar { @@ -31,12 +31,12 @@ class CoreTest extends FlatSpec with Matchers with MockitoSugar { "Id" should "have equality and ordering working correctly" in { - (Id[String](1234213L) === Id[String](1234213L)) should be(true) - (Id[String](1234213L) === Id[String](213414L)) should be(false) - (Id[String](213414L) === Id[String](1234213L)) should be(false) + (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) - Seq(Id[String](4L), Id[String](3L), Id[String](2L), Id[String](1L)).sorted should contain - theSameElementsInOrderAs(Seq(Id[String](1L), Id[String](2L), Id[String](3L), Id[String](4L))) + Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")).sorted should contain + theSameElementsInOrderAs(Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4"))) } "Name" should "have equality and ordering working correctly" in { diff --git a/src/test/scala/com/drivergrp/core/FileTest.scala b/src/test/scala/xyz/driver/core/FileTest.scala index 2c9c2c9..57af1c2 100644 --- a/src/test/scala/com/drivergrp/core/FileTest.scala +++ b/src/test/scala/xyz/driver/core/FileTest.scala @@ -1,15 +1,15 @@ -package com.drivergrp.core +package xyz.driver.core import java.io.File import java.nio.file.Paths import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.model._ -import com.drivergrp.core.file.{FileSystemStorage, S3Storage} +import org.mockito.Matchers._ +import org.mockito.Mockito._ import org.scalatest.mock.MockitoSugar import org.scalatest.{FlatSpec, Matchers} -import org.mockito.Mockito._ -import org.mockito.Matchers._ +import xyz.driver.core.file.{FileSystemStorage, S3Storage} import scala.concurrent.Await import scala.concurrent.duration._ @@ -44,14 +44,14 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { false, // after file is uploaded it contains this one file (one page) false) // after file is deleted it is empty (zero pages) again when(s3ResultsMock.getObjectSummaries).thenReturn( - // before file created it is empty, `getObjectSummaries` is never called - List[S3ObjectSummary](s3ObjectSummaryMock).asJava, // after file is uploaded it contains this one file - List.empty[S3ObjectSummary].asJava) // after file is deleted it is empty again + // before file created it is empty, `getObjectSummaries` is never called + List[S3ObjectSummary](s3ObjectSummaryMock).asJava, // after file is uploaded it contains this one file + List.empty[S3ObjectSummary].asJava) // after file is deleted it is empty again val s3ObjectMetadataMock = mock[ObjectMetadata] val amazonS3Mock = mock[AmazonS3] when(amazonS3Mock.listObjectsV2(any[ListObjectsV2Request]())).thenReturn(s3ResultsMock) - when(amazonS3Mock.putObject(testBucket, testFilePath.toString, sourceTestFile)).thenReturn(s3PutMock) + when(amazonS3Mock.putObject(testBucket.value, testFilePath.toString, sourceTestFile)).thenReturn(s3PutMock) when(amazonS3Mock.getObject(any[GetObjectRequest](), any[File]())).thenReturn(s3ObjectMetadataMock) val s3Storage = new S3Storage(amazonS3Mock, testBucket, scala.concurrent.ExecutionContext.global) diff --git a/src/test/scala/com/drivergrp/core/GeneratorsTest.scala b/src/test/scala/xyz/driver/core/GeneratorsTest.scala index 631149e..4ec73ec 100644 --- a/src/test/scala/com/drivergrp/core/GeneratorsTest.scala +++ b/src/test/scala/xyz/driver/core/GeneratorsTest.scala @@ -1,4 +1,4 @@ -package com.drivergrp.core +package xyz.driver.core import org.scalatest.{Assertions, FlatSpec, Matchers} @@ -11,25 +11,25 @@ class GeneratorsTest extends FlatSpec with Matchers with Assertions { val generatedId2 = nextId[String]() val generatedId3 = nextId[Long]() - generatedId1 should be >= 0L - generatedId2 should be >= 0L - generatedId3 should be >= 0L + generatedId1.length should be >= 0 + generatedId2.length should be >= 0 + generatedId3.length should be >= 0 generatedId1 should not be generatedId2 generatedId2 should !==(generatedId3) } it should "be able to generate com.drivergrp.core.Id identifiers with max value" in { - val generatedLimitedId1 = nextId[String](10000) - val generatedLimitedId2 = nextId[String](1000) - val generatedLimitedId3 = nextId[Long](2000) + val generatedLimitedId1 = nextId[String](5) + val generatedLimitedId2 = nextId[String](4) + val generatedLimitedId3 = nextId[Long](3) - generatedLimitedId1 should be >= 0L - generatedLimitedId1 should be < 10000L - generatedLimitedId2 should be >= 0L - generatedLimitedId2 should be < 1000L - generatedLimitedId3 should be >= 0L - generatedLimitedId3 should be < 2000L + generatedLimitedId1.length should be >= 0 + generatedLimitedId1.length should be < 6 + generatedLimitedId2.length should be >= 0 + generatedLimitedId2.length should be < 5 + generatedLimitedId3.length should be >= 0 + generatedLimitedId3.length should be < 4 generatedLimitedId1 should not be generatedLimitedId2 generatedLimitedId2 should !==(generatedLimitedId3) } @@ -37,11 +37,11 @@ class GeneratorsTest extends FlatSpec with Matchers with Assertions { it should "be able to generate com.drivergrp.core.Name names" in { nextName[String]() should not be nextName[String]() - nextName[String]().length should be >= 0 + nextName[String]().value.length should be >= 0 val fixedLengthName = nextName[String](10) fixedLengthName.length should be <= 10 - assert(!fixedLengthName.exists(_.isControl)) + assert(!fixedLengthName.value.exists(_.isControl)) } it should "be able to generate proper UUIDs" in { @@ -82,11 +82,11 @@ class GeneratorsTest extends FlatSpec with Matchers with Assertions { val generatedPair = nextPair(nextId[Int](), nextName[Int]()) - generatedPair._1 should be > 0L + generatedPair._1.length should be > 0 generatedPair._2.length should be > 0 nextPair(nextId[Int](), nextName[Int]()) should not be - nextPair(nextId[Int](), nextName[Int]()) + nextPair(nextId[Int](), nextName[Int]()) } it should "be able to generate a triad of two generated values" in { @@ -98,12 +98,12 @@ class GeneratorsTest extends FlatSpec with Matchers with Assertions { val generatedTriad = nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) - generatedTriad._1 should be > 0L + generatedTriad._1.length should be > 0 generatedTriad._2.length should be > 0 generatedTriad._3 should be >= BigDecimal(0.00) nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) should not be - nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) + nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) } it should "be able to generate a time value" in { diff --git a/src/test/scala/com/drivergrp/core/JsonTest.scala b/src/test/scala/xyz/driver/core/JsonTest.scala index 125e97c..eb8d5d8 100644 --- a/src/test/scala/com/drivergrp/core/JsonTest.scala +++ b/src/test/scala/xyz/driver/core/JsonTest.scala @@ -1,20 +1,22 @@ -package com.drivergrp.core +package xyz.driver.core -import com.drivergrp.core.json.{EnumJsonFormat, ValueClassFormat} -import com.drivergrp.core.revision.Revision -import com.drivergrp.core.time.provider.SystemTimeProvider import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.json.{EnumJsonFormat, GadtJsonFormat, ValueClassFormat} +import xyz.driver.core.revision.Revision +import xyz.driver.core.time.provider.SystemTimeProvider +import spray.json._ +import xyz.driver.core.TestTypes.CustomGADT class JsonTest extends FlatSpec with Matchers { "Json format for Id" should "read and write correct JSON" in { - val referenceId = Id[String](1312L) + val referenceId = Id[String]("1312-34A") - val writtenJson = com.drivergrp.core.json.idFormat.write(referenceId) - writtenJson.prettyPrint should be("1312") + val writtenJson = json.idFormat.write(referenceId) + writtenJson.prettyPrint should be("\"1312-34A\"") - val parsedId = com.drivergrp.core.json.idFormat.read(writtenJson) + val parsedId = json.idFormat.read(writtenJson) parsedId should be(referenceId) } @@ -22,10 +24,10 @@ class JsonTest extends FlatSpec with Matchers { val referenceName = Name[String]("Homer") - val writtenJson = com.drivergrp.core.json.nameFormat.write(referenceName) + val writtenJson = json.nameFormat.write(referenceName) writtenJson.prettyPrint should be("\"Homer\"") - val parsedName = com.drivergrp.core.json.nameFormat.read(writtenJson) + val parsedName = json.nameFormat.read(writtenJson) parsedName should be(referenceName) } @@ -33,10 +35,10 @@ class JsonTest extends FlatSpec with Matchers { val referenceTime = new SystemTimeProvider().currentTime() - val writtenJson = com.drivergrp.core.json.timeFormat.write(referenceTime) + val writtenJson = json.timeFormat.write(referenceTime) writtenJson.prettyPrint should be("{\n \"timestamp\": " + referenceTime.millis + "\n}") - val parsedTime = com.drivergrp.core.json.timeFormat.read(writtenJson) + val parsedTime = json.timeFormat.read(writtenJson) parsedTime should be(referenceTime) } @@ -44,10 +46,10 @@ class JsonTest extends FlatSpec with Matchers { val referenceRevision = Revision[String]("037e2ec0-8901-44ac-8e53-6d39f6479db4") - val writtenJson = com.drivergrp.core.json.revisionFormat.write(referenceRevision) + val writtenJson = json.revisionFormat.write(referenceRevision) writtenJson.prettyPrint should be("\"" + referenceRevision.id + "\"") - val parsedRevision = com.drivergrp.core.json.revisionFormat.read(writtenJson) + val parsedRevision = json.revisionFormat.read(writtenJson) parsedRevision should be(referenceRevision) } @@ -98,4 +100,38 @@ class JsonTest extends FlatSpec with Matchers { parsedValue1 should be(referenceValue1) parsedValue2 should be(referenceValue2) } + + "Json format for classes GADT" should "read and write correct JSON" in { + + import CustomGADT._ + import DefaultJsonProtocol._ + implicit val case1Format = jsonFormat1(GadtCase1) + implicit val case2Format = jsonFormat1(GadtCase2) + implicit val case3Format = jsonFormat1(GadtCase3) + + val format = GadtJsonFormat.create[CustomGADT]("gadtTypeField") { + case t1: CustomGADT.GadtCase1 => "case1" + case t2: CustomGADT.GadtCase2 => "case2" + case t3: CustomGADT.GadtCase3 => "case3" + } { + case "case1" => case1Format + case "case2" => case2Format + case "case3" => case3Format + } + + val referenceValue1 = CustomGADT.GadtCase1("4") + val referenceValue2 = CustomGADT.GadtCase2("Hi!") + + val writtenJson1 = format.write(referenceValue1) + writtenJson1 should be("{\n \"field\": \"4\",\n\"gadtTypeField\": \"case1\"\n}".parseJson) + + val writtenJson2 = format.write(referenceValue2) + writtenJson2 should be("{\"field\":\"Hi!\",\"gadtTypeField\":\"case2\"}".parseJson) + + val parsedValue1 = format.read(writtenJson1) + val parsedValue2 = format.read(writtenJson2) + + parsedValue1 should be(referenceValue1) + parsedValue2 should be(referenceValue2) + } } diff --git a/src/test/scala/com/drivergrp/core/MessagesTest.scala b/src/test/scala/xyz/driver/core/MessagesTest.scala index 21fe30a..dc44ee1 100644 --- a/src/test/scala/com/drivergrp/core/MessagesTest.scala +++ b/src/test/scala/xyz/driver/core/MessagesTest.scala @@ -1,13 +1,13 @@ -package com.drivergrp.core +package xyz.driver.core import java.util.Locale -import com.drivergrp.core.logging.Logger -import com.drivergrp.core.messages.Messages import com.typesafe.config.{ConfigException, ConfigFactory} import org.mockito.Mockito._ import org.scalatest.mock.MockitoSugar import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.logging.Logger +import xyz.driver.core.messages.Messages import scala.collection.JavaConversions._ @@ -33,11 +33,11 @@ class MessagesTest extends FlatSpec with Matchers with MockitoSugar { val log = mock[Logger] val messagesConfig = ConfigFactory.parseMap( - englishLocaleMessages ++ Map( - "zh.hello" -> "你好,世界!", - "zh.greeting" -> "你好,{0}!", - "zh.greetingFullName" -> "你好,{0} {1} {2}!" - )) + englishLocaleMessages ++ Map( + "zh.hello" -> "你好,世界!", + "zh.greeting" -> "你好,{0}!", + "zh.greetingFullName" -> "你好,{0} {1} {2}!" + )) val englishMessages = Messages.messages(messagesConfig, log, Locale.US) val englishMessagesToo = Messages.messages(messagesConfig, log, Locale.ENGLISH) @@ -50,7 +50,7 @@ class MessagesTest extends FlatSpec with Matchers with MockitoSugar { englishMessagesToo("hello") should be(englishMessages("hello")) englishMessagesToo("greeting", "Homer") should be(englishMessages("greeting", "Homer")) englishMessagesToo("greetingFullName", "Homer", "J", "Simpson") should be( - englishMessages("greetingFullName", "Homer", "J", "Simpson")) + englishMessages("greetingFullName", "Homer", "J", "Simpson")) chineseMessages("hello") should be("你好,世界!") chineseMessages("greeting", "Homer") should be("你好,Homer!") @@ -63,7 +63,7 @@ class MessagesTest extends FlatSpec with Matchers with MockitoSugar { val messagesConfig = ConfigFactory.parseMap(englishLocaleMessages) an[ConfigException.Missing] should be thrownBy - Messages.messages(messagesConfig, log, Locale.GERMAN) + Messages.messages(messagesConfig, log, Locale.GERMAN) } it should "log a problem, when there is no message for key" in { diff --git a/src/test/scala/com/drivergrp/core/StatsTest.scala b/src/test/scala/xyz/driver/core/StatsTest.scala index c4f449b..27ea1bd 100644 --- a/src/test/scala/com/drivergrp/core/StatsTest.scala +++ b/src/test/scala/xyz/driver/core/StatsTest.scala @@ -1,11 +1,11 @@ -package com.drivergrp.core +package xyz.driver.core -import com.drivergrp.core.logging.Logger -import com.drivergrp.core.stats.LogStats -import com.drivergrp.core.time.{Time, TimeRange} +import org.mockito.Mockito._ import org.scalatest.mock.MockitoSugar import org.scalatest.{FlatSpec, Matchers} -import org.mockito.Mockito._ +import xyz.driver.core.logging.Logger +import xyz.driver.core.stats.LogStats +import xyz.driver.core.time.{Time, TimeRange} class StatsTest extends FlatSpec with Matchers with MockitoSugar { diff --git a/src/test/scala/xyz/driver/core/TestTypes.scala b/src/test/scala/xyz/driver/core/TestTypes.scala new file mode 100644 index 0000000..bb25deb --- /dev/null +++ b/src/test/scala/xyz/driver/core/TestTypes.scala @@ -0,0 +1,14 @@ +package xyz.driver.core + +object TestTypes { + + sealed trait CustomGADT { + val field: String + } + + object CustomGADT { + final case class GadtCase1(field: String) extends CustomGADT + final case class GadtCase2(field: String) extends CustomGADT + final case class GadtCase3(field: String) extends CustomGADT + } +} diff --git a/src/test/scala/com/drivergrp/core/TimeTest.scala b/src/test/scala/xyz/driver/core/TimeTest.scala index b928413..76ef42c 100644 --- a/src/test/scala/com/drivergrp/core/TimeTest.scala +++ b/src/test/scala/xyz/driver/core/TimeTest.scala @@ -1,13 +1,13 @@ -package com.drivergrp.core +package xyz.driver.core import java.util.TimeZone -import com.drivergrp.core.time.{Time, _} -import org.scalacheck.{Arbitrary, Gen} -import org.scalatest.{FlatSpec, Matchers} -import org.scalatest.prop.Checkers 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.time.{Time, _} import scala.concurrent.duration._ @@ -56,7 +56,7 @@ class TimeTest extends FlatSpec with Matchers with Checkers { 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)) + contain theSameElementsInOrderAs Seq(Time(123L), Time(231L), Time(321L)) check { times: List[Time] => times.sorted.sliding(2).filter(_.size == 2).forall { |