diff --git a/build.sbt b/build.sbt index c74f3023e3..1716589386 100644 --- a/build.sbt +++ b/build.sbt @@ -261,6 +261,18 @@ lazy val webapiJavaTestOptions = Seq( // DSP's new codebase ////////////////////////////////////// +// dsp-api-main project + +lazy val dspApiMain = project + .in(file("dsp-api-main")) + .settings( + scalacOptions ++= customScalacOptions, + name := "dspApiMain", + libraryDependencies ++= Dependencies.dspApiMainLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(userInterface, userHandler, userRepo) + // Role projects lazy val roleInterface = project @@ -317,7 +329,7 @@ lazy val userInterface = project libraryDependencies ++= Dependencies.userInterfaceLibraryDependencies, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) - .dependsOn(shared, userHandler) + .dependsOn(shared % "compile->compile;test->test", userHandler, userRepo % "test->test") lazy val userHandler = project .in(file("dsp-user/handler")) @@ -328,7 +340,7 @@ lazy val userHandler = project testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) .dependsOn( - shared, + shared % "compile->compile;test->test", userCore % "compile->compile;test->test", userRepo % "test->test" // userHandler tests need mock implementation of UserRepo ) diff --git a/dsp-api-main/src/main/resources/logback.xml b/dsp-api-main/src/main/resources/logback.xml new file mode 100644 index 0000000000..df12845727 --- /dev/null +++ b/dsp-api-main/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg [%mdc]%n + + + + + + + + true + + yyyy-MM-dd' 'HH:mm:ss.SSS + + + + + + + + + + + + diff --git a/dsp-api-main/src/main/scala/dsp/api/main/DspMain.scala b/dsp-api-main/src/main/scala/dsp/api/main/DspMain.scala new file mode 100644 index 0000000000..a2d4930b1d --- /dev/null +++ b/dsp-api-main/src/main/scala/dsp/api/main/DspMain.scala @@ -0,0 +1,35 @@ +package dsp.api.main + +import zio._ +import dsp.user.route.UserRoutes +import dsp.user.handler.UserHandler +import dsp.user.repo.impl.UserRepoLive +import zio.logging.removeDefaultLoggers +import zio.logging.backend.SLF4J +import dsp.config.AppConfig +import dsp.util.UuidGeneratorLive + +object DspMain extends ZIOAppDefault { + + override val run: Task[Unit] = + ZIO + .serviceWithZIO[DspServer](_.start) + .provide( + // ZLayer.Debug.mermaid, + // server + DspServer.layer, + // configuration + AppConfig.live, + // routes + UserRoutes.layer, + // handlers + UserHandler.layer, + // repositories + UserRepoLive.layer, + // slf4j facade, we use it with logback.xml + removeDefaultLoggers, + SLF4J.slf4j, + UuidGeneratorLive.layer + ) + +} diff --git a/dsp-api-main/src/main/scala/dsp/api/main/DspMiddleware.scala b/dsp-api-main/src/main/scala/dsp/api/main/DspMiddleware.scala new file mode 100644 index 0000000000..516e261d95 --- /dev/null +++ b/dsp-api-main/src/main/scala/dsp/api/main/DspMiddleware.scala @@ -0,0 +1,24 @@ +package dsp.api.main + +import zhttp.http.middleware.HttpMiddleware +import zhttp.http._ +import zio._ + +object DspMiddleware { + // adds a requestId to all logs that were triggered by the same request + val logging: HttpMiddleware[Any, Nothing] = + new HttpMiddleware[Any, Nothing] { + override def apply[R1 <: Any, E1 >: Nothing]( + http: HttpApp[R1, E1] + ): HttpApp[R1, E1] = + Http.fromOptionFunction[Request] { request => + Random.nextUUID.flatMap { requestId => + ZIO.logAnnotate("RequestId", requestId.toString) { + for { + result <- http(request) + } yield result + } + } + } + } +} diff --git a/dsp-api-main/src/main/scala/dsp/api/main/DspServer.scala b/dsp-api-main/src/main/scala/dsp/api/main/DspServer.scala new file mode 100644 index 0000000000..6ef9f361f7 --- /dev/null +++ b/dsp-api-main/src/main/scala/dsp/api/main/DspServer.scala @@ -0,0 +1,32 @@ +package dsp.api.main + +import dsp.user.route.UserRoutes +import zio._ +import zhttp.http._ +import zio.ZLayer +import zhttp.service.Server +import dsp.api.main.DspMiddleware +import dsp.config.AppConfig +import dsp.util.UuidGeneratorLive + +final case class DspServer( + appConfig: AppConfig, + userRoutes: UserRoutes +) { + + // adds up the routes of all slices + val dspRoutes: HttpApp[AppConfig & UuidGeneratorLive, Throwable] = + userRoutes.routes // ++ projectRoutes.routes + + // starts the server with the provided settings from the appConfig + def start = { + val port = appConfig.dspApi.externalPort + Server.start(port, dspRoutes @@ DspMiddleware.logging) + } + +} + +object DspServer { + val layer: ZLayer[AppConfig & UserRoutes, Nothing, DspServer] = + ZLayer.fromFunction(DspServer.apply _) +} diff --git a/dsp-api-main/src/main/scala/dsp/api/main/MainApp.scala b/dsp-api-main/src/main/scala/dsp/api/main/MainApp.scala deleted file mode 100644 index 6ec670cf37..0000000000 --- a/dsp-api-main/src/main/scala/dsp/api/main/MainApp.scala +++ /dev/null @@ -1,18 +0,0 @@ -package dsp.api.main - -import dsp.schema.repo.SchemaRepo -import dsp.schema.repo.SchemaRepoLive -import zio.Console.printLine -import zio._ - -object MainApp extends ZIOAppDefault { - val effect: ZIO[SchemaRepo, Nothing, Unit] = - for { - profile <- SchemaRepo.lookup("user1").orDie - // _ <- printLine(profile).orDie - // _ <- printLine(42).orDie - } yield () - - val mainApp: UIO[Unit] = effect.provide(SchemaRepoLive.layer) - def run: UIO[Unit] = mainApp -} diff --git a/dsp-project/handler/src/main/scala/dsp/project/handler/ProjectHandler.scala b/dsp-project/handler/src/main/scala/dsp/project/handler/ProjectHandler.scala index 918ca60ad7..5c5efb735e 100644 --- a/dsp-project/handler/src/main/scala/dsp/project/handler/ProjectHandler.scala +++ b/dsp-project/handler/src/main/scala/dsp/project/handler/ProjectHandler.scala @@ -154,9 +154,6 @@ final case class ProjectHandler(repo: ProjectRepo) { } -/** - * Companion object providing the layer with an initialized implementation - */ object ProjectHandler { val layer: ZLayer[ProjectRepo, Nothing, ProjectHandler] = ZLayer { diff --git a/dsp-role/core/src/test/scala/dsp/role/domain/RoleDomainSpec.scala b/dsp-role/core/src/test/scala/dsp/role/domain/RoleDomainSpec.scala index d026fb6be5..7ff4448fcd 100644 --- a/dsp-role/core/src/test/scala/dsp/role/domain/RoleDomainSpec.scala +++ b/dsp-role/core/src/test/scala/dsp/role/domain/RoleDomainSpec.scala @@ -7,6 +7,8 @@ package dsp.role.domain import zio.test._ +import java.util.UUID + import dsp.role.sharedtestdata.RoleTestData import dsp.valueobjects.Id import dsp.valueobjects.Permission @@ -86,7 +88,7 @@ object RoleDomainSpec extends ZIOSpecDefault { ( for { role <- RoleTestData.role1 - newValue = List(RoleUser(Id.UserId.make().fold(e => throw e.head, v => v))) + newValue = List(RoleUser(Id.UserId.make(UUID.randomUUID()).fold(e => throw e.head, v => v))) updatedRole <- role.updateUsers(newValue) } yield assertTrue(updatedRole.name == role.name) && assertTrue(updatedRole.description == role.description) && diff --git a/dsp-role/core/src/test/scala/dsp/role/sharedtestdata/RoleTestData.scala b/dsp-role/core/src/test/scala/dsp/role/sharedtestdata/RoleTestData.scala index 900e8cf27e..fec84179d7 100644 --- a/dsp-role/core/src/test/scala/dsp/role/sharedtestdata/RoleTestData.scala +++ b/dsp-role/core/src/test/scala/dsp/role/sharedtestdata/RoleTestData.scala @@ -5,6 +5,8 @@ package dsp.role.sharedtestdata +import java.util.UUID + import dsp.role.domain.Role import dsp.role.domain.RoleUser import dsp.valueobjects.Id @@ -15,10 +17,13 @@ import dsp.valueobjects.Role._ * Contains shared role test data. */ object RoleTestData { + val uuid1 = UUID.randomUUID() + val uuid2 = UUID.randomUUID() + val id1 = Id.RoleId.make() val name1 = LangString.make("Name", "en") val description1 = LangString.make("Description", "en") - val users1 = List(RoleUser(Id.UserId.make().fold(e => throw e.head, v => v))) + val users1 = List(RoleUser(Id.UserId.make(uuid1).fold(e => throw e.head, v => v))) val permission1 = Permission.make(Permission.View) val role1 = for { @@ -39,7 +44,7 @@ object RoleTestData { val id2 = Id.RoleId.make() val name2 = LangString.make("Name 2", "en") val description2 = LangString.make("Description 2", "en") - val users2 = List(RoleUser(Id.UserId.make().fold(e => throw e.head, v => v))) + val users2 = List(RoleUser(Id.UserId.make(uuid2).fold(e => throw e.head, v => v))) val permission2 = Permission.make(Permission.Admin) val role2 = for { diff --git a/dsp-role/handler/src/main/scala/dsp/role/handler/RoleHandler.scala b/dsp-role/handler/src/main/scala/dsp/role/handler/RoleHandler.scala index ace6a93d3d..749711610f 100644 --- a/dsp-role/handler/src/main/scala/dsp/role/handler/RoleHandler.scala +++ b/dsp-role/handler/src/main/scala/dsp/role/handler/RoleHandler.scala @@ -135,9 +135,6 @@ final case class RoleHandler(repo: RoleRepo) { } yield id).tap(_ => ZIO.logInfo(s"Deleted role with ID: $id")) } -/** - * Companion object providing the layer with an initialized implementation - */ object RoleHandler { val layer: ZLayer[RoleRepo, Nothing, RoleHandler] = ZLayer { diff --git a/dsp-role/repo/src/main/scala/dsp/role/repo/impl/RoleRepoLive.scala b/dsp-role/repo/src/main/scala/dsp/role/repo/impl/RoleRepoLive.scala index 083816339e..c6347bca7f 100644 --- a/dsp-role/repo/src/main/scala/dsp/role/repo/impl/RoleRepoLive.scala +++ b/dsp-role/repo/src/main/scala/dsp/role/repo/impl/RoleRepoLive.scala @@ -60,9 +60,6 @@ final case class RoleRepoLive( } yield id).commit.tap(_ => ZIO.logInfo(s"Deleted role: ${id.uuid}")) } -/** - * Companion object providing the layer with an initialized implementation of [[RoleRepo]] - */ object RoleRepoLive { val layer: ZLayer[Any, Nothing, RoleRepo] = ZLayer { diff --git a/dsp-role/repo/src/test/scala/dsp/role/repo/impl/RoleRepoMock.scala b/dsp-role/repo/src/test/scala/dsp/role/repo/impl/RoleRepoMock.scala index 1166140905..b627daa432 100644 --- a/dsp-role/repo/src/test/scala/dsp/role/repo/impl/RoleRepoMock.scala +++ b/dsp-role/repo/src/test/scala/dsp/role/repo/impl/RoleRepoMock.scala @@ -60,9 +60,6 @@ final case class RoleRepoMock( } yield id).commit.tap(_ => ZIO.logInfo(s"Deleted role: ${id.uuid}")) } -/** - * Companion object providing the layer with an initialized implementation of [[RoleRepo]] - */ object RoleRepoMock { val layer: ZLayer[Any, Nothing, RoleRepo] = ZLayer { diff --git a/dsp-shared/src/main/resources/application.conf b/dsp-shared/src/main/resources/application.conf new file mode 100644 index 0000000000..fd22ddd272 --- /dev/null +++ b/dsp-shared/src/main/resources/application.conf @@ -0,0 +1,19 @@ +app { + dsp-api { + // relevant for direct communication inside the dsp stack + internal-host = "0.0.0.0" + internal-host = ${?DSP_API_INTERNAL_HOST} + internal-port = 4444 + internal-port = ${?DSP_API_INTERNAL_PORT} + + // relevant for the client, i.e. browser + external-protocol = "http" // optional ssl termination needs to be done by the proxy + external-protocol = ${?DSP_API_EXTERNAL_PROTOCOL} + external-host = "0.0.0.0" + external-host = ${?DSP_API_EXTERNAL_HOST} + external-port = 4444 + external-port = ${?DSP_API_EXTERNAL_PORT} + } + + bcrypt-password-strength = 12 +} diff --git a/dsp-shared/src/main/scala/dsp/config/AppConfig.scala b/dsp-shared/src/main/scala/dsp/config/AppConfig.scala new file mode 100644 index 0000000000..c6a82104b8 --- /dev/null +++ b/dsp-shared/src/main/scala/dsp/config/AppConfig.scala @@ -0,0 +1,46 @@ +package dsp.config + +import com.typesafe.config.ConfigFactory +import zio._ +import zio.config._ +import zio.config.magnolia.descriptor +import zio.config.typesafe.TypesafeConfigSource + +import dsp.valueobjects.User._ + +/** + * Configuration + */ +final case class AppConfig( + dspApi: DspApi, + bcryptPasswordStrength: PasswordStrength +) + +final case class DspApi( + internalHost: String, + internalPort: Int, + externalHost: String, + externalPort: Int +) + +object AppConfig { + + /** + * Reads in the applicaton configuration using ZIO-Config. ZIO-Config is capable of loading + * the Typesafe-Config format. Reads the 'app' configuration from 'application.conf'. + */ + private val source: ConfigSource = + TypesafeConfigSource.fromTypesafeConfig(ZIO.attempt(ConfigFactory.load().getConfig("app").resolve)) + + /** + * Instantiates the config class hierarchy using the data from the 'app' configuration from 'application.conf'. + */ + private val configFromSource: IO[ReadError[String], AppConfig] = read( + descriptor[AppConfig].mapKey(toKebabCase) from source + ) + + /** + * Application configuration from application.conf + */ + val live: ULayer[AppConfig] = ZLayer(configFromSource.orDie) +} diff --git a/dsp-shared/src/main/scala/dsp/util/UuidGenerator.scala b/dsp-shared/src/main/scala/dsp/util/UuidGenerator.scala new file mode 100644 index 0000000000..a82a5af854 --- /dev/null +++ b/dsp-shared/src/main/scala/dsp/util/UuidGenerator.scala @@ -0,0 +1,34 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.util + +import zio._ + +import java.util.UUID + +/** + * Handles UUID creation + */ +trait UuidGenerator { + def createRandomUuid: UIO[UUID] +} + +/** + * Live instance of the UuidGenerator + */ +final case class UuidGeneratorLive() extends UuidGenerator { + + /** + * Creates a random UUID + * + * @return a random UUID + */ + override def createRandomUuid: UIO[UUID] = ZIO.succeed(UUID.randomUUID()) +} +object UuidGeneratorLive { + val layer: ULayer[UuidGeneratorLive] = + ZLayer.succeed(UuidGeneratorLive()) +} diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala index 69efd0f941..c8acacaf1b 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala @@ -5,6 +5,8 @@ package dsp.valueobjects +import zio.json.JsonDecoder +import zio.json.JsonEncoder import zio.prelude.Validation import java.util.UUID @@ -48,7 +50,8 @@ object Id { * @return new RoleId instance */ def fromUuid(uuid: UUID): Validation[Throwable, RoleId] = { - val iri = Iri.RoleIri.make(roleIriPrefix + uuid.toString).fold(e => throw e.head, v => v) + val iri = + Iri.RoleIri.make(roleIriPrefix + uuid.toString).getOrElseWith(e => throw ValidationException(e.head.getMessage)) Validation.succeed(new RoleId(uuid, iri) {}) } @@ -59,7 +62,8 @@ object Id { */ def make(): Validation[Throwable, RoleId] = { val uuid = UUID.randomUUID() - val iri = Iri.RoleIri.make(roleIriPrefix + uuid.toString).fold(e => throw e.head, v => v) + val iri = + Iri.RoleIri.make(roleIriPrefix + uuid.toString).getOrElseWith(e => throw ValidationException(e.head.getMessage)) Validation.succeed(new RoleId(uuid, iri) {}) } } @@ -80,6 +84,11 @@ object Id { */ object UserId { + implicit val decoder: JsonDecoder[UserId] = JsonDecoder[Iri.UserIri].mapOrFail { case iri => + UserId.fromIri(iri).toEitherWith(e => e.head.getMessage()) + } + implicit val encoder: JsonEncoder[UserId] = JsonEncoder[String].contramap((userId: UserId) => userId.iri.value) + private val userIriPrefix = "http://rdfh.ch/users/" /** @@ -92,24 +101,16 @@ object Id { Validation.succeed(new UserId(uuid, iri) {}) } - /** - * Generates a UserId instance from a given UUID and an IRI which is created from a prefix and the UUID. - * - * @return a new UserId instance - */ - def fromUuid(uuid: UUID): Validation[Throwable, UserId] = { - val iri = Iri.UserIri.make(userIriPrefix + uuid.toString).fold(e => throw e.head, v => v) - Validation.succeed(new UserId(uuid, iri) {}) - } - /** * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. * + * @param uuid a valid UUID has to be provided in order to create a UserId + * * @return a new UserId instance */ - def make(): Validation[Throwable, UserId] = { - val uuid: UUID = UUID.randomUUID() - val iri = Iri.UserIri.make(userIriPrefix + uuid.toString).fold(e => throw e.head, v => v) + def make(uuid: UUID): Validation[Throwable, UserId] = { + val iri = + Iri.UserIri.make(userIriPrefix + uuid.toString).getOrElseWith(e => throw ValidationException(e.head.getMessage)) Validation.succeed(new UserId(uuid, iri) {}) } } diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala index 348170f227..1aaa3b7e5b 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala @@ -6,6 +6,8 @@ package dsp.valueobjects import org.apache.commons.validator.routines.UrlValidator +import zio.json.JsonDecoder +import zio.json.JsonEncoder import zio.prelude.Validation import scala.util.Try @@ -183,6 +185,11 @@ object Iri { */ sealed abstract case class UserIri private (value: String) extends Iri object UserIri { + implicit val decoder: JsonDecoder[UserIri] = JsonDecoder[String].mapOrFail { case value => + UserIri.make(value).toEitherWith(e => e.head.getMessage()) + } + implicit val encoder: JsonEncoder[UserIri] = JsonEncoder[String].contramap((userIri: UserIri) => userIri.value) + def make(value: String): Validation[Throwable, UserIri] = if (value.isEmpty) { Validation.fail(BadRequestException(IriErrorMessages.UserIriMissing)) diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/LanguageCode.scala b/dsp-shared/src/main/scala/dsp/valueobjects/LanguageCode.scala index b6b271b241..b42976bb62 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/LanguageCode.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/LanguageCode.scala @@ -5,6 +5,7 @@ package dsp.valueobjects +import zio.json.JsonCodec import zio.prelude.Validation import dsp.errors.ValidationException @@ -15,6 +16,12 @@ import dsp.errors.ValidationException sealed abstract case class LanguageCode private (value: String) object LanguageCode { self => + implicit val codec: JsonCodec[LanguageCode] = + JsonCodec[String].transformOrFail( + value => LanguageCode.make(value).toEitherWith(e => e.head.getMessage()), + languageCode => languageCode.value + ) + val DE: String = "de" val EN: String = "en" val FR: String = "fr" diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/User.scala b/dsp-shared/src/main/scala/dsp/valueobjects/User.scala index 1d0c940122..cd78d74453 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/User.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/User.scala @@ -8,6 +8,10 @@ package dsp.valueobjects import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder import zio._ +import zio.config.magnolia.Descriptor +import zio.json.JsonCodec +import zio.json.JsonDecoder +import zio.json.JsonEncoder import zio.prelude.Assertion._ import zio.prelude.Subtype import zio.prelude.Validation @@ -15,7 +19,6 @@ import zio.prelude.Validation import java.security.SecureRandom import scala.util.matching.Regex -import dsp.errors.BadRequestException import dsp.errors.ValidationException object User { @@ -27,6 +30,12 @@ object User { */ sealed abstract case class Username private (value: String) object Username { self => + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[Username] = + JsonCodec[String].transformOrFail( + value => Username.make(value).toEitherWith(e => e.head.getMessage()), + username => username.value + ) /** * A regex that matches a valid username @@ -38,14 +47,14 @@ object User { private val UsernameRegex: Regex = """^(?=.{4,50}$)(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(? Validation.succeed(new Username(value) {}) - case None => Validation.fail(BadRequestException(UserErrorMessages.UsernameInvalid)) + case None => Validation.fail(ValidationException(UserErrorMessages.UsernameInvalid)) } } @@ -57,7 +66,7 @@ object User { * * @param value The value the value object is created from */ - def unsafeMake(value: String): Validation[Throwable, Username] = + def unsafeMake(value: String): Validation[ValidationException, Username] = Username .make(value) .fold( @@ -74,15 +83,22 @@ object User { */ sealed abstract case class Email private (value: String) object Email { self => + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[Email] = + JsonCodec[String].transformOrFail( + value => Email.make(value).toEitherWith(e => e.head.getMessage()), + email => email.value + ) + private val EmailRegex: Regex = """^.+@.+$""".r - def make(value: String): Validation[Throwable, Email] = + def make(value: String): Validation[ValidationException, Email] = if (value.isEmpty) { - Validation.fail(BadRequestException(UserErrorMessages.EmailMissing)) + Validation.fail(ValidationException(UserErrorMessages.EmailMissing)) } else { EmailRegex.findFirstIn(value) match { case Some(value) => Validation.succeed(new Email(value) {}) - case None => Validation.fail(BadRequestException(UserErrorMessages.EmailInvalid)) + case None => Validation.fail(ValidationException(UserErrorMessages.EmailInvalid)) } } } @@ -92,9 +108,16 @@ object User { */ sealed abstract case class GivenName private (value: String) object GivenName { self => - def make(value: String): Validation[Throwable, GivenName] = + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[GivenName] = + JsonCodec[String].transformOrFail( + value => GivenName.make(value).toEitherWith(e => e.head.getMessage()), + givenName => givenName.value + ) + + def make(value: String): Validation[ValidationException, GivenName] = if (value.isEmpty) { - Validation.fail(BadRequestException(UserErrorMessages.GivenNameMissing)) + Validation.fail(ValidationException(UserErrorMessages.GivenNameMissing)) } else { Validation.succeed(new GivenName(value) {}) } @@ -105,9 +128,16 @@ object User { */ sealed abstract case class FamilyName private (value: String) object FamilyName { self => - def make(value: String): Validation[Throwable, FamilyName] = + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[FamilyName] = + JsonCodec[String].transformOrFail( + value => FamilyName.make(value).toEitherWith(e => e.head.getMessage()), + familyName => familyName.value + ) + + def make(value: String): Validation[ValidationException, FamilyName] = if (value.isEmpty) { - Validation.fail(BadRequestException(UserErrorMessages.FamilyNameMissing)) + Validation.fail(ValidationException(UserErrorMessages.FamilyNameMissing)) } else { Validation.succeed(new FamilyName(value) {}) } @@ -120,13 +150,13 @@ object User { object Password { self => private val PasswordRegex: Regex = """^[\s\S]*$""".r - def make(value: String): Validation[Throwable, Password] = + def make(value: String): Validation[ValidationException, Password] = if (value.isEmpty) { - Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) + Validation.fail(ValidationException(UserErrorMessages.PasswordMissing)) } else { PasswordRegex.findFirstIn(value) match { case Some(value) => Validation.succeed(new Password(value) {}) - case None => Validation.fail(BadRequestException(UserErrorMessages.PasswordInvalid)) + case None => Validation.fail(ValidationException(UserErrorMessages.PasswordInvalid)) } } } @@ -160,11 +190,25 @@ object User { } object PasswordHash { + // TODO: get the passwordStrength from appConfig instead (see CreateUser.scala as example) + + // the decoder defines how to decode json to an object + implicit val decoder: JsonDecoder[PasswordHash] = JsonDecoder[(String, PasswordStrength)].mapOrFail { + case (password: String, passwordStrengthInt: Int) => + val passwordStrength = + PasswordStrength.make(passwordStrengthInt).fold(e => throw new ValidationException(e.head), v => v) + + PasswordHash.make(password, passwordStrength).toEitherWith(e => e.head.getMessage()) + } + // the encoder defines how to encode the object into json + implicit val encoder: JsonEncoder[PasswordHash] = + JsonEncoder[String].contramap((passwordHash: PasswordHash) => passwordHash.value) + private val PasswordRegex: Regex = """^[\s\S]*$""".r - def make(value: String, passwordStrength: PasswordStrength): Validation[Throwable, PasswordHash] = + def make(value: String, passwordStrength: PasswordStrength): Validation[ValidationException, PasswordHash] = if (value.isEmpty) { - Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) + Validation.fail(ValidationException(UserErrorMessages.PasswordMissing)) } else { PasswordRegex.findFirstIn(value) match { case Some(value) => @@ -175,7 +219,7 @@ object User { ) val hashedValue = encoder.encode(value) Validation.succeed(new PasswordHash(hashedValue, passwordStrength) {}) - case None => Validation.fail(BadRequestException(UserErrorMessages.PasswordInvalid)) + case None => Validation.fail(ValidationException(UserErrorMessages.PasswordInvalid)) } } } @@ -184,10 +228,29 @@ object User { * PasswordStrength value object. */ object PasswordStrength extends Subtype[Int] { + + // the codec defines how to decode json to an object and vice versa + implicit val codec: JsonCodec[PasswordStrength] = + JsonCodec[Int].transformOrFail( + value => PasswordStrength.make(value).toEitherWith(e => e.head), + passwordStrength => passwordStrength + ) + + // this is used for the configuration descriptor + implicit val descriptorForPasswordStrength: Descriptor[PasswordStrength] = + Descriptor[Int].transformOrFail( + int => PasswordStrength.make(int).toEitherWith(_.toString()), + r => Right(r.toInt) + ) + override def assertion = assert { greaterThanOrEqualTo(4) && lessThanOrEqualTo(31) } + + // ignores the assertion! + def unsafeMake(value: Int): PasswordStrength = PasswordStrength.wrap(value) + } type PasswordStrength = PasswordStrength.Type @@ -196,7 +259,15 @@ object User { */ sealed abstract case class UserStatus private (value: Boolean) object UserStatus { - def make(value: Boolean): Validation[Throwable, UserStatus] = + + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[UserStatus] = + JsonCodec[Boolean].transformOrFail( + value => UserStatus.make(value).toEitherWith(e => e.head.getMessage()), + userStatus => userStatus.value + ) + + def make(value: Boolean): Validation[ValidationException, UserStatus] = Validation.succeed(new UserStatus(value) {}) } @@ -205,6 +276,14 @@ object User { */ sealed abstract case class SystemAdmin private (value: Boolean) object SystemAdmin { + + // the codec defines how to decode/encode the object from/into json + implicit val codec: JsonCodec[SystemAdmin] = + JsonCodec[Boolean].transformOrFail( + value => SystemAdmin.make(value).toEitherWith(e => e.head.getMessage()), + systemAdmin => systemAdmin.value + ) + def make(value: Boolean): Validation[ValidationException, SystemAdmin] = Validation.succeed(new SystemAdmin(value) {}) } diff --git a/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMock.scala b/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMock.scala new file mode 100644 index 0000000000..c195b23708 --- /dev/null +++ b/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMock.scala @@ -0,0 +1,69 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.util + +import zio._ + +import java.util.UUID + +/** + * Test instance of the UuidGenerator which stores the created UUIDs, so that + * they can be queried / returned in tests + * + * @param uuidsForGeneration + * @param generatedUuids the list of created UUIDs + */ +final case class UuidGeneratorMock(uuidsForGeneration: Ref[List[UUID]], generatedUuids: Ref[List[UUID]]) + extends UuidGenerator { + + /** + * Sets the given UUIDs that can be queried later + * + * @param uuids A list of UUIDs that should be set as known UUIDs + */ + def setKnownUuidsToGenerate(uuids: List[UUID]) = uuidsForGeneration.set(uuids) + + /** + * Creates a random UUID and stores it in a list, so that it can be queried later + * which is necessary in tests. + * + * @return the created UUID + */ + override def createRandomUuid: UIO[UUID] = + for { + uuids <- uuidsForGeneration.getAndUpdate(_.tail) + uuid = uuids.head + _ <- generatedUuids.getAndUpdate(_.appended(uuid)) + } yield uuid + + /** + * Returns the list of created UUIDs of this instance + * + * @return the list of created UUIDs + */ + def getCreatedUuids: UIO[List[UUID]] = generatedUuids.get + +} +object UuidGeneratorMock { + + /** + * Returns the list of created UUIDs + * + * @return the list of created UUIDs + */ + def getCreatedUuids = ZIO.service[UuidGeneratorMock].flatMap(_.getCreatedUuids) + + val layer: ULayer[UuidGeneratorMock] = { + val listOfRandomUuids = List.fill(20)(UUID.randomUUID()) + ZLayer { + for { + uuidsForGeneration <- Ref.make(listOfRandomUuids) // initialize the list with 20 random UUIDs + generatedUuids <- Ref.make(List.empty[UUID]) + } yield UuidGeneratorMock(uuidsForGeneration, generatedUuids) + } + } + +} diff --git a/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMockSpec.scala b/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMockSpec.scala new file mode 100644 index 0000000000..e69d8cdaaa --- /dev/null +++ b/dsp-shared/src/test/scala/dsp/util/UuidGeneratorMockSpec.scala @@ -0,0 +1,42 @@ +package dsp.util + +import zio.ZIO +import zio.test._ + +import java.util.UUID + +/** + * This spec is used to test the [[UuidGeneratorMockSpec]]. + */ +object UuidGeneratorMockSpec extends ZIOSpecDefault { + override def spec = + suite("UuidGeneratorMockSpec - UUID generation")( + test( + "create several random UUIDs, store them as expected UUIDs and get them back IN THE SAME ORDER when calling the createRandomUuid method" + ) { + val expected1 = UUID.randomUUID() + val expected2 = UUID.randomUUID() + val expected3 = UUID.randomUUID() + for { + uuidGenerator <- ZIO.service[UuidGeneratorMock] + _ <- uuidGenerator.setKnownUuidsToGenerate(List(expected1, expected2, expected3)) + uuid1 <- uuidGenerator.createRandomUuid + uuid2 <- uuidGenerator.createRandomUuid + uuid3 <- uuidGenerator.createRandomUuid + } yield assertTrue( + uuid1 == expected1 && + uuid2 == expected2 && + uuid3 == expected3 + ) + }, + test("create a random UUID, store it as expected UUID and retrieve it from the list of created UUIDs") { + val expected = UUID.randomUUID() + for { + uuidGenerator <- ZIO.service[UuidGeneratorMock] + _ <- uuidGenerator.setKnownUuidsToGenerate(List(expected)) + _ <- uuidGenerator.createRandomUuid + uuid <- uuidGenerator.getCreatedUuids.map(_.head) + } yield assertTrue(uuid == expected) + } + ).provide(UuidGeneratorMock.layer) +} diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala index 6404f6790a..a73902722f 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala @@ -5,10 +5,11 @@ package dsp.valueobjects +import zio.json._ import zio.prelude.Validation import zio.test._ -import dsp.errors.BadRequestException +import dsp.errors.ValidationException import dsp.valueobjects.User._ /** @@ -30,6 +31,7 @@ object UserSpec extends ZIOSpecDefault { private val validPassword = "pass-word" private val validGivenName = "John" private val validFamilyName = "Rambo" + private val validPasswordHash = PasswordHash.make("test", PasswordStrength(12)).fold(e => throw e.head, v => v) def spec = (usernameTest + emailTest + givenNameTest + familyNameTest + passwordTest + passwordHashTest + systemAdminTest) @@ -37,69 +39,69 @@ object UserSpec extends ZIOSpecDefault { private val usernameTest = suite("Username")( test("pass an empty value and return an error") { assertTrue( - Username.make("") == Validation.fail(BadRequestException(UserErrorMessages.UsernameMissing)) + Username.make("") == Validation.fail(ValidationException(UserErrorMessages.UsernameMissing)) ) }, test("pass an invalid value and return an error") { assertTrue( Username.make(invalidUsername) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass too short value and return an error") { assertTrue( Username.make(tooShortUsername) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass too long value and return an error") { assertTrue( Username.make(tooLongUsername) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '_' as the first char and return an error") { assertTrue( Username.make(invalidUsernameWithUnderscoreAsFirstChar) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '_' as the last char and return an error") { assertTrue( Username.make(invalidUsernameWithUnderscoreAsLastChar) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '_' used multiple times in a row and return an error") { assertTrue( Username.make(invalidUsernameWithMultipleUnderscoresInRow) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '.' as the first char and return an error") { assertTrue( Username.make(invalidUsernameWithDotAsFirstChar) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '.' as the last char and return an error") { assertTrue( Username.make(invalidUsernameWithDotAsLastChar) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, test("pass an invalid value with '.' used multiple times in a row and return an error") { assertTrue( Username.make(invalidUsernameWithMultipleDotsInRow) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) + ValidationException(UserErrorMessages.UsernameInvalid) ) ) }, @@ -110,12 +112,12 @@ object UserSpec extends ZIOSpecDefault { private val emailTest = suite("Email")( test("pass an empty value and return an error") { - assertTrue(Email.make("") == Validation.fail(BadRequestException(UserErrorMessages.EmailMissing))) + assertTrue(Email.make("") == Validation.fail(ValidationException(UserErrorMessages.EmailMissing))) }, test("pass an invalid value and return an error") { assertTrue( Email.make(invalidEmailAddress) == Validation.fail( - BadRequestException(UserErrorMessages.EmailInvalid) + ValidationException(UserErrorMessages.EmailInvalid) ) ) }, @@ -127,7 +129,7 @@ object UserSpec extends ZIOSpecDefault { private val givenNameTest = suite("GivenName")( test("pass an empty value and return an error") { assertTrue( - GivenName.make("") == Validation.fail(BadRequestException(UserErrorMessages.GivenNameMissing)) + GivenName.make("") == Validation.fail(ValidationException(UserErrorMessages.GivenNameMissing)) ) }, test("pass a valid value and successfully create value object") { @@ -138,7 +140,7 @@ object UserSpec extends ZIOSpecDefault { private val familyNameTest = suite("FamilyName")( test("pass an empty value and return an error") { assertTrue( - FamilyName.make("") == Validation.fail(BadRequestException(UserErrorMessages.FamilyNameMissing)) + FamilyName.make("") == Validation.fail(ValidationException(UserErrorMessages.FamilyNameMissing)) ) }, test("pass a valid value and successfully create value object") { @@ -149,7 +151,7 @@ object UserSpec extends ZIOSpecDefault { private val passwordTest = suite("Password")( test("pass an empty value and return an error") { assertTrue( - Password.make("") == Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) + Password.make("") == Validation.fail(ValidationException(UserErrorMessages.PasswordMissing)) ) }, test("pass a valid value and successfully create value object") { @@ -161,7 +163,7 @@ object UserSpec extends ZIOSpecDefault { test("pass an empty value and return an error") { assertTrue( PasswordHash.make("", PasswordStrength(12)) == Validation.fail( - BadRequestException(UserErrorMessages.PasswordMissing) + ValidationException(UserErrorMessages.PasswordMissing) ) ) }, @@ -190,6 +192,20 @@ object UserSpec extends ZIOSpecDefault { assertTrue( PasswordStrength.make(12) == Validation.succeed(PasswordStrength(12)) ) + }, + test("decode a PasswordHash from JSON") { + val passwordHashFromJson = + """[ "$2a$12$DulNTvjUALMJufhJ.FR37uqXOCeXp7HFHWzwcjodLlmhUSMe2XKT", 12 ]""".fromJson[PasswordHash] + val result = passwordHashFromJson match { + case Right(passwordHash) => passwordHash.value.startsWith("$2a") + case Left(_) => false + } + assertTrue(result) + }, + test("encode a PasswordHash into JSON") { + val passwordHash = PasswordHash.make("test", PasswordStrength(12)).getOrElse(validPasswordHash) + val passwordHashJson = passwordHash.toJson + assertTrue(passwordHashJson.startsWith(""""$2a""")) } ) diff --git a/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala index acb237d897..2b231eac2a 100644 --- a/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala +++ b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala @@ -5,6 +5,8 @@ package dsp.user.domain +import zio.json.DeriveJsonCodec +import zio.json.JsonCodec import zio.prelude.Validation import dsp.errors.ValidationException @@ -25,7 +27,7 @@ import dsp.valueobjects.User._ * @param status the status of the user * @param role the role of the user */ -sealed abstract case class User private ( +final case class User private ( id: UserId, givenName: GivenName, familyName: FamilyName, @@ -171,6 +173,9 @@ sealed abstract case class User private ( } object User { + + implicit val codec: JsonCodec[User] = DeriveJsonCodec.gen[User] + def make( id: UserId, givenName: GivenName, @@ -182,6 +187,6 @@ object User { status: UserStatus // role: Role ): Validation[ValidationException, User] = - Validation.succeed(new User(id, givenName, familyName, username, email, password, language, status) {}) + Validation.succeed(User(id, givenName, familyName, username, email, password, language, status)) } diff --git a/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala b/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala index 6b0649da0b..f93719ca57 100644 --- a/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala +++ b/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala @@ -5,15 +5,18 @@ package dsp.user.sharedtestdata +import java.util.UUID + import dsp.user.domain.User import dsp.valueobjects.Id import dsp.valueobjects.LanguageCode import dsp.valueobjects.User._ object SharedTestData { + // TODO check faker to create test data: https://index.scala-lang.org/bitblitconsulting/scala-faker val passwordStrength = PasswordStrength(12) - val userId1 = Id.UserId.make() + val userId1 = Id.UserId.make(UUID.randomUUID) val givenName1 = GivenName.make("GivenName1") val familyName1 = FamilyName.make("FamilyName1") val username1 = Username.make("username1") @@ -22,7 +25,7 @@ object SharedTestData { password <- PasswordHash.make("password1", passwordStrength) } yield password - val userId2 = Id.UserId.make() + val userId2 = Id.UserId.make(UUID.randomUUID) val givenName2 = GivenName.make("GivenName2") val familyName2 = FamilyName.make("FamilyName2") val username2 = Username.make("username2") @@ -31,7 +34,7 @@ object SharedTestData { password <- PasswordHash.make("password2", passwordStrength) } yield password - val userId3 = Id.UserId.make() + val userId3 = Id.UserId.make(UUID.randomUUID) val givenName3 = GivenName.make("GivenName3") val familyName3 = FamilyName.make("FamilyName3") val username3 = Username.make("username3") diff --git a/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala index b49d1510f9..cd4502f146 100644 --- a/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala +++ b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala @@ -13,6 +13,7 @@ import dsp.errors.NotFoundException import dsp.errors.RequestRejectedException import dsp.user.api.UserRepo import dsp.user.domain.User +import dsp.util.UuidGenerator import dsp.valueobjects.Id.UserId import dsp.valueobjects.LanguageCode import dsp.valueobjects.User._ @@ -75,7 +76,10 @@ final case class UserHandler(repo: UserRepo) { _ <- repo .checkIfUsernameExists(username) .mapError(_ => DuplicateValueException(s"Username '${username.value}' already taken")) - .tap(_ => ZIO.logInfo(s"Checked if username '${username.value}' is already taken")) + .tapBoth( + _ => ZIO.logInfo(s"Username '${username.value}' already taken"), + _ => ZIO.logInfo(s"Checked if username '${username.value}' is already taken") + ) } yield () /** @@ -88,12 +92,16 @@ final case class UserHandler(repo: UserRepo) { _ <- repo .checkIfEmailExists(email) .mapError(_ => DuplicateValueException(s"Email '${email.value}' already taken")) - .tap(_ => ZIO.logInfo(s"Checked if email '${email.value}' is already taken")) + .tapBoth( + _ => ZIO.logInfo(s"Email '${email.value}' already taken"), + _ => ZIO.logInfo(s"Checked if email '${email.value}' is already taken") + ) } yield () /** - * Creates a new user + * Migrates an existing user. Same as [[createUser]] but with an existing ID. * + * @param id the user's id * @param username the user's username * @param email the user's email * @param givenName the user's givenName @@ -102,7 +110,8 @@ final case class UserHandler(repo: UserRepo) { * @param language the user's language * @param role the user's role */ - def createUser( + def migrateUser( + id: UserId, username: Username, email: Email, givenName: GivenName, @@ -110,14 +119,45 @@ final case class UserHandler(repo: UserRepo) { password: PasswordHash, language: LanguageCode, status: UserStatus - // role: Role ): IO[Throwable, UserId] = (for { + // _ <- checkIfIdExists(id) // TODO implement this _ <- checkIfUsernameTaken(username) // TODO reserve username _ <- checkIfEmailTaken(email) // TODO reserve email - id <- UserId.make().toZIO user <- User.make(id, givenName, familyName, username, email, password, language, status).toZIO userId <- repo.storeUser(user) + } yield userId).tap(userId => ZIO.logInfo(s"Migrated user with ID '${userId}'")) + + /** + * Creates a new user + * + * @param username the user's username + * @param email the user's email + * @param givenName the user's givenName + * @param familyName the user's familyName + * @param password the user's password (hashed) + * @param language the user's language + * @param role the user's role + * @return the UserId of the newly created user + */ + def createUser( + username: Username, + email: Email, + givenName: GivenName, + familyName: FamilyName, + password: PasswordHash, + language: LanguageCode, + status: UserStatus + // role: Role + ): ZIO[UuidGenerator, Throwable, UserId] = + (for { + uuidGenerator <- ZIO.service[UuidGenerator] + _ <- checkIfUsernameTaken(username) // TODO reserve username + _ <- checkIfEmailTaken(email) // TODO reserve email + uuid <- uuidGenerator.createRandomUuid + id <- UserId.make(uuid).toZIO + user <- User.make(id, givenName, familyName, username, email, password, language, status).toZIO + userId <- repo.storeUser(user) } yield userId).tap(userId => ZIO.logInfo(s"Created user with ID '${userId}'")) /** @@ -125,6 +165,7 @@ final case class UserHandler(repo: UserRepo) { * * @param id the user's ID * @param newValue the new username + * @return the UserId of the newly created user */ def updateUsername(id: UserId, newValue: Username): IO[RequestRejectedException, UserId] = (for { @@ -141,6 +182,7 @@ final case class UserHandler(repo: UserRepo) { * * @param id the user's ID * @param newValue the new email + * @return the UserId of the newly created user */ def updateEmail(id: UserId, newValue: Email): IO[RequestRejectedException, UserId] = (for { @@ -157,6 +199,7 @@ final case class UserHandler(repo: UserRepo) { * * @param id the user's ID * @param newValue the new given name + * @return the UserId of the newly created user */ def updateGivenName(id: UserId, newValue: GivenName): IO[RequestRejectedException, UserId] = (for { @@ -171,6 +214,7 @@ final case class UserHandler(repo: UserRepo) { * * @param id the user's ID * @param newValue the new family name + * @return the UserId of the newly created user */ def updateFamilyName(id: UserId, newValue: FamilyName): IO[RequestRejectedException, UserId] = (for { @@ -187,6 +231,7 @@ final case class UserHandler(repo: UserRepo) { * @param newPassword the new password * @param currentPassword the user's current password * @param requestingUser the requesting user + * @return the UserId of the newly created user */ def updatePassword( id: UserId, @@ -219,6 +264,7 @@ final case class UserHandler(repo: UserRepo) { * * @param id the user's ID * @param newValue the new language + * @return the UserId of the newly created user */ def updateLanguage(id: UserId, newValue: LanguageCode): IO[RequestRejectedException, UserId] = (for { @@ -232,6 +278,7 @@ final case class UserHandler(repo: UserRepo) { * Deletes the user which means that it is marked as deleted. * * @param id the user's ID + * @return the UserId of the newly created user */ def deleteUser(id: UserId): IO[NotFoundException, UserId] = (for { @@ -242,14 +289,9 @@ final case class UserHandler(repo: UserRepo) { } -/** - * Companion object providing the layer with an initialized implementation - */ object UserHandler { - val layer: ZLayer[UserRepo, Nothing, UserHandler] = - ZLayer { - for { - repo <- ZIO.service[UserRepo] - } yield UserHandler(repo) - }.tap(_ => ZIO.logInfo(">>> User handler initialized <<<")) + val layer: ZLayer[UserRepo & UuidGenerator, Nothing, UserHandler] = + ZLayer + .fromFunction(UserHandler.apply _) + .tap(_ => ZIO.logInfo(">>> User handler initialized <<<")) } diff --git a/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala index 60cb70889c..70f3590baa 100644 --- a/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala +++ b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala @@ -9,11 +9,14 @@ import zio._ import zio.test.Assertion._ import zio.test._ +import java.util.UUID + import dsp.errors.DuplicateValueException import dsp.errors.ForbiddenException import dsp.errors.NotFoundException import dsp.user.repo.impl.UserRepoMock import dsp.user.sharedtestdata.SharedTestData +import dsp.util.UuidGeneratorMock import dsp.valueobjects.Id.UserId import dsp.valueobjects.LanguageCode import dsp.valueobjects.User._ @@ -91,7 +94,7 @@ object UserHandlerSpec extends ZIOSpecDefault { retrievedUsers <- userHandler.getUsers() } yield assertTrue(retrievedUsers.size == 3) } - ).provide(UserRepoMock.layer, UserHandler.layer) + ).provide(UuidGeneratorMock.layer, UserRepoMock.layer, UserHandler.layer) private val createUserTest = suite("createUser")( test("return an Error when creating a user if a username is already taken") { @@ -182,7 +185,7 @@ object UserHandlerSpec extends ZIOSpecDefault { fails(equalTo(DuplicateValueException(s"Email '${email1.value}' already taken"))) ) } - ).provide(UserRepoMock.layer, UserHandler.layer) + ).provide(UuidGeneratorMock.layer, UserRepoMock.layer, UserHandler.layer) private val getUserByTest = suite("getUserBy")( test("store a user and retrieve by ID") { @@ -219,7 +222,8 @@ object UserHandlerSpec extends ZIOSpecDefault { test("return an Error if user not found by ID") { for { userHandler <- ZIO.service[UserHandler] - newUserId <- UserId.make().toZIO + uuid = UUID.randomUUID() + newUserId <- UserId.make(uuid).toZIO error <- userHandler.getUserById(newUserId).exit } yield assert(error)(fails(equalTo(NotFoundException(s"User with ID '${newUserId}' not found")))) }, @@ -300,7 +304,7 @@ object UserHandlerSpec extends ZIOSpecDefault { error <- userHandler.getUserByEmail(email).exit } yield assert(error)(fails(equalTo(NotFoundException(s"User with Email '${email.value}' not found")))) } - ).provide(UserRepoMock.layer, UserHandler.layer) + ).provide(UuidGeneratorMock.layer, UserRepoMock.layer, UserHandler.layer) private val updateUserTest = suite("updateUser")( test("store a user and update the username") { @@ -640,7 +644,7 @@ object UserHandlerSpec extends ZIOSpecDefault { assertTrue(retrievedUser.language != language) && assertTrue(retrievedUser.status == status) } - ).provide(UserRepoMock.layer, UserHandler.layer) + ).provide(UuidGeneratorMock.layer, UserRepoMock.layer, UserHandler.layer) private val deleteUserTest = suite("deleteUser")( test("delete a user") { @@ -670,19 +674,7 @@ object UserHandlerSpec extends ZIOSpecDefault { usernameNotFound <- userHandler.getUserByUsername(username1).exit emailNotFound <- userHandler.getUserByEmail(email1).exit - // create new user with same values - - newUserId <- userHandler.createUser( - username1, - email1, - givenName1, - familyName1, - password1, - language, - status - ) } yield assertTrue(id == userId) && - assertTrue(userId != newUserId) && assert(idNotFound)(fails(equalTo(NotFoundException(s"User with ID '${userId}' not found")))) && assert(usernameNotFound)( fails(equalTo(NotFoundException(s"User with Username '${username1.value}' not found"))) @@ -694,11 +686,12 @@ object UserHandlerSpec extends ZIOSpecDefault { test("return an error if the ID of a user is not found when trying to delete the user") { for { userHandler <- ZIO.service[UserHandler] - userId <- UserId.make().toZIO + uuid = UUID.randomUUID() + userId <- UserId.make(uuid).toZIO error <- userHandler.deleteUser(userId).exit } yield assert(error)( fails(equalTo(NotFoundException(s"User with ID '${userId}' not found"))) ) } - ).provide(UserRepoMock.layer, UserHandler.layer) + ).provide(UuidGeneratorMock.layer, UserRepoMock.layer, UserHandler.layer) } diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/CreateUser.scala b/dsp-user/interface/src/main/scala/dsp/user/route/CreateUser.scala new file mode 100644 index 0000000000..eef7e2a8c6 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/route/CreateUser.scala @@ -0,0 +1,94 @@ +package dsp.user.route + +import zhttp.http.Request +import zhttp.http._ +import zio._ +import zio.json._ +import zio.prelude.Validation + +import dsp.config.AppConfig +import dsp.errors.ValidationException +import dsp.user.handler.UserHandler +import dsp.util.UuidGenerator +import dsp.valueobjects.LanguageCode +import dsp.valueobjects.User._ + +object CreateUser { + + /** + * The route to create a user + * + * @param req the request that was sent + * @param userHandler the userHandler that handles user actions + * @return a response with the user as json + */ + def route(req: Request, userHandler: UserHandler): RIO[AppConfig & UuidGenerator, Response] = + for { + // get the appConfig from the environment + appConfig <- ZIO.service[AppConfig] + + userCreatePayload <- + req.body.asString.map(_.fromJson[CreateUserPayload]).orElseFail(ValidationException("Couldn't parse payload")) + + response <- + userCreatePayload match { + case Left(e) => { + ZIO.fail(ValidationException(s"Invalid payload: $e")) + } + + case Right(u) => { + val username = Username.make(u.username) + val email = Email.make(u.email) + val givenName = GivenName.make(u.givenName) + val familyName = FamilyName.make(u.familyName) + val password = PasswordHash.make( + u.password, + // at this point in time the config has already been checked, so it is OK to use unsafeMake() + PasswordStrength.unsafeMake( + appConfig.bcryptPasswordStrength + ) + ) + val language = LanguageCode.make(u.language) + val status = UserStatus.make(u.status) + + for { + userId <- + Validation + .validateWith(username, email, givenName, familyName, password, language, status)( + userHandler.createUser _ + ) + // in case of errors, all errors are collected and returned in a list + // TODO what should be the type of the exception? + .fold(e => ZIO.fail(ValidationException(e.map(err => err.getMessage()).toCons.toString)), v => v) + user <- userHandler.getUserById(userId).orDie + } yield Response.json(user.toJson) + } + } + } yield response + + /** + * The payload needed to create a user. + * + * @param username + * @param email + * @param givenName + * @param familyName + * @param password + * @param language + * @param status + */ + final case class CreateUserPayload private ( + username: String, + email: String, + givenName: String, + familyName: String, + password: String, + language: String, + status: Boolean + ) + + object CreateUserPayload { + implicit val decoder: JsonDecoder[CreateUserPayload] = DeriveJsonDecoder.gen[CreateUserPayload] + } + +} diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/GetUser.scala b/dsp-user/interface/src/main/scala/dsp/user/route/GetUser.scala new file mode 100644 index 0000000000..a06e629930 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/route/GetUser.scala @@ -0,0 +1,29 @@ +package dsp.user.route + +import zhttp.http.Response +import zio._ +import zio.json._ + +import java.util.UUID + +import dsp.user.handler.UserHandler +import dsp.valueobjects.Id + +object GetUser { + + /** + * The route to get a user by UUID + * + * @param uuid the UUID of the user + * @param userHandler the userHandler that handles user actions + * @return a response with the user as json + */ + def route(uuid: String, userHandler: UserHandler): Task[Response] = { + val userUuid = UUID.fromString(uuid) + for { + userId <- Id.UserId.make(userUuid).toZIO + user <- userHandler.getUserById(userId) + } yield Response.json(user.toJson) + } + +} diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/MigrateUser.scala b/dsp-user/interface/src/main/scala/dsp/user/route/MigrateUser.scala new file mode 100644 index 0000000000..40249802cb --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/route/MigrateUser.scala @@ -0,0 +1,104 @@ +package dsp.user.route + +import zhttp.http.Request +import zhttp.http._ +import zio._ +import zio.json._ +import zio.prelude.Validation +import zio.prelude.ZValidation.Failure +import zio.prelude.ZValidation.Success + +import dsp.config.AppConfig +import dsp.errors.ValidationException +import dsp.user.handler.UserHandler +import dsp.valueobjects.Id +import dsp.valueobjects.Iri +import dsp.valueobjects.LanguageCode +import dsp.valueobjects.User._ + +object MigrateUser { + + /** + * The route to migrate an existing user + * + * @param req the request that was sent + * @param userHandler the userHandler that handles user actions + * @return a response with the user as json + */ + def route(req: Request, userHandler: UserHandler): RIO[AppConfig, Response] = + for { + // get the appConfig from the environment + appConfig <- ZIO.service[AppConfig] + + userMigratePayload <- + req.body.asString.map(_.fromJson[MigrateUserPayload]).orElseFail(ValidationException("Couldn't parse payload")) + + response <- + userMigratePayload match { + case Left(e) => { + ZIO.fail(ValidationException(s"Invalid payload: $e")) + } + + case Right(u) => { + val iri = Iri.UserIri.make(u.iri) + val id = iri match { + case Failure(_, errors) => Validation.failNonEmptyChunk(errors) + case Success(_, value) => Id.UserId.fromIri(value) + } + val username = Username.make(u.username) + val email = Email.make(u.email) + val givenName = GivenName.make(u.givenName) + val familyName = FamilyName.make(u.familyName) + val password = PasswordHash.make( + u.password, + // at this point in time the config has already been checked, so it is OK to use unsafeMake() + PasswordStrength.unsafeMake( + appConfig.bcryptPasswordStrength + ) + ) + val language = LanguageCode.make(u.language) + val status = UserStatus.make(u.status) + + for { + userId <- + Validation + .validateWith(id, username, email, givenName, familyName, password, language, status)( + userHandler.migrateUser _ + ) + // in case of errors, all errors are collected and returned in a list + // TODO what should be the type of the exception? + .fold(e => ZIO.fail(ValidationException(e.map(err => err.getMessage()).toCons.toString)), v => v) + user <- userHandler.getUserById(userId).orDie + } yield Response.json(user.toJson) + } + } + } yield response + + /** + * The payload needed to migrate a user. It is the same as [[CreateUserPayload]] but with the existing IRI. + * + * @param iri + * @param username + * @param email + * @param givenName + * @param familyName + * @param password + * @param language + * @param status + */ + final case class MigrateUserPayload private ( + iri: String, + username: String, + email: String, + givenName: String, + familyName: String, + password: String, + language: String, + status: Boolean + ) + + object MigrateUserPayload { + implicit val decoder: JsonDecoder[MigrateUserPayload] = DeriveJsonDecoder.gen[MigrateUserPayload] + } + +} diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala deleted file mode 100644 index 85db86dc14..0000000000 --- a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. - * SPDX-License-Identifier: Apache-2.0 - */ - -package dsp.user.route - -// this placeholder can be removed as soon as the code are there -// without it there is a warning -object placeholder diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoutes.scala b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoutes.scala new file mode 100644 index 0000000000..ec301d3153 --- /dev/null +++ b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoutes.scala @@ -0,0 +1,53 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.route + +import zhttp.http._ +import zio._ + +import dsp.config.AppConfig +import dsp.errors.ValidationException +import dsp.user.handler.UserHandler +import dsp.util.UuidGenerator + +/** + * The UserRoutes case class which needs an instance of a userHandler + */ +final case class UserRoutes(userHandler: UserHandler) { + + /** + * The user related routes which need AppConfig in the environment + */ + val routes: HttpApp[AppConfig & UuidGenerator, ValidationException] = Http.collectZIO[Request] { + // POST /admin/users + case req @ (Method.POST -> !! / "admin" / "users") => + CreateUser + .route(req, userHandler) + .catchAll { e => + ZIO.succeed(Response.text(e.getMessage).setStatus(Status.BadRequest)) + } + + // POST /admin/users/migration + case req @ (Method.POST -> !! / "admin" / "users" / "migration") => + MigrateUser + .route(req, userHandler) + .catchAll { e => + ZIO.succeed(Response.text(e.getMessage).setStatus(Status.BadRequest)) + } + + // GET /admin/users/:uuid + case Method.GET -> !! / "admin" / "users" / uuid => + GetUser.route(uuid, userHandler).catchAll { e => + ZIO.succeed(Response.text(e.getMessage).setStatus(Status.BadRequest)) + } + + } +} + +object UserRoutes { + val layer: ZLayer[UserHandler, Nothing, UserRoutes] = + ZLayer.fromFunction(UserRoutes.apply _) +} diff --git a/dsp-user/interface/src/test/scala/dsp/user/route/CreateUserSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/route/CreateUserSpec.scala new file mode 100644 index 0000000000..81429fe039 --- /dev/null +++ b/dsp-user/interface/src/test/scala/dsp/user/route/CreateUserSpec.scala @@ -0,0 +1,153 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.route + +import zhttp.http._ +import zio._ +import zio.test.Assertion._ +import zio.test._ + +import dsp.config.AppConfig +import dsp.errors.ValidationException +import dsp.user.handler.UserHandler +import dsp.user.repo.impl.UserRepoMock +import dsp.util._ + +/** + * This spec is used to test [[dsp.user.route.CreateUser]]. + */ +object CreateUserSpec extends ZIOSpecDefault { + + def spec = (createUserTests) + + private val createUserTests = suite("createUser")( + test("return an error when the payload is invalid (empty body)") { + val request: Request = Request( + version = Version.`HTTP/1.1`, + method = Method.POST, + url = URL(!! / "admin" / "users"), + headers = Headers.empty, + body = Body.empty + ) + + for { + userHandler <- ZIO.service[UserHandler] + error <- CreateUser.route(request, userHandler).exit + } yield assert(error)( + fails(equalTo(ValidationException(s"Invalid payload: Unexpected end of input"))) + ) + }, + test("return an error when the payload is invalid (missing attribute)") { + for { + userHandler <- ZIO.service[UserHandler] + request = Request( + version = Version.`HTTP/1.1`, + method = Method.POST, + url = URL(!! / "admin" / "users"), + headers = Headers.empty, + body = Body + .fromString("""{ + "givenName": "Hans", + "username": "hansmuster", + "email": "hans.muster@example.org", + "password": "test", + "language": "en", + "status": true, + "systemAdmin": false + }""".stripMargin) + ) + error <- CreateUser.route(request, userHandler).map(_.body).exit + } yield assert(error)( + fails(equalTo(ValidationException(s"Invalid payload: .familyName(missing)"))) + ) + }, + test("return an error when the payload contains invalid data") { + for { + userHandler <- ZIO.service[UserHandler] + request = Request( + version = Version.`HTTP/1.1`, + method = Method.POST, + url = URL(!! / "admin" / "users"), + headers = Headers.empty, + body = Body + .fromString("""{ + "givenName": "Hans", + "familyName": "Muster", + "username": "hansmuster", + "email": "hans.musterexample.org", + "password": "test", + "language": "en", + "status": true, + "systemAdmin": false + }""".stripMargin) + ) + error <- CreateUser.route(request, userHandler).map(_.body).exit + _ = println(error) + } yield assert(error)( + fails(equalTo(ValidationException(s"List(Email is invalid.)"))) + ) + }, + test("return all errors when the payload contains multiple invalid data") { + for { + userHandler <- ZIO.service[UserHandler] + request = Request( + version = Version.`HTTP/1.1`, + method = Method.POST, + url = URL(!! / "admin" / "users"), + headers = Headers.empty, + body = Body + .fromString("""{ + "givenName": "Hans", + "familyName": "Muster", + "username": "hansmuster", + "email": "hans.musterexample.org", + "password": "test", + "language": "ch", + "status": true, + "systemAdmin": false + }""".stripMargin) + ) + error <- CreateUser.route(request, userHandler).map(_.body).exit + _ = println(error) + } yield assert(error)( + fails(equalTo(ValidationException(s"List(Email is invalid., LanguageCode 'ch' is invalid.)"))) + ) + }, + test("successfully create a user") { + for { + userHandler <- ZIO.service[UserHandler] + request = Request( + version = Version.`HTTP/1.1`, + method = Method.POST, + url = URL(!! / "admin" / "users"), + headers = Headers.empty, + body = Body + .fromString("""{ + "givenName": "Hans", + "familyName": "Muster", + "username": "hansmuster", + "email": "hans.muster@example.org", + "password": "test", + "language": "en", + "status": true, + "systemAdmin": false + }""".stripMargin) + ) + response <- CreateUser.route(request, userHandler).map(_.body) + uuid <- UuidGeneratorMock.getCreatedUuids.head + responseStr <- response.asString + } yield assertTrue( + responseStr.startsWith( + s"""{"id":"http://rdfh.ch/users/$uuid","givenName":"Hans","familyName":"Muster","username":"hansmuster","email":"hans.muster@example.org","password":"$$2a""" + ) && + responseStr.endsWith( + """"language":"en","status":true}""" + ) + ) + } + ).provide(UuidGeneratorMock.layer, AppConfig.live, UserRepoMock.layer, UserHandler.layer) + +} diff --git a/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala deleted file mode 100644 index 85db86dc14..0000000000 --- a/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. - * SPDX-License-Identifier: Apache-2.0 - */ - -package dsp.user.route - -// this placeholder can be removed as soon as the code are there -// without it there is a warning -object placeholder diff --git a/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala index b4a8418218..ba11c32513 100644 --- a/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala +++ b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala @@ -120,9 +120,6 @@ final case class UserRepoLive( } yield id).commit.tap(_ => ZIO.logInfo(s"Deleted user: ${id}")) } -/** - * Companion object providing the layer with an initialized implementation of UserRepo - */ object UserRepoLive { val layer: ZLayer[Any, Nothing, UserRepo] = ZLayer { diff --git a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala index 93f92e285f..15f67bc8e2 100644 --- a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala +++ b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala @@ -124,9 +124,6 @@ final case class UserRepoMock( } -/** - * Companion object providing the layer with an initialized implementation of UserRepo - */ object UserRepoMock { val layer: ZLayer[Any, Nothing, UserRepo] = ZLayer { diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 9fbd9be618..862967c18d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,7 +21,7 @@ object Dependencies { val JenaVersion = "4.6.1" val ZioVersion = "2.0.4" - val ZioHttpVersion = "2.0.0-RC4" + val ZioHttpVersion = "2.0.0-RC11" val ZioJsonVersion = "0.3.0" val ZioConfigVersion = "3.0.2" val ZioSchemaVersion = "0.2.0" @@ -56,9 +56,12 @@ object Dependencies { val jenaText = "org.apache.jena" % "jena-text" % JenaVersion // logging - val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5" // Scala 3 compatible - val slf4jApi = "org.slf4j" % "slf4j-api" % "2.0.5" // the logging interface - val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.4.5" // the logging implementation + val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5" // Scala 3 compatible + val slf4jApi = "org.slf4j" % "slf4j-api" % "2.0.5" // the logging interface + val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.4.5" // the logging implementation + val logbackJsonClassic = "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5" // the logging implementation + val logbackJackson = "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5" // the logging implementation + val jacksonDatabind = "com.fasterxml.jackson.core" % "jackson-databind" % "2.14.0" // the logging implementation // Metrics val aspectjweaver = "org.aspectj" % "aspectjweaver" % "1.9.9.1" @@ -84,19 +87,18 @@ object Dependencies { val chill = "com.twitter" %% "chill" % "0.10.0" // Scala 3 incompatible // other - val diff = "com.sksamuel.diff" % "diff" % "1.1.11" - val gwtServlet = "com.google.gwt" % "gwt-servlet" % "2.10.0" - val icu4j = "com.ibm.icu" % "icu4j" % "72.1" - val jakartaJSON = "org.glassfish" % "jakarta.json" % "2.0.1" - val jodd = "org.jodd" % "jodd" % "3.2.7" - val rdf4jClient = "org.eclipse.rdf4j" % "rdf4j-client" % "4.2.1" - val rdf4jShacl = "org.eclipse.rdf4j" % "rdf4j-shacl" % "4.2.1" - val saxonHE = "net.sf.saxon" % "Saxon-HE" % "11.4" - val scalaGraph = "org.scala-graph" %% "graph-core" % "1.13.5" // Scala 3 incompatible - val scallop = "org.rogach" %% "scallop" % "4.1.0" // Scala 3 compatible - val titaniumJSONLD = "com.apicatalog" % "titanium-json-ld" % "1.3.1" - val xmlunitCore = "org.xmlunit" % "xmlunit-core" % "2.9.0" - val jacksonDatabind = "com.fasterxml.jackson.core" % "jackson-databind" % "2.14.1" + val diff = "com.sksamuel.diff" % "diff" % "1.1.11" + val gwtServlet = "com.google.gwt" % "gwt-servlet" % "2.10.0" + val icu4j = "com.ibm.icu" % "icu4j" % "72.1" + val jakartaJSON = "org.glassfish" % "jakarta.json" % "2.0.1" + val jodd = "org.jodd" % "jodd" % "3.2.7" + val rdf4jClient = "org.eclipse.rdf4j" % "rdf4j-client" % "4.2.1" + val rdf4jShacl = "org.eclipse.rdf4j" % "rdf4j-shacl" % "4.2.1" + val saxonHE = "net.sf.saxon" % "Saxon-HE" % "11.4" + val scalaGraph = "org.scala-graph" %% "graph-core" % "1.13.5" // Scala 3 incompatible + val scallop = "org.rogach" %% "scallop" % "4.1.0" // Scala 3 compatible + val titaniumJSONLD = "com.apicatalog" % "titanium-json-ld" % "1.3.1" + val xmlunitCore = "org.xmlunit" % "xmlunit-core" % "2.9.0" // test val akkaHttpTestkit = "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion // Scala 3 incompatible @@ -176,7 +178,16 @@ object Dependencies { val dspApiMainLibraryDependencies = Seq( zio, - zioMacros + zioMacros, + zioHttp, + zioJson, + zioMetricsConnectors, + zioLogging, + zioLoggingSlf4j, + logbackClassic, + logbackJsonClassic, + logbackJackson, + jacksonDatabind ) // schema project dependencies @@ -200,7 +211,10 @@ object Dependencies { zioTest % Test, zioTestSbt % Test, zioLogging, - zioLoggingSlf4j + zioLoggingSlf4j, + logbackClassic, + zioJson, + zioHttp ) val userHandlerLibraryDependencies = Seq( bouncyCastle, @@ -210,7 +224,9 @@ object Dependencies { zioTest % Test, zioTestSbt % Test, zioLogging, - zioLoggingSlf4j + zioLoggingSlf4j, + logbackClassic, + zioJson ) val userCoreLibraryDependencies = Seq( bouncyCastle, @@ -220,7 +236,9 @@ object Dependencies { zioTest % Test, zioTestSbt % Test, zioLogging, - zioLoggingSlf4j + zioLoggingSlf4j, + logbackClassic, + zioJson ) val userRepoLibraryDependencies = Seq( zio, @@ -228,7 +246,9 @@ object Dependencies { zioTest % Test, zioTestSbt % Test, zioLogging, - zioLoggingSlf4j + zioLoggingSlf4j, + logbackClassic, + zioJson ) // role projects dependencies @@ -278,10 +298,14 @@ object Dependencies { scalaLogging, springSecurityCore, zioPrelude, + zioConfig, + zioConfigMagnolia, + zioConfigTypesafe, zioTest % Test, zioTestSbt % Test, zioLogging, - zioLoggingSlf4j + zioLoggingSlf4j, + zioJson ) // project project dependencies diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index fc9545bd0e..74c24a1305 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -283,6 +283,8 @@ app { external-host = ${?KNORA_WEBAPI_KNORA_API_EXTERNAL_HOST} external-port = 3333 external-port = ${?KNORA_WEBAPI_KNORA_API_EXTERNAL_PORT} + + external-zio-port = 5555 } sipi { diff --git a/webapi/src/main/scala/org/knora/webapi/Main.scala b/webapi/src/main/scala/org/knora/webapi/Main.scala index 524a1a6c0f..2b123f8ffb 100644 --- a/webapi/src/main/scala/org/knora/webapi/Main.scala +++ b/webapi/src/main/scala/org/knora/webapi/Main.scala @@ -7,7 +7,7 @@ package org.knora.webapi import zio._ import zio.logging.backend.SLF4J -import org.knora.webapi.core.AppServer +import org.knora.webapi.core._ object Main extends ZIOApp { @@ -16,7 +16,7 @@ object Main extends ZIOApp { /** * The `Environment` that we require to exist at startup. */ - override type Environment = core.LayersLive.DspEnvironmentLive + override type Environment = LayersLive.DspEnvironmentLive /** * `Bootstrap` will ensure that everything is instantiated when the Runtime is created @@ -26,7 +26,7 @@ object Main extends ZIOApp { ZIOAppArgs, Any, Environment - ] = ZLayer.empty ++ Runtime.removeDefaultLoggers ++ SLF4J.slf4j ++ core.LayersLive.dspLayersLive + ] = ZLayer.empty ++ Runtime.removeDefaultLoggers ++ SLF4J.slf4j ++ LayersLive.dspLayersLive /* Here we start our Application */ override def run = AppServer.live.launch diff --git a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala index be06bf5a8d..be2cefc9c4 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -90,7 +90,8 @@ final case class KnoraApi( internalPort: Int, externalProtocol: String, externalHost: String, - externalPort: Int + externalPort: Int, + externalZioPort: Int ) { val internalKnoraApiBaseUrl: String = "http://" + internalHost + (if (internalPort != 80) ":" + internalPort diff --git a/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala b/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala new file mode 100644 index 0000000000..480c451366 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala @@ -0,0 +1,23 @@ +package org.knora.webapi.core + +import zhttp.service.Server +import zio.ZLayer +import zio._ + +import org.knora.webapi.config.AppConfig +import org.knora.webapi.core._ +import org.knora.webapi.routing.ApiRoutesWithZIOHttp + +object HttpServerWithZIOHttp { + val layer: ZLayer[AppConfig & State & ApiRoutesWithZIOHttp, Nothing, Unit] = + ZLayer { + for { + appConfig <- ZIO.service[AppConfig] + routes <- ZIO.service[ApiRoutesWithZIOHttp].map(_.routes) + port = appConfig.knoraApi.externalZioPort + _ <- Server.start(port, routes).forkDaemon + _ <- ZIO.logInfo(">>> Acquire ZIO HTTP Server <<<") + } yield () + } + +} diff --git a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala index 82cfabe441..4b529ec470 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -1,10 +1,13 @@ package org.knora.webapi.core +import zio.ULayer import zio.ZLayer import org.knora.webapi.auth.JWTService import org.knora.webapi.config.AppConfig import org.knora.webapi.routing.ApiRoutes +import org.knora.webapi.routing.ApiRoutesWithZIOHttp +import org.knora.webapi.routing.HealthRouteWithZIOHttp import org.knora.webapi.store.cache.CacheServiceManager import org.knora.webapi.store.cache.api.CacheService import org.knora.webapi.store.cache.impl.CacheServiceInMemImpl @@ -40,15 +43,18 @@ object LayersLive { /** * All effect layers needed to provide the `Environment` */ - val dspLayersLive = + val dspLayersLive: ULayer[DspEnvironmentLive] = ZLayer.make[DspEnvironmentLive]( ActorSystem.layer, ApiRoutes.layer, + ApiRoutesWithZIOHttp.layer, // this is the new layer that composes all new routes + HealthRouteWithZIOHttp.layer, // this is the new health route AppConfig.live, AppRouter.layer, CacheServiceManager.layer, CacheServiceInMemImpl.layer, HttpServer.layer, + HttpServerWithZIOHttp.layer, // this is the new ZIO HTTP server layer IIIFServiceManager.layer, IIIFServiceSipiImpl.layer, JWTService.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/core/State.scala b/webapi/src/main/scala/org/knora/webapi/core/State.scala index dd6f277401..15f1167606 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/State.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/State.scala @@ -13,7 +13,7 @@ import org.knora.webapi.core.domain.AppState @accessible trait State { def set(v: AppState): UIO[Unit] - def get: UIO[AppState] + val getAppState: UIO[AppState] } object State { @@ -30,7 +30,7 @@ object State { override def set(v: AppState): UIO[Unit] = state.set(v) *> ZIO.logInfo(s"AppState set to ${v.toString()}") - override val get: UIO[AppState] = + override val getAppState: UIO[AppState] = state.get } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutesWithZIOHttp.scala b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutesWithZIOHttp.scala new file mode 100644 index 0000000000..8efd0f9317 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutesWithZIOHttp.scala @@ -0,0 +1,33 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +import zhttp.http._ +import zio.ZLayer + +import org.knora.webapi.core._ + +/** + * The accumulated routes + * + * @param healthRoute + */ +final case class ApiRoutesWithZIOHttp( + healthRoute: HealthRouteWithZIOHttp +) { + // adds up all the routes + val routes: HttpApp[State, Nothing] = + healthRoute.route // TODO add more routes here with `++ projectRoutes.routes` + +} + +/** + * The layer providing all instantiated routes + */ +object ApiRoutesWithZIOHttp { + val layer: ZLayer[HealthRouteWithZIOHttp, Nothing, ApiRoutesWithZIOHttp] = + ZLayer.fromFunction(ApiRoutesWithZIOHttp.apply _) +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/HealthRoute.scala b/webapi/src/main/scala/org/knora/webapi/routing/HealthRoute.scala index 09d6266075..11b7fde9e4 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/HealthRoute.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/HealthRoute.scala @@ -26,7 +26,7 @@ trait HealthCheck { protected def healthCheck(state: State): UIO[HttpResponse] = for { _ <- ZIO.logDebug("get application state") - state <- state.get + state <- state.getAppState result <- setHealthState(state) _ <- ZIO.logDebug("set health state") response <- createResponse(result) diff --git a/webapi/src/main/scala/org/knora/webapi/routing/HealthRouteWithZIOHttp.scala b/webapi/src/main/scala/org/knora/webapi/routing/HealthRouteWithZIOHttp.scala new file mode 100644 index 0000000000..55477b3f1a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/HealthRouteWithZIOHttp.scala @@ -0,0 +1,155 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +import spray.json.JsObject +import spray.json.JsString +import zhttp.http._ +import zio._ + +import org.knora.webapi.core.State +import org.knora.webapi.core.domain.AppState + +/** + * Provides health check logic + */ +trait HealthCheckWithZIOHttp { + + /** + * gets the application state from a state service called `State` + * + * @param state the state service + * @return a response with the application state + */ + protected def healthCheck(state: State): UIO[Response] = + for { + _ <- ZIO.logDebug("get application state") + state <- state.getAppState + result <- setHealthState(state) + _ <- ZIO.logDebug("set health state") + response <- createResponse(result) + _ <- ZIO.logDebug("getting application state done") + } yield response + + /** + * sets the application's health state to healthy or unhealthy according to the provided state + * + * @param state the application's state + * @return the result which is either unhealthy or healthy + */ + private def setHealthState(state: AppState): UIO[HealthCheckResult] = + ZIO.succeed( + state match { + case AppState.Stopped => unhealthy("Stopped. Please retry later.") + case AppState.StartingUp => unhealthy("Starting up. Please retry later.") + case AppState.WaitingForTriplestore => unhealthy("Waiting for triplestore. Please retry later.") + case AppState.TriplestoreReady => unhealthy("Triplestore ready. Please retry later.") + case AppState.UpdatingRepository => unhealthy("Updating repository. Please retry later.") + case AppState.RepositoryUpToDate => unhealthy("Repository up to date. Please retry later.") + case AppState.CreatingCaches => unhealthy("Creating caches. Please retry later.") + case AppState.CachesReady => unhealthy("Caches ready. Please retry later.") + case AppState.UpdatingSearchIndex => unhealthy("Updating search index. Please retry later.") + case AppState.SearchIndexReady => unhealthy("Search index ready. Please retry later.") + case AppState.LoadingOntologies => unhealthy("Loading ontologies. Please retry later.") + case AppState.OntologiesReady => unhealthy("Ontologies ready. Please retry later.") + case AppState.WaitingForIIIFService => unhealthy("Waiting for IIIF service. Please retry later.") + case AppState.IIIFServiceReady => unhealthy("IIIF service ready. Please retry later.") + case AppState.WaitingForCacheService => unhealthy("Waiting for cache service. Please retry later.") + case AppState.CacheServiceReady => unhealthy("Cache service ready. Please retry later.") + case AppState.MaintenanceMode => unhealthy("Application is in maintenance mode. Please retry later.") + case AppState.Running => healthy + } + ) + + /** + * creates the HTTP response from the health check result (healthy/unhealthy) + * + * @param result the result of the health check + * @return an HTTP response + */ + private def createResponse(result: HealthCheckResult): UIO[Response] = + ZIO.succeed( + Response + .json( + JsObject( + "name" -> JsString("AppState"), + "severity" -> JsString("non fatal"), + "status" -> JsString(status(result.status)), + "message" -> JsString(result.message) + ).toString() + ) + .setStatus(statusCode(result.status)) + ) + + /** + * returns a string representation "healthy" or "unhealthy" from a boolean + * + * @param s a boolean from which to derive the state + * @return either "healthy" or "unhealthy" + */ + private def status(s: Boolean): String = if (s) "healthy" else "unhealthy" + + /** + * returns the HTTP status according to the input boolean + * + * @param s a boolean from which to derive the HTTP status + * @return the HTTP status (OK or ServiceUnavailable) + */ + private def statusCode(s: Boolean): Status = if (s) Status.Ok else Status.ServiceUnavailable + + /** + * The result of a health check which is either unhealthy or healthy. + * + * @param name ??? + * @param severity ??? + * @param status the status (either false = unhealthy or true = healthy) + * @param message the message + */ + private case class HealthCheckResult(name: String, severity: String, status: Boolean, message: String) + + private def unhealthy(message: String) = + HealthCheckResult( + name = "AppState", + severity = "non fatal", + status = false, + message = message + ) + + private val healthy = + HealthCheckResult( + name = "AppState", + severity = "non fatal", + status = true, + message = "Application is healthy" + ) +} + +/** + * Provides the '/healthZ' endpoint serving the health status. + */ +final case class HealthRouteWithZIOHttp(state: State) extends HealthCheckWithZIOHttp { + + /** + * Returns the route. + */ + val route: HttpApp[State, Nothing] = + Http.collectZIO[Request] { case Method.GET -> !! / "healthZ" => + for { + // ec <- ZIO.executor.map(_.asExecutionContext) // leave this for reference about how to get the execution context + state <- ZIO.service[State] + response <- healthCheck(state) + } yield response + + } +} + +/** + * Companion object providing the layer + */ +object HealthRouteWithZIOHttp { + val layer: ZLayer[State, Nothing, HealthRouteWithZIOHttp] = + ZLayer.fromFunction(HealthRouteWithZIOHttp.apply _) +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RejectingRoute.scala b/webapi/src/main/scala/org/knora/webapi/routing/RejectingRoute.scala index 27cf4a35f0..40cf4e375b 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RejectingRoute.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RejectingRoute.scala @@ -40,7 +40,7 @@ class RejectingRoute(routeData: KnoraRouteData, runtime: Runtime[State]) { self .runToFuture( for { state <- ZIO.service[State] - state <- state.get + state <- state.getAppState } yield state ) } diff --git a/webapi/src/main/scala/org/knora/webapi/store/cache/impl/CacheServiceInMemImpl.scala b/webapi/src/main/scala/org/knora/webapi/store/cache/impl/CacheServiceInMemImpl.scala index 3d774c60a8..0f683d5820 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/cache/impl/CacheServiceInMemImpl.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/cache/impl/CacheServiceInMemImpl.scala @@ -223,9 +223,6 @@ case class CacheServiceInMemImpl( ZIO.succeed(CacheServiceStatusOK) } -/** - * Companion object providing the layer with an initialized implementation - */ object CacheServiceInMemImpl { val layer: ZLayer[Any, Nothing, CacheService] = ZLayer {