diff --git a/Makefile b/Makefile index c2dcbd7c8b..c1e4e67607 100644 --- a/Makefile +++ b/Makefile @@ -202,15 +202,39 @@ test-repository-upgrade: build init-db-test-minimal ## runs DB upgrade integrati @$(MAKE) -f $(THIS_FILE) stack-up .PHONY: test -test: build ## runs all tests +test: build test-shared test-user-slice test-role-slice test-project-slice ## runs all tests sbt -v coverage "webapi/test" + sbt -v coverage "schemaCore/test" + sbt coverageAggregate + +.PHONY: test-shared +test-shared: ## tests the shared projects (build is not called from this target) sbt -v coverage "shared/test" + +.PHONY: test-user-slice +test-user-slice: ## tests all projects relating to the user slice (build is not called from this target) sbt -v coverage "userCore/test" sbt -v coverage "userHandler/test" sbt -v coverage "userInterface/test" sbt -v coverage "userRepo/test" sbt coverageAggregate +.PHONY: test-role-slice +test-role-slice: ## tests all projects relating to the role slice (build is not called from this target) + sbt -v coverage "roleCore/test" + sbt -v coverage "roleHandler/test" + sbt -v coverage "roleInterface/test" + sbt -v coverage "roleRepo/test" + sbt coverageAggregate + +.PHONY: test-project-slice +test-project-slice: ## tests all projects relating to the project slice (build is not called from this target) + sbt -v coverage "projectCore/test" + sbt -v coverage "projectHandler/test" + sbt -v coverage "projectInterface/test" + sbt -v coverage "projectRepo/test" + sbt coverageAggregate + ################################# ## Database Management diff --git a/build.sbt b/build.sbt index a962036640..a25bd5db0f 100644 --- a/build.sbt +++ b/build.sbt @@ -37,14 +37,22 @@ lazy val root: Project = Project(id = "root", file(".")) webapi, sipi, shared, + // user userCore, userHandler, userRepo, userInterface, + // role roleCore, - roleRepo, roleHandler, + roleRepo, roleInterface, + // project + projectCore, + projectHandler, + projectRepo, + projectInterface, + // schema schemaCore ) .enablePlugins(GitVersioning, GitBranchPrompt) @@ -352,6 +360,52 @@ lazy val userCore = project ) .dependsOn(shared) +// project projects + +lazy val projectInterface = project + .in(file("dsp-project/interface")) + .settings( + scalacOptions ++= customScalacOptions, + name := "projectInterface", + libraryDependencies ++= Dependencies.projectInterfaceLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared, projectHandler) + +lazy val projectHandler = project + .in(file("dsp-project/handler")) + .settings( + scalacOptions ++= customScalacOptions, + name := "projectHandler", + libraryDependencies ++= Dependencies.projectHandlerLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn( + shared, + projectCore, + projectRepo % "test->test" + ) // projectHandler tests need mock implementation of ProjectRepo + +lazy val projectCore = project + .in(file("dsp-project/core")) + .settings( + scalacOptions ++= customScalacOptions, + name := "projectCore", + libraryDependencies ++= Dependencies.projectCoreLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared) + +lazy val projectRepo = project + .in(file("dsp-project/repo")) + .settings( + scalacOptions ++= customScalacOptions, + name := "projectRepo", + libraryDependencies ++= Dependencies.projectRepoLibraryDependencies, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) + ) + .dependsOn(shared, projectCore) + // schema projects lazy val schemaCore = project diff --git a/dsp-project/core/src/main/scala/dsp/project/api/ProjectRepo.scala b/dsp-project/core/src/main/scala/dsp/project/api/ProjectRepo.scala new file mode 100644 index 0000000000..ef5eaa9e12 --- /dev/null +++ b/dsp-project/core/src/main/scala/dsp/project/api/ProjectRepo.scala @@ -0,0 +1,70 @@ +/* + * 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.project.api + +import zio._ +import zio.macros.accessible + +import dsp.project.domain._ +import dsp.valueobjects.Project._ +import dsp.valueobjects._ + +/** + * The trait (interface) for the project repository. The project repository is responsible for storing and retrieving projects. + * Needs to be used by the project repository implementations. + */ +@accessible // with this annotation we don't have to write the companion object ourselves +trait ProjectRepo { + + /** + * Writes a project to the repository (used for both create and update). + * If this fails (e.g. the triplestore is not available), it's a non-recovable error. That's why we need UIO. + * When used, we should do it like: ...store(...).orDie + * + * @param project the project to write + * @return The project ID + */ + def storeProject(project: Project): UIO[ProjectId] + + /** + * Gets all projects from the repository. + * + * @return a list of [[Project]] + */ + def getProjects(): UIO[List[Project]] + + /** + * Retrieves the project from the repository by ID. + * + * @param id the project's ID + * @return an optional [[Project]] + */ + def getProjectById(id: ProjectId): IO[Option[Nothing], Project] + + /** + * Retrieves the project from the repository by ShortCode. + * + * @param shortCode ShortCode of the project. + * @return an optional [[Project]]. + */ + def getProjectByShortCode(shortCode: ShortCode): IO[Option[Nothing], Project] + + /** + * Checks if a project ShortCode is available or if it already exists in the repo. + * + * @param shortCode ShortCode of the project. + * @return Success of Unit if the ShortCode is available, Error of None if not. + */ + def checkIfShortCodeIsAvailable(shortCode: ShortCode): IO[Option[Nothing], Unit] + + /** + * Deletes a [[Project]] from the repository by its [[ProjectId]]. + * + * @param id the project ID + * @return Project ID or None if not found + */ + def deleteProject(id: ProjectId): IO[Option[Nothing], ProjectId] +} diff --git a/dsp-project/core/src/main/scala/dsp/project/domain/ProjectDomain.scala b/dsp-project/core/src/main/scala/dsp/project/domain/ProjectDomain.scala new file mode 100644 index 0000000000..d3237a76ac --- /dev/null +++ b/dsp-project/core/src/main/scala/dsp/project/domain/ProjectDomain.scala @@ -0,0 +1,67 @@ +/* + * Copyright © 2021 - 2022 Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.project.domain + +import zio.prelude.Validation + +import dsp.errors.ValidationException +import dsp.valueobjects.Project._ +import dsp.valueobjects._ + +/** + * Represents the project domain object. + * + * @param id the ID of the project + * @param name the name of the project + * @param description the description of the project + */ +sealed abstract case class Project private ( + id: ProjectId, + name: Name, + description: ProjectDescription + // TODO-BL: [domain-model] missing status, shortname, selfjoin +) extends Ordered[Project] { self => + + /** + * Allows to sort collections of [[Project]]s. Sorting is done by the IRI. + */ + def compare(that: Project): Int = self.id.iri.toString().compareTo(that.id.iri.toString()) + + /** + * Updates the name of the project. + * + * @param name the new name + * @return the updated Project or a ValidationException + */ + def updateProjectName(name: Name): Validation[ValidationException, Project] = + Project.make( + id = self.id, + name = name, + description = self.description + ) + + /** + * Updates the description of the project. + * + * @param description the new description + * @return the updated Project or a ValidationException + */ + def updateProjectDescription(description: ProjectDescription): Validation[ValidationException, Project] = + Project.make( + id = self.id, + name = self.name, + description = description + ) +} +object Project { + def make( + id: ProjectId, + name: Name, + description: ProjectDescription + ): Validation[ValidationException, Project] = + Validation.succeed(new Project(id = id, name = name, description = description) {}) + +} diff --git a/dsp-project/core/src/test/scala/dsp/project/domain/ProjectDomainSpec.scala b/dsp-project/core/src/test/scala/dsp/project/domain/ProjectDomainSpec.scala new file mode 100644 index 0000000000..a7d14e73c6 --- /dev/null +++ b/dsp-project/core/src/test/scala/dsp/project/domain/ProjectDomainSpec.scala @@ -0,0 +1,96 @@ +/* + * 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.project.domain + +import zio.test._ + +import dsp.valueobjects.Iri +import dsp.valueobjects.Project._ +import dsp.valueobjects.ProjectId +import dsp.valueobjects.V2 + +/** + * This spec is used to test [[dsp.project.domain.ProjectDomain]]. + */ +object ProjectDomainSpec extends ZIOSpecDefault { + + private val shortCode = ShortCode.make("0001").fold(e => throw e.head, v => v) + private val id = ProjectId.make(shortCode).fold(e => throw e.head, v => v) + private val name = Name.make("proj").fold(e => throw e.head, v => v) + private val description = ProjectDescription + .make(Seq(V2.StringLiteralV2("A Project", Some("en")))) + .fold(e => throw e.head, v => v) + + override def spec = + suite("ProjectDomainSpec")( + projectCreateTests, + projectCompareTests, + projectUpdateTests + ) + + val projectCreateTests = suite("create project")( + test("create a project from valid input") { + (for { + project <- Project.make(id, name, description) + } yield ( + assertTrue(project.id == id) && + assertTrue(project.name == name) && + assertTrue(project.description == description) + )).toZIO + } + ) + + val projectCompareTests = suite("compare projects")( + test("compare projects by IRI") { + val iri1String = s"http://rdfh.ch/projects/d78c6cc8-0a18-4131-af72-4bb6cb688bed" + val iri2String = s"http://rdfh.ch/projects/f4184d7a-caf7-4ab9-991e-d5da9eb7ec17" + (for { + iri1 <- Iri.ProjectIri.make(iri1String) + iri2 <- Iri.ProjectIri.make(iri2String) + id1 <- ProjectId.fromIri(iri1, shortCode) + id2 <- ProjectId.fromIri(iri2, shortCode) + project1 <- Project.make(id1, name, description) + project2 <- Project.make(id2, name, description) + listInitial = List(project1, project2) + listSorted = listInitial.sorted + listSortedInverse = listInitial.sortWith(_ > _) + } yield ( + assertTrue(listInitial == listSorted) && + assertTrue(listInitial != listSortedInverse) && + assertTrue(listInitial == listSortedInverse.reverse) + )).toZIO + } + ) + + val projectUpdateTests = suite("update project")( + test("update a project name when provided a valid new name") { + (for { + newName <- Name.make("new project name") + project <- Project.make(id, name, description) + updatedProject <- project.updateProjectName(newName) + } yield ( + assertTrue(project.id == updatedProject.id) && + assertTrue(project.name != updatedProject.name) && + assertTrue(project.description == updatedProject.description) && + assertTrue(project.name == name) && + assertTrue(updatedProject.name == newName) + )).toZIO + }, + test("update a project description when provided a valid new description") { + (for { + newDescription <- ProjectDescription.make(Seq(V2.StringLiteralV2("new project name", Some("en")))) + project <- Project.make(id, name, description) + updatedProject <- project.updateProjectDescription(newDescription) + } yield ( + assertTrue(project.id == updatedProject.id) && + assertTrue(project.name == updatedProject.name) && + assertTrue(project.description != updatedProject.description) && + assertTrue(project.description == description) && + assertTrue(updatedProject.description == newDescription) + )).toZIO + } + ) +} 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 new file mode 100644 index 0000000000..918ca60ad7 --- /dev/null +++ b/dsp-project/handler/src/main/scala/dsp/project/handler/ProjectHandler.scala @@ -0,0 +1,167 @@ +/* + * 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.project.handler + +import zio._ + +import dsp.errors.DuplicateValueException +import dsp.errors.NotFoundException +import dsp.errors.RequestRejectedException +import dsp.project.api.ProjectRepo +import dsp.project.domain.Project +import dsp.valueobjects.Project._ +import dsp.valueobjects._ + +/** + * The project handler. + * + * @param repo the project repository + */ +final case class ProjectHandler(repo: ProjectRepo) { + + /** + * Retrieves all projects (sorted by IRI). + * + * @return a list of all projects + */ + def getProjects(): UIO[List[Project]] = + repo.getProjects().map(_.sorted).tap(p => ZIO.logInfo(s"Got all projects: ${p.size}")) + + /** + * Retrieves a project by ID. + * + * @param id the project's ID + * @return either a NotFoundException or the requested Project + */ + def getProjectById(id: ProjectId): IO[NotFoundException, Project] = + for { + user <- repo + .getProjectById(id) + .mapError(_ => NotFoundException(s"Project with ID ${id} found")) + .tapBoth( + _ => ZIO.logInfo(s"Could not find project with ID '${id}'"), + _ => ZIO.logInfo(s"Looked up project by ID '${id}'") + ) + } yield user + + /** + * Retrieves a project by short code. + * + * @param shortCode the project's short code + * @return either a NotFoundException or the requested Project + */ + def getProjectByShortCode(shortCode: ShortCode): IO[NotFoundException, Project] = + repo + .getProjectByShortCode(shortCode) + .mapError(_ => NotFoundException(s"Project with shortCode ${shortCode.value} not found")) + .tapBoth( + _ => ZIO.logInfo(s"Could not find project with shortCode '${shortCode}'"), + _ => ZIO.logInfo(s"Looked up project by shortCode '${shortCode}'") + ) + + /** + * Checks if a short code is already taken. + * + * @param shortCode the project's shortCode + * @return either a DuplicatedValueException or Unit if the short code is not taken + */ + private def checkIfShortCodeTaken(shortCode: ShortCode): IO[DuplicateValueException, Unit] = + for { + _ <- repo + .checkIfShortCodeIsAvailable(shortCode) + .mapError(_ => DuplicateValueException(s"ShortCode ${shortCode.value} already exists")) + .tapBoth( + _ => ZIO.logInfo(s"ShortCode '${shortCode}' is already taken"), + _ => ZIO.logInfo(s"Checked shortCode '${shortCode}' which is not yet taken") + ) + } yield () + + /** + * Creates a project given all necessary information. + * + * @param shortCode the project's short code + * @param name the project's name + * @param description the project's descriptions + * @return either a throwable if creation failed, or the ID of the newly created project + */ + def createProject( + project: Project + ): IO[Throwable, ProjectId] = + (for { + _ <- checkIfShortCodeTaken(project.id.shortCode) // TODO: reserve shortcode + id <- repo.storeProject(project) + } yield id) + .tapBoth( + e => ZIO.logInfo(s"Failed to create project with shortCode '${project.id.shortCode}': $e"), + projectId => ZIO.logInfo(s"Created project with ID '${projectId}'") + ) + + /** + * Deletes a project. + * + * @param id the project's ID + * @return either a NotFoundException or the project ID of the successfully deleted project + */ + def deleteProject(id: ProjectId): IO[NotFoundException, ProjectId] = + (for { + _ <- repo + .deleteProject(id) + .mapError(_ => NotFoundException(s"Project with ID '${id}' not found")) + } yield id) + .tapBoth( + _ => ZIO.logInfo(s"Could not delete project with ID '${id}'"), + _ => ZIO.logInfo(s"Deleted project with ID '${id}'") + ) + + /** + * Updates the name of a project. + * + * @param id the ID of the project to be updated + * @param value the new name of the project + * @return either the project ID if successfful, or a RequestRejectedException if not successful + */ + def updateProjectName(id: ProjectId, value: Name): IO[RequestRejectedException, ProjectId] = + (for { + project <- getProjectById(id) + updatedProject <- project.updateProjectName(value).toZIO + resultId <- repo.storeProject(updatedProject) + } yield resultId) + .tapBoth( + _ => ZIO.logInfo(s"Failed to update project name of project $id: "), + _ => ZIO.logInfo(s"Successfully updated the name of project $id to '$value'") + ) + + /** + * Updates the description of a project. + * + * @param id the ID of the project to be updated + * @param value the new description of the project + * @return either the project ID if successful, or a RequestRejectedException if not successful + */ + def updateProjectDescription(id: ProjectId, value: ProjectDescription): IO[RequestRejectedException, ProjectId] = + (for { + project <- getProjectById(id) + updatedProject <- project.updateProjectDescription(value).toZIO + resultId <- repo.storeProject(updatedProject) + } yield resultId) + .tapBoth( + _ => ZIO.logInfo(s"Failed to update project description of project $id."), + _ => ZIO.logInfo(s"Successfully updated the description of project $id.") + ) + +} + +/** + * Companion object providing the layer with an initialized implementation + */ +object ProjectHandler { + val layer: ZLayer[ProjectRepo, Nothing, ProjectHandler] = + ZLayer { + for { + repo <- ZIO.service[ProjectRepo] + } yield ProjectHandler(repo) + }.tap(_ => ZIO.debug(">>> Project handler initialized <<<")) +} diff --git a/dsp-project/handler/src/test/scala/dsp/project/handler/ProjectHandlerSpec.scala b/dsp-project/handler/src/test/scala/dsp/project/handler/ProjectHandlerSpec.scala new file mode 100644 index 0000000000..e11a90e281 --- /dev/null +++ b/dsp-project/handler/src/test/scala/dsp/project/handler/ProjectHandlerSpec.scala @@ -0,0 +1,169 @@ +/* + * 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.project.handler + +import zio._ +import zio.prelude._ +import zio.test.Assertion._ +import zio.test._ + +import dsp.errors.DuplicateValueException +import dsp.errors.NotFoundException +import dsp.project.domain.Project +import dsp.project.repo.impl.ProjectRepoMock +import dsp.valueobjects.Project._ +import dsp.valueobjects.ProjectId +import dsp.valueobjects.V2 + +object ProjectHandlerSpec extends ZIOSpecDefault { + + private def initializeHandler(projects: Project*): URIO[ProjectHandler with ProjectRepoMock, ProjectHandler] = + for { + repo <- ZIO.service[ProjectRepoMock] + _ <- repo.initializeRepo(projects: _*) + handler <- ZIO.service[ProjectHandler] + } yield handler + + private def getValidated[NonEmptyChunk[E], A](validation: Validation[Throwable, A]): A = + validation.fold(e => throw e.head, v => v) + + private val shortCode = getValidated(ShortCode.make("0000")) + private val shortCode2 = getValidated(ShortCode.make("0001")) + private val id = getValidated(ProjectId.make(shortCode)) + private val id2 = getValidated(ProjectId.make(shortCode2)) + private val name = getValidated(Name.make("projectName")) + private val name2 = getValidated(Name.make("projectName 2")) + private val description = getValidated( + ProjectDescription.make(Seq(V2.StringLiteralV2("project description", Some("en")))) + ) + private val description2 = getValidated( + ProjectDescription.make(Seq(V2.StringLiteralV2("different project description", Some("en")))) + ) + private val project = getValidated(Project.make(id, name, description)) + private val project2 = getValidated(Project.make(id2, name2, description2)) + + def spec = suite("ProjectHandlerSpec")( + getProjectsTests, + getProjectTests, + createProjectTests, + deleteProjectTests, + updateProjectTests + ).provide(ProjectRepoMock.layer, ProjectHandler.layer) + + private val getProjectsTests = suite("get all projects")( + test("return an empty list when requesting all projects, when there are none") { + for { + handler <- initializeHandler() + retrievedProjects <- handler.getProjects() + } yield assertTrue(retrievedProjects == List.empty) + }, + test("return all projects when several are stored") { + for { + handler <- initializeHandler(project, project2) + retrievedProjects <- handler.getProjects() + shortCodeSet = retrievedProjects.map(_.id.shortCode).toSet + } yield assertTrue(retrievedProjects.length == 2) && + assertTrue(shortCodeSet == Set(shortCode, shortCode2)) + } + ) + + private val getProjectTests = suite("get a single project")( + suite("get a project by ID")( + test("return an error if a project is requested that does not exist") { + for { + handler <- initializeHandler() + res <- handler.getProjectById(id).exit + } yield assert(res)(failsWithA[NotFoundException]) + }, + test("return a project that exists") { + for { + handler <- initializeHandler(project) + res <- handler.getProjectById(id) + } yield assertTrue(res.id == id) + } + ), + suite("get a project by shortCode")( + test("return an error if a project is requested that does not exist") { + for { + handler <- initializeHandler() + res <- handler.getProjectByShortCode(shortCode).exit + } yield assert(res)(failsWithA[NotFoundException]) + }, + test("return a project that exists") { + for { + handler <- initializeHandler(project) + res <- handler.getProjectByShortCode(shortCode) + } yield assertTrue(res.id == id) + } + ) + ) + + private val createProjectTests = suite("create a project")( + test("successfully create a project") { + for { + handler <- ZIO.service[ProjectHandler] + id <- handler.createProject(project) + } yield assertTrue(id.shortCode == shortCode) + }, + test("fail to create a project with an occupied shortCode") { + for { + handler <- ZIO.service[ProjectHandler] + _ <- handler.createProject(project) + idWithOccupiedShortCode <- ProjectId.make(shortCode).toZIO + projectWithOccupiedShortCode <- Project.make(idWithOccupiedShortCode, name2, description2).toZIO + res <- handler.createProject(projectWithOccupiedShortCode).exit + } yield assert(res)(fails(isSubtype[DuplicateValueException](anything))) + } + ) + + private val deleteProjectTests = suite("delete a project")( + test("successfully delete a project") { + for { + handler <- initializeHandler(project) + deletedId <- handler.deleteProject(id) + retieveAfterDelete <- handler.getProjectById(id).exit + } yield assertTrue(deletedId == id) && + assert(retieveAfterDelete)(fails(isSubtype[NotFoundException](anything))) + }, + test("fail to delete a project that does not exist") { + for { + handler <- initializeHandler() + delete <- handler.deleteProject(id).exit + } yield assert(delete)(fails(isSubtype[NotFoundException](anything))) + } + ) + + private val updateProjectTests = suite("update a project")( + suite("update project name")( + test("successfully update a project name with a valid name") { + for { + handler <- initializeHandler(project) + res <- handler.updateProjectName(id, name2).exit + } yield assert(res)(succeeds(equalTo(id))) + }, + test("not update a project name of a non-existing project")( + for { + handler <- initializeHandler(project) + res <- handler.updateProjectName(id2, name2).exit + } yield assert(res)(fails(isSubtype[NotFoundException](anything))) + ) + ), + suite("update project description")( + test("successfully update a project description with a valid description") { + for { + handler <- initializeHandler(project) + res <- handler.updateProjectDescription(id, description2).exit + } yield assert(res)(succeeds(equalTo(id))) + }, + test("not update a project description of a non-existing project")( + for { + handler <- initializeHandler(project) + res <- handler.updateProjectDescription(id2, description2).exit + } yield assert(res)(fails(isSubtype[NotFoundException](anything))) + ) + ) + ) +} diff --git a/dsp-project/interface/src/main/scala/dsp/project/listener/external/ProjectListenerExternal.scala b/dsp-project/interface/src/main/scala/dsp/project/listener/external/ProjectListenerExternal.scala new file mode 100644 index 0000000000..24dd0971ac --- /dev/null +++ b/dsp-project/interface/src/main/scala/dsp/project/listener/external/ProjectListenerExternal.scala @@ -0,0 +1,6 @@ +/* + * 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.project.listener.external diff --git a/dsp-project/interface/src/main/scala/dsp/project/listener/internal/ProjectListenerInternal.scala b/dsp-project/interface/src/main/scala/dsp/project/listener/internal/ProjectListenerInternal.scala new file mode 100644 index 0000000000..d1250b9f2a --- /dev/null +++ b/dsp-project/interface/src/main/scala/dsp/project/listener/internal/ProjectListenerInternal.scala @@ -0,0 +1,6 @@ +/* + * 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.project.listener.internal diff --git a/dsp-project/interface/src/main/scala/dsp/project/route/ProjectRoute.scala b/dsp-project/interface/src/main/scala/dsp/project/route/ProjectRoute.scala new file mode 100644 index 0000000000..13b9eaef43 --- /dev/null +++ b/dsp-project/interface/src/main/scala/dsp/project/route/ProjectRoute.scala @@ -0,0 +1,6 @@ +/* + * 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.project.route diff --git a/dsp-project/interface/src/test/scala/dsp/project/listener/external/ProjectListenerExternalSpec.scala b/dsp-project/interface/src/test/scala/dsp/project/listener/external/ProjectListenerExternalSpec.scala new file mode 100644 index 0000000000..24dd0971ac --- /dev/null +++ b/dsp-project/interface/src/test/scala/dsp/project/listener/external/ProjectListenerExternalSpec.scala @@ -0,0 +1,6 @@ +/* + * 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.project.listener.external diff --git a/dsp-project/interface/src/test/scala/dsp/project/listener/internal/ProjectListenerInternalSpec.scala b/dsp-project/interface/src/test/scala/dsp/project/listener/internal/ProjectListenerInternalSpec.scala new file mode 100644 index 0000000000..d1250b9f2a --- /dev/null +++ b/dsp-project/interface/src/test/scala/dsp/project/listener/internal/ProjectListenerInternalSpec.scala @@ -0,0 +1,6 @@ +/* + * 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.project.listener.internal diff --git a/dsp-project/interface/src/test/scala/dsp/project/route/ProjectRouteSpec.scala b/dsp-project/interface/src/test/scala/dsp/project/route/ProjectRouteSpec.scala new file mode 100644 index 0000000000..13b9eaef43 --- /dev/null +++ b/dsp-project/interface/src/test/scala/dsp/project/route/ProjectRouteSpec.scala @@ -0,0 +1,6 @@ +/* + * 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.project.route diff --git a/dsp-project/repo/src/main/scala/dsp/project/repo/impl/ProjectRepoLive.scala b/dsp-project/repo/src/main/scala/dsp/project/repo/impl/ProjectRepoLive.scala new file mode 100644 index 0000000000..c5b61615cc --- /dev/null +++ b/dsp-project/repo/src/main/scala/dsp/project/repo/impl/ProjectRepoLive.scala @@ -0,0 +1,105 @@ +/* + * 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.project.repo.impl + +import zio._ +import zio.stm.TMap + +import java.util.UUID + +import dsp.project.api.ProjectRepo +import dsp.project.domain.Project +import dsp.valueobjects.Project._ +import dsp.valueobjects.ProjectId + +/** + * Project repo live implementation. + * + * @param projects a map of project UUIDs to projects (UUID -> Project). + * @param lookupTableShortCodeToUuid a map of shortcodes to projects (shortCode -> UUID). + */ +final case class ProjectRepoLive( + projects: TMap[UUID, Project], + lookupTableShortCodeToUuid: TMap[ShortCode, UUID] +) extends ProjectRepo { + + /** + * @inheritDoc + */ + override def storeProject(project: Project): UIO[ProjectId] = + (for { + _ <- projects.put(project.id.uuid, project) + _ <- lookupTableShortCodeToUuid.put(project.id.shortCode, project.id.uuid) + } yield project.id).commit.tap(id => ZIO.logInfo(s"Stored project: ${id.uuid}")) + + /** + * @inheritDoc + */ + override def getProjects(): UIO[List[Project]] = + projects.values.commit.tap(projectList => ZIO.logInfo(s"Looked up all projects, found ${projectList.size}")) + + /** + * @inheritDoc + */ + override def getProjectById(id: ProjectId): IO[Option[Nothing], Project] = + projects + .get(id.uuid) + .commit + .some + .tapBoth( + _ => ZIO.logInfo(s"Could not find project with UUID '${id.uuid}'"), + _ => ZIO.logInfo(s"Looked up project by UUID '${id.uuid}'") + ) + + /** + * @inheritDoc + */ + override def getProjectByShortCode(shortCode: ShortCode): IO[Option[Nothing], Project] = + (for { + uuid <- lookupTableShortCodeToUuid.get(shortCode).some + project <- projects.get(uuid).some + } yield project).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find project with shortCode '${shortCode}'"), + _ => ZIO.logInfo(s"Looked up project by shortCode '${shortCode}'") + ) + + /** + * @inheritDoc + */ + override def checkIfShortCodeIsAvailable(shortCode: ShortCode): IO[Option[Nothing], Unit] = + (for { + exists <- lookupTableShortCodeToUuid.contains(shortCode).commit + _ <- if (exists) ZIO.fail(None) // project shortcode does exist + else ZIO.succeed(()) // project shortcode does not exist + } yield ()).tapBoth( + _ => ZIO.logInfo(s"Checked for project with shortCode '${shortCode.value}', project not found."), + uuid => ZIO.logInfo(s"Checked for project with shortCode '${shortCode.value}', found project with UUID '$uuid'.") + ) + + /** + * @inheritDoc + */ + override def deleteProject(id: ProjectId): IO[Option[Nothing], ProjectId] = + (for { + _ <- projects.get(id.uuid).some + _ <- projects.delete(id.uuid) // removes the values (Project) for the key (UUID) + _ <- lookupTableShortCodeToUuid.delete(id.shortCode) // remove the project also from the lookup table + } yield id).commit.tapBoth( + _ => ZIO.logDebug(s"Did not delete project '${id.uuid}' because it was not in the repository"), + _ => ZIO.logDebug(s"Deleted project: ${id.uuid}") + ) + +} + +object ProjectRepoLive { + val layer: ZLayer[Any, Nothing, ProjectRepo] = + ZLayer { + for { + projects <- TMap.empty[UUID, Project].commit + lookUp <- TMap.empty[ShortCode, UUID].commit + } yield ProjectRepoLive(projects, lookUp) + }.tap(_ => ZIO.logInfo(">>> Project repository initialized <<<")) +} diff --git a/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoImplSpec.scala b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoImplSpec.scala new file mode 100644 index 0000000000..5d2fada29d --- /dev/null +++ b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoImplSpec.scala @@ -0,0 +1,176 @@ +/* + * 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.project.repo.impl + +import zio.prelude.Validation +import zio.test.Assertion._ +import zio.test._ + +import dsp.project.api.ProjectRepo +import dsp.project.domain.Project +import dsp.valueobjects.Project._ +import dsp.valueobjects._ + +/** + * This spec is used to test all [[dsp.user.repo.ProjectRepo]] implementations. + */ +object ProjectRepoImplSpec extends ZIOSpecDefault { + + private def getValidated[NonEmptyChunk[E], A](validation: Validation[Throwable, A]): A = + validation.fold(e => throw e.head, v => v) + + private val shortCode = getValidated(ShortCode.make("0000")) + private val id = getValidated(ProjectId.make(shortCode)) + private val name = getValidated(Name.make("projectName")) + private val description = getValidated( + ProjectDescription.make(Seq(V2.StringLiteralV2("project description", Some("en")))) + ) + private val project = getValidated(Project.make(id, name, description)) + + def spec = suite("ProjectRepoImplSpec")( + projectRepoMockTest, + projectRepoLiveTest + ) + + val getProjectTest = suite("retrieve a single project")( + test("store a project and retrieve it by ID") { + for { + _ <- ProjectRepo.storeProject(project) + storedProject <- ProjectRepo.getProjectById(id) + } yield ( + assertTrue(project == storedProject) + ) + }, + test("store a project and retrieve it by shortCode") { + for { + _ <- ProjectRepo.storeProject(project) + storedProject <- ProjectRepo.getProjectByShortCode(shortCode) + } yield ( + assertTrue(project == storedProject) + ) + }, + test("not retrieve a project by ID that is not in the repo") { + for { + _ <- ProjectRepo.storeProject(project) + shortCode2 <- ShortCode.make("0001").toZIO + id2 <- ProjectId.make(shortCode2).toZIO + storedProject <- ProjectRepo.getProjectById(id2).exit + } yield assert(storedProject)(fails(equalTo(None))) + }, + test("not retrieve a project by shortcode that is not in the repo") { + for { + _ <- ProjectRepo.storeProject(project) + shortCode2 <- ShortCode.make("0001").toZIO + storedProject <- ProjectRepo.getProjectByShortCode(shortCode2).exit + } yield assert(storedProject)(fails(equalTo(None))) + } + ) + + val getProjectsTest = suiteAll("retrieve all projects") { + val project2 = (for { + shortCode <- ShortCode.make("0001") + id <- ProjectId.make(shortCode) + name <- Name.make("projectName") + description <- ProjectDescription.make(Seq(V2.StringLiteralV2("project description", Some("en")))) + project <- Project.make(id, name, description) + } yield project).toZIO.orDie + + test("get no project from an empty repository") { + for { + res <- ProjectRepo.getProjects() + } yield ( + assert(res)(isEmpty) + ) + } + + test("get one project from a repository with one project") { + for { + _ <- ProjectRepo.storeProject(project) + res <- ProjectRepo.getProjects() + } yield ( + assertTrue(res.size == 1) && + assert(res)(hasAt(0)(equalTo(project))) + ) + } + + test("get multiple projects from a repository with multiple projects") { + for { + _ <- ProjectRepo.storeProject(project) + project2 <- project2 + _ <- ProjectRepo.storeProject(project2) + res <- ProjectRepo.getProjects() + resSet = res.toSet + expectedSet = Set(project2, project) + } yield ( + assertTrue(res.size == 2) && + assertTrue(resSet == expectedSet) + ) + } + } + + val storeProjectTest = test("store a project") { + for { + storedId <- ProjectRepo.storeProject(project) + } yield ( + assertTrue(id == storedId) + ) + } + + val checkShortCodeExists = suite("check if shortCode already exists")( + test("not return that a shortCode exists if it does not exist") { + for { + res <- ProjectRepo.checkIfShortCodeIsAvailable(shortCode) + } yield ( + assert(res)(isUnit) + ) + }, + test("return that a shortCode exists if it does indeed exist") { + for { + _ <- ProjectRepo.storeProject(project) + res <- ProjectRepo.checkIfShortCodeIsAvailable(shortCode).exit + } yield ( + assert(res)(fails(equalTo(None))) + ) + } + ) + + val deleteProject = suite("delete project")( + test("do not delete a project that is not in the repository") { + for { + shortCode <- ShortCode.make("0000").toZIO + id <- ProjectId.make(shortCode).toZIO + res <- ProjectRepo.deleteProject(id).exit + } yield ( + assert(res)(fails(equalTo(None))) + ) + }, + test("delete a project if it exists in the repository") { + for { + _ <- ProjectRepo.storeProject(project) + res <- ProjectRepo.deleteProject(id) + } yield ( + assertTrue(res == id) + ) + } + ) + + val projectTests = suite("ProjectRepo")( + storeProjectTest, + getProjectTest, + getProjectsTest, + checkShortCodeExists, + deleteProject + ) + + val projectRepoMockTest = suite("ProjectRepo - Mock")( + projectTests + ).provide(ProjectRepoMock.layer) + + val projectRepoLiveTest = suite("ProjectRepo - Live")( + projectTests + ).provide(ProjectRepoLive.layer) + +} diff --git a/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMock.scala b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMock.scala new file mode 100644 index 0000000000..b430f0fa0e --- /dev/null +++ b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMock.scala @@ -0,0 +1,115 @@ +/* + * 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.project.repo.impl + +import zio._ +import zio.stm.TMap + +import java.util.UUID + +import dsp.project.api.ProjectRepo +import dsp.project.domain.Project +import dsp.valueobjects.Project._ +import dsp.valueobjects.ProjectId + +/** + * Project repo test implementation. Mocks the project repo for tests. + * + * @param projects a map of project UUIDs to projects (UUID -> Project). + * @param lookupTableShortCodeToUuid a map of shortcodes to projects (shortCode -> UUID). + */ +final case class ProjectRepoMock( + projects: TMap[UUID, Project], + lookupTableShortCodeToUuid: TMap[ShortCode, UUID] +) extends ProjectRepo { + + /** + * @inheritDoc + */ + override def storeProject(project: Project): UIO[ProjectId] = + (for { + _ <- projects.put(project.id.uuid, project) + _ <- lookupTableShortCodeToUuid.put(project.id.shortCode, project.id.uuid) + } yield project.id).commit.tap(id => ZIO.logInfo(s"Stored project: ${id.uuid}")) + + /** + * @inheritDoc + */ + override def getProjects(): UIO[List[Project]] = + projects.values.commit.tap(projectList => ZIO.logInfo(s"Looked up all projects, found ${projectList.size}")) + + /** + * @inheritDoc + */ + override def getProjectById(id: ProjectId): IO[Option[Nothing], Project] = + projects + .get(id.uuid) + .commit + .some + .tapBoth( + _ => ZIO.logInfo(s"Could not find project with UUID '${id.uuid}'"), + _ => ZIO.logInfo(s"Looked up project by UUID '${id.uuid}'") + ) + + /** + * @inheritDoc + */ + override def getProjectByShortCode(shortCode: ShortCode): IO[Option[Nothing], Project] = + (for { + uuid <- lookupTableShortCodeToUuid.get(shortCode).some + project <- projects.get(uuid).some + } yield project).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find project with shortCode '${shortCode}'"), + _ => ZIO.logInfo(s"Looked up project by shortCode '${shortCode}'") + ) + + /** + * @inheritDoc + */ + override def checkIfShortCodeIsAvailable(shortCode: ShortCode): IO[Option[Nothing], Unit] = + (for { + exists <- lookupTableShortCodeToUuid.contains(shortCode).commit + _ <- if (exists) ZIO.fail(None) // project shortcode does exist + else ZIO.succeed(()) // project shortcode does not exist + } yield ()).tapBoth( + _ => ZIO.logInfo(s"Checked for project with shortCode '$shortCode', project not found."), + uuid => ZIO.logInfo(s"Checked for project with shortCode '$shortCode', found project with UUID '$uuid'.") + ) + + /** + * @inheritDoc + */ + override def deleteProject(id: ProjectId): IO[Option[Nothing], ProjectId] = + (for { + _ <- projects.get(id.uuid).some + _ <- projects.delete(id.uuid) // removes the values (Project) for the key (UUID) + _ <- lookupTableShortCodeToUuid.delete(id.shortCode) // remove the project also from the lookup table + } yield id).commit.tapBoth( + _ => ZIO.logDebug(s"Did not delete project '${id.uuid}' because it was not in the repository"), + _ => ZIO.logDebug(s"Deleted project: ${id.uuid}") + ) + + /** + * Additional method for the test implementation of ProjectRepo. + * Adds a variable amount of Projects to the Repo + * + * @param pp a various length parameter of Project value objects that should be added to the repo as initialization + */ + def initializeRepo(pp: Project*) = for { + ids <- ZIO.collectAll(pp.map(storeProject(_))) + } yield ids + +} + +object ProjectRepoMock { + val layer: ZLayer[Any, Nothing, ProjectRepoMock] = + ZLayer { + for { + projects <- TMap.empty[UUID, Project].commit + lookUp <- TMap.empty[ShortCode, UUID].commit + } yield ProjectRepoMock(projects, lookUp) + }.tap(_ => ZIO.logInfo(">>> In-memory project repository initialized <<<")) +} diff --git a/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMockSpec.scala b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMockSpec.scala new file mode 100644 index 0000000000..c02e944037 --- /dev/null +++ b/dsp-project/repo/src/test/scala/dsp/project/repo/impl/ProjectRepoMockSpec.scala @@ -0,0 +1,47 @@ +/* + * 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.project.repo.impl + +import zio._ +import zio.test._ + +import dsp.project.domain.Project +import dsp.valueobjects.Project._ +import dsp.valueobjects.ProjectId +import dsp.valueobjects.V2 + +object ProjectRepoMockSpec extends ZIOSpecDefault { + + def spec = + suite("ProjectRepoMockSpec")( + testInitializeRepo + ).provide(ProjectRepoMock.layer) + + val testInitializeRepo = + suite("initialize repo with projects")( + test("initialize repo empty") { + for { + repo <- ZIO.service[ProjectRepoMock] + _ <- repo.initializeRepo() + contents <- repo.getProjects() + } yield (assertTrue(contents == Nil)) + }, + test("initialize repo with a project") { + for { + shortCode <- ShortCode.make("0000").toZIO + id <- ProjectId.make(shortCode).toZIO + name <- Name.make("projectName").toZIO + description <- ProjectDescription.make(Seq(V2.StringLiteralV2("project description", Some("en")))).toZIO + project <- Project.make(id, name, description).toZIO + + repo <- ZIO.service[ProjectRepoMock] + _ <- repo.initializeRepo(project) + contents <- repo.getProjects() + } yield (assertTrue(contents == scala.collection.immutable.List(project))) + } + ) + +} diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala index 6c89f7738e..69efd0f941 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala @@ -8,6 +8,10 @@ package dsp.valueobjects import zio.prelude.Validation import java.util.UUID +import scala.util.Try + +import dsp.errors.ValidationException +import dsp.valueobjects.Iri sealed trait Id object Id { @@ -110,3 +114,66 @@ object Id { } } } + +/** + * Stores the project ID, i.e. UUID, IRI and short code of the project + * + * @param uuid the UUID of the project + * @param iri the IRI of the project + * @param shortCode the shortcode of the project + */ +abstract case class ProjectId private ( + uuid: UUID, + iri: Iri.ProjectIri, + shortCode: Project.ShortCode +) + +/** + * Companion object for ProjectId. Contains factory methods for creating ProjectId instances. + */ +object ProjectId { + + /** + * Generates a ProjectId instance provided a Project IRI and a ShortCode. The UUID is extracted from the IRI. + * + * @param iri the project IRI + * @param shortCode the project short code (as defined by the ARK resolver) + * @return a new ProjectId instance + */ + def fromIri(iri: Iri.ProjectIri, shortCode: Project.ShortCode): Validation[ValidationException, ProjectId] = { + val uuid: Try[UUID] = Try(UUID.fromString(iri.value.split("/").last)) + val projectId: Either[ValidationException, ProjectId] = uuid.toEither.fold( + _ => Left(new ValidationException(IdErrorMessages.IriDoesNotContainUuid(iri))), + uuid => Right(new ProjectId(uuid = uuid, iri = iri, shortCode = shortCode) {}) + ) + Validation.fromEither(projectId) + } + + /** + * Generates a ProjectId instance provided a UUID and a ShortCode. The Project IRI is generated on basis of the UUID. + * + * @param uuid the project UUID + * @param shortCode the project short code (as defined by the ARK resolver) + * @return a new ProjectId instance + */ + def fromUuid(uuid: UUID, shortCode: Project.ShortCode): Validation[ValidationException, ProjectId] = { + val iri = Iri.ProjectIri.make(s"http://rdfh.ch/projects/${uuid}") + iri.map(iri => new ProjectId(uuid = uuid, iri = iri, shortCode = shortCode) {}) + } + + /** + * Generates a ProjectId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * + * @param shortCode the project short code (as defined by the ARK resolver) + * @return a new ProjectId instance + */ + def make(shortCode: Project.ShortCode): Validation[ValidationException, ProjectId] = { + val uuid = UUID.randomUUID() + val iri = Iri.ProjectIri.make(s"http://rdfh.ch/projects/${uuid}") + iri.map(iri => new ProjectId(uuid = uuid, iri = iri, shortCode = shortCode) {}) + } +} + +object IdErrorMessages { + def IriDoesNotContainUuid(iri: Iri) = s"No UUID can be extracted from IRI '${iri.value}'" +} diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala index 07864a1c54..c3bd78054b 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala @@ -8,9 +8,14 @@ package dsp.valueobjects import org.apache.commons.validator.routines.UrlValidator import zio.prelude.Validation +import scala.util.Try + import dsp.errors.BadRequestException +import dsp.errors.ValidationException -sealed trait Iri +sealed trait Iri { + val value: String +} object Iri { // A validator for URLs @@ -99,29 +104,30 @@ object Iri { */ sealed abstract case class ProjectIri private (value: String) extends Iri object ProjectIri { self => - def make(value: String): Validation[Throwable, ProjectIri] = + def make(value: String): Validation[ValidationException, ProjectIri] = if (value.isEmpty) { - Validation.fail(BadRequestException(IriErrorMessages.ProjectIriMissing)) + Validation.fail(ValidationException(IriErrorMessages.ProjectIriMissing)) } else { val isUuid: Boolean = V2UuidValidation.hasUuidLength(value.split("/").last) if (!V2IriValidation.isKnoraProjectIriStr(value)) { - Validation.fail(BadRequestException(IriErrorMessages.ProjectIriInvalid)) + Validation.fail(ValidationException(IriErrorMessages.ProjectIriInvalid)) } else if (isUuid && !V2UuidValidation.isUuidVersion4Or5(value)) { - Validation.fail(BadRequestException(IriErrorMessages.UuidVersionInvalid)) + Validation.fail(ValidationException(IriErrorMessages.UuidVersionInvalid)) } else { - val validatedValue = Validation( + val eitherValue = Try( V2IriValidation.validateAndEscapeProjectIri( value, - throw BadRequestException(IriErrorMessages.ProjectIriInvalid) + throw ValidationException(IriErrorMessages.ProjectIriInvalid) ) - ) + ).toEither.left.map(_.asInstanceOf[ValidationException]) + val validatedValue = Validation.fromEither(eitherValue) validatedValue.map(new ProjectIri(_) {}) } } - def make(value: Option[String]): Validation[Throwable, Option[ProjectIri]] = + def make(value: Option[String]): Validation[ValidationException, Option[ProjectIri]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/LangString.scala b/dsp-shared/src/main/scala/dsp/valueobjects/LangString.scala index b48583c436..89436219d1 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/LangString.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/LangString.scala @@ -1,6 +1,6 @@ package dsp.valueobjects -import zio._ +import com.typesafe.scalalogging.Logger import zio.prelude.Validation import dsp.errors.ValidationException @@ -12,6 +12,8 @@ sealed abstract case class LangString private (language: LanguageCode, value: St object LangString { + val log: Logger = Logger(this.getClass()) + /** * Creates a [[zio.prelude.Validation]] that either fails with a ValidationException or succeeds with a LangString value object. * @@ -53,13 +55,49 @@ object LangString { .fold( e => { val unsafe = new LangString(language, value) {} - ZIO.logWarning(s"Called unsafeMake() for an invalid $unsafe: $e") // TODO-BL: get this to actually log + log.warn(s"Called unsafeMake() for an invalid LangString '$unsafe': $e") unsafe }, langString => langString ) } +/** + * MultiLangString value object + */ +sealed abstract case class MultiLangString private (langStrings: Set[LangString]) + +object MultiLangString { + + /** + * Creates a [[zio.prelude.Validation]] that either fails with a ValidationException, or succeeds with a MultiLangString. + * Ensures that the MultiLangString is not empty and that the languages are unique. + * + * @param values a set of LangString value objects + * @return a validation of a MultiLangString value object + */ + def make(values: Set[LangString]): Validation[ValidationException, MultiLangString] = + values match { + case v if v.isEmpty => Validation.fail(ValidationException(MultiLangStringErrorMessages.MultiLangStringEmptySet)) + case v if v.size > v.map(_.language).size => + val languages = v.toList.map(_.language) + val languageCount = languages.foldLeft[Map[LanguageCode, Int]](Map.empty) { (acc, lang) => + acc.updated(lang, acc.getOrElse(lang, 0) + 1) + } + val nonUniqueLanguages = languageCount.filter { case (_, count) => count > 1 }.keySet + Validation.fail(ValidationException(MultiLangStringErrorMessages.LanguageNotUnique(nonUniqueLanguages))) + case _ => Validation.succeed(new MultiLangString(values) {}) + } +} + object LangStringErrorMessages { val LangStringValueEmpty = "String value cannot be empty." } + +object MultiLangStringErrorMessages { + val MultiLangStringEmptySet = "MultiLangString must consist of at least one LangStirng." + val LanguageNotUnique = (nonUniqueLanguages: Set[LanguageCode]) => { + val issuesString = nonUniqueLanguages.toList.map(_.value).sorted + s"Each Language must only appear once. $issuesString" + } +} diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Project.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Project.scala index 886a87387f..0bde3db951 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Project.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Project.scala @@ -7,31 +7,36 @@ package dsp.valueobjects import zio.prelude.Validation -import dsp.errors.BadRequestException +import scala.util.matching.Regex + +import dsp.errors.ValidationException object Project { + // A regex sub-pattern for project IDs, which must consist of 4 hexadecimal digits. + private val ProjectIDPattern: String = + """\p{XDigit}{4,4}""" + + // A regex for matching a string containing the project ID. + private val ProjectIDRegex: Regex = ("^" + ProjectIDPattern + "$").r // TODO-mpro: longname, description, keywords, logo are missing enhanced validation /** - * Project Shortcode value object. + * Project ShortCode value object. */ - sealed abstract case class Shortcode private (value: String) - object Shortcode { self => - def make(value: String): Validation[Throwable, Shortcode] = + sealed abstract case class ShortCode private (value: String) + object ShortCode { self => + def make(value: String): Validation[ValidationException, ShortCode] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.ShortcodeMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.ShortCodeMissing)) } else { - val validatedValue: Validation[Throwable, String] = Validation( - V2ProjectIriValidation.validateProjectShortcode( - value, - throw BadRequestException(ProjectErrorMessages.ShortcodeInvalid) - ) - ) - validatedValue.map(new Shortcode(_) {}) + ProjectIDRegex.matches(value.toUpperCase) match { + case false => Validation.fail(ValidationException(ProjectErrorMessages.ShortCodeInvalid(value))) + case true => Validation.succeed(new ShortCode(value.toUpperCase) {}) + } } - def make(value: Option[String]): Validation[Throwable, Option[Shortcode]] = + def make(value: Option[String]): Validation[ValidationException, Option[ShortCode]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -39,24 +44,24 @@ object Project { } /** - * Project Shortname value object. + * Project ShortName value object. */ - sealed abstract case class Shortname private (value: String) - object Shortname { self => - def make(value: String): Validation[Throwable, Shortname] = + sealed abstract case class ShortName private (value: String) + object ShortName { self => + def make(value: String): Validation[ValidationException, ShortName] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.ShortnameMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.ShortNameMissing)) } else { val validatedValue = Validation( V2ProjectIriValidation.validateAndEscapeProjectShortname( value, - throw BadRequestException(ProjectErrorMessages.ShortnameInvalid) + throw ValidationException(ProjectErrorMessages.ShortNameInvalid(value)) ) - ) - validatedValue.map(new Shortname(_) {}) + ).mapError(e => new ValidationException(e.getMessage())) + validatedValue.map(new ShortName(_) {}) } - def make(value: Option[String]): Validation[Throwable, Option[Shortname]] = + def make(value: Option[String]): Validation[ValidationException, Option[ShortName]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -64,18 +69,20 @@ object Project { } /** - * Project Longname value object. + * Project Name value object. + * (Formerly `Longname`) */ - sealed abstract case class Longname private (value: String) - object Longname { self => - def make(value: String): Validation[Throwable, Longname] = + // TODO-BL: [domain-model] this should be multi-lang-string, I suppose; needs real validation once value constraints are defined + sealed abstract case class Name private (value: String) + object Name { self => + def make(value: String): Validation[ValidationException, Name] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.LongnameMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.NameMissing)) } else { - Validation.succeed(new Longname(value) {}) + Validation.succeed(new Name(value) {}) } - def make(value: Option[String]): Validation[Throwable, Option[Longname]] = + def make(value: Option[String]): Validation[ValidationException, Option[Name]] = value match { case None => Validation.succeed(None) case Some(v) => self.make(v).map(Some(_)) @@ -85,16 +92,18 @@ object Project { /** * ProjectDescription value object. */ + // TODO-BL: [domain-model] should probably be MultiLangString; should probably be called `Description` as it's clear that it's part of Project + // ATM it can't be changed to MultiLangString, because that has the language tag required, whereas in V2, it's currently optional, so this would be a breaking change. sealed abstract case class ProjectDescription private (value: Seq[V2.StringLiteralV2]) // make it plural object ProjectDescription { self => - def make(value: Seq[V2.StringLiteralV2]): Validation[Throwable, ProjectDescription] = + def make(value: Seq[V2.StringLiteralV2]): Validation[ValidationException, ProjectDescription] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.ProjectDescriptionsMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.ProjectDescriptionsMissing)) } else { Validation.succeed(new ProjectDescription(value) {}) } - def make(value: Option[Seq[V2.StringLiteralV2]]): Validation[Throwable, Option[ProjectDescription]] = + def make(value: Option[Seq[V2.StringLiteralV2]]): Validation[ValidationException, Option[ProjectDescription]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -106,14 +115,14 @@ object Project { */ sealed abstract case class Keywords private (value: Seq[String]) object Keywords { self => - def make(value: Seq[String]): Validation[Throwable, Keywords] = + def make(value: Seq[String]): Validation[ValidationException, Keywords] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.KeywordsMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.KeywordsMissing)) } else { Validation.succeed(new Keywords(value) {}) } - def make(value: Option[Seq[String]]): Validation[Throwable, Option[Keywords]] = + def make(value: Option[Seq[String]]): Validation[ValidationException, Option[Keywords]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -125,13 +134,13 @@ object Project { */ sealed abstract case class Logo private (value: String) object Logo { self => - def make(value: String): Validation[Throwable, Logo] = + def make(value: String): Validation[ValidationException, Logo] = if (value.isEmpty) { - Validation.fail(BadRequestException(ProjectErrorMessages.LogoMissing)) + Validation.fail(ValidationException(ProjectErrorMessages.LogoMissing)) } else { Validation.succeed(new Logo(value) {}) } - def make(value: Option[String]): Validation[Throwable, Option[Logo]] = + def make(value: Option[String]): Validation[ValidationException, Option[Logo]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -143,10 +152,10 @@ object Project { */ sealed abstract case class ProjectSelfJoin private (value: Boolean) object ProjectSelfJoin { self => - def make(value: Boolean): Validation[Throwable, ProjectSelfJoin] = + def make(value: Boolean): Validation[ValidationException, ProjectSelfJoin] = Validation.succeed(new ProjectSelfJoin(value) {}) - def make(value: Option[Boolean]): Validation[Throwable, Option[ProjectSelfJoin]] = + def make(value: Option[Boolean]): Validation[ValidationException, Option[ProjectSelfJoin]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -158,10 +167,10 @@ object Project { */ sealed abstract case class ProjectStatus private (value: Boolean) object ProjectStatus { self => - def make(value: Boolean): Validation[Throwable, ProjectStatus] = + def make(value: Boolean): Validation[ValidationException, ProjectStatus] = Validation.succeed(new ProjectStatus(value) {}) - def make(value: Option[Boolean]): Validation[Throwable, Option[ProjectStatus]] = + def make(value: Option[Boolean]): Validation[ValidationException, Option[ProjectStatus]] = value match { case Some(v) => self.make(v).map(Some(_)) case None => Validation.succeed(None) @@ -170,16 +179,16 @@ object Project { } object ProjectErrorMessages { - val ShortcodeMissing = "Shortcode cannot be empty." - val ShortcodeInvalid = "Shortcode is invalid." - val ShortnameMissing = "Shortname cannot be empty." - val ShortnameInvalid = "Shortname is invalid." - val LongnameMissing = "Longname cannot be empty." - val LongnameInvalid = "Longname is invalid." + val ShortCodeMissing = "ShortCode cannot be empty." + val ShortCodeInvalid = (v: String) => s"ShortCode is invalid: $v" + val ShortNameMissing = "Shortname cannot be empty." + val ShortNameInvalid = (v: String) => s"Shortname is invalid: $v" + val NameMissing = "Name cannot be empty." + val NameInvalid = (v: String) => s"Name is invalid: $v" val ProjectDescriptionsMissing = "Description cannot be empty." - val ProjectDescriptionsInvalid = "Description is invalid." + val ProjectDescriptionsInvalid = (v: String) => s"Description is invalid: $v" val KeywordsMissing = "Keywords cannot be empty." - val KeywordsInvalid = "Keywords are invalid." + val KeywordsInvalid = (v: String) => s"Keywords are invalid: $v" val LogoMissing = "Logo cannot be empty." - val LogoInvalid = "Logo is invalid." + val LogoInvalid = (v: String) => s"Logo is invalid: $v" } diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/V2.scala b/dsp-shared/src/main/scala/dsp/valueobjects/V2.scala index b890ac8b81..d2dd0b72a2 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/V2.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/V2.scala @@ -293,8 +293,8 @@ object V2ProjectIriValidation { * @param shortcode the project's shortcode. * @return the shortcode in upper case. */ - def validateProjectShortcode(shortcode: String, errorFun: => Nothing): String = - ProjectIDRegex.findFirstIn(shortcode.toUpperCase) match { + def validateProjectShortCode(shortCode: String, errorFun: => Nothing): String = + ProjectIDRegex.findFirstIn(shortCode.toUpperCase) match { case Some(value) => value case None => errorFun } diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/IdSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/IdSpec.scala new file mode 100644 index 0000000000..46dc8b97bc --- /dev/null +++ b/dsp-shared/src/test/scala/dsp/valueobjects/IdSpec.scala @@ -0,0 +1,53 @@ +package dsp.valueobjects + +import zio.prelude.Validation +import zio.test._ + +import java.util.UUID + +import dsp.errors.ValidationException + +object IdSpec extends ZIOSpecDefault { + + private val shortCode = Project.ShortCode.make("0001").fold(e => throw e.head, v => v) + private val uuid = UUID.randomUUID() + private val iri = Iri.ProjectIri + .make(s"http://rdfh.ch/projects/${UUID.randomUUID()}") + .fold(e => throw e.head, v => v) + private val invalidIri = Iri.ProjectIri + .make(s"http://rdfh.ch/projects/anything") + .fold(e => throw e.head, v => v) + + override def spec = suite("ID Specs")(projectIdTests) + + // TODO: should have tests for other IDs too + val projectIdTests = suite("ProjectId")( + test("should create an ID from only a shortcode") { + (for { + projectId <- ProjectId.make(shortCode) + } yield assertTrue(projectId.shortCode == shortCode) && + assertTrue(!projectId.iri.value.isEmpty()) && + assertTrue(!projectId.uuid.toString().isEmpty())).toZIO + }, + test("should create an ID from a shortcode and a UUID") { + val expectedIri = Iri.ProjectIri.make(s"http://rdfh.ch/projects/${uuid}").fold(e => throw e.head, v => v) + (for { + projectId <- ProjectId.fromUuid(uuid, shortCode) + } yield assertTrue(projectId.shortCode == shortCode) && + assertTrue(projectId.iri == expectedIri) && + assertTrue(projectId.uuid == uuid)).toZIO + }, + test("should create an ID from a shortcode and an IRI") { + (for { + projectId <- ProjectId.fromIri(iri, shortCode) + } yield assertTrue(projectId.shortCode == shortCode) && + assertTrue(projectId.iri == iri) && + assertTrue(projectId.uuid.toString().length() == 36)).toZIO + }, + test("should not create an ID from a shortcode and an IRI if the IRI does not contain a valid UUID") { + val idFromInvalidIri = ProjectId.fromIri(invalidIri, shortCode) + val expectedResult = Validation.fail(ValidationException(IdErrorMessages.IriDoesNotContainUuid(invalidIri))) + assertTrue(idFromInvalidIri == expectedResult) + } + ) +} diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala index 718cb71304..81e8c5d6c0 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala @@ -9,6 +9,7 @@ import zio.prelude.Validation import zio.test._ import dsp.errors.BadRequestException +import dsp.errors.ValidationException import dsp.valueobjects.Iri._ /** @@ -115,32 +116,32 @@ object IriSpec extends ZIOSpecDefault { private val projectIriTest = suite("IriSpec - ProjectIri")( test("pass an empty value and return an error") { - assertTrue(ProjectIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.ProjectIriMissing))) && + assertTrue(ProjectIri.make("") == Validation.fail(ValidationException(IriErrorMessages.ProjectIriMissing))) && assertTrue( - ProjectIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.ProjectIriMissing)) + ProjectIri.make(Some("")) == Validation.fail(ValidationException(IriErrorMessages.ProjectIriMissing)) ) }, test("pass an invalid value and return an error") { assertTrue( ProjectIri.make(invalidIri) == Validation.fail( - BadRequestException(IriErrorMessages.ProjectIriInvalid) + ValidationException(IriErrorMessages.ProjectIriInvalid) ) ) && assertTrue( ProjectIri.make(Some(invalidIri)) == Validation.fail( - BadRequestException(IriErrorMessages.ProjectIriInvalid) + ValidationException(IriErrorMessages.ProjectIriInvalid) ) ) }, test("pass an invalid IRI containing unsupported UUID version and return an error") { assertTrue( ProjectIri.make(projectIriWithUUIDVersion3) == Validation.fail( - BadRequestException(IriErrorMessages.UuidVersionInvalid) + ValidationException(IriErrorMessages.UuidVersionInvalid) ) ) && assertTrue( ProjectIri.make(Some(projectIriWithUUIDVersion3)) == Validation.fail( - BadRequestException(IriErrorMessages.UuidVersionInvalid) + ValidationException(IriErrorMessages.UuidVersionInvalid) ) ) }, diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/LangStringSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/LangStringSpec.scala index 95f5066b66..b29d1bb475 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/LangStringSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/LangStringSpec.scala @@ -15,7 +15,10 @@ import dsp.errors.ValidationException */ object LangStringSpec extends ZIOSpecDefault { - def spec = (langStringTest) + def spec = suite("LangStringSpec")( + langStringTest, + multiLangStringTest + ) private val langStringTest = suite("LangString")( suite("`make()` smart constructor")( @@ -74,7 +77,44 @@ object LangStringSpec extends ZIOSpecDefault { val str = "" val unsafeValid = LangString.unsafeMake(LanguageCode.en, str) assertTrue(unsafeValid.language.value == "en") && - assertTrue(unsafeValid.value == str) // TODO-BL: once logging works, figure out how to test for logging output + assertTrue(unsafeValid.value == str) + } + ) + ) + + private val multiLangStringTest = suite("MultiLangString")( + suite("create MultiLangString")( + test("pass an empty set of LangString and return an error") { + val res = MultiLangString.make(Set.empty) + val expected = Validation.fail(ValidationException(MultiLangStringErrorMessages.MultiLangStringEmptySet)) + assertTrue(res == expected) + }, + test("pass a set of LangString with non unique languages and return an error") { + val langStrings = Set( + LangString.unsafeMake(LanguageCode.en, "english 1"), + LangString.unsafeMake(LanguageCode.en, "english 2"), + LangString.unsafeMake(LanguageCode.de, "german 1"), + LangString.unsafeMake(LanguageCode.de, "german 2"), + LangString.unsafeMake(LanguageCode.fr, "french 1") + ) + val nonUniqueLanguages = Set(LanguageCode.en, LanguageCode.de) + val res = MultiLangString.make(langStrings) + val expected = + Validation.fail(ValidationException(MultiLangStringErrorMessages.LanguageNotUnique(nonUniqueLanguages))) + assertTrue(res == expected) + }, + test("pass a valid set of LangString and return a MultiLangString") { + val langStrings = Set( + LangString.unsafeMake(LanguageCode.en, "string in english"), + LangString.unsafeMake(LanguageCode.de, "string in german"), + LangString.unsafeMake(LanguageCode.fr, "string in french") + ) + (for { + res <- MultiLangString.make(langStrings) + } yield ( + assertTrue(res.langStrings.size == 3) && + assertTrue(res.langStrings == langStrings) + )).toZIO } ) ) diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala index 432ee279e4..c79a9287b1 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala @@ -5,108 +5,133 @@ package dsp.valueobjects +import zio._ import zio.prelude.Validation +import zio.test.Assertion._ import zio.test._ -import dsp.errors.BadRequestException +import dsp.errors.ValidationException import dsp.valueobjects.Project._ /** * This spec is used to test the [[Project]] value objects creation. */ object ProjectSpec extends ZIOSpecDefault { - private val validShortcode = "1234" - private val invalidShortcode = "12345" - private val validShortname = "validShortname" + private val validShortCode = "1234" + private val invalidShortCode = "12345" + private val validShortName = "validShortname" private val invalidShortname = "~!@#$%^&*()_+" - private val validLongname = "That is the project longname" + private val validName = "That is the project longname" private val validDescription = Seq( V2.StringLiteralV2(value = "Valid project description", language = Some("en")) ) private val validKeywords = Seq("key", "word") private val validLogo = "/fu/bar/baz.jpg" - def spec = - (shortcodeTest + shortnameTest + longnameTest + projectDescriptionsTest + keywordsTest + logoTest + projectStatusTest + projectSelfJoinTest) + def spec = suite("ProjectSpec")( + shortCodeTest, + shortNameTest, + nameTest, + projectDescriptionsTest, + keywordsTest, + logoTest, + projectStatusTest, + projectSelfJoinTest + ) - private val shortcodeTest = suite("ProjectSpec - Shortcode")( + private val shortCodeTest = suite("ProjectSpec - ShortCode")( test("pass an empty value and return an error") { assertTrue( - Shortcode.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.ShortcodeMissing)) + ShortCode.make("") == Validation.fail(ValidationException(ProjectErrorMessages.ShortCodeMissing)) ) && assertTrue( - Shortcode.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.ShortcodeMissing)) + ShortCode.make(Some("")) == Validation.fail(ValidationException(ProjectErrorMessages.ShortCodeMissing)) ) }, test("pass an invalid value and return an error") { assertTrue( - Shortcode.make(invalidShortcode) == Validation.fail( - BadRequestException(ProjectErrorMessages.ShortcodeInvalid) + ShortCode.make(invalidShortCode) == Validation.fail( + ValidationException(ProjectErrorMessages.ShortCodeInvalid(invalidShortCode)) ) ) && assertTrue( - Shortcode.make(Some(invalidShortcode)) == Validation.fail( - BadRequestException(ProjectErrorMessages.ShortcodeInvalid) + ShortCode.make(Some(invalidShortCode)) == Validation.fail( + ValidationException(ProjectErrorMessages.ShortCodeInvalid(invalidShortCode)) ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(Shortcode.make(validShortcode).toOption.get.value == validShortcode) && - assertTrue(Shortcode.make(Option(validShortcode)).getOrElse(null).get.value == validShortcode) + for { + shortCode <- ShortCode.make(validShortCode).toZIO + optionalShortCode <- ShortCode.make(Option(validShortCode)).toZIO + shortCodeFromOption <- ZIO.fromOption(optionalShortCode) + } yield assertTrue(shortCode.value == validShortCode) && + assert(optionalShortCode)(isSome(isSubtype[ShortCode](Assertion.anything))) && + assertTrue(shortCodeFromOption.value == validShortCode) }, test("successfully validate passing None") { assertTrue( - Shortcode.make(None) == Validation.succeed(None) + ShortCode.make(None) == Validation.succeed(None) ) } ) - private val shortnameTest = suite("ProjectSpec - Shortname")( + private val shortNameTest = suite("ProjectSpec - ShortName")( test("pass an empty value and return an error") { assertTrue( - Shortname.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.ShortnameMissing)) + ShortName.make("") == Validation.fail(ValidationException(ProjectErrorMessages.ShortNameMissing)) ) && assertTrue( - Shortname.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.ShortnameMissing)) + ShortName.make(Some("")) == Validation.fail(ValidationException(ProjectErrorMessages.ShortNameMissing)) ) }, test("pass an invalid value and return an error") { assertTrue( - Shortname.make(invalidShortname) == Validation.fail( - BadRequestException(ProjectErrorMessages.ShortnameInvalid) + ShortName.make(invalidShortname) == Validation.fail( + ValidationException(ProjectErrorMessages.ShortNameInvalid(invalidShortname)) ) ) && assertTrue( - Shortname.make(Some(invalidShortname)) == Validation.fail( - BadRequestException(ProjectErrorMessages.ShortnameInvalid) + ShortName.make(Some(invalidShortname)) == Validation.fail( + ValidationException(ProjectErrorMessages.ShortNameInvalid(invalidShortname)) ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(Shortname.make(validShortname).toOption.get.value == validShortname) && - assertTrue(Shortname.make(Option(validShortname)).getOrElse(null).get.value == validShortname) + for { + shortName <- ShortName.make(validShortName).toZIO + optionalShortName <- ShortName.make(Option(validShortName)).toZIO + shortNameFromOption <- ZIO.fromOption(optionalShortName) + } yield assertTrue(shortName.value == validShortName) && + assert(optionalShortName)(isSome(isSubtype[ShortName](Assertion.anything))) && + assertTrue(shortNameFromOption.value == validShortName) }, test("successfully validate passing None") { assertTrue( - Shortcode.make(None) == Validation.succeed(None) + ShortName.make(None) == Validation.succeed(None) ) } ) - private val longnameTest = suite("ProjectSpec - Longname")( + private val nameTest = suite("ProjectSpec - Name")( test("pass an empty value and return an error") { - assertTrue(Longname.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.LongnameMissing))) && + assertTrue(Name.make("") == Validation.fail(ValidationException(ProjectErrorMessages.NameMissing))) && assertTrue( - Longname.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.LongnameMissing)) + Name.make(Some("")) == Validation.fail(ValidationException(ProjectErrorMessages.NameMissing)) ) }, test("pass a valid value and successfully create value object") { - assertTrue(Longname.make(validLongname).toOption.get.value == validLongname) && - assertTrue(Longname.make(Option(validLongname)).getOrElse(null).get.value == validLongname) + for { + name <- Name.make(validName).toZIO + optionalName <- Name.make(Option(validName)).toZIO + nameFromOption <- ZIO.fromOption(optionalName) + } yield assertTrue(name.value == validName) && + assert(optionalName)(isSome(isSubtype[Name](Assertion.anything))) && + assertTrue(nameFromOption.value == validName) }, test("successfully validate passing None") { assertTrue( - Shortcode.make(None) == Validation.succeed(None) + ShortCode.make(None) == Validation.succeed(None) ) } ) @@ -115,18 +140,23 @@ object ProjectSpec extends ZIOSpecDefault { test("pass an empty object and return an error") { assertTrue( ProjectDescription.make(Seq.empty) == Validation.fail( - BadRequestException(ProjectErrorMessages.ProjectDescriptionsMissing) + ValidationException(ProjectErrorMessages.ProjectDescriptionsMissing) ) ) && assertTrue( ProjectDescription.make(Some(Seq.empty)) == Validation.fail( - BadRequestException(ProjectErrorMessages.ProjectDescriptionsMissing) + ValidationException(ProjectErrorMessages.ProjectDescriptionsMissing) ) ) }, test("pass a valid object and successfully create value object") { - assertTrue(ProjectDescription.make(validDescription).toOption.get.value == validDescription) && - assertTrue(ProjectDescription.make(Option(validDescription)).getOrElse(null).get.value == validDescription) + for { + description <- ProjectDescription.make(validDescription).toZIO + optionalDescription <- ProjectDescription.make(Option(validDescription)).toZIO + descriptionFromOption <- ZIO.fromOption(optionalDescription) + } yield assertTrue(description.value == validDescription) && + assert(optionalDescription)(isSome(isSubtype[ProjectDescription](Assertion.anything))) && + assertTrue(descriptionFromOption.value == validDescription) }, test("successfully validate passing None") { assertTrue( @@ -139,18 +169,23 @@ object ProjectSpec extends ZIOSpecDefault { test("pass an empty object and return an error") { assertTrue( Keywords.make(Seq.empty) == Validation.fail( - BadRequestException(ProjectErrorMessages.KeywordsMissing) + ValidationException(ProjectErrorMessages.KeywordsMissing) ) ) && assertTrue( Keywords.make(Some(Seq.empty)) == Validation.fail( - BadRequestException(ProjectErrorMessages.KeywordsMissing) + ValidationException(ProjectErrorMessages.KeywordsMissing) ) ) }, test("pass a valid object and successfully create value object") { - assertTrue(Keywords.make(validKeywords).toOption.get.value == validKeywords) && - assertTrue(Keywords.make(Option(validKeywords)).getOrElse(null).get.value == validKeywords) + for { + keywords <- Keywords.make(validKeywords).toZIO + optionalKeywords <- Keywords.make(Option(validKeywords)).toZIO + keywordsFromOption <- ZIO.fromOption(optionalKeywords) + } yield assertTrue(keywords.value == validKeywords) && + assert(optionalKeywords)(isSome(isSubtype[Keywords](Assertion.anything))) && + assertTrue(keywordsFromOption.value == validKeywords) }, test("successfully validate passing None") { assertTrue( @@ -163,18 +198,23 @@ object ProjectSpec extends ZIOSpecDefault { test("pass an empty object and return an error") { assertTrue( Logo.make("") == Validation.fail( - BadRequestException(ProjectErrorMessages.LogoMissing) + ValidationException(ProjectErrorMessages.LogoMissing) ) ) && assertTrue( Logo.make(Some("")) == Validation.fail( - BadRequestException(ProjectErrorMessages.LogoMissing) + ValidationException(ProjectErrorMessages.LogoMissing) ) ) }, test("pass a valid object and successfully create value object") { - assertTrue(Logo.make(validLogo).toOption.get.value == validLogo) && - assertTrue(Logo.make(Option(validLogo)).getOrElse(null).get.value == validLogo) + for { + logo <- Logo.make(validLogo).toZIO + optionalLogo <- Logo.make(Option(validLogo)).toZIO + logoFromOption <- ZIO.fromOption(optionalLogo) + } yield assertTrue(logo.value == validLogo) && + assert(optionalLogo)(isSome(isSubtype[Logo](Assertion.anything))) && + assertTrue(logoFromOption.value == validLogo) }, test("successfully validate passing None") { assertTrue( @@ -185,8 +225,13 @@ object ProjectSpec extends ZIOSpecDefault { private val projectStatusTest = suite("ProjectSpec - ProjectStatus")( test("pass a valid object and successfully create value object") { - assertTrue(ProjectStatus.make(true).toOption.get.value == true) && - assertTrue(ProjectStatus.make(Some(false)).getOrElse(null).get.value == false) + for { + status <- ProjectStatus.make(true).toZIO + optionalStatus <- ProjectStatus.make(Option(false)).toZIO + statusFromOption <- ZIO.fromOption(optionalStatus) + } yield assertTrue(status.value == true) && + assert(optionalStatus)(isSome(isSubtype[ProjectStatus](Assertion.anything))) && + assertTrue(statusFromOption.value == false) }, test("successfully validate passing None") { assertTrue( @@ -197,8 +242,13 @@ object ProjectSpec extends ZIOSpecDefault { private val projectSelfJoinTest = suite("ProjectSpec - ProjectSelfJoin")( test("pass a valid object and successfully create value object") { - assertTrue(ProjectSelfJoin.make(true).toOption.get.value == true) && - assertTrue(ProjectSelfJoin.make(Some(false)).getOrElse(null).get.value == false) + for { + selfJoin <- ProjectSelfJoin.make(true).toZIO + optionalSelfJoin <- ProjectSelfJoin.make(Option(false)).toZIO + selfJoinFromOption <- ZIO.fromOption(optionalSelfJoin) + } yield assertTrue(selfJoin.value == true) && + assert(optionalSelfJoin)(isSome(isSubtype[ProjectSelfJoin](Assertion.anything))) && + assertTrue(selfJoinFromOption.value == false) }, test("successfully validate passing None") { assertTrue( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7ea76353e7..a17da977b6 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -273,4 +273,30 @@ object Dependencies { zioTest % Test, zioTestSbt % Test ) + + // project project dependencies + val projectInterfaceLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val projectHandlerLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val projectCoreLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) + val projectRepoLibraryDependencies = Seq( + zio, + zioMacros, + zioTest % Test, + zioTestSbt % Test + ) } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsPayloadsADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsPayloadsADM.scala index 2e9a6bc6e6..e06093fa9d 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsPayloadsADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsPayloadsADM.scala @@ -13,9 +13,9 @@ import dsp.valueobjects.Project._ */ final case class ProjectCreatePayloadADM( id: Option[ProjectIri] = None, - shortname: Shortname, - shortcode: Shortcode, - longname: Option[Longname] = None, + shortname: ShortName, + shortcode: ShortCode, + longname: Option[Name] = None, description: ProjectDescription, keywords: Keywords, logo: Option[Logo] = None, diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala index 78d218e225..36d2d7108c 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala @@ -130,11 +130,10 @@ class ProjectsRouteADM(routeData: KnoraRouteData) private def addProject(): Route = path(projectsBasePath) { post { entity(as[CreateProjectApiRequestADM]) { apiRequest => requestContext => - // zio prelude: validation val id: Validation[Throwable, Option[ProjectIri]] = ProjectIri.make(apiRequest.id) - val shortname: Validation[Throwable, Shortname] = Shortname.make(apiRequest.shortname) - val shortcode: Validation[Throwable, Shortcode] = Shortcode.make(apiRequest.shortcode) - val longname: Validation[Throwable, Option[Longname]] = Longname.make(apiRequest.longname) + val shortname: Validation[Throwable, ShortName] = ShortName.make(apiRequest.shortname) + val shortcode: Validation[Throwable, ShortCode] = ShortCode.make(apiRequest.shortcode) + val longname: Validation[Throwable, Option[Name]] = Name.make(apiRequest.longname) val description: Validation[Throwable, ProjectDescription] = ProjectDescription.make(apiRequest.description) val keywords: Validation[Throwable, Keywords] = Keywords.make(apiRequest.keywords) val logo: Validation[Throwable, Option[Logo]] = Logo.make(apiRequest.logo) diff --git a/webapi/src/test/scala/org/knora/webapi/responders/admin/ProjectsResponderADMSpec.scala b/webapi/src/test/scala/org/knora/webapi/responders/admin/ProjectsResponderADMSpec.scala index bb68560364..4e11d29933 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/admin/ProjectsResponderADMSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/admin/ProjectsResponderADMSpec.scala @@ -180,9 +180,9 @@ class ProjectsResponderADMSpec extends CoreSpec(ProjectsResponderADMSpec.config) val shortCode = "111c" appActor ! ProjectCreateRequestADM( createRequest = ProjectCreatePayloadADM( - shortname = Shortname.make("newproject").fold(error => throw error.head, value => value), - shortcode = Shortcode.make(shortCode).fold(error => throw error.head, value => value), // lower case - longname = Longname.make(Some("project longname")).fold(error => throw error.head, value => value), + shortname = ShortName.make("newproject").fold(error => throw error.head, value => value), + shortcode = ShortCode.make(shortCode).fold(error => throw error.head, value => value), // lower case + longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value), description = ProjectDescription .make(Seq(V2.StringLiteralV2(value = "project description", language = Some("en")))) .fold(error => throw error.head, value => value), @@ -273,9 +273,9 @@ class ProjectsResponderADMSpec extends CoreSpec(ProjectsResponderADMSpec.config) "CREATE a project and return the project info if the supplied shortname and shortcode is unique" in { appActor ! ProjectCreateRequestADM( createRequest = ProjectCreatePayloadADM( - shortname = Shortname.make("newproject2").fold(error => throw error.head, value => value), - shortcode = Shortcode.make("1112").fold(error => throw error.head, value => value), // lower case - longname = Some(Longname.make("project longname").fold(error => throw error.head, value => value)), + shortname = ShortName.make("newproject2").fold(error => throw error.head, value => value), + shortcode = ShortCode.make("1112").fold(error => throw error.head, value => value), // lower case + longname = Some(Name.make("project longname").fold(error => throw error.head, value => value)), description = ProjectDescription .make(Seq(V2.StringLiteralV2(value = "project description", language = Some("en")))) .fold(error => throw error.head, value => value), @@ -305,10 +305,9 @@ class ProjectsResponderADMSpec extends CoreSpec(ProjectsResponderADMSpec.config) val keywordWithSpecialCharacter = "new \\\"keyword\\\"" appActor ! ProjectCreateRequestADM( createRequest = ProjectCreatePayloadADM( - shortname = Shortname.make("project_with_character").fold(error => throw error.head, value => value), - shortcode = Shortcode.make("1312").fold(error => throw error.head, value => value), // lower case - longname = - Longname.make(Some(longnameWithSpecialCharacter)).fold(error => throw error.head, value => value), + shortname = ShortName.make("project_with_character").fold(error => throw error.head, value => value), + shortcode = ShortCode.make("1312").fold(error => throw error.head, value => value), // lower case + longname = Name.make(Some(longnameWithSpecialCharacter)).fold(error => throw error.head, value => value), description = ProjectDescription .make(Seq(V2.StringLiteralV2(value = descriptionWithSpecialCharacter, language = Some("en")))) .fold(error => throw error.head, value => value), @@ -338,9 +337,9 @@ class ProjectsResponderADMSpec extends CoreSpec(ProjectsResponderADMSpec.config) "return a 'DuplicateValueException' during creation if the supplied project shortname is not unique" in { appActor ! ProjectCreateRequestADM( createRequest = ProjectCreatePayloadADM( - shortname = Shortname.make("newproject").fold(error => throw error.head, value => value), - shortcode = Shortcode.make("111C").fold(error => throw error.head, value => value), // lower case - longname = Longname.make(Some("project longname")).fold(error => throw error.head, value => value), + shortname = ShortName.make("newproject").fold(error => throw error.head, value => value), + shortcode = ShortCode.make("111C").fold(error => throw error.head, value => value), // lower case + longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value), description = ProjectDescription .make(Seq(V2.StringLiteralV2(value = "project description", language = Some("en")))) .fold(error => throw error.head, value => value), @@ -358,9 +357,9 @@ class ProjectsResponderADMSpec extends CoreSpec(ProjectsResponderADMSpec.config) "return a 'DuplicateValueException' during creation if the supplied project shortname is unique but the shortcode is not" in { appActor ! ProjectCreateRequestADM( createRequest = ProjectCreatePayloadADM( - shortname = Shortname.make("newproject3").fold(error => throw error.head, value => value), - shortcode = Shortcode.make("111C").fold(error => throw error.head, value => value), // lower case - longname = Longname.make(Some("project longname")).fold(error => throw error.head, value => value), + shortname = ShortName.make("newproject3").fold(error => throw error.head, value => value), + shortcode = ShortCode.make("111C").fold(error => throw error.head, value => value), // lower case + longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value), description = ProjectDescription .make(Seq(V2.StringLiteralV2(value = "project description", language = Some("en")))) .fold(error => throw error.head, value => value),