diff options
author | kseniya <ktomskih@datamonsters.co> | 2017-09-20 18:01:15 +0700 |
---|---|---|
committer | kseniya <ktomskih@datamonsters.co> | 2017-09-20 18:01:15 +0700 |
commit | 9968eaefa2a97ebe495fa51b640e31c78db61ac6 (patch) | |
tree | 4eed12a4ebb2829e336a3da673c7c8462e7ab845 | |
parent | d5ecec043a3d70dd09bda8a79fcd188f411b47df (diff) | |
parent | d4b18efda238f506103dddbf3b400ae17c797276 (diff) | |
download | rest-query-9968eaefa2a97ebe495fa51b640e31c78db61ac6.tar.gz rest-query-9968eaefa2a97ebe495fa51b640e31c78db61ac6.tar.bz2 rest-query-9968eaefa2a97ebe495fa51b640e31c78db61ac6.zip |
Merge branch 'master' into slick-query-builder
36 files changed, 1383 insertions, 98 deletions
@@ -10,6 +10,7 @@ lazy val core = (project in file(".")) .settings(libraryDependencies ++= Seq( "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.8.3", "com.github.pureconfig" %% "pureconfig" % "0.7.2", + "com.lihaoyi" %% "fastparse" % "0.3.7", "com.typesafe.akka" %% "akka-http" % "10.0.9", "com.typesafe.play" %% "play" % "2.5.15", "com.typesafe.scala-logging" %% "scala-logging" % "3.5.0", @@ -17,8 +18,8 @@ lazy val core = (project in file(".")) "io.github.cloudify" %% "spdf" % "1.4.0", "org.davidbild" %% "tristate-core" % "0.2.0", "org.davidbild" %% "tristate-play" % "0.2.0" exclude ("com.typesafe.play", "play-json"), - "xyz.driver" %% "core" % "0.16.3", - "xyz.driver" %% "domain-model" % "0.12.5", + "xyz.driver" %% "core" % "0.16.7", + "xyz.driver" %% "domain-model" % "0.12.26", "ch.qos.logback" % "logback-classic" % "1.1.7", "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % "2.8.4", "com.github.spullara.mustache.java" % "scala-extensions-2.11" % "0.9.4", @@ -28,6 +29,6 @@ lazy val core = (project in file(".")) "org.asynchttpclient" % "async-http-client" % "2.0.24", "org.slf4j" % "slf4j-api" % "1.7.21", "ai.x" %% "diff" % "1.2.0-get-simple-name-fix" % "test", - "org.scalatest" %% "scalatest" % "3.0.0" % "test", - "xyz.driver" %% "core" % "0.16.3" excludeAll (ExclusionRule(organization = "io.netty")) + "org.scalacheck" %% "scalacheck" % "1.13.4" % "test", + "org.scalatest" %% "scalatest" % "3.0.1" % "test" )) diff --git a/project/build.properties b/project/build.properties index 6561361..3507256 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,4 +1,4 @@ #Activator-generated Properties #Wed Jul 06 16:08:49 PDT 2016 template.uuid=a675a7df-bee3-48df-9eaa-688d99e5814e -sbt.version=0.13.15 +sbt.version=0.13.16 diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/MySqlContext.scala b/src/main/scala/xyz/driver/pdsuicommon/db/MySqlContext.scala index c547bf4..9d2664d 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/db/MySqlContext.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/db/MySqlContext.scala @@ -28,10 +28,7 @@ object MySqlContext extends PhiLogging { connectionParams: String, url: String) - final case class Settings(credentials: DbCredentials, - connection: Config, - connectionAttemptsOnStartup: Int, - threadPoolSize: Int) + final case class Settings(credentials: DbCredentials, connection: Config, threadPoolSize: Int) def apply(settings: Settings): MySqlContext = { // Prevent leaking credentials to a log diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala new file mode 100644 index 0000000..7bdfd1b --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala @@ -0,0 +1,73 @@ +package xyz.driver.pdsuicommon.db + +import java.io.Closeable +import java.time._ +import java.util.UUID +import java.util.concurrent.Executors +import javax.sql.DataSource + +import io.getquill._ +import xyz.driver.pdsuicommon.concurrent.MdcExecutionContext +import xyz.driver.pdsuicommon.db.PostgresContext.Settings +import xyz.driver.pdsuicommon.domain.UuidId +import xyz.driver.pdsuicommon.logging._ + +import scala.concurrent.ExecutionContext +import scala.util.control.NonFatal +import scala.util.{Failure, Success, Try} + +object PostgresContext extends PhiLogging { + + final case class Settings(connection: com.typesafe.config.Config, + connectionAttemptsOnStartup: Int, + threadPoolSize: Int) + + def apply(settings: Settings): PostgresContext = { + // Prevent leaking credentials to a log + Try(JdbcContextConfig(settings.connection).dataSource) match { + case Success(dataSource) => new PostgresContext(dataSource, settings) + case Failure(NonFatal(e)) => + logger.error(phi"Can not load dataSource, error: ${Unsafe(e.getClass.getName)}") + throw new IllegalArgumentException("Can not load dataSource from config. Check your database and config", e) + } + } + +} + +class PostgresContext(val dataSource: DataSource with Closeable, settings: Settings) + extends PostgresJdbcContext[SnakeCase](dataSource) with TransactionalContext + with EntityExtractorDerivation[SnakeCase] { + + private val tpe = Executors.newFixedThreadPool(settings.threadPoolSize) + + implicit val executionContext: ExecutionContext = { + val orig = ExecutionContext.fromExecutor(tpe) + MdcExecutionContext.from(orig) + } + + override def close(): Unit = { + super.close() + tpe.shutdownNow() + } + + /** + * Usable for QueryBuilder's extractors + */ + def timestampToLocalDateTime(timestamp: java.sql.Timestamp): LocalDateTime = { + LocalDateTime.ofInstant(timestamp.toInstant, ZoneOffset.UTC) + } + + implicit def encodeUuidId[T] = MappedEncoding[UuidId[T], String](_.toString) + implicit def decodeUuidId[T] = MappedEncoding[String, UuidId[T]] { uuid => + UuidId[T](UUID.fromString(uuid)) + } + + def decodeOptUuidId[T] = MappedEncoding[Option[String], Option[UuidId[T]]] { + case Some(x) => Option(x).map(y => UuidId[T](UUID.fromString(y))) + case None => None + } + + implicit def decodeUuid[T] = MappedEncoding[String, UUID] { uuid => + UUID.fromString(uuid) + } +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/PostgresQueryBuilder.scala b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresQueryBuilder.scala new file mode 100644 index 0000000..0ddf811 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresQueryBuilder.scala @@ -0,0 +1,108 @@ +package xyz.driver.pdsuicommon.db + +import java.sql.ResultSet + +import io.getquill.{PostgresDialect, PostgresEscape} +import xyz.driver.pdsuicommon.db.PostgresQueryBuilder.SmartPostgresEscape + +import scala.collection.breakOut + +object PostgresQueryBuilder { + + import xyz.driver.pdsuicommon.db.QueryBuilder._ + + trait SmartPostgresEscape extends PostgresEscape { + override def column(s: String): String = + if (s.startsWith("$")) s else super.column(s) + override def default(s: String): String = + s.split("\\.").map(ss => s""""$ss"""").mkString(".") + } + + object SmartPostgresEscape extends SmartPostgresEscape + + type Escape = SmartPostgresEscape + val Escape = SmartPostgresEscape + + def apply[T](tableName: String, + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[TableLink], + runner: Runner[T], + countRunner: CountRunner): PostgresQueryBuilder[T] = { + val parameters = PostgresQueryBuilderParameters( + tableData = TableData(tableName, lastUpdateFieldName, nullableFields), + links = links.map(x => x.foreignTableName -> x)(breakOut) + ) + new PostgresQueryBuilder[T](parameters)(runner, countRunner) + } + + def apply[T](tableName: String, + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[TableLink], + extractor: ResultSet => T)(implicit sqlContext: PostgresContext): PostgresQueryBuilder[T] = { + apply(tableName, QueryBuilderParameters.AllFields, lastUpdateFieldName, nullableFields, links, extractor) + } + + def apply[T](tableName: String, + fields: Set[String], + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[TableLink], + extractor: ResultSet => T)(implicit sqlContext: PostgresContext): PostgresQueryBuilder[T] = { + + val runner: Runner[T] = { parameters => + val (sql, binder) = parameters.toSql(countQuery = false, fields = fields, namingStrategy = SmartPostgresEscape) + sqlContext.executeQuery[T](sql, binder, { resultSet => + extractor(resultSet) + }) + } + + val countRunner: CountRunner = { parameters => + val (sql, binder) = parameters.toSql(countQuery = true, namingStrategy = SmartPostgresEscape) + sqlContext + .executeQuery[CountResult]( + sql, + binder, { resultSet => + val count = resultSet.getInt(1) + val lastUpdate = if (parameters.tableData.lastUpdateFieldName.isDefined) { + Option(resultSet.getTimestamp(2)).map(sqlContext.timestampToLocalDateTime) + } else None + + (count, lastUpdate) + } + ) + .head + } + + apply[T]( + tableName = tableName, + lastUpdateFieldName = lastUpdateFieldName, + nullableFields = nullableFields, + links = links, + runner = runner, + countRunner = countRunner + ) + } +} + +class PostgresQueryBuilder[T](parameters: PostgresQueryBuilderParameters)(implicit runner: QueryBuilder.Runner[T], + countRunner: QueryBuilder.CountRunner) + extends QueryBuilder[T, PostgresDialect, PostgresQueryBuilder.Escape](parameters) { + + def withFilter(newFilter: SearchFilterExpr): QueryBuilder[T, PostgresDialect, SmartPostgresEscape] = { + new PostgresQueryBuilder[T](parameters.copy(filter = newFilter)) + } + + def withSorting(newSorting: Sorting): QueryBuilder[T, PostgresDialect, SmartPostgresEscape] = { + new PostgresQueryBuilder[T](parameters.copy(sorting = newSorting)) + } + + def withPagination(newPagination: Pagination): QueryBuilder[T, PostgresDialect, SmartPostgresEscape] = { + new PostgresQueryBuilder[T](parameters.copy(pagination = Some(newPagination))) + } + + def resetPagination: QueryBuilder[T, PostgresDialect, SmartPostgresEscape] = { + new PostgresQueryBuilder[T](parameters.copy(pagination = None)) + } +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/http/Directives.scala b/src/main/scala/xyz/driver/pdsuicommon/http/Directives.scala new file mode 100644 index 0000000..e9a4132 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/http/Directives.scala @@ -0,0 +1,121 @@ +package xyz.driver.pdsuicommon.http + +import akka.http.scaladsl.server._ +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.model._ +import xyz.driver.core.rest.ContextHeaders +import xyz.driver.entities.users.AuthUserInfo +import xyz.driver.pdsuicommon.auth._ +import xyz.driver.pdsuicommon.error._ +import xyz.driver.pdsuicommon.error.DomainError._ +import xyz.driver.pdsuicommon.error.ErrorsResponse.ResponseError +import xyz.driver.pdsuicommon.parsers._ +import xyz.driver.pdsuicommon.db.{Pagination, Sorting, SearchFilterExpr} +import xyz.driver.pdsuicommon.domain._ +import xyz.driver.pdsuicommon.serialization.PlayJsonSupport._ +import xyz.driver.core.rest.AuthProvider +import scala.util.control._ + +import scala.util._ + +trait Directives { + + val paginated: Directive1[Pagination] = parameterSeq.flatMap { params => + PaginationParser.parse(params) match { + case Success(pagination) => provide(pagination) + case Failure(ex) => + reject(ValidationRejection("invalid pagination parameter", Some(ex))) + } + } + + def sorted(validDimensions: Set[String] = Set.empty): Directive1[Sorting] = parameterSeq.flatMap { params => + SortingParser.parse(validDimensions, params) match { + case Success(sorting) => provide(sorting) + case Failure(ex) => + reject(ValidationRejection("invalid sorting parameter", Some(ex))) + } + } + + val dimensioned: Directive1[Dimensions] = parameterSeq.flatMap { params => + DimensionsParser.tryParse(params) match { + case Success(dims) => provide(dims) + case Failure(ex) => + reject(ValidationRejection("invalid dimension parameter", Some(ex))) + } + } + + val searchFiltered: Directive1[SearchFilterExpr] = parameterSeq.flatMap { params => + SearchFilterParser.parse(params) match { + case Success(sorting) => provide(sorting) + case Failure(ex) => + reject(ValidationRejection("invalid filter parameter", Some(ex))) + } + } + + def StringIdInPath[T]: PathMatcher1[StringId[T]] = + PathMatchers.Segment.map((id) => StringId(id.toString)) + + def LongIdInPath[T]: PathMatcher1[LongId[T]] = + PathMatchers.LongNumber.map((id) => LongId(id)) + + def UuidIdInPath[T]: PathMatcher1[UuidId[T]] = + PathMatchers.JavaUUID.map((id) => UuidId(id)) + + def failFast[A](reply: A): A = reply match { + case err: NotFoundError => throw new NotFoundException(err.getMessage) + case err: AuthenticationError => throw new AuthenticationException(err.getMessage) + case err: AuthorizationError => throw new AuthorizationException(err.getMessage) + case err: DomainError => throw new DomainException(err.getMessage) + case other => other + } + + def domainExceptionHandler(req: RequestId): ExceptionHandler = { + def errorResponse(ex: Throwable) = + ErrorsResponse(Seq(ResponseError(None, ex.getMessage, ErrorCode.Unspecified)), req) + ExceptionHandler { + case ex: AuthenticationException => complete(StatusCodes.Unauthorized -> errorResponse(ex)) + case ex: AuthorizationException => complete(StatusCodes.Forbidden -> errorResponse(ex)) + case ex: NotFoundException => complete(StatusCodes.NotFound -> errorResponse(ex)) + case ex: DomainException => complete(StatusCodes.BadRequest -> errorResponse(ex)) + case NonFatal(ex) => complete(StatusCodes.InternalServerError -> errorResponse(ex)) + } + } + + def domainRejectionHandler(req: RequestId): RejectionHandler = { + def wrapContent(message: String) = { + import play.api.libs.json._ + val err = ErrorsResponse(Seq(ResponseError(None, message, ErrorCode.Unspecified)), req) + val text = Json.stringify(implicitly[Writes[ErrorsResponse]].writes(err)) + HttpEntity(ContentTypes.`application/json`, text) + } + RejectionHandler.default.mapRejectionResponse { + case res @ HttpResponse(_, _, ent: HttpEntity.Strict, _) => + res.copy(entity = wrapContent(ent.data.utf8String)) + case x => x // pass through all other types of responses + } + } + + val tracked: Directive1[RequestId] = optionalHeaderValueByName(ContextHeaders.TrackingIdHeader) flatMap { + case Some(id) => provide(RequestId(id)) + case None => provide(RequestId()) + } + + val domainResponse: Directive0 = tracked.flatMap { id => + handleExceptions(domainExceptionHandler(id)) & handleRejections(domainRejectionHandler(id)) + } + + implicit class AuthProviderWrapper(provider: AuthProvider[AuthUserInfo]) { + val authenticated: Directive1[AuthenticatedRequestContext] = (provider.authorize() & tracked) tflatMap { + case (core, requestId) => + provide( + new AuthenticatedRequestContext( + core.authenticatedUser, + requestId, + core.contextHeaders(ContextHeaders.AuthenticationTokenHeader) + )) + } + } + +} + +object Directives extends Directives diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala new file mode 100644 index 0000000..17c09ed --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala @@ -0,0 +1,30 @@ +package xyz.driver.pdsuicommon.parsers + +import scala.util.{Failure, Success, Try} + +class Dimensions(private val xs: Set[String] = Set.empty) { + def contains(x: String): Boolean = xs.isEmpty || xs.contains(x) +} + +object DimensionsParser { + + @deprecated("play-akka transition", "0") + def tryParse(query: Map[String, Seq[String]]): Try[Dimensions] = + tryParse(query.toSeq.flatMap { + case (key, values) => + values.map(value => key -> value) + }) + + def tryParse(query: Seq[(String, String)]): Try[Dimensions] = { + query.collect { case ("dimensions", value) => value } match { + case Nil => Success(new Dimensions()) + + case x +: Nil => + val raw: Set[String] = x.split(",").view.map(_.trim).filter(_.nonEmpty).to[Set] + Success(new Dimensions(raw)) + + case xs => + Failure(new IllegalArgumentException(s"Dimensions are specified ${xs.size} times")) + } + } +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala new file mode 100644 index 0000000..b59b1a5 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala @@ -0,0 +1,29 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.db._ +import scala.util._ + +object PaginationParser { + + @deprecated("play-akka transition", "0") + def parse(query: Map[String, Seq[String]]): Try[Pagination] = + parse(query.toSeq.flatMap { + case (key, values) => + values.map(value => key -> value) + }) + + def parse(query: Seq[(String, String)]): Try[Pagination] = { + val IntString = """(\d+)""".r + def validate(field: String, default: Int) = query.collectFirst { case (`field`, size) => size } match { + case Some(IntString(x)) if x.toInt > 0 => x.toInt + case Some(IntString(x)) => throw new ParseQueryArgException((field, s"must greater than zero (found $x)")) + case Some(str) => throw new ParseQueryArgException((field, s"must be an integer (found $str)")) + case None => default + } + + Try { + Pagination(validate("pageSize", Pagination.Default.pageSize), + validate("pageNumber", Pagination.Default.pageNumber)) + } + } +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala new file mode 100644 index 0000000..64b3d2e --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala @@ -0,0 +1,3 @@ +package xyz.driver.pdsuicommon.parsers + +class ParseQueryArgException(val errors: (String, String)*) extends Exception(errors.mkString(",")) diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala new file mode 100644 index 0000000..8aff397 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala @@ -0,0 +1,152 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.utils.Implicits.{toCharOps, toStringOps} +import fastparse.all._ +import fastparse.core.Parsed +import fastparse.parsers.Intrinsics.CharPred +import xyz.driver.pdsuicommon.db.{SearchFilterBinaryOperation, SearchFilterExpr, SearchFilterNAryOperation} +import xyz.driver.pdsuicommon.utils.Utils._ + +import scala.util.Try + +@SuppressWarnings(Array("org.wartremover.warts.Product", "org.wartremover.warts.Serializable")) +object SearchFilterParser { + + private object BinaryAtomFromTuple { + def unapply(input: (SearchFilterExpr.Dimension, (String, String))): Option[SearchFilterExpr.Atom.Binary] = { + val (dimensionName, (strOperation, value)) = input + parseOperation(strOperation.toLowerCase).map { op => + SearchFilterExpr.Atom.Binary(dimensionName, op, value.safeTrim) + } + } + } + + private object NAryAtomFromTuple { + // Compiler warning: unchecked since it is eliminated by erasure, if we user Seq[String] + def unapply(input: (SearchFilterExpr.Dimension, (String, Seq[_]))): Option[SearchFilterExpr.Atom.NAry] = { + val (dimensionName, (strOperation, xs)) = input + if (strOperation.toLowerCase == "in") { + val values = xs.asInstanceOf[Seq[String]].map(_.safeTrim) + Some(SearchFilterExpr.Atom.NAry(dimensionName, SearchFilterNAryOperation.In, values)) + } else { + None + } + } + } + + private val operationsMapping = { + import xyz.driver.pdsuicommon.db.SearchFilterBinaryOperation._ + + Map[String, SearchFilterBinaryOperation]( + "eq" -> Eq, + "noteq" -> NotEq, + "like" -> Like, + "gt" -> Gt, + "gteq" -> GtEq, + "lt" -> Lt, + "lteq" -> LtEq + ) + } + + private def parseOperation(x: String): Option[SearchFilterBinaryOperation] = operationsMapping.get(x) + + private val whitespaceParser = P(CharPred(_.isSafeWhitespace)) + + val dimensionParser: Parser[SearchFilterExpr.Dimension] = { + val identParser = P( + CharPred(c => c.isLetterOrDigit) + .rep(min = 1)).!.map(s => SearchFilterExpr.Dimension(None, toSnakeCase(s))) + val pathParser = P(identParser.! ~ "." ~ identParser.!) map { + case (left, right) => + SearchFilterExpr.Dimension(Some(toSnakeCase(left)), toSnakeCase(right)) + } + P(pathParser | identParser) + } + + private val commonOperatorParser: Parser[String] = { + P(IgnoreCase("eq") | IgnoreCase("like") | IgnoreCase("noteq")).! + } + + private val numericOperatorParser: Parser[String] = { + P((IgnoreCase("gt") | IgnoreCase("lt")) ~ IgnoreCase("eq").?).! + } + + private val naryOperatorParser: Parser[String] = P(IgnoreCase("in")).! + + private val isPositiveParser: Parser[Boolean] = P(CharIn("-+").!.?).map { + case Some("-") => false + case _ => true + } + + // Exclude Unicode "digits" + private val digitsParser: Parser[String] = P(CharIn('0' to '9').rep(min = 1).!) + + // @TODO Make complex checking here + private val numberParser: Parser[String] = P(isPositiveParser ~ digitsParser.! ~ ("." ~ digitsParser).!.?).map { + case (false, intPart, Some(fracPart)) => s"-$intPart.${fracPart.tail}" + case (false, intPart, None) => s"-$intPart" + case (_, intPart, Some(fracPart)) => s"$intPart.${fracPart.tail}" + case (_, intPart, None) => s"$intPart" + } + + private val nAryValueParser: Parser[String] = P(CharPred(_ != ',').rep(min = 1).!) + + private val binaryAtomParser: Parser[SearchFilterExpr.Atom.Binary] = P( + dimensionParser ~ whitespaceParser ~ ( + (commonOperatorParser.! ~/ whitespaceParser ~/ AnyChar.rep(min = 1).!) + | (numericOperatorParser.! ~/ whitespaceParser ~/ numberParser.!) + ) ~ End + ).map { + case BinaryAtomFromTuple(atom) => atom + } + + private val nAryAtomParser: Parser[SearchFilterExpr.Atom.NAry] = P( + dimensionParser ~ whitespaceParser ~ ( + naryOperatorParser ~/ whitespaceParser ~/ nAryValueParser.!.rep(min = 1, sep = ",") + ) ~ End + ).map { + case NAryAtomFromTuple(atom) => atom + } + + private val atomParser: Parser[SearchFilterExpr.Atom] = P(binaryAtomParser | nAryAtomParser) + + @deprecated("play-akka transition", "0") + def parse(query: Map[String, Seq[String]]): Try[SearchFilterExpr] = + parse(query.toSeq.flatMap { + case (key, values) => + values.map(value => key -> value) + }) + + def parse(query: Seq[(String, String)]): Try[SearchFilterExpr] = Try { + query.toList.collect { case ("filters", value) => value } match { + case Nil => SearchFilterExpr.Empty + + case head :: Nil => + atomParser.parse(head) match { + case Parsed.Success(x, _) => x + case e: Parsed.Failure => throw new ParseQueryArgException("filters" -> formatFailure(1, e)) + } + + case xs => + val parsed = xs.map(x => atomParser.parse(x)) + val failures: Seq[String] = parsed.zipWithIndex.collect { + case (e: Parsed.Failure, index) => formatFailure(index, e) + } + + if (failures.isEmpty) { + val filters = parsed.collect { + case Parsed.Success(x, _) => x + } + + SearchFilterExpr.Intersection.create(filters: _*) + } else { + throw new ParseQueryArgException("filters" -> failures.mkString("; ")) + } + } + } + + private def formatFailure(sectionIndex: Int, e: Parsed.Failure): String = { + s"section $sectionIndex: ${ParseError.msg(e.extra.input, e.extra.traced.expected, e.index)}" + } + +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala new file mode 100644 index 0000000..4bfc669 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala @@ -0,0 +1,64 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.db.{Sorting, SortingOrder} +import fastparse.all._ +import fastparse.core.Parsed +import xyz.driver.pdsuicommon.utils.Utils._ + +import scala.util.Try + +object SortingParser { + + private val sortingOrderParser: Parser[SortingOrder] = P("-".!.?).map { + case Some(_) => SortingOrder.Descending + case None => SortingOrder.Ascending + } + + private def dimensionSortingParser(validDimensions: Seq[String]): Parser[Sorting.Dimension] = { + P(sortingOrderParser ~ StringIn(validDimensions: _*).!).map { + case (sortingOrder, field) => + val prefixedFields = field.split("\\.", 2) + prefixedFields.size match { + case 1 => Sorting.Dimension(None, toSnakeCase(field), sortingOrder) + case 2 => + Sorting.Dimension(Some(prefixedFields.head).map(toSnakeCase), + toSnakeCase(prefixedFields.last), + sortingOrder) + } + } + } + + private def sequentialSortingParser(validDimensions: Seq[String]): Parser[Sorting.Sequential] = { + P(dimensionSortingParser(validDimensions).rep(min = 1, sep = ",") ~ End).map { dimensions => + Sorting.Sequential(dimensions) + } + } + + @deprecated("play-akka transition", "0") + def parse(validDimensions: Set[String], query: Map[String, Seq[String]]): Try[Sorting] = + parse(validDimensions, query.toSeq.flatMap { + case (key, values) => + values.map(value => key -> value) + }) + + def parse(validDimensions: Set[String], query: Seq[(String, String)]): Try[Sorting] = Try { + query.toList.collect { case ("sort", value) => value } match { + case Nil => Sorting.Sequential(Seq.empty) + + case rawSorting :: Nil => + val parser = sequentialSortingParser(validDimensions.toSeq) + parser.parse(rawSorting) match { + case Parsed.Success(x, _) => x + case e: Parsed.Failure => + throw new ParseQueryArgException("sort" -> formatFailure(e)) + } + + case _ => throw new ParseQueryArgException("sort" -> "multiple sections are not allowed") + } + } + + private def formatFailure(e: Parsed.Failure): String = { + ParseError.msg(e.extra.input, e.extra.traced.expected, e.index) + } + +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala b/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala new file mode 100644 index 0000000..c1a2c7c --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala @@ -0,0 +1,55 @@ +package xyz.driver.pdsuicommon.utils + +import java.time.{LocalDate, LocalDateTime} + +import io.swagger.models.properties.Property +import spray.json.JsValue +import xyz.driver.pdsuicommon.domain.{LongId, StringId, UuidId} +import xyz.driver.pdsuidomain.entities._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.arm._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.criterion._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.intervention._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.hypothesis._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.studydesign._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.trial._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.trialhistory._ +import xyz.driver.pdsuidomain.formats.json.sprayformats.trialissue._ +import xyz.driver.core.swagger.CustomSwaggerJsonConverter._ +import xyz.driver.pdsuidomain.services.CriterionService.RichCriterion + +object CustomSwaggerJsonFormats { + + val customCommonProperties = Map[Class[_], Property]( + classOf[LocalDateTime] -> stringProperty(example = Some("2010-12-31'T'18:59:59Z")), + classOf[LocalDate] -> stringProperty(example = Some("2010-12-31")), + classOf[UuidId[_]] -> stringProperty(example = Some("370b0450-35cb-4aab-ba74-0145be75add5")), + classOf[StringId[_]] -> stringProperty(), + classOf[LongId[_]] -> stringProperty() + ) + val customTrialCurationProperties = Map[Class[_], Property]( + classOf[Trial.Status] -> stringProperty(), + classOf[Trial.Condition] -> stringProperty(), + classOf[TrialHistory.Action] -> stringProperty(), + classOf[TrialHistory.State] -> stringProperty() + ) ++ customCommonProperties + + val customTrialCurationObjectsExamples = Map[Class[_], JsValue]( + classOf[Trial] -> trialWriter.write(xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextTrial()), + classOf[Arm] -> armFormat.write(xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextArm()), + classOf[TrialHistory] -> trialHistoryFormat.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextTrialHistory()), + classOf[TrialIssue] -> trialIssueWriter.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextTrialIssue()), + classOf[RichCriterion] -> richCriterionFormat.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextRichCriterion()), + classOf[InterventionWithArms] -> interventionWriter.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextInterventionWithArms()), + classOf[InterventionType] -> interventionTypeFormat.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextInterventionType()), + classOf[Hypothesis] -> hypothesisFormat.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextHypothesis()), + classOf[StudyDesign] -> studyDesignFormat.write( + xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextStudyDesign()) + ) + +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/utils/Utils.scala b/src/main/scala/xyz/driver/pdsuicommon/utils/Utils.scala index 02c9e28..63b0572 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/utils/Utils.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/utils/Utils.scala @@ -1,6 +1,7 @@ package xyz.driver.pdsuicommon.utils import java.time.LocalDateTime +import java.util.regex.{Matcher, Pattern} object Utils { @@ -20,4 +21,23 @@ object Utils { fullClassName.substring(fullClassName.lastIndexOf("$") + 1) } } + + def toSnakeCase(str: String): String = + str + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") + .replaceAll("([a-z\\d])([A-Z])", "$1_$2") + .toLowerCase + + def toCamelCase(str: String): String = { + val sb = new StringBuffer() + def loop(m: Matcher): Unit = if (m.find()) { + m.appendReplacement(sb, m.group(1).toUpperCase()) + loop(m) + } + val m: Matcher = Pattern.compile("_(.)").matcher(str) + loop(m) + m.appendTail(sb) + sb.toString + } + } diff --git a/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala b/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala index e691547..cb677cf 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala @@ -27,8 +27,8 @@ final case class Intervention(id: LongId[Intervention], originalName: String, typeId: Option[LongId[InterventionType]], originalType: Option[String], - description: String, - originalDescription: String, + dosage: String, + originalDosage: String, isActive: Boolean) object Intervention { diff --git a/src/main/scala/xyz/driver/pdsuidomain/entities/Trial.scala b/src/main/scala/xyz/driver/pdsuidomain/entities/Trial.scala index 2f90820..db4def2 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/entities/Trial.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/entities/Trial.scala @@ -1,6 +1,5 @@ package xyz.driver.pdsuidomain.entities -import java.nio.file.Path import java.time.LocalDateTime import xyz.driver.pdsuicommon.domain.{LongId, StringId, User, UuidId} @@ -52,8 +51,6 @@ object Trial { implicit def toPhiString(x: Status): PhiString = Unsafe(Utils.getClassSimpleName(x.getClass)) } - final case class PdfSource(path: Path) extends AnyVal - implicit def toPhiString(x: Trial): PhiString = { import x._ phi"Trial(id=$id, externalId=$externalId, status=$status, previousStatus=$previousStatus, " + diff --git a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala new file mode 100644 index 0000000..b259b07 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala @@ -0,0 +1,36 @@ +package xyz.driver.pdsuidomain.fakes.entities + +import java.time.{LocalDate, LocalDateTime, LocalTime} + +import xyz.driver.pdsuicommon.domain.{LongId, StringId, UuidId} +import xyz.driver.pdsuidomain.entities.{Trial, TrialHistory} +import scala.util.Random + +object common { + import xyz.driver.core.generators + + def nextUuidId[T] = UuidId[T](generators.nextUuid()) + + def nextLongId[T] = LongId[T](generators.nextInt(Int.MaxValue).toLong) + + def nextStringId[T] = StringId[T](generators.nextString(maxLength = 20)) + + def nextTrialStatus = generators.oneOf[Trial.Status](Trial.Status.All) + + def nextPreviousTrialStatus = generators.oneOf[Trial.Status](Trial.Status.AllPrevious) + + def nextLocalDateTime = LocalDateTime.of(nextLocalDate, LocalTime.MIDNIGHT) + + def nextLocalDate = LocalDate.of( + 1970 + Random.nextInt(68), + 1 + Random.nextInt(12), + 1 + Random.nextInt(28) // all months have at least 28 days + ) + + def nextCondition = generators.oneOf[Trial.Condition](Trial.Condition.All) + + def nextTrialAction = generators.oneOf[TrialHistory.Action](TrialHistory.Action.All) + + def nextTrialState = generators.oneOf[TrialHistory.State](TrialHistory.State.All) + +} diff --git a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala new file mode 100644 index 0000000..ecb6e0a --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala @@ -0,0 +1,137 @@ +package xyz.driver.pdsuidomain.fakes.entities + +import xyz.driver.pdsuicommon.domain.{LongId, User} +import xyz.driver.pdsuidomain.entities._ +import xyz.driver.pdsuidomain.services.CriterionService.RichCriterion + +object trialcuration { + import xyz.driver.core.generators + import common._ + + def nextTrial(): Trial = Trial( + id = nextStringId[Trial], + externalId = nextUuidId[Trial], + status = nextTrialStatus, + assignee = Option(nextStringId[User]), + previousStatus = Option(nextPreviousTrialStatus), + previousAssignee = Option(nextStringId[User]), + lastActiveUserId = Option(nextStringId[User]), + lastUpdate = nextLocalDateTime, + condition = nextCondition, + phase = generators.nextString(), + hypothesisId = Option(nextUuidId[Hypothesis]), + studyDesignId = Option(nextLongId[StudyDesign]), + originalStudyDesign = Option(generators.nextString()), + isPartner = generators.nextBoolean(), + overview = Option(generators.nextString()), + overviewTemplate = generators.nextString(), + isUpdated = generators.nextBoolean(), + title = generators.nextString(), + originalTitle = generators.nextString() + ) + + def nextArm(): Arm = Arm( + id = nextLongId[Arm], + name = generators.nextString(), + originalName = generators.nextString(), + trialId = nextStringId[Trial], + deleted = Option(nextLocalDateTime) + ) + + def nextCriterion(): Criterion = Criterion( + id = nextLongId[Criterion], + trialId = nextStringId[Trial], + text = Option(generators.nextString()), + isCompound = generators.nextBoolean(), + meta = generators.nextString() + ) + + def nextCriterionLabel(criterionId: LongId[Criterion]): CriterionLabel = CriterionLabel( + id = nextLongId[CriterionLabel], + labelId = Option(nextLongId[Label]), + criterionId = criterionId, + categoryId = Option(nextLongId[Category]), + value = Option(generators.nextBoolean()), + isDefining = generators.nextBoolean() + ) + + def nextRichCriterion(): RichCriterion = { + val criterion = nextCriterion() + RichCriterion( + criterion = criterion, + armIds = Seq(nextLongId[Arm], nextLongId[Arm]), + labels = Seq( + nextCriterionLabel(criterion.id), + nextCriterionLabel(criterion.id) + ) + ) + } + + def nextIntervention(): Intervention = Intervention( + id = nextLongId[Intervention], + trialId = nextStringId[Trial], + name = generators.nextString(), + originalName = generators.nextString(), + typeId = Option(nextLongId[InterventionType]), + originalType = Option(generators.nextString()), + dosage = generators.nextString(), + originalDosage = generators.nextString(), + isActive = generators.nextBoolean() + ) + + def nextInterventionArm(interventionId: LongId[Intervention]): InterventionArm = InterventionArm( + interventionId = interventionId, + armId = nextLongId[Arm] + ) + + def nextInterventionWithArms(): InterventionWithArms = { + val intervention = nextIntervention() + InterventionWithArms( + intervention = intervention, + arms = List( + nextInterventionArm(intervention.id), + nextInterventionArm(intervention.id), + nextInterventionArm(intervention.id) + ) + ) + } + + def nextTrialIssue(): TrialIssue = TrialIssue( + id = nextLongId[TrialIssue], + userId = nextStringId[User], + trialId = nextStringId[Trial], + lastUpdate = nextLocalDateTime, + isDraft = generators.nextBoolean(), + text = generators.nextString(), + evidence = generators.nextString(), + archiveRequired = generators.nextBoolean(), + meta = generators.nextString() + ) + + def nextTrialHistory(): TrialHistory = TrialHistory( + id = nextLongId[TrialHistory], + executor = nextStringId[User], + trialId = nextStringId[Trial], + state = nextTrialState, + action = nextTrialAction, + created = nextLocalDateTime + ) + + def nextHypothesis(): Hypothesis = Hypothesis( + id = nextUuidId[Hypothesis], + name = generators.nextString(), + treatmentType = generators.nextString(), + description = generators.nextString() + ) + + def nextStudyDesign(): StudyDesign = StudyDesign( + id = nextLongId[StudyDesign], + name = generators.nextString() + ) + + def nextInterventionType(): InterventionType = InterventionType( + id = nextLongId[InterventionType], + name = generators.nextString() + ) + +} diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala index 39acbde..f306a71 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala @@ -8,12 +8,12 @@ import play.api.libs.json.{Format, JsPath} final case class ApiIntervention(id: Long, name: String, typeId: Option[Long], - description: String, + dosage: String, isActive: Boolean, arms: List[Long], trialId: String, originalName: String, - originalDescription: String, + originalDosage: String, originalType: Option[String]) { def toDomain = { @@ -24,8 +24,8 @@ final case class ApiIntervention(id: Long, originalName = this.originalName, typeId = this.typeId.map(id => LongId(id)), originalType = this.originalType.map(id => id.toString), - description = this.description, - originalDescription = this.originalDescription, + dosage = this.dosage, + originalDosage = this.originalDosage, isActive = this.isActive ) @@ -43,12 +43,12 @@ object ApiIntervention { (JsPath \ "id").format[Long] and (JsPath \ "name").format[String] and (JsPath \ "typeId").formatNullable[Long] and - (JsPath \ "description").format[String] and + (JsPath \ "dosage").format[String] and (JsPath \ "isActive").format[Boolean] and (JsPath \ "arms").format[List[Long]] and (JsPath \ "trialId").format[String] and (JsPath \ "originalName").format[String] and - (JsPath \ "originalDescription").format[String] and + (JsPath \ "originalDosage").format[String] and (JsPath \ "originalType").formatNullable[String] )(ApiIntervention.apply, unlift(ApiIntervention.unapply)) @@ -60,12 +60,12 @@ object ApiIntervention { id = intervention.id.id, name = intervention.name, typeId = intervention.typeId.map(_.id), - description = intervention.description, + dosage = intervention.dosage, isActive = intervention.isActive, arms = arms.map(_.armId.id), trialId = intervention.trialId.id, originalName = intervention.originalName, - originalDescription = intervention.originalDescription, + originalDosage = intervention.originalDosage, originalType = intervention.originalType ) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala index f67ba6b..aa55506 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala @@ -6,7 +6,7 @@ import play.api.libs.functional.syntax._ import play.api.libs.json._ final case class ApiPartialIntervention(typeId: Option[Long], - description: Option[String], + dosage: Option[String], isActive: Option[Boolean], arms: Option[List[Long]]) { @@ -16,7 +16,7 @@ final case class ApiPartialIntervention(typeId: Option[Long], orig.copy( intervention = origIntervention.copy( typeId = typeId.map(LongId(_)).orElse(origIntervention.typeId), - description = description.getOrElse(origIntervention.description), + dosage = dosage.getOrElse(origIntervention.dosage), isActive = isActive.getOrElse(origIntervention.isActive) ), arms = draftArmList.getOrElse(orig.arms) @@ -28,14 +28,14 @@ object ApiPartialIntervention { private val reads: Reads[ApiPartialIntervention] = ( (JsPath \ "typeId").readNullable[Long] and - (JsPath \ "description").readNullable[String] and + (JsPath \ "dosage").readNullable[String] and (JsPath \ "isActive").readNullable[Boolean] and (JsPath \ "arms").readNullable[List[Long]] )(ApiPartialIntervention.apply _) private val writes: Writes[ApiPartialIntervention] = ( (JsPath \ "typeId").writeNullable[Long] and - (JsPath \ "description").writeNullable[String] and + (JsPath \ "dosage").writeNullable[String] and (JsPath \ "isActive").writeNullable[Boolean] and (JsPath \ "arms").writeNullable[List[Long]] )(unlift(ApiPartialIntervention.unapply)) diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documentissue.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documentissue.scala index 28b2a5e..a658cfa 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documentissue.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documentissue.scala @@ -61,17 +61,6 @@ object documentissue { case _ => deserializationError(s"Expected Json Object as DocumentIssue, but got $json") } - implicit val documentIssueWriter = new JsonWriter[DocumentIssue] { - override def write(obj: DocumentIssue) = JsObject( - "id" -> obj.id.toJson, - "startPage" -> obj.startPage.toJson, - "endPage" -> obj.endPage.toJson, - "text" -> obj.text.toJson, - "lastUpdate" -> obj.lastUpdate.toJson, - "userId" -> obj.userId.toJson, - "isDraft" -> obj.isDraft.toJson, - "archiveRequired" -> obj.archiveRequired.toJson - ) - } + implicit val documentIssueFormat: RootJsonFormat[DocumentIssue] = jsonFormat9(DocumentIssue.apply) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documenttype.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documenttype.scala new file mode 100644 index 0000000..8119d35 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/documenttype.scala @@ -0,0 +1,13 @@ +package xyz.driver.pdsuidomain.formats.json.sprayformats + +import spray.json._ +import xyz.driver.pdsuidomain.entities._ +import xyz.driver.pdsuidomain.entities.DocumentType + +object documenttype { + import DefaultJsonProtocol._ + import common._ + + implicit val format: RootJsonFormat[DocumentType] = jsonFormat2(DocumentType.apply) + +} diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala index a8ce950..9314391 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala @@ -11,16 +11,16 @@ object intervention { implicit val interventionWriter: JsonWriter[InterventionWithArms] = new JsonWriter[InterventionWithArms] { override def write(obj: InterventionWithArms) = JsObject( - "id" -> obj.intervention.id.toJson, - "name" -> obj.intervention.name.toJson, - "typeId" -> obj.intervention.typeId.toJson, - "description" -> obj.intervention.description.toJson, - "isActive" -> obj.intervention.isActive.toJson, - "arms" -> obj.arms.map(_.armId).toJson, - "trialId" -> obj.intervention.trialId.toJson, - "originalName" -> obj.intervention.originalName.toJson, - "originalDescription" -> obj.intervention.originalDescription.toJson, - "originalType" -> obj.intervention.originalType.toJson + "id" -> obj.intervention.id.toJson, + "name" -> obj.intervention.name.toJson, + "typeId" -> obj.intervention.typeId.toJson, + "dosage" -> obj.intervention.dosage.toJson, + "isActive" -> obj.intervention.isActive.toJson, + "arms" -> obj.arms.map(_.armId).toJson, + "trialId" -> obj.intervention.trialId.toJson, + "originalName" -> obj.intervention.originalName.toJson, + "originalDosage" -> obj.intervention.originalDosage.toJson, + "originalType" -> obj.intervention.originalType.toJson ) } @@ -30,8 +30,8 @@ object intervention { .get("typeId") .map(_.convertTo[LongId[InterventionType]]) - val description = fields - .get("description") + val dosage = fields + .get("dosage") .map(_.convertTo[String]) val isActive = fields @@ -46,7 +46,7 @@ object intervention { orig.copy( intervention = origIntervention.copy( typeId = typeId.orElse(origIntervention.typeId), - description = description.getOrElse(origIntervention.description), + dosage = dosage.getOrElse(origIntervention.dosage), isActive = isActive.getOrElse(origIntervention.isActive) ), arms = arms.getOrElse(orig.arms) diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/providerttype.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/providerttype.scala new file mode 100644 index 0000000..385feb2 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/providerttype.scala @@ -0,0 +1,12 @@ +package xyz.driver.pdsuidomain.formats.json.sprayformats + +import spray.json._ +import xyz.driver.pdsuidomain.entities.ProviderType + +object providertype { + import DefaultJsonProtocol._ + import common._ + + implicit val providerTypeFormat: RootJsonFormat[ProviderType] = jsonFormat2(ProviderType.apply) + +} diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/recordissue.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/recordissue.scala index 4ae04d0..4ac5f6d 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/recordissue.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/recordissue.scala @@ -63,17 +63,6 @@ object recordissue { case _ => deserializationError(s"Expected Json Object as MedicalRecordIssue, but got $json") } - implicit val recordIssueWriter = new JsonWriter[MedicalRecordIssue] { - override def write(obj: MedicalRecordIssue) = JsObject( - "id" -> obj.id.toJson, - "startPage" -> obj.startPage.toJson, - "endPage" -> obj.endPage.toJson, - "text" -> obj.text.toJson, - "lastUpdate" -> obj.lastUpdate.toJson, - "userId" -> obj.userId.toJson, - "isDraft" -> obj.isDraft.toJson, - "archiveRequired" -> obj.archiveRequired.toJson - ) - } + implicit val recordIssueFormat = jsonFormat9(MedicalRecordIssue.apply) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/trialissue/ApiTrialIssue.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/trialissue/ApiTrialIssue.scala index 852c4f6..c9475c6 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/trialissue/ApiTrialIssue.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/trialissue/ApiTrialIssue.scala @@ -4,20 +4,37 @@ import java.time.{ZoneId, ZonedDateTime} import play.api.libs.functional.syntax._ import play.api.libs.json._ +import xyz.driver.pdsuicommon.domain._ import xyz.driver.pdsuidomain.entities.TrialIssue final case class ApiTrialIssue(id: Long, + trialId: String, text: String, lastUpdate: ZonedDateTime, userId: String, isDraft: Boolean, evidence: String, archiveRequired: Boolean, - meta: String) + meta: String) { + + def toDomain = TrialIssue( + id = LongId(this.id), + trialId = StringId(this.trialId), + text = this.text, + userId = StringId(this.userId), + lastUpdate = this.lastUpdate.toLocalDateTime, + isDraft = this.isDraft, + evidence = this.evidence, + archiveRequired = this.archiveRequired, + meta = this.meta + ) + +} object ApiTrialIssue { implicit val format: Format[ApiTrialIssue] = ( (JsPath \ "id").format[Long] and + (JsPath \ "trialId").format[String] and (JsPath \ "text").format[String] and (JsPath \ "lastUpdate").format[ZonedDateTime] and (JsPath \ "userId").format[String] and @@ -31,6 +48,7 @@ object ApiTrialIssue { def fromDomain(x: TrialIssue) = ApiTrialIssue( id = x.id.id, + trialId = x.trialId.id, text = x.text, lastUpdate = ZonedDateTime.of(x.lastUpdate, ZoneId.of("Z")), userId = x.userId.id, diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/TrialService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/TrialService.scala index d140d27..5bd99a8 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/TrialService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/TrialService.scala @@ -2,13 +2,15 @@ package xyz.driver.pdsuidomain.services import java.time.LocalDateTime +import akka.NotUsed +import akka.stream.scaladsl.Source +import akka.util.ByteString import xyz.driver.pdsuicommon.auth.AuthenticatedRequestContext import xyz.driver.pdsuicommon.db._ import xyz.driver.pdsuicommon.domain.StringId import xyz.driver.pdsuicommon.error.DomainError import xyz.driver.pdsuicommon.logging._ import xyz.driver.pdsuidomain.entities.Trial -import xyz.driver.pdsuidomain.entities.Trial.PdfSource import xyz.driver.pdsuidomain.entities.export.trial.ExportTrialWithLabels import scala.concurrent.Future @@ -66,24 +68,6 @@ object TrialService { extends GetTrialWithLabelsReply with DomainError.AuthorizationError with DefaultAccessDeniedError } - sealed trait GetPdfSourceReply - object GetPdfSourceReply { - type Error = GetPdfSourceReply with DomainError - - final case class Entity(x: PdfSource) extends GetPdfSourceReply - - case object AuthorizationError - extends GetPdfSourceReply with DomainError.AuthorizationError with DefaultAccessDeniedError - - case object NotFoundError extends GetPdfSourceReply with DomainError.NotFoundError { - def userMessage: String = "Trial's PDF hasn't been found" - } - - case object TrialNotFoundError extends GetPdfSourceReply with DomainError.NotFoundError with DefaultNotFoundError - - final case class CommonError(userMessage: String) extends GetPdfSourceReply with DomainError - } - sealed trait UpdateReply object UpdateReply { type Error = UpdateReply with DomainError @@ -114,7 +98,7 @@ trait TrialService { implicit requestContext: AuthenticatedRequestContext): Future[GetTrialWithLabelsReply] def getPdfSource(trialId: StringId[Trial])( - implicit requestContext: AuthenticatedRequestContext): Future[GetPdfSourceReply] + implicit requestContext: AuthenticatedRequestContext): Future[Source[ByteString, NotUsed]] def getAll(filter: SearchFilterExpr = SearchFilterExpr.Empty, sorting: Option[Sorting] = None, diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/fake/FakeTrialService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/fake/FakeTrialService.scala index 7c0e313..3793c1f 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/fake/FakeTrialService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/fake/FakeTrialService.scala @@ -2,6 +2,9 @@ package xyz.driver.pdsuidomain.services.fake import java.time.LocalDateTime +import akka.NotUsed +import akka.stream.scaladsl.Source +import akka.util.ByteString import xyz.driver.core.generators import xyz.driver.pdsuicommon.auth.AuthenticatedRequestContext import xyz.driver.pdsuicommon.db._ @@ -44,7 +47,7 @@ class FakeTrialService extends TrialService { ) def getPdfSource(trialId: StringId[Trial])( - implicit requestContext: AuthenticatedRequestContext): Future[GetPdfSourceReply] = + implicit requestContext: AuthenticatedRequestContext): Future[Source[ByteString, NotUsed]] = Future.failed(new NotImplementedError("fake pdf download is not implemented")) def getAll(filter: SearchFilterExpr = SearchFilterExpr.Empty, diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala index 9f72760..f826b98 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala @@ -1,6 +1,9 @@ package xyz.driver.pdsuidomain.services.rest import scala.concurrent.{ExecutionContext, Future} +import akka.NotUsed +import akka.stream.scaladsl.Source +import akka.util.ByteString import akka.http.scaladsl.marshalling.Marshal import akka.http.scaladsl.model._ import akka.stream.Materializer @@ -43,8 +46,15 @@ class RestTrialService(transport: ServiceTransport, baseUri: Uri)(implicit prote } def getPdfSource(trialId: StringId[Trial])( - implicit requestContext: AuthenticatedRequestContext): Future[GetPdfSourceReply] = - Future.failed(new NotImplementedError("Streaming PDF over network is not supported.")) + implicit requestContext: AuthenticatedRequestContext): Future[Source[ByteString, NotUsed]] = { + val request = HttpRequest(HttpMethods.GET, endpointUri(baseUri, s"/v1/trial/${trialId}/source")) + for { + response <- transport.sendRequestGetResponse(requestContext)(request) + reply <- apiResponse[HttpEntity](response) + } yield { + reply.dataBytes.mapMaterializedValue(_ => NotUsed) + } + } def getAll(filter: SearchFilterExpr = SearchFilterExpr.Empty, sorting: Option[Sorting] = None, diff --git a/src/test/scala/xyz/driver/pdsuicommon/Mocks.scala b/src/test/scala/xyz/driver/pdsuicommon/Mocks.scala index 1c01483..51d39e5 100644 --- a/src/test/scala/xyz/driver/pdsuicommon/Mocks.scala +++ b/src/test/scala/xyz/driver/pdsuicommon/Mocks.scala @@ -42,7 +42,6 @@ object MockMySqlContext { url = "" ), connection = ConfigFactory.empty(), - connectionAttemptsOnStartup = 1, threadPoolSize = 10 ) } diff --git a/src/test/scala/xyz/driver/pdsuicommon/parsers/PaginationParserSuite.scala b/src/test/scala/xyz/driver/pdsuicommon/parsers/PaginationParserSuite.scala new file mode 100644 index 0000000..48fc99b --- /dev/null +++ b/src/test/scala/xyz/driver/pdsuicommon/parsers/PaginationParserSuite.scala @@ -0,0 +1,95 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.db.Pagination +import xyz.driver.pdsuicommon.parsers.TestUtils._ +import org.scalatest.{FreeSpecLike, MustMatchers} + +import scala.util.{Failure, Try} + +class PaginationParserSuite extends FreeSpecLike with MustMatchers { + + "parse" - { + "pageSize" - { + "should parse positive value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "10", + "pageNumber" -> "1" + )) + pagination must success + pagination.get.pageSize mustBe 10 + } + + "should return a default value if there is no one" in { + val pagination = PaginationParser.parse(Seq( + "pageNumber" -> "1" + )) + pagination must success + pagination.get.pageSize mustBe 100 + } + + "should return a error for zero value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "0", + "pageNumber" -> "1" + )) + + checkFailedValidationOnlyOn(pagination, "pageSize") + } + + "should return a error for negative value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "-10", + "pageNumber" -> "1" + )) + + checkFailedValidationOnlyOn(pagination, "pageSize") + } + } + + "pageNumber" - { + "should parse positive value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "1", + "pageNumber" -> "1" + )) + pagination must success + pagination.get.pageSize mustBe 1 + } + + "should return a default value if there is no one" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "1" + )) + pagination must success + pagination.get.pageNumber mustBe 1 + } + + "should return a error for zero value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "1", + "pageNumber" -> "0" + )) + + checkFailedValidationOnlyOn(pagination, "pageNumber") + } + + "should return a error for negative value" in { + val pagination = PaginationParser.parse(Seq( + "pageSize" -> "1", + "pageNumber" -> "-1" + )) + + checkFailedValidationOnlyOn(pagination, "pageNumber") + } + } + } + + private def checkFailedValidationOnlyOn(pagination: Try[Pagination], key: String): Unit = { + pagination must failWith[ParseQueryArgException] + + val Failure(e: ParseQueryArgException) = pagination + e.errors.size mustBe 1 + e.errors.head._1 mustBe key + } + +} diff --git a/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala b/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala new file mode 100644 index 0000000..f47f4c2 --- /dev/null +++ b/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala @@ -0,0 +1,207 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.db.SearchFilterExpr.Dimension +import xyz.driver.pdsuicommon.db.{SearchFilterBinaryOperation, SearchFilterExpr, SearchFilterNAryOperation} +import xyz.driver.pdsuicommon.utils.Implicits.toStringOps +import xyz.driver.pdsuicommon.parsers.TestUtils._ +import fastparse.core.Parsed +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.{Gen, Prop} +import org.scalatest.FreeSpecLike +import org.scalatest.prop.Checkers +import xyz.driver.pdsuicommon.db.SearchFilterNAryOperation.In +import xyz.driver.pdsuicommon.utils.Utils +import xyz.driver.pdsuicommon.utils.Utils._ + +import scala.util.Success + +object SearchFilterParserSuite { + + class UnexpectedSearchFilterExprException(x: SearchFilterExpr) extends Exception(s"unexpected $x") + +} + +class SearchFilterParserSuite extends FreeSpecLike with Checkers { + + import SearchFilterParserSuite._ + + "parse" - { + "should convert column names to snake case" in { + import SearchFilterBinaryOperation._ + + val filter = SearchFilterParser.parse(Seq( + "filters" -> "status IN Summarized,ReviewCriteria,Flagged,Done", + "filters" -> "previousStatus NOTEQ New", + "filters" -> "previousStatus NOTEQ ReviewSummary" + )) + + assert(filter === Success(SearchFilterExpr.Intersection(List( + SearchFilterExpr.Atom.NAry(Dimension(None, "status"), In, Seq("Summarized", "ReviewCriteria", "Flagged", "Done")), + SearchFilterExpr.Atom.Binary(Dimension(None, "previous_status"), NotEq, "New"), + SearchFilterExpr.Atom.Binary(Dimension(None, "previous_status"), NotEq, "ReviewSummary"))))) + } + "dimensions" - { + "with table name" in check { + val dimensionGen = { + for (left <- Gen.identifier; right <- Gen.identifier) + yield left -> right + } + Prop.forAllNoShrink(dimensionGen) { + case (left, right) => + val raw = s"$left.$right" + val l = toSnakeCase(left) + val r = toSnakeCase(right) + SearchFilterParser.dimensionParser.parse(raw) match { + case Parsed.Success(Dimension(Some(`l`), `r`), _) => true + case _ => false + } + } + } + "just with field name" in check { + Prop.forAllNoShrink(Gen.identifier) { s => + val databaseS = Utils.toSnakeCase(s) + SearchFilterParser.dimensionParser.parse(s) match { + case Parsed.Success(Dimension(None, `databaseS`), _) => true + case _ => false + } + } + } + } + "atoms" - { + "binary" - { + "common operators" - { + "should be parsed with text values" in check { + import SearchFilterBinaryOperation._ + + val testQueryGen = queryGen( + dimensionGen = Gen.identifier, + opGen = commonBinaryOpsGen, + valueGen = nonEmptyString + ) + + Prop.forAllNoShrink(testQueryGen) { query => + SearchFilterParser.parse(Seq("filters" -> query)) + .map { + case SearchFilterExpr.Atom.Binary(_, Eq | NotEq | Like, _) => true + case x => throw new UnexpectedSearchFilterExprException(x) + } + .successProp + } + } + } + + "numeric operators" - { + "should not be parsed with text values" in check { + val testQueryGen = queryGen( + dimensionGen = Gen.identifier, + opGen = numericBinaryOpsGen, + valueGen = nonEmptyString.filter { s => !s.matches("^\\d+$") } + ) + + Prop.forAllNoShrink(testQueryGen) { query => + SearchFilterParser.parse(Seq("filters" -> query)).failureProp + } + } + } + + "all operators" - { + "should be parsed with numeric values" in check { + val testQueryGen = queryGen( + dimensionGen = Gen.identifier, + opGen = allBinaryOpsGen, + valueGen = numericBinaryAtomValuesGen + ) + + Prop.forAllNoShrink(testQueryGen) { query => + SearchFilterParser.parse(Seq("filters" -> query)) + .map { + case _: SearchFilterExpr.Atom.Binary => true + case x => throw new UnexpectedSearchFilterExprException(x) + } + .successProp + } + } + } + } + + "n-ary" - { + "in" in check { + val testQueryGen = queryGen( + dimensionGen = Gen.identifier, + opGen = Gen.const("in"), + valueGen = inValuesGen + ) + + Prop.forAllNoShrink(testQueryGen) { query => + SearchFilterParser.parse(Seq("filters" -> query)) + .map { + case SearchFilterExpr.Atom.NAry(_, SearchFilterNAryOperation.In, _) => true + case x => throw new UnexpectedSearchFilterExprException(x) + } + .successProp + } + } + } + } + + "intersections" - { + "should be parsed" in check { + val commonAtomsGen = queryGen( + dimensionGen = Gen.identifier, + opGen = commonBinaryOpsGen, + valueGen = nonEmptyString + ) + + val numericAtomsGen = queryGen( + dimensionGen = Gen.identifier, + opGen = numericBinaryOpsGen, + valueGen = numericBinaryAtomValuesGen + ) + + val allAtomsGen = Gen.oneOf(commonAtomsGen, numericAtomsGen) + val intersectionsGen = Gen.choose(1, 3).flatMap { size => + Gen.containerOfN[Seq, String](size, allAtomsGen) + } + + Prop.forAllNoShrink(intersectionsGen) { queries => + SearchFilterParser.parse(queries.map(query => "filters" -> query)).successProp + } + } + } + } + + private val CommonBinaryOps = Seq("eq", "noteq", "like") + private val NumericBinaryOps = Seq("gt", "gteq", "lt", "lteq") + + private val allBinaryOpsGen: Gen[String] = Gen.oneOf(CommonBinaryOps ++ NumericBinaryOps).flatMap(randomCapitalization) + private val commonBinaryOpsGen: Gen[String] = Gen.oneOf(CommonBinaryOps).flatMap(randomCapitalization) + private val numericBinaryOpsGen: Gen[String] = Gen.oneOf(NumericBinaryOps).flatMap(randomCapitalization) + + private val inValueCharsGen: Gen[Char] = arbitrary[Char].filter(_ != ',') + + private val nonEmptyString = arbitrary[String].filter { s => !s.safeTrim.isEmpty } + + private val numericBinaryAtomValuesGen: Gen[String] = arbitrary[BigInt].map(_.toString) + private val inValueGen: Gen[String] = { + Gen.nonEmptyContainerOf[Seq, Char](inValueCharsGen).map(_.mkString).filter(_.safeTrim.nonEmpty) + } + private val inValuesGen: Gen[String] = Gen.choose(1, 5).flatMap { size => + Gen.containerOfN[Seq, String](size, inValueGen).map(_.mkString(",")) + } + + private def queryGen(dimensionGen: Gen[String], opGen: Gen[String], valueGen: Gen[String]): Gen[String] = for { + dimension <- dimensionGen + op <- opGen + value <- valueGen + } yield s"$dimension $op $value" + + private def randomCapitalization(input: String): Gen[String] = { + Gen.containerOfN[Seq, Boolean](input.length, arbitrary[Boolean]).map { capitalize => + input.view.zip(capitalize).map { + case (currChar, true) => currChar.toUpper + case (currChar, false) => currChar + }.mkString + } + } + +} diff --git a/src/test/scala/xyz/driver/pdsuicommon/parsers/SortingParserSuite.scala b/src/test/scala/xyz/driver/pdsuicommon/parsers/SortingParserSuite.scala new file mode 100644 index 0000000..e46015c --- /dev/null +++ b/src/test/scala/xyz/driver/pdsuicommon/parsers/SortingParserSuite.scala @@ -0,0 +1,91 @@ +package xyz.driver.pdsuicommon.parsers + +import xyz.driver.pdsuicommon.parsers.TestUtils._ +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.{Gen, Prop} +import org.scalatest.prop.Checkers +import org.scalatest.{FreeSpecLike, MustMatchers} + +class SortingParserSuite extends FreeSpecLike with MustMatchers with Checkers { + + "parse" - { + "single dimension" - commonTests(singleSortingQueryGen) + "multiple dimensions in one query" - commonTests(multipleSortingQueryGen) + "multiple queries" in { + val r = SortingParser.parse(Set("foo", "bar"), Seq("sort" -> "foo", "sort" ->"bar")) + r must failWith[ParseQueryArgException] + } + } + + private def commonTests(queryGen: Set[String] => Gen[String]): Unit = { + "valid" in check { + val inputGen: Gen[(Set[String], String)] = for { + validDimensions <- dimensionsGen + sorting <- queryGen(validDimensions) + } yield (validDimensions, sorting) + + Prop.forAllNoShrink(inputGen) { + case (validDimensions, query) => + SortingParser.parse(validDimensions, Seq("sort" -> query)).successProp + } + } + + "invalid" in check { + val inputGen: Gen[(Set[String], String)] = for { + validDimensions <- dimensionsGen + invalidDimensions <- dimensionsGen.filter { xs => xs.intersect(validDimensions).isEmpty } + sorting <- queryGen(invalidDimensions) + } yield (validDimensions, sorting) + + Prop.forAllNoShrink(inputGen) { + case (validDimensions, query) => + SortingParser.parse(validDimensions, Seq("sort" -> query)).failureProp + } + } + } + + private val dimensionsGen: Gen[Set[String]] = for { + unPrefixedSize <- Gen.choose(0, 3) + prefixedSize <- Gen.choose(0, 3) + if (unPrefixedSize + prefixedSize) > 0 + + unPrefixedDimensions <- Gen.containerOfN[Set, String](unPrefixedSize, Gen.identifier) + + prefixes <- Gen.containerOfN[Set, String](prefixedSize, Gen.identifier) + dimensions <- Gen.containerOfN[Set, String](prefixedSize, Gen.identifier) + } yield { + val prefixedDimensions = prefixes.zip(dimensions).map { + case (prefix, dimension) => s"$prefix.$dimension" + } + unPrefixedDimensions ++ prefixedDimensions + } + + private def multipleSortingQueryGen(validDimensions: Set[String]): Gen[String] = { + val validDimensionsSeq = validDimensions.toSeq + val indexGen = Gen.oneOf(validDimensionsSeq.indices) + val multipleDimensionsGen = Gen.nonEmptyContainerOf[Set, Int](indexGen).filter(_.size >= 2).map { indices => + indices.map(validDimensionsSeq.apply) + } + + for { + dimensions <- multipleDimensionsGen + isAscending <- Gen.containerOfN[Seq, Boolean](dimensions.size, arbitrary[Boolean]) + } yield { + isAscending.zip(dimensions) + .map { + case (true, dimension) => dimension + case (false, dimension) => "-" + dimension + } + .mkString(",") + } + } + + private def singleSortingQueryGen(validDimensions: Set[String]): Gen[String] = for { + isAscending <- arbitrary[Boolean] + dimensions <- Gen.oneOf(validDimensions.toSeq) + } yield isAscending match { + case true => dimensions + case false => "-" + dimensions + } + +} diff --git a/src/test/scala/xyz/driver/pdsuicommon/parsers/TestUtils.scala b/src/test/scala/xyz/driver/pdsuicommon/parsers/TestUtils.scala new file mode 100644 index 0000000..4892b95 --- /dev/null +++ b/src/test/scala/xyz/driver/pdsuicommon/parsers/TestUtils.scala @@ -0,0 +1,52 @@ +package xyz.driver.pdsuicommon.parsers + +import org.scalacheck.Prop +import org.scalacheck.Prop.BooleanOperators +import org.scalatest.matchers.{MatchResult, Matcher} +import xyz.driver.pdsuicommon.utils.Utils + +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +object TestUtils { + + object success extends Matcher[Try[Any]] { + override def apply(left: Try[Any]) = { + MatchResult(left.isSuccess, s"$left did not fail", s"did fail with $left") + } + } + + class FailWith[ThrowableT <: Throwable](implicit ct: ClassTag[ThrowableT]) extends Matcher[Try[Any]] { + override def apply(left: Try[Any]): MatchResult = { + MatchResult( + left.isFailure && left.failed.get.getClass == ct.runtimeClass, + left match { + case Success(x) => s"$left did not fail" + case Failure(e) => s"$left did fail with ${Utils.getClassSimpleName(e.getClass)}, " + + s"not ${Utils.getClassSimpleName(ct.runtimeClass)}" + }, + left match { + case Success(_) => s"$left failed with ${Utils.getClassSimpleName(ct.runtimeClass)}" + case Failure(e) => s"$left failed with ${Utils.getClassSimpleName(e.getClass)}" + } + ) + } + } + + def failWith[ThrowableT <:Throwable](implicit ct: ClassTag[ThrowableT]) = new FailWith[ThrowableT] + + final implicit class TryPropOps(val self: Try[Any]) extends AnyVal { + + def successProp: Prop = self match { + case Success(_) => true :| "ok" + case Failure(e) => false :| e.getMessage + } + + def failureProp: Prop = self match { + case Success(x) => false :| s"invalid: $x" + case Failure(e) => true + } + + } + +} diff --git a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/DocumentIssueFormatSuite.scala b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/DocumentIssueFormatSuite.scala index 1a8e3f0..c4c9f7c 100644 --- a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/DocumentIssueFormatSuite.scala +++ b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/DocumentIssueFormatSuite.scala @@ -22,10 +22,10 @@ class DocumentIssueFormatSuite extends FlatSpec with Matchers { startPage = Some(1.0), endPage = Some(2.0) ) - val writtenJson = documentIssueWriter.write(documentIssue) + val writtenJson = documentIssueFormat.write(documentIssue) writtenJson should be( - """{"id":10,"userId":"userId-001","lastUpdate":"2017-08-10T18:00Z","isDraft":false, + """{"id":10,"userId":"userId-001","documentId":1,"lastUpdate":"2017-08-10T18:00Z","isDraft":false, "text":"message text","archiveRequired":false,"startPage":1.0,"endPage":2.0}""".parseJson) val createDocumentIssueJson = """{"text":"message text","startPage":1.0,"endPage":2.0}""".parseJson diff --git a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala index 784a655..0f01d4a 100644 --- a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala +++ b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala @@ -16,8 +16,8 @@ class InterventionFormatSuite extends FlatSpec with Matchers { originalName = "orig name", typeId = Some(LongId(10)), originalType = Some("orig type"), - description = "", - originalDescription = "", + dosage = "", + originalDosage = "", isActive = true ) val arms = List( @@ -32,12 +32,12 @@ class InterventionFormatSuite extends FlatSpec with Matchers { val writtenJson = interventionWriter.write(orig) writtenJson should be( - """{"id":1,"name":"intervention name","typeId":10,"description":"","isActive":true,"arms":[20,21,22], - "trialId":"NCT000001","originalName":"orig name","originalDescription":"","originalType":"orig type"}""".parseJson) + """{"id":1,"name":"intervention name","typeId":10,"dosage":"","isActive":true,"arms":[20,21,22], + "trialId":"NCT000001","originalName":"orig name","originalDosage":"","originalType":"orig type"}""".parseJson) - val updateInterventionJson = """{"description":"descr","arms":[21,22]}""".parseJson + val updateInterventionJson = """{"dosage":"descr","arms":[21,22]}""".parseJson val expectedUpdatedIntervention = orig.copy( - intervention = intervention.copy(description = "descr"), + intervention = intervention.copy(dosage = "descr"), arms = List( InterventionArm(interventionId = intervention.id, armId = LongId(21)), InterventionArm(interventionId = intervention.id, armId = LongId(22)) diff --git a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/MedicalRecordIssueFormatSuite.scala b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/MedicalRecordIssueFormatSuite.scala index 9b89c97..c23ca37 100644 --- a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/MedicalRecordIssueFormatSuite.scala +++ b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/MedicalRecordIssueFormatSuite.scala @@ -22,10 +22,10 @@ class MedicalRecordIssueFormatSuite extends FlatSpec with Matchers { startPage = Some(1.0), endPage = Some(2.0) ) - val writtenJson = recordIssueWriter.write(recordIssue) + val writtenJson = recordIssueFormat.write(recordIssue) writtenJson should be( - """{"id":10,"userId":"userId-001","lastUpdate":"2017-08-10T18:00Z","isDraft":false, + """{"id":10,"recordId":1,"userId":"userId-001","lastUpdate":"2017-08-10T18:00Z","isDraft":false, "text":"message text","archiveRequired":false,"startPage":1.0,"endPage":2.0}""".parseJson) val createRecordIssueJson = """{"text":"message text","startPage":1.0,"endPage":2.0}""".parseJson |