From 488788a57c6c572aa76388016b0c3ff13bf1ef6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Fri, 14 Jun 2024 09:05:52 +0200 Subject: [PATCH] feat: Add endpoint to erase projects (DEV-3681) (#3272) --- docker-compose.yml | 2 + .../admin/UserRestServiceSpec.scala | 2 +- .../service/ProjectImportServiceIT.scala | 3 + webapi/src/main/resources/application.conf | 4 + .../org/knora/webapi/config/AppConfig.scala | 10 +- .../webapi/slice/admin/AdminModule.scala | 6 + .../slice/admin/api/AdminApiModule.scala | 4 + .../slice/admin/api/ProjectsEndpoints.scala | 11 ++ .../admin/api/ProjectsEndpointsHandler.scala | 7 + .../api/service/ProjectRestService.scala | 22 +++ .../admin/domain/AdminDomainModule.scala | 7 + .../model/AdministrativePermissionRepo.scala | 8 +- .../DefaultObjectAccessPermissionRepo.scala | 25 ++++ .../domain/service/DspIngestClient.scala | 8 ++ .../admin/domain/service/KnoraGroupRepo.scala | 13 +- .../domain/service/KnoraGroupService.scala | 5 + .../domain/service/KnoraProjectRepo.scala | 4 +- .../domain/service/KnoraProjectService.scala | 14 +- .../domain/service/KnoraUserService.scala | 131 +++++++++++------- .../domain/service/ProjectEraseService.scala | 99 +++++++++++++ .../domain/service/ProjectExportService.scala | 2 +- .../admin/domain/service/ProjectService.scala | 8 -- .../slice/admin/repo/AdminRepoModule.scala | 11 +- .../slice/admin/repo/rdf/Vocabulary.scala | 7 +- .../repo/service/AbstractEntityRepo.scala | 25 +++- .../AdministrativePermissionRepoLive.scala | 3 + .../repo/service/CachingEntityRepo.scala | 2 + ...efaultObjectAccessPermissionRepoLive.scala | 72 ++++++++++ .../admin/repo/service/EntityCache.scala | 5 +- .../repo/service/KnoraGroupRepoLive.scala | 4 + .../repo/service/KnoraProjectRepoLive.scala | 6 + .../slice/infrastructure/CacheManager.scala | 7 +- .../domain/service/OntologyService.scala | 1 + .../ontology/repo/service/OntologyCache.scala | 7 + .../triplestore/api/TriplestoreService.scala | 2 + .../AuthorizationRestServiceSpec.scala | 3 + .../api/service/MaintenanceServiceSpec.scala | 10 +- .../domain/service/DspIngestClientMock.scala | 3 + .../repo/service/CachingEntityRepoSpec.scala | 127 +++++++++++++++++ .../repo/service/KnoraGroupRepoLiveSpec.scala | 4 + .../repo/service/OntologyRepoInMemory.scala | 10 ++ 41 files changed, 608 insertions(+), 96 deletions(-) create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/DefaultObjectAccessPermissionRepo.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectEraseService.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/DefaultObjectAccessPermissionRepoLive.scala create mode 100644 webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepoSpec.scala create mode 100644 webapi/src/test/scala/org/knora/webapi/slice/ontology/repo/service/OntologyRepoInMemory.scala diff --git a/docker-compose.yml b/docker-compose.yml index 8a2831bc68..747f614427 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,7 @@ services: - JWT_ISSUER=0.0.0.0:3333 - JWT_SECRET=UP 4888, nice 4-8-4 steam engine - SIPI_USE_LOCAL_DEV=false + - ALLOW_ERASE_PROJECTS=true # - JWT_DISABLE_AUTH=true # Uncomment this line if you want to disable authentication for the ingest service deploy: resources: @@ -115,6 +116,7 @@ services: - KNORA_WEBAPI_DSP_INGEST_AUDIENCE=http://localhost:3340 - KNORA_WEBAPI_DSP_INGEST_BASE_URL=http://ingest:3340 - DSP_API_LOG_LEVEL=INFO + - ALLOW_ERASE_PROJECTS=true # - DSP_API_LOG_APPENDER=JSON # if this variable is set, JSON logs are activated locally deploy: resources: diff --git a/integration/src/test/scala/org/knora/webapi/responders/admin/UserRestServiceSpec.scala b/integration/src/test/scala/org/knora/webapi/responders/admin/UserRestServiceSpec.scala index a107b594a9..6e38d9a4a3 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/admin/UserRestServiceSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/admin/UserRestServiceSpec.scala @@ -463,7 +463,7 @@ class UserRestServiceSpec extends CoreSpec with ImplicitSender { ) assertFailsWithA[BadRequestException]( exit, - "User http://rdfh.ch/users/normaluser is not a member of project http://rdfh.ch/projects/00FF. A user needs to be a member of the project to be added as project admin.", + "User http://rdfh.ch/users/normaluser is not member of project http://rdfh.ch/projects/00FF.", ) } diff --git a/integration/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectImportServiceIT.scala b/integration/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectImportServiceIT.scala index f012b92ac3..8a96b09940 100644 --- a/integration/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectImportServiceIT.scala +++ b/integration/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectImportServiceIT.scala @@ -15,6 +15,7 @@ import org.knora.webapi.config.Fuseki import org.knora.webapi.config.Triplestore import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode import org.knora.webapi.testcontainers.FusekiTestContainer object ProjectImportServiceIT extends ZIOSpecDefault { @@ -132,6 +133,8 @@ final case class DspIngestClientITMock() extends DspIngestClient { originalMimeType = Some("text/plain"), ), ) + + override def eraseProject(shortcode: Shortcode): Task[Unit] = ZIO.unit } object DspIngestClientITMock { val layer = ZLayer.derive[DspIngestClientITMock] diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index b27a4a42c9..a1a47283b0 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -453,4 +453,8 @@ app { interval = 5 seconds } + features { + allow-erase-projects = false + allow-erase-projects = ${?ALLOW_ERASE_PROJECTS} + } } 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 19bc32f5f3..79f4103397 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -40,6 +40,7 @@ final case class AppConfig( instrumentationServerConfig: InstrumentationServerConfig, jwt: JwtConfig, dspIngest: DspIngestConfig, + features: Features, ) { val tmpDataDirPath: zio.nio.file.Path = zio.nio.file.Path(this.tmpDatadir) } @@ -173,8 +174,10 @@ final case class InstrumentationServerConfig( interval: Duration, ) +final case class Features(allowEraseProjects: Boolean) + object AppConfig { - type AppConfigurationsTest = AppConfig & DspIngestConfig & Triplestore & Sipi + type AppConfigurationsTest = AppConfig & DspIngestConfig & Triplestore & Features & Sipi type AppConfigurations = AppConfigurationsTest & InstrumentationServerConfig & JwtConfig & KnoraApi val descriptor: Config[AppConfig] = deriveConfig[AppConfig].mapKey(toKebabCase) @@ -182,7 +185,9 @@ object AppConfig { val layer: ULayer[AppConfigurations] = { val appConfigLayer = ZLayer { val source = TypesafeConfigProvider.fromTypesafeConfig(ConfigFactory.load().getConfig("app").resolve) - read(descriptor from source).orDie + read(descriptor from source) + .tap(c => ZIO.logInfo("Feature: ALLOW_ERASE_PROJECTS enabled").when(c.features.allowEraseProjects)) + .orDie } projectAppConfigurations(appConfigLayer).tap(_ => ZIO.logInfo(">>> AppConfig Initialized <<<")) } @@ -194,6 +199,7 @@ object AppConfig { appConfigLayer.project(_.dspIngest) ++ appConfigLayer.project(_.triplestore) ++ appConfigLayer.project(_.instrumentationServerConfig) ++ + appConfigLayer.project(_.features) ++ appConfigLayer.project { appConfig => val jwtConfig = appConfig.jwt val issuerFromConfigOrDefault: Option[String] = diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/AdminModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/AdminModule.scala index 39f552e436..767e1d3590 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/AdminModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/AdminModule.scala @@ -10,10 +10,13 @@ import zio.URLayer import org.knora.webapi.config.AppConfig import org.knora.webapi.responders.IriService import org.knora.webapi.slice.admin.domain.AdminDomainModule +import org.knora.webapi.slice.admin.domain.service.DspIngestClient import org.knora.webapi.slice.admin.repo.AdminRepoModule import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper import org.knora.webapi.slice.infrastructure.CacheManager import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.store.triplestore.api.TriplestoreService object AdminModule { @@ -22,8 +25,11 @@ object AdminModule { // format: off AppConfig & CacheManager & + DspIngestClient & IriService & + IriConverter & OntologyRepo & + OntologyCache & PredicateObjectMapper & TriplestoreService // format: on diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala index 658ab2c4b5..7b800bc69d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala @@ -8,6 +8,7 @@ package org.knora.webapi.slice.admin.api import zio.ZLayer import org.knora.webapi.config.AppConfig +import org.knora.webapi.config.Features import org.knora.webapi.responders.admin.AssetPermissionsResponder import org.knora.webapi.responders.admin.ListsResponder import org.knora.webapi.responders.admin.PermissionsResponder @@ -24,6 +25,7 @@ import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.admin.domain.service.KnoraUserService import org.knora.webapi.slice.admin.domain.service.KnoraUserToUserConverter import org.knora.webapi.slice.admin.domain.service.PasswordService +import org.knora.webapi.slice.admin.domain.service.ProjectEraseService import org.knora.webapi.slice.admin.domain.service.ProjectExportService import org.knora.webapi.slice.admin.domain.service.ProjectImportService import org.knora.webapi.slice.admin.domain.service.ProjectService @@ -44,6 +46,7 @@ object AdminApiModule { AuthorizationRestService & BaseEndpoints & CacheManager & + Features & GroupService & HandlerMapper & KnoraGroupService & @@ -56,6 +59,7 @@ object AdminApiModule { OntologyCache & PasswordService & PermissionsResponder & + ProjectEraseService & ProjectExportService & ProjectImportService & ProjectService & diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala index 89b07e9593..52b6525aa2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala @@ -153,6 +153,16 @@ final case class ProjectsEndpoints( .out(jsonBody[ProjectOperationResponseADM]) .description("Deletes a project identified by the IRI.") + val deleteAdminProjectsByProjectShortcodeErase = baseEndpoints.securedEndpoint.delete + .in(projectsByShortcode / "erase") + .out(jsonBody[ProjectOperationResponseADM]) + .description( + """|!ATTENTION! Erase a project with the given shortcode. + |This will permanently and irrecoverably remove the project and all of its assets. + |Authorization: Requires system admin permissions. + |Only available if the feature has been configured on the server side.""".stripMargin, + ) + val getAdminProjectsExports = baseEndpoints.securedEndpoint.get .in(projectsBase / `export`) .out(jsonBody[Chunk[ProjectExportInfoResponse]]) @@ -209,6 +219,7 @@ final case class ProjectsEndpoints( Public.getAdminProjectsKeywordsByProjectIri, ) ++ Seq( Secured.deleteAdminProjectsByIri, + Secured.deleteAdminProjectsByProjectShortcodeErase, Secured.getAdminProjectsByIriAllData, Secured.getAdminProjectsByProjectIriAdminMembers, Secured.getAdminProjectsByProjectIriMembers, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala index 1371abb106..8352d1d539 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala @@ -136,6 +136,12 @@ final case class ProjectsEndpointsHandler( user => (id: ProjectIri) => restService.deleteProject(id, user), ) + val deleteAdminProjectsByProjectShortcodeEraseHandler = + SecuredEndpointHandler( + projectsEndpoints.Secured.deleteAdminProjectsByProjectShortcodeErase, + user => (id: Shortcode) => restService.eraseProject(id, user), + ) + val getAdminProjectsExportsHandler = SecuredEndpointHandler( projectsEndpoints.Secured.getAdminProjectsExports, @@ -220,6 +226,7 @@ final case class ProjectsEndpointsHandler( getAdminProjectsByProjectShortcodeAdminMembersHandler, getAdminProjectsByProjectShortnameAdminMembersHandler, deleteAdminProjectsByIriHandler, + deleteAdminProjectsByProjectShortcodeEraseHandler, getAdminProjectsExportsHandler, postAdminProjectsByShortcodeExportHandler, postAdminProjectsByShortcodeExportAwaitingHandler, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala index 9226ac47df..f57b1a000a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala @@ -8,7 +8,9 @@ package org.knora.webapi.slice.admin.api.service import zio.* import dsp.errors.BadRequestException +import dsp.errors.ForbiddenException import dsp.errors.NotFoundException +import org.knora.webapi.config.Features import org.knora.webapi.responders.admin.PermissionsResponder import org.knora.webapi.slice.admin.api.model.* import org.knora.webapi.slice.admin.api.model.ProjectDataGetResponseADM @@ -26,6 +28,7 @@ import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname import org.knora.webapi.slice.admin.domain.model.KnoraProject.Status import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.admin.domain.service.ProjectEraseService import org.knora.webapi.slice.admin.domain.service.ProjectExportService import org.knora.webapi.slice.admin.domain.service.ProjectImportService import org.knora.webapi.slice.admin.domain.service.ProjectService @@ -39,10 +42,12 @@ final case class ProjectRestService( projectService: ProjectService, knoraProjectService: KnoraProjectService, permissionResponder: PermissionsResponder, + projectEraseService: ProjectEraseService, projectExportService: ProjectExportService, projectImportService: ProjectImportService, userService: UserService, auth: AuthorizationRestService, + features: Features, ) { /** @@ -124,6 +129,23 @@ final case class ProjectRestService( external <- format.toExternal(internal) } yield external + def eraseProject(shortcode: Shortcode, user: User): Task[ProjectOperationResponseADM] = + for { + _ <- auth.ensureSystemAdmin(user) + _ <- ZIO.unless(features.allowEraseProjects)( + ZIO.fail(ForbiddenException("The feature to erase projects is not enabled.")), + ) + internal <- projectService + .findByShortcode(shortcode) + .someOrFail(NotFoundException(s"$shortcode not found")) + project <- knoraProjectService + .findByShortcode(shortcode) + .someOrFail(NotFoundException(s"$shortcode not found")) + _ <- ZIO.logInfo(s"${user.userIri} erases project $shortcode") + _ <- projectEraseService.eraseProject(project) + external <- format.toExternal(ProjectOperationResponseADM(internal)) + } yield external + /** * Updates a project, identified by its [[ProjectIri]]. * diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/AdminDomainModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/AdminDomainModule.scala index b1fd372885..46f19e40ad 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/AdminDomainModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/AdminDomainModule.scala @@ -19,6 +19,8 @@ import org.knora.webapi.slice.admin.domain.service.maintenance.MaintenanceServic import org.knora.webapi.slice.admin.repo.AdminRepoModule import org.knora.webapi.slice.infrastructure.CacheManager import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.store.triplestore.api.TriplestoreService object AdminDomainModule { @@ -28,8 +30,11 @@ object AdminDomainModule { AdminRepoModule.Provided & AppConfig & CacheManager & + DspIngestClient & IriService & + IriConverter & OntologyRepo & + OntologyCache & TriplestoreService // format: on @@ -44,6 +49,7 @@ object AdminDomainModule { MaintenanceService & PasswordService & ProjectService & + ProjectEraseService & UserService // format: on @@ -56,6 +62,7 @@ object AdminDomainModule { KnoraUserToUserConverter.layer, MaintenanceService.layer, PasswordService.layer, + ProjectEraseService.layer, ProjectService.layer, UserService.layer, ) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/AdministrativePermissionRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/AdministrativePermissionRepo.scala index 99804c8f14..f2e550ab0c 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/AdministrativePermissionRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/AdministrativePermissionRepo.scala @@ -10,7 +10,7 @@ import zio.Task import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.repo.service.EntityWithId -import org.knora.webapi.slice.common.repo.service.Repository +import org.knora.webapi.slice.common.repo.service.CrudRepository import org.knora.webapi.slice.resourceinfo.domain.InternalIri final case class AdministrativePermission( @@ -53,9 +53,11 @@ object AdministrativePermissionPart { ) } -trait AdministrativePermissionRepo extends Repository[AdministrativePermission, PermissionIri] { +trait AdministrativePermissionRepo extends CrudRepository[AdministrativePermission, PermissionIri] { def findByGroupAndProject(groupIri: GroupIri, projectIri: ProjectIri): Task[Option[AdministrativePermission]] - def save(permission: AdministrativePermission): Task[AdministrativePermission] + def findByProject(projectIri: ProjectIri): Task[Chunk[AdministrativePermission]] + + final def findByProject(project: KnoraProject): Task[Chunk[AdministrativePermission]] = findByProject(project.id) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/DefaultObjectAccessPermissionRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/DefaultObjectAccessPermissionRepo.scala new file mode 100644 index 0000000000..53febb89c7 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/DefaultObjectAccessPermissionRepo.scala @@ -0,0 +1,25 @@ +/* + * Copyright © 2021 - 2024 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.slice.admin.domain.model + +import zio.Chunk +import zio.Task + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.repo.service.EntityWithId +import org.knora.webapi.slice.common.repo.service.CrudRepository + +final case class DefaultObjectAccessPermission( + id: PermissionIri, + forProject: ProjectIri, +) extends EntityWithId[PermissionIri] + +trait DefaultObjectAccessPermissionRepo extends CrudRepository[DefaultObjectAccessPermission, PermissionIri] { + + def findByProject(projectIri: ProjectIri): Task[Chunk[DefaultObjectAccessPermission]] + + final def findByProject(project: KnoraProject): Task[Chunk[DefaultObjectAccessPermission]] = findByProject(project.id) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClient.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClient.scala index 87e244be02..d0e96b609a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClient.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClient.scala @@ -44,6 +44,8 @@ trait DspIngestClient { def importProject(shortcode: Shortcode, fileToImport: Path): Task[Path] + def eraseProject(shortcode: Shortcode): Task[Unit] + def getAssetInfo(shortcode: Shortcode, assetId: AssetId): Task[AssetInfoResponse] } @@ -100,6 +102,12 @@ final case class DspIngestClientLive( _ <- ZIO.logInfo(s"Response from ingest :${response.code}") } yield exportFile + override def eraseProject(shortcode: Shortcode): Task[Unit] = for { + request <- authenticatedRequest.map(_.delete(uri"${projectsPath(shortcode)}/erase")) + response <- ZIO.blocking(request.send(backend = sttpBackend)) + _ <- ZIO.logInfo(s"Response from ingest :${response.body}") + } yield () + override def importProject(shortcode: Shortcode, fileToImport: Path): Task[Path] = ZIO.scoped { for { importUrl <- ZIO.fromEither(URL.decode(s"${projectsPath(shortcode)}/import")) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupRepo.scala index b44bc4c243..4969395e3d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupRepo.scala @@ -15,21 +15,16 @@ import org.knora.webapi.slice.admin.domain.model.GroupName import org.knora.webapi.slice.admin.domain.model.GroupSelfJoin import org.knora.webapi.slice.admin.domain.model.GroupStatus import org.knora.webapi.slice.admin.domain.model.KnoraGroup -import org.knora.webapi.slice.common.repo.service.Repository +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.common.repo.service.CrudRepository -trait KnoraGroupRepo extends Repository[KnoraGroup, GroupIri] { +trait KnoraGroupRepo extends CrudRepository[KnoraGroup, GroupIri] { def findByName(name: GroupName): Task[Option[KnoraGroup]] def existsByName(name: GroupName): Task[Boolean] = findByName(name).map(_.isDefined) - /** - * Saves the user group, returns the created data. Updates not supported. - * - * @param group The [[KnoraGroup]] to be saved, can be an update or a creation. - * @return The saved entity. - */ - def save(group: KnoraGroup): Task[KnoraGroup] + def findByProjectIri(projectIri: ProjectIri): Task[Chunk[KnoraGroup]] } object KnoraGroupRepo { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupService.scala index da3c8ebd1f..24b423149d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraGroupService.scala @@ -34,6 +34,11 @@ case class KnoraGroupService( def findByIds(ids: Seq[GroupIri]): Task[Chunk[KnoraGroup]] = knoraGroupRepo.findByIds(ids) + def findByProject(project: KnoraProject): Task[Chunk[KnoraGroup]] = knoraGroupRepo.findByProjectIri(project.id) + + def deleteAll(groups: Seq[KnoraGroup]): Task[Unit] = + knoraGroupRepo.deleteAll(groups) + def createGroup(request: GroupCreateRequest, project: KnoraProject): Task[KnoraGroup] = for { _ <- ensureGroupNameIsUnique(request.name) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectRepo.scala index 8ffbf62361..0e231dce98 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectRepo.scala @@ -16,9 +16,9 @@ import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname import org.knora.webapi.slice.admin.domain.model.RestrictedView -import org.knora.webapi.slice.common.repo.service.Repository +import org.knora.webapi.slice.common.repo.service.CrudRepository -trait KnoraProjectRepo extends Repository[KnoraProject, ProjectIri] { +trait KnoraProjectRepo extends CrudRepository[KnoraProject, ProjectIri] { def save(project: KnoraProject): Task[KnoraProject] def findByShortcode(shortcode: Shortcode): Task[Option[KnoraProject]] def findByShortname(shortname: Shortname): Task[Option[KnoraProject]] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala index eadb5035e1..9c3837ecf5 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala @@ -20,8 +20,10 @@ import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname import org.knora.webapi.slice.admin.domain.model.RestrictedView +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.resourceinfo.domain.InternalIri -final case class KnoraProjectService(knoraProjectRepo: KnoraProjectRepo) { +final case class KnoraProjectService(knoraProjectRepo: KnoraProjectRepo, ontologyRepo: OntologyRepo) { def findById(id: ProjectIri): Task[Option[KnoraProject]] = knoraProjectRepo.findById(id) def existsById(id: ProjectIri): Task[Boolean] = knoraProjectRepo.existsById(id) def findByShortcode(code: Shortcode): Task[Option[KnoraProject]] = knoraProjectRepo.findByShortcode(code) @@ -75,6 +77,8 @@ final case class KnoraProjectService(knoraProjectRepo: KnoraProjectRepo) { DuplicateValueException(s"Project with the shortname: '${shortname.value}' already exists"), ) + def erase(project: KnoraProject): Task[Unit] = knoraProjectRepo.delete(project) + def updateProject(project: KnoraProject, updateReq: ProjectUpdateRequest): Task[KnoraProject] = for { desc <- updateReq.description match { @@ -96,6 +100,14 @@ final case class KnoraProjectService(knoraProjectRepo: KnoraProjectRepo) { ), ) } yield updated + + def getNamedGraphsForProject(project: KnoraProject): Task[List[InternalIri]] = { + val projectGraph = ProjectService.projectDataNamedGraphV2(project) + ontologyRepo + .findByProject(project.id) + .map(_.map(_.ontologyMetadata.ontologyIri.toInternalIri)) + .map(_ :+ projectGraph) + } } object KnoraProjectService { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserService.scala index 1735cd2dcf..30e652cb1b 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserService.scala @@ -22,6 +22,7 @@ import org.knora.webapi.slice.admin.domain.model.FamilyName import org.knora.webapi.slice.admin.domain.model.GivenName import org.knora.webapi.slice.admin.domain.model.Group import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraGroup import org.knora.webapi.slice.admin.domain.model.KnoraProject import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.KnoraUser @@ -32,8 +33,14 @@ import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.admin.domain.model.UserStatus import org.knora.webapi.slice.admin.domain.model.Username -import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.UserServiceError +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.IsGroupMember +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.IsProjectAdminMember +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.IsProjectMember +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.NotGroupMember +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.NotProjectAdminMember +import org.knora.webapi.slice.admin.domain.service.KnoraUserService.Errors.NotProjectMember import org.knora.webapi.slice.admin.domain.service.KnoraUserService.UserChangeRequest +import org.knora.webapi.slice.common.Value.StringValue case class KnoraUserService( private val userRepo: KnoraUserRepo, @@ -49,6 +56,8 @@ case class KnoraUserService( def findByProjectAdminMembership(project: KnoraProject): Task[Chunk[KnoraUser]] = userRepo.findByProjectAdminMembership(project.id) + def findByGroupMembership(group: KnoraGroup): Task[Chunk[KnoraUser]] = + findByGroupMembership(group.id) def findByGroupMembership(groupIri: GroupIri): Task[Chunk[KnoraUser]] = userRepo.findByGroupMembership(groupIri) @@ -126,30 +135,27 @@ case class KnoraUserService( userCreated <- userRepo.save(newUser) } yield userCreated - def addUserToGroup(user: KnoraUser, group: Group): IO[UserServiceError, KnoraUser] = for { - _ <- ZIO.when(user.isInGroup.contains(group.groupIri))( - ZIO.fail(UserServiceError(s"User ${user.id.value} is already member of group ${group.groupIri.value}.")), - ) + def addUserToGroup(user: KnoraUser, group: Group): IO[IsGroupMember, KnoraUser] = for { + _ <- ZIO.fail(IsGroupMember(user.id, group.groupIri)).when(user.isInGroup.contains(group.groupIri)) user <- updateUser(user, UserChangeRequest(groups = Some(user.isInGroup :+ group.groupIri))).orDie } yield user - def removeUserFromGroup(user: User, group: Group): IO[UserServiceError, KnoraUser] = + def removeUserFromGroup(user: User, group: Group): IO[NotGroupMember, KnoraUser] = userRepo.findById(user.userIri).someOrFailException.orDie.flatMap(removeUserFromGroup(_, group)) - def removeUserFromGroup(user: KnoraUser, group: Group): IO[UserServiceError, KnoraUser] = for { - _ <- ZIO - .fail(UserServiceError(s"User ${user.id.value} is not member of group ${group.groupIri.value}.")) - .unless(user.isInGroup.contains(group.groupIri)) + def removeUserFromGroup(user: KnoraUser, group: Group): IO[NotGroupMember, KnoraUser] = for { + _ <- ZIO.fail(NotGroupMember(user.id, group.groupIri)).unless(user.isInGroup.contains(group.groupIri)) user <- updateUser(user, UserChangeRequest(groups = Some(user.isInGroup.filterNot(_ == group.groupIri)))).orDie } yield user + def removeUsersFromKnoraGroup(users: Seq[KnoraUser], group: KnoraGroup): UIO[Unit] = + ZIO.foreachDiscard(users)(removeUserFromKnoraGroup(_, group.id)) + def removeUserFromKnoraGroup(user: KnoraUser, groupIri: GroupIri): UIO[KnoraUser] = userRepo.save(user.copy(isInGroup = user.isInGroup.filterNot(_ == groupIri))).orDie - def addUserToProject(user: KnoraUser, project: Project): IO[UserServiceError, KnoraUser] = for { - _ <- ZIO - .fail(UserServiceError(s"User ${user.id.value} is already member of project ${project.projectIri.value}.")) - .when(user.isInProject.contains(project.projectIri)) + def addUserToProject(user: KnoraUser, project: Project): IO[IsProjectMember, KnoraUser] = for { + _ <- ZIO.fail(IsProjectMember(user.id, project.projectIri)).when(user.isInProject.contains(project.projectIri)) user <- updateUser(user, UserChangeRequest(projects = Some(user.isInProject :+ project.projectIri))).orDie } yield user @@ -161,18 +167,18 @@ case class KnoraUserService( * @param project The project to remove the user from * @return The updated user. If the user is not a member of the project, an error is returned. */ - def removeUserFromProject( - user: KnoraUser, - project: Project, - ): IO[UserServiceError, KnoraUser] = for { - _ <- ZIO - .fail(UserServiceError(s"User ${user.id.value} is not member of project ${project.projectIri.value}.")) - .unless(user.isInProject.contains(project.projectIri)) - projectIri = project.projectIri - newIsInProject = user.isInProject.filterNot(_ == projectIri) - newIsInProjectAdminGroup = user.isInProjectAdminGroup.filterNot(_ == projectIri) - theChange = UserChangeRequest(projects = Some(newIsInProject), projectsAdmin = Some(newIsInProjectAdminGroup)) - user <- updateUser(user, theChange).orDie + def removeUserFromProject(user: KnoraUser, project: Project): IO[NotProjectMember, KnoraUser] = + removeUserFromProject(user, project.projectIri) + + def removeUsersFromProject(users: Seq[KnoraUser], project: KnoraProject): UIO[Unit] = + ZIO.foreachDiscard(users)(removeUserFromProject(_, project.id).ignore) + + private def removeUserFromProject(user: KnoraUser, iri: ProjectIri): IO[NotProjectMember, KnoraUser] = for { + _ <- ZIO.fail(NotProjectMember(user.id, iri)).unless(user.isInProject.contains(iri)) + memberOfProjects = user.isInProject.filterNot(_ == iri) + adminMemberOfProjects = user.isInProjectAdminGroup.filterNot(_ == iri) + theChange = UserChangeRequest(projects = Some(memberOfProjects), projectsAdmin = Some(adminMemberOfProjects)) + user <- updateUser(user, theChange).orDie } yield user /** @@ -186,22 +192,16 @@ case class KnoraUserService( def addUserToProjectAsAdmin( user: KnoraUser, project: Project, - ): IO[UserServiceError, KnoraUser] = for { - _ <- - ZIO - .fail( - UserServiceError(s"User ${user.id.value} is already admin member of project ${project.projectIri.value}."), - ) - .when(user.isInProjectAdminGroup.contains(project.projectIri)) - _ <- - ZIO.fail { - val msg = - s"User ${user.id.value} is not a member of project ${project.projectIri.value}. A user needs to be a member of the project to be added as project admin." - UserServiceError(msg) - }.unless(user.isInProject.contains(project.projectIri)) - theChange = UserChangeRequest(projectsAdmin = Some(user.isInProjectAdminGroup :+ project.projectIri)) - user <- updateUser(user, theChange).orDie - } yield user + ): IO[IsProjectAdminMember | NotProjectMember, KnoraUser] = { + val projectIri = project.projectIri + for { + _ <- + ZIO.fail(IsProjectAdminMember(user.id, projectIri)).when(user.isInProjectAdminGroup.contains(projectIri)) + _ <- ZIO.fail(NotProjectMember(user.id, projectIri)).unless(user.isInProject.contains(projectIri)) + theChange = UserChangeRequest(projectsAdmin = Some(user.isInProjectAdminGroup :+ projectIri)) + user <- updateUser(user, theChange).orDie + } yield user + } /** * Removes a user from the project admin group of a project. @@ -211,16 +211,18 @@ case class KnoraUserService( * @param project The project from which the user is to be removed as project admin. * @return The updated user. If the user is not an admin member of the project, an error is returned. */ - def removeUserFromProjectAsAdmin( - user: KnoraUser, - project: Project, - ): IO[UserServiceError, KnoraUser] = for { - _ <- ZIO - .fail(UserServiceError(s"User ${user.id.value} is not admin member of project ${project.projectIri.value}.")) - .unless(user.isInProjectAdminGroup.contains(project.projectIri)) - theChange = UserChangeRequest(projectsAdmin = Some(user.isInProjectAdminGroup.filterNot(_ == project.projectIri))) - user <- updateUser(user, theChange).orDie - } yield user + def removeUserFromProjectAsAdmin(user: KnoraUser, project: Project): IO[NotProjectAdminMember, KnoraUser] = + removeUserFromProjectIriAsAdmin(user, project.projectIri) + + def removeUsersFromProjectAsAdmin(users: Seq[KnoraUser], project: KnoraProject): IO[NotProjectAdminMember, Unit] = + ZIO.foreachDiscard(users)(removeUserFromProjectIriAsAdmin(_, project.id)) + + private def removeUserFromProjectIriAsAdmin(user: KnoraUser, projectIri: ProjectIri) = + for { + _ <- ZIO.fail(NotProjectAdminMember(user.id, projectIri)).unless(user.isInProjectAdminGroup.contains(projectIri)) + theChange = UserChangeRequest(projectsAdmin = Some(user.isInProjectAdminGroup.filterNot(_ == projectIri))) + user <- updateUser(user, theChange).orDie + } yield user def changePassword(knoraUser: KnoraUser, newPassword: Password): UIO[KnoraUser] = { val newPasswordHash = passwordService.hashPassword(newPassword) @@ -245,7 +247,30 @@ object KnoraUserService { ) object Errors { - final case class UserServiceError(message: String) + sealed trait UserServiceError { + def message: String + } + private def msg(userIri: UserIri, reason: String, value: StringValue) = + s"User ${userIri.value} is $reason ${value.value}." + + final case class NotGroupMember(userIri: UserIri, groupIri: GroupIri) extends UserServiceError { + override def message: String = msg(userIri, "is not member of group", groupIri) + } + final case class IsGroupMember(userIri: UserIri, groupIri: GroupIri) extends UserServiceError { + override def message: String = s"User ${userIri.value} is already member of group ${groupIri.value}." + } + final case class NotProjectMember(userIri: UserIri, projectIri: ProjectIri) extends UserServiceError { + override def message: String = s"User ${userIri.value} is not member of project ${projectIri.value}." + } + final case class IsProjectMember(userIri: UserIri, projectIri: ProjectIri) extends UserServiceError { + override def message: String = s"User ${userIri.value} is already member of project ${projectIri.value}." + } + final case class NotProjectAdminMember(userIri: UserIri, projectIri: ProjectIri) extends UserServiceError { + override def message: String = s"User ${userIri.value} is not admin of project ${projectIri.value}." + } + final case class IsProjectAdminMember(userIri: UserIri, projectIri: ProjectIri) extends UserServiceError { + override def message: String = s"User ${userIri.value} is already admin of project ${projectIri.value}." + } } val layer = ZLayer.derive[KnoraUserService] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectEraseService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectEraseService.scala new file mode 100644 index 0000000000..64ec8f0fce --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectEraseService.scala @@ -0,0 +1,99 @@ +/* + * Copyright © 2021 - 2024 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.slice.admin.domain.service + +import zio.Chunk +import zio.Task +import zio.UIO +import zio.ZIO +import zio.ZLayer + +import org.knora.webapi.slice.admin.domain.model.AdministrativePermissionRepo +import org.knora.webapi.slice.admin.domain.model.DefaultObjectAccessPermissionRepo +import org.knora.webapi.slice.admin.domain.model.KnoraGroup +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.common.Value.StringValue +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.store.triplestore.api.TriplestoreService + +final case class ProjectEraseService( + private val apRepo: AdministrativePermissionRepo, + private val doapRepo: DefaultObjectAccessPermissionRepo, + private val groupService: KnoraGroupService, + private val ingestClient: DspIngestClient, + private val ontologyCache: OntologyCache, + private val projectService: KnoraProjectService, + private val triplestore: TriplestoreService, + private val userService: KnoraUserService, +) { + + private def logPrefix(project: KnoraProject): String = s"ERASE Project ${project.shortcode.value}:" + private def mkString(values: Seq[StringValue]): String = s"'${values.map(_.value).mkString(",")}'" + + def eraseProject(project: KnoraProject): Task[Unit] = for { + groupsToDelete <- groupService.findByProject(project) + _ <- cleanUpUsersAndGroups(project, groupsToDelete) + _ <- cleanUpPermissions(project) + _ <- removeOntologyAndDataGraphs(project) + _ <- projectService.erase(project) + _ <- ingestClient.eraseProject(project.shortcode).logError.ignore + } yield () + + private def cleanUpUsersAndGroups(project: KnoraProject, groups: Chunk[KnoraGroup]): UIO[Unit] = for { + _ <- ZIO.logInfo(s"${logPrefix(project)} Cleaning up memberships and groups ${mkString(groups.map(_.id))}") + _ <- removeUserProjectAdminMemberships(project).logError.ignore + _ <- removeUserProjectMemberShips(project).logError.ignore + _ <- removeUserGroupMemberShips(groups, project).logError.ignore + _ <- groupService.deleteAll(groups).orDie + } yield () + + private def removeUserProjectAdminMemberships(project: KnoraProject) = + userService + .findByProjectAdminMembership(project) + .tap(u => + ZIO.logInfo(s"${logPrefix(project)} Removing project admins: ${mkString(u.map(_.id))}").unless(u.isEmpty), + ) + .flatMap(userService.removeUsersFromProjectAsAdmin(_, project)) + + private def removeUserProjectMemberShips(project: KnoraProject) = + userService + .findByProjectMembership(project) + .tap(u => + ZIO.logInfo(s"${logPrefix(project)} Removing project members: ${mkString(u.map(_.id))}").unless(u.isEmpty), + ) + .flatMap(userService.removeUsersFromProject(_, project)) + + private def removeUserGroupMemberShips(groupsToRemove: Chunk[KnoraGroup], project: KnoraProject) = + ZIO.foreachDiscard(groupsToRemove)(group => + userService + .findByGroupMembership(group) + .tap(u => + ZIO.logInfo(s"${logPrefix(project)} Removing users from group ${group.id.value}: ${mkString(u.map(_.id))}"), + ) + .flatMap(userService.removeUsersFromKnoraGroup(_, group)), + ) + + private def cleanUpPermissions(project: KnoraProject) = for { + ap <- apRepo.findByProject(project) + doap <- doapRepo.findByProject(project) + _ <- ZIO.logInfo( + s"${logPrefix(project)} Removing permissions ap ${mkString(ap.map(_.id))} , doap ${mkString(doap.map(_.id))}", + ) + _ <- apRepo.deleteAll(ap) + _ <- doapRepo.deleteAll(doap) + } yield () + + private def removeOntologyAndDataGraphs(project: KnoraProject) = for { + graphsToDelete <- projectService.getNamedGraphsForProject(project) + _ <- ZIO.logInfo(s"${logPrefix(project)} Removing graphs ${graphsToDelete.map(_.value)}") + _ <- ZIO.foreachDiscard(graphsToDelete)(triplestore.dropGraphByIri) + _ <- ontologyCache.loadOntologies() + } yield () +} + +object ProjectEraseService { + val layer = ZLayer.derive[ProjectEraseService] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala index 6c10891e9e..c198d001c3 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala @@ -121,7 +121,7 @@ private object TriGCombiner { } final case class ProjectExportServiceLive( - private val projectService: ProjectService, + private val projectService: KnoraProjectService, private val triplestore: TriplestoreService, private val dspIngestClient: DspIngestClient, private val exportStorage: ProjectExportStorageService, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectService.scala index 119e8e0fa0..82a2db205a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectService.scala @@ -75,14 +75,6 @@ final case class ProjectService( restrictedView, ) - def getNamedGraphsForProject(project: KnoraProject): Task[List[InternalIri]] = { - val projectGraph = ProjectService.projectDataNamedGraphV2(project) - ontologyRepo - .findByProject(project.id) - .map(_.map(_.ontologyMetadata.ontologyIri.toInternalIri)) - .map(_ :+ projectGraph) - } - def setProjectRestrictedView(project: Project, settings: RestrictedView): Task[RestrictedView] = knoraProjectService.setProjectRestrictedView(toKnoraProject(project, settings), settings) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/AdminRepoModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/AdminRepoModule.scala index 88703a5071..e819fdb130 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/AdminRepoModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/AdminRepoModule.scala @@ -8,10 +8,12 @@ package org.knora.webapi.slice.admin.repo import zio.ZLayer import org.knora.webapi.slice.admin.domain.model.AdministrativePermissionRepo +import org.knora.webapi.slice.admin.domain.model.DefaultObjectAccessPermissionRepo import org.knora.webapi.slice.admin.domain.service.KnoraGroupRepo import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo import org.knora.webapi.slice.admin.domain.service.KnoraUserRepo import org.knora.webapi.slice.admin.repo.service.AdministrativePermissionRepoLive +import org.knora.webapi.slice.admin.repo.service.DefaultObjectAccessPermissionRepoLive import org.knora.webapi.slice.admin.repo.service.KnoraGroupRepoLive import org.knora.webapi.slice.admin.repo.service.KnoraProjectRepoLive import org.knora.webapi.slice.admin.repo.service.KnoraUserRepoLive @@ -28,9 +30,11 @@ object AdminRepoModule { type Provided = // format: off - AdministrativePermissionRepo & - KnoraGroupRepo & - KnoraProjectRepo & + AdministrativePermissionRepo & + CacheManager & + DefaultObjectAccessPermissionRepo & + KnoraGroupRepo & + KnoraProjectRepo & KnoraUserRepo // format: on @@ -39,5 +43,6 @@ object AdminRepoModule { KnoraProjectRepoLive.layer, KnoraUserRepoLive.layer, AdministrativePermissionRepoLive.layer, + DefaultObjectAccessPermissionRepoLive.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala index bc0d43e30c..c296c41578 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala @@ -54,9 +54,10 @@ object Vocabulary { val projectShortname: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "projectShortname") // permission properties - val AdministrativePermission: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "AdministrativePermission") - val forProject: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "forProject") - val forGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "forGroup") + val AdministrativePermission: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "AdministrativePermission") + val DefaultObjectAccessPermission: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "DefaultObjectAccessPermission") + val forProject: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "forProject") + val forGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "forGroup") } object KnoraBase { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AbstractEntityRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AbstractEntityRepo.scala index 4e5b3f6b1e..a5c7628875 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AbstractEntityRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AbstractEntityRepo.scala @@ -27,7 +27,7 @@ import org.knora.webapi.slice.admin.repo.rdf.Vocabulary import org.knora.webapi.slice.common.Value.StringValue import org.knora.webapi.slice.common.repo.rdf.Errors.RdfError import org.knora.webapi.slice.common.repo.rdf.RdfResource -import org.knora.webapi.slice.common.repo.service.Repository +import org.knora.webapi.slice.common.repo.service.CrudRepository import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Construct import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Update @@ -51,7 +51,7 @@ final case class EntityProperties(req: NonEmptyChunk[Iri], opt: Chunk[Iri] = Chu abstract class AbstractEntityRepo[E <: EntityWithId[Id], Id <: StringValue]( triplestore: TriplestoreService, mapper: RdfEntityMapper[E], -) extends Repository[E, Id] { +) extends CrudRepository[E, Id] { self => protected def resourceClass: ParsedIRI @@ -147,4 +147,25 @@ abstract class AbstractEntityRepo[E <: EntityWithId[Id], Id <: StringValue]( Update(query.getQueryString) } + + override def deleteById(id: Id): Task[Unit] = findById(id).flatMap { + case None => ZIO.unit + case Some(e) => delete(e) + } + + override def delete(entity: E): Task[Unit] = triplestore.query(eraseQuery(entity)) + + private def eraseQuery(entity: E): Update = { + val deletePattern = Rdf + .iri(entity.id.value) + .isA(Rdf.iri(resourceClass.toString)) + .andHas(variable("p"), variable("o")) + val query = Queries + .MODIFY() + .prefix(prefix(RDF.NS), prefix(Vocabulary.KnoraAdmin.NS), prefix(XSD.NS)) + .`with`(namedGraphIri) + .delete(deletePattern) + .where(deletePattern) + Update(query.getQueryString) + } } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AdministrativePermissionRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AdministrativePermissionRepoLive.scala index 3d92183e39..2a1e96e6a6 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AdministrativePermissionRepoLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/AdministrativePermissionRepoLive.scala @@ -62,6 +62,9 @@ final case class AdministrativePermissionRepoLive( _.has(Vocabulary.KnoraAdmin.forGroup, Rdf.iri(groupIri.value)) .andHas(Vocabulary.KnoraAdmin.forProject, Rdf.iri(projectIri.value)), ) + + override def findByProject(projectIri: ProjectIri): Task[Chunk[AdministrativePermission]] = + findAllByTriplePattern(_.has(Vocabulary.KnoraAdmin.forProject, Rdf.iri(projectIri.value))) } object AdministrativePermissionRepoLive { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepo.scala index 503f718a65..1b2808e0d2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepo.scala @@ -21,4 +21,6 @@ abstract class CachingEntityRepo[E <: EntityWithId[Id], Id <: StringValue]( cache.get(id).fold(super.findById(id).map(_.map(cache.put)))(ZIO.some(_)) override def save(entity: E): Task[E] = super.save(entity).map(cache.put) + + override def delete(entity: E): Task[Unit] = super.delete(entity) <* ZIO.succeed(cache.remove(entity)) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/DefaultObjectAccessPermissionRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/DefaultObjectAccessPermissionRepoLive.scala new file mode 100644 index 0000000000..47e20c9d95 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/DefaultObjectAccessPermissionRepoLive.scala @@ -0,0 +1,72 @@ +/* + * Copyright © 2021 - 2024 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.slice.admin.repo.service + +import org.eclipse.rdf4j.common.net.ParsedIRI +import org.eclipse.rdf4j.sparqlbuilder.graphpattern.TriplePattern +import org.eclipse.rdf4j.sparqlbuilder.rdf.Iri +import org.eclipse.rdf4j.sparqlbuilder.rdf.Rdf +import zio.Chunk +import zio.IO +import zio.NonEmptyChunk +import zio.Task +import zio.ZIO +import zio.ZLayer + +import org.knora.webapi.messages.OntologyConstants.KnoraAdmin +import org.knora.webapi.slice.admin.AdminConstants.permissionsDataNamedGraph +import org.knora.webapi.slice.admin.domain.model.DefaultObjectAccessPermission +import org.knora.webapi.slice.admin.domain.model.DefaultObjectAccessPermissionRepo +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.PermissionIri +import org.knora.webapi.slice.admin.repo.rdf.RdfConversions.* +import org.knora.webapi.slice.admin.repo.rdf.Vocabulary +import org.knora.webapi.slice.common.repo.rdf.Errors.ConversionError +import org.knora.webapi.slice.common.repo.rdf.Errors.RdfError +import org.knora.webapi.slice.common.repo.rdf.RdfResource +import org.knora.webapi.store.triplestore.api.TriplestoreService + +final case class DefaultObjectAccessPermissionRepoLive( + triplestore: TriplestoreService, + mapper: RdfEntityMapper[DefaultObjectAccessPermission], +) extends AbstractEntityRepo[DefaultObjectAccessPermission, PermissionIri](triplestore, mapper) + with DefaultObjectAccessPermissionRepo { + + override protected val resourceClass: ParsedIRI = ParsedIRI.create(KnoraAdmin.DefaultObjectAccessPermission) + override protected val namedGraphIri: Iri = Rdf.iri(permissionsDataNamedGraph.value) + + override protected def entityProperties: EntityProperties = + EntityProperties( + NonEmptyChunk(Vocabulary.KnoraAdmin.forProject), + ) + + override def findByProject(projectIri: ProjectIri): Task[Chunk[DefaultObjectAccessPermission]] = + findAllByTriplePattern(_.has(Vocabulary.KnoraAdmin.forProject, Rdf.iri(projectIri.value))) + + override def save(entity: DefaultObjectAccessPermission): Task[DefaultObjectAccessPermission] = + ZIO.die(UnsupportedOperationException("Mapper not yet fully implemented")) +} + +object DefaultObjectAccessPermissionRepoLive { + private val mapper = new RdfEntityMapper[DefaultObjectAccessPermission] { + + override def toEntity(resource: RdfResource): IO[RdfError, DefaultObjectAccessPermission] = + for { + id <- resource.iri.flatMap { iri => + ZIO.fromEither(PermissionIri.from(iri.value).left.map(ConversionError.apply)) + } + forProject <- resource.getObjectIrisConvert[ProjectIri](KnoraAdmin.ForProject).map(_.head) + } yield DefaultObjectAccessPermission(id, forProject) + + override def toTriples(entity: DefaultObjectAccessPermission): TriplePattern = { + val id = Rdf.iri(entity.id.value) + id.isA(Vocabulary.KnoraAdmin.DefaultObjectAccessPermission) + .andHas(Vocabulary.KnoraAdmin.forProject, Rdf.iri(entity.forProject.value)) + } + } + + val layer = ZLayer.succeed(mapper) >>> ZLayer.derive[DefaultObjectAccessPermissionRepoLive] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/EntityCache.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/EntityCache.scala index 3baae83f27..f97b2deb85 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/EntityCache.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/EntityCache.scala @@ -18,8 +18,9 @@ import org.knora.webapi.slice.infrastructure.CacheManager import org.knora.webapi.slice.infrastructure.EhCache final case class EntityCache[I <: StringValue, E <: EntityWithId[I]](cache: EhCache[I, E]) { - def put(value: E): E = { cache.put(value.id, value); value } - def get(id: I): Option[E] = cache.get(id) + def put(value: E): E = { cache.put(value.id, value); value } + def get(id: I): Option[E] = cache.get(id) + def remove(value: E): Boolean = cache.remove(value.id, value) } object EntityCache { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLive.scala index 78b56d7d49..6ad88ae66e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLive.scala @@ -18,6 +18,7 @@ import zio.ZIO import zio.ZLayer import org.knora.webapi.messages.OntologyConstants.KnoraAdmin +import org.knora.webapi.messages.OntologyConstants.KnoraAdmin.BelongsToProject import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 import org.knora.webapi.slice.admin.domain.model.* import org.knora.webapi.slice.admin.domain.model.GroupIri @@ -60,6 +61,9 @@ final case class KnoraGroupRepoLive( override def findByName(name: GroupName): Task[Option[KnoraGroup]] = findOneByTriplePattern(_.has(groupName, Rdf.literalOf(name.value))) .map(_.orElse(KnoraGroupRepo.builtIn.findOneBy(_.groupName == name))) + + override def findByProjectIri(projectIri: ProjectIri): Task[Chunk[KnoraGroup]] = + findAllByTriplePattern(_.has(belongsToProject, Rdf.iri(projectIri.value))) } object KnoraGroupRepoLive { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraProjectRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraProjectRepoLive.scala index 3e229eb0fe..7b7a162f2a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraProjectRepoLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraProjectRepoLive.scala @@ -69,6 +69,12 @@ final case class KnoraProjectRepoLive( .die(new IllegalArgumentException("Update not supported for built-in projects")) .when(project.id.isBuiltInProjectIri) *> super.save(project) + + override def delete(project: KnoraProject): Task[Unit] = + ZIO + .die(new IllegalArgumentException("Erase not supported for built-in projects")) + .when(project.id.isBuiltInProjectIri) *> + super.delete(project) } object KnoraProjectRepoLive { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala index 2bb9587834..0e7ca2f4dc 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala @@ -20,9 +20,10 @@ import scala.reflect.ClassTag import org.knora.webapi.slice.infrastructure.CacheManager.defaultCacheConfigBuilder final case class EhCache[K, V](cache: org.ehcache.Cache[K, V]) { - def put(key: K, value: V): Unit = cache.put(key, value) - def get(key: K): Option[V] = Option(cache.get(key)) - def clear(): Unit = cache.clear() + def put(key: K, value: V): Unit = cache.put(key, value) + def get(key: K): Option[V] = Option(cache.get(key)) + def remove(key: K, value: V): Boolean = cache.remove(key, value) + def clear(): Unit = cache.clear() } final case class CacheManager(manager: org.ehcache.CacheManager, knownCaches: Ref[Set[EhCache[_, _]]]) { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala index 690cb8c6cd..0bb37a7a72 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala @@ -22,6 +22,7 @@ final case class OntologyServiceLive(ontologyCache: OntologyCache) extends Ontol .get(ontologyIri.value) .flatMap(_.ontologyMetadata.projectIri.map(_.toString())) } + } object OntologyServiceLive { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/repo/service/OntologyCache.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/repo/service/OntologyCache.scala index eb64b63da7..9542cd6df1 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/repo/service/OntologyCache.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/repo/service/OntologyCache.scala @@ -474,6 +474,13 @@ trait OntologyCache { updatedOntologyData: ReadOntologyV2, updatedClassIri: SmartIri, ): Task[OntologyCacheData] + + /** + * Loads and caches all ontology information. + * + * @return [[Unit]] + */ + final def loadOntologies(): Task[Unit] = loadOntologies(KnoraSystemInstances.Users.SystemUser) } final case class OntologyCacheLive(triplestore: TriplestoreService, cacheDataRef: Ref[OntologyCacheData])(implicit diff --git a/webapi/src/main/scala/org/knora/webapi/store/triplestore/api/TriplestoreService.scala b/webapi/src/main/scala/org/knora/webapi/store/triplestore/api/TriplestoreService.scala index b89a0dc065..2f6c001248 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/triplestore/api/TriplestoreService.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/triplestore/api/TriplestoreService.scala @@ -144,6 +144,8 @@ trait TriplestoreService { def dropGraph(graphName: String): Task[Unit] def compact(): Task[Boolean] + + final def dropGraphByIri(graphName: InternalIri): Task[Unit] = dropGraph(graphName.value) } object TriplestoreService { diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala index 7fc07d796b..65e709fd4e 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala @@ -28,6 +28,8 @@ import org.knora.webapi.slice.admin.repo.service.KnoraGroupRepoInMemory import org.knora.webapi.slice.admin.repo.service.KnoraUserRepoLive import org.knora.webapi.slice.common.api.AuthorizationRestService import org.knora.webapi.slice.infrastructure.CacheManager +import org.knora.webapi.slice.ontology.repo.service.OntologyRepoInMemory +import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.store.triplestore.impl.TriplestoreServiceLive @@ -145,6 +147,7 @@ object AuthorizationRestServiceSpec extends ZIOSpecDefault { KnoraProjectService.layer, KnoraUserRepoLive.layer, KnoraUserService.layer, + OntologyRepoInMemory.emptyLayer, PasswordService.layer, StringFormatter.live, TriplestoreServiceLive.layer, diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/MaintenanceServiceSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/MaintenanceServiceSpec.scala index a6eacf9f4a..e8b3abbf12 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/MaintenanceServiceSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/MaintenanceServiceSpec.scala @@ -20,6 +20,8 @@ import org.knora.webapi.slice.admin.domain.repo.KnoraProjectRepoInMemory import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.admin.domain.service.ProjectService import org.knora.webapi.slice.admin.domain.service.maintenance.MaintenanceService +import org.knora.webapi.slice.ontology.repo.service.OntologyRepoInMemory +import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.store.triplestore.api.TestTripleStore import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select @@ -113,10 +115,12 @@ object MaintenanceServiceSpec extends ZIOSpecDefault { } yield assertTrue(actualDimension == expectedDimension) }, ).provide( - MaintenanceService.layer, - KnoraProjectService.layer, + IriConverter.layer, KnoraProjectRepoInMemory.layer, - emptyDatasetRefLayer >>> TriplestoreServiceInMemory.layer, + KnoraProjectService.layer, + MaintenanceService.layer, + OntologyRepoInMemory.emptyLayer, StringFormatter.test, + emptyDatasetRefLayer >>> TriplestoreServiceInMemory.layer, ) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClientMock.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClientMock.scala index 3747c3c5e3..beb4d0201d 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClientMock.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClientMock.scala @@ -13,6 +13,7 @@ import zio.nio.file.Path import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode final case class DspIngestClientMock() extends DspIngestClient { override def exportProject(shortcode: KnoraProject.Shortcode): ZIO[Scope, Throwable, Path] = @@ -32,6 +33,8 @@ final case class DspIngestClientMock() extends DspIngestClient { originalMimeType = Some("text/plain"), ), ) + + def eraseProject(shortcode: Shortcode): Task[Unit] = ZIO.unit } object DspIngestClientMock { diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepoSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepoSpec.scala new file mode 100644 index 0000000000..836da9936c --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/CachingEntityRepoSpec.scala @@ -0,0 +1,127 @@ +/* + * Copyright © 2021 - 2024 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.slice.admin.repo.service +import org.eclipse.rdf4j.common.net.ParsedIRI +import org.eclipse.rdf4j.sparqlbuilder.graphpattern.TriplePattern +import org.eclipse.rdf4j.sparqlbuilder.rdf.Iri +import org.eclipse.rdf4j.sparqlbuilder.rdf.Rdf +import zio.IO +import zio.NonEmptyChunk +import zio.ZIO +import zio.ZLayer +import zio.test.* + +import org.knora.webapi.messages.StringFormatter +import org.knora.webapi.slice.common.Value.StringValue +import org.knora.webapi.slice.common.repo.rdf.Errors +import org.knora.webapi.slice.common.repo.rdf.RdfResource +import org.knora.webapi.slice.infrastructure.CacheManager +import org.knora.webapi.store.triplestore.api.TriplestoreService +import org.knora.webapi.store.triplestore.api.TriplestoreServiceInMemory + +object CachingEntityRepoSpec extends ZIOSpecDefault { + + private val repo = ZIO.serviceWithZIO[TestRepo] + private val cache = ZIO.serviceWith[EntityCache[TestId, TestEntity]] + private val entity1 = TestEntity(TestId("https://example.com/1"), "someName1") + private val entity2 = TestEntity(TestId("https://example.com/2"), "someName2") + private val entity3 = TestEntity(TestId("https://example.com/3"), "someName3") + + val spec = suite("CachingEntityRepo")( + test("findById returns None") { + for { + actual <- repo(_.findById(TestId("https://example.com/1"))) + } yield assertTrue(actual.isEmpty) + }, + test("save") { + for { + _ <- repo(_.save(entity1)) + actual <- repo(_.findById(entity1.id)) + actualCache <- cache(_.get(entity1.id)) + } yield assertTrue(actual.contains(entity1), actualCache.contains(entity1)) + }, + test("delete") { + for { + _ <- repo(_.save(entity2)) + created <- repo(_.findById(entity2.id)).someOrFail(IllegalStateException("Entity Missing")) + _ <- repo(_.delete(created)) + actual <- repo(_.findById(entity2.id)) + cacheEmpty <- checkAllEntityRemovedFromCache(entity2) + } yield assertTrue(actual.isEmpty, cacheEmpty) + }, + test("deleteById") { + for { + _ <- repo(_.save(entity3)) + _ <- repo(_.findById(entity3.id)).someOrFail(IllegalStateException("Entity Missing")) + _ <- repo(_.deleteById(entity3.id)) + actual <- repo(_.findById(entity3.id)) + cacheEmpty <- checkAllEntityRemovedFromCache(entity3) + } yield assertTrue(actual.isEmpty, cacheEmpty) + }, + test("deleteAll") { + val entities = Seq(entity1, entity2) + for { + _ <- repo(_.saveAll(entities)) + _ <- repo(_.deleteAll(entities)) + actual1 <- repo(_.findById(entity1.id)) + actual2 <- repo(_.findById(entity2.id)) + cacheEmpty <- checkAllEntitiesRemovedFromCache(entities) + } yield assertTrue(actual1.isEmpty, actual2.isEmpty, cacheEmpty) + }, + test("deleteAllById") { + val entities = Seq(entity2, entity3) + for { + _ <- repo(_.saveAll(entities)) + _ <- repo(_.deleteAllById(entities.map(_.id))) + actual2 <- repo(_.findById(entity2.id)) + actual3 <- repo(_.findById(entity3.id)) + cacheEmpty <- checkAllEntitiesRemovedFromCache(entities) + } yield assertTrue(actual2.isEmpty, actual3.isEmpty, cacheEmpty) + }, + ).provide( + TriplestoreServiceInMemory.emptyLayer, + CacheManager.layer, + EntityCache.layer[TestId, TestEntity]("testEntity"), + StringFormatter.test, + TestRepo.layer, + ) + + private def checkAllEntityRemovedFromCache(entity: TestEntity) = checkAllEntitiesRemovedFromCache(Seq(entity)) + private def checkAllEntitiesRemovedFromCache(entities: Seq[TestEntity]) = + cache(c => entities.map(_.id).map(id => c.cache.get(id).isEmpty).forall(b => b)) +} + +final case class TestId(value: String) extends StringValue +final case class TestEntity(id: TestId, name: String) extends EntityWithId[TestId] +final case class TestMapper() extends RdfEntityMapper[TestEntity] { + override def toTriples(entity: TestEntity): TriplePattern = + Rdf + .iri(entity.id.value) + .isA(Rdf.iri(TestRepo.resourceClass)) + .andHas(TestRepo.propertyIri, entity.name) + + override def toEntity(resource: RdfResource): IO[Errors.RdfError, TestEntity] = + for { + id <- resource.getSubjectIri + name <- resource.getStringLiteralOrFail[String](TestRepo.property)(Right(_)) + } yield TestEntity(TestId(id.value), name) +} + +final case class TestRepo( + triplestore: TriplestoreService, + cache: EntityCache[TestId, TestEntity], +) extends CachingEntityRepo(triplestore, TestMapper(), cache) { + override protected def resourceClass: ParsedIRI = ParsedIRI.create(TestRepo.resourceClass) + override protected def namedGraphIri: Iri = Rdf.iri("https://example.com/namedGraph") + override protected def entityProperties: EntityProperties = EntityProperties(NonEmptyChunk(TestRepo.propertyIri)) +} + +object TestRepo { + val property: String = "https://example.com/prop/#name" + val propertyIri: Iri = Rdf.iri(property) + val resourceClass: String = "https://example.com/class/test-entity" + val layer = ZLayer.derive[TestRepo] +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLiveSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLiveSpec.scala index cbf246f0b8..e8a942459b 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLiveSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraGroupRepoLiveSpec.scala @@ -21,6 +21,7 @@ import org.knora.webapi.messages.StringFormatter import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.GroupName import org.knora.webapi.slice.admin.domain.model.KnoraGroup +import org.knora.webapi.slice.admin.domain.model.KnoraProject import org.knora.webapi.slice.admin.domain.service.KnoraGroupRepo import org.knora.webapi.slice.common.repo.service.AbstractInMemoryCrudRepository import org.knora.webapi.slice.infrastructure.CacheManager @@ -31,6 +32,9 @@ final case class KnoraGroupRepoInMemory(groups: Ref[Chunk[KnoraGroup]]) with KnoraGroupRepo { override def findByName(name: GroupName): Task[Option[KnoraGroup]] = groups.get.map(_.find(_.groupName == name)) + + override def findByProjectIri(projectIri: KnoraProject.ProjectIri): Task[Chunk[KnoraGroup]] = + groups.get.map(_.filter(_.belongsToProject.contains(projectIri))) } object KnoraGroupRepoInMemory { diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/repo/service/OntologyRepoInMemory.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/repo/service/OntologyRepoInMemory.scala new file mode 100644 index 0000000000..4fbc9b0777 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/repo/service/OntologyRepoInMemory.scala @@ -0,0 +1,10 @@ +/* + * Copyright © 2021 - 2024 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.slice.ontology.repo.service + +object OntologyRepoInMemory { + val emptyLayer = OntologyCacheFake.emptyCache >>> OntologyRepoLive.layer +}