From 557730e370bd7238e0ab37d035412ff4836ee623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Fri, 20 Oct 2023 15:15:11 +0200 Subject: [PATCH] refactor!: Port resourceinfo endpoint to tapir and remove code/zio-http server running on port 5555 (DEV-2807) (#2880) --- .../org/knora/webapi/core/LayersTest.scala | 11 +- .../org/knora/webapi/core/HttpServerZ.scala | 29 ----- .../org/knora/webapi/core/LayersLive.scala | 12 +- .../ProjectsMessagesADM.scala | 16 +++ .../org/knora/webapi/routing/ApiRoutes.scala | 20 ++-- .../knora/webapi/routing/PathVariables.scala | 9 +- .../webapi/routing/v2/ResourcesRouteV2.scala | 67 +---------- .../resourceinfo/ResourceInfoLayers.scala | 30 +++++ .../api/ResourceInfoEndpoints.scala | 32 +++++ .../resourceinfo/api/ResourceInfoRoute.scala | 86 -------------- .../resourceinfo/api/ResourceInfoRoutes.scala | 48 ++++++++ .../api/RestResourceInfoService.scala | 45 ------- .../api/RestResourceInfoServiceLive.scala | 86 -------------- .../resourceinfo/api/model/QueryParams.scala | 54 +++++++++ .../api/{ => model}/ResourceInfoDto.scala | 8 +- .../api/service/RestResourceInfoService.scala | 78 ++++++++++++ .../domain/ResourceInfoRepo.scala | 14 +-- .../repo/ResourceInfoRepoLive.scala | 40 +++++-- .../v2/resourcesByCreationDate.scala.txt | 22 ---- .../api/LiveRestResourceInfoServiceSpec.scala | 72 +++++------ .../api/ResourceInfoRouteSpec.scala | 112 ------------------ .../api/RestResourceInfoServiceSpy.scala | 48 -------- .../repo/ResourceInfoRepoFake.scala | 22 ++-- 23 files changed, 366 insertions(+), 595 deletions(-) delete mode 100644 webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/ResourceInfoLayers.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoEndpoints.scala delete mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoutes.scala delete mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala delete mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceLive.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/QueryParams.scala rename webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/{ => model}/ResourceInfoDto.scala (81%) create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/service/RestResourceInfoService.scala delete mode 100644 webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/resourcesByCreationDate.scala.txt delete mode 100644 webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRouteSpec.scala delete mode 100644 webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceSpy.scala diff --git a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala index c3f610fd38..1e03bc545c 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -43,10 +43,9 @@ import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive import org.knora.webapi.slice.ontology.repo.service.PredicateRepositoryLive -import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.ResourceInfoLayers +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceRequestMessageHandler import org.knora.webapi.store.cache.CacheServiceRequestMessageHandlerLive import org.knora.webapi.store.cache.api.CacheService @@ -109,7 +108,6 @@ object LayersTest { with ProjectsResponderADM with QueryTraverser with RepositoryUpdater - with ResourceInfoRepo with ResourceUtilV2 with ResourcesResponderV2 with RestCardinalityService @@ -145,7 +143,6 @@ object LayersTest { GroupsResponderADMLive.layer, HandlerMapper.layer, HttpServer.layer, - HttpServerZ.layer, IIIFRequestMessageHandlerLive.layer, InferenceOptimizationService.layer, IriConverter.layer, @@ -177,13 +174,11 @@ object LayersTest { ProjectsResponderADMLive.layer, QueryTraverser.layer, RepositoryUpdater.layer, - ResourceInfoRepo.layer, - ResourceInfoRoute.layer, + ResourceInfoLayers.live, ResourceUtilV2Live.layer, ResourcesResponderV2Live.layer, RestCardinalityServiceLive.layer, RestPermissionServiceLive.layer, - RestResourceInfoService.layer, SearchResponderV2Live.layer, SipiResponderADMLive.layer, StandoffResponderV2Live.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala b/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala deleted file mode 100644 index 95cc21df30..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.core - -import zio._ -import zio.http._ - -import org.knora.webapi.config.AppConfig -import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute - -object HttpServerZ { - - private val apiRoutes: URIO[ResourceInfoRoute, HttpApp[Any, Nothing]] = for { - riRoute <- ZIO.serviceWith[ResourceInfoRoute](_.route) - } yield riRoute - - val layer: ZLayer[ResourceInfoRoute with AppConfig, Nothing, Unit] = ZLayer { - for { - port <- ZIO.serviceWith[AppConfig](_.knoraApi.externalZioPort) - routes <- apiRoutes - routesWithMiddleware = routes - _ <- Server.serve(routesWithMiddleware).provide(Server.defaultWithPort(port)).forkDaemon - _ <- ZIO.logInfo(">>> Acquire ZIO HTTP Server <<<") - } yield () - } -} diff --git a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala index 60b40cca05..1671eaa26c 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -43,10 +43,10 @@ import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive import org.knora.webapi.slice.ontology.repo.service.PredicateRepositoryLive -import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.ResourceInfoLayers +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoServiceLive import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceRequestMessageHandler import org.knora.webapi.store.cache.CacheServiceRequestMessageHandlerLive import org.knora.webapi.store.cache.api.CacheService @@ -145,7 +145,6 @@ object LayersLive { GroupsResponderADMLive.layer, HandlerMapper.layer, HttpServer.layer, - HttpServerZ.layer, // this is the new ZIO HTTP server layer IIIFRequestMessageHandlerLive.layer, IIIFServiceSipiImpl.layer, InferenceOptimizationService.layer, @@ -179,13 +178,12 @@ object LayersLive { ProjectsResponderADMLive.layer, QueryTraverser.layer, RepositoryUpdater.layer, - ResourceInfoRepo.layer, - ResourceInfoRoute.layer, + ResourceInfoLayers.live, ResourceUtilV2Live.layer, ResourcesResponderV2Live.layer, RestCardinalityServiceLive.layer, RestPermissionServiceLive.layer, - RestResourceInfoService.layer, + RestResourceInfoServiceLive.layer, SearchResponderV2Live.layer, SipiResponderADMLive.layer, StandoffResponderV2Live.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala index bb133d2997..86dbdf16e0 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala @@ -11,12 +11,16 @@ import spray.json.DefaultJsonProtocol import spray.json.JsValue import spray.json.JsonFormat import spray.json.RootJsonFormat +import sttp.tapir.Codec +import sttp.tapir.CodecFormat.TextPlain +import sttp.tapir.DecodeResult import zio.json.DeriveJsonCodec import zio.json.JsonCodec import zio.prelude.Validation import java.util.UUID +import dsp.errors.BadRequestException import dsp.errors.OntologyConstraintException import dsp.errors.ValidationException import dsp.valueobjects.Iri @@ -365,9 +369,21 @@ object ProjectIdentifierADM { object IriIdentifier { def from(projectIri: ProjectIri): IriIdentifier = IriIdentifier(projectIri) + def unsafeFrom(projectIri: String): IriIdentifier = + fromString(projectIri).fold( + err => throw new IllegalArgumentException(s"Invalid project IRI: $projectIri: ${err.head.msg}"), + identity + ) def fromString(value: String): Validation[ValidationException, IriIdentifier] = ProjectIri.make(value).map(IriIdentifier(_)) + + implicit val tapirCodec: Codec[String, IriIdentifier, TextPlain] = + Codec.string.mapDecode(str => + IriIdentifier + .fromString(str) + .fold(err => DecodeResult.Error(str, BadRequestException(err.head.msg)), DecodeResult.Value(_)) + )(_.value.value) } /** diff --git a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala index ca225476e3..0447ef7bdf 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -30,7 +30,8 @@ import org.knora.webapi.slice.admin.api.ProjectsEndpointsHandler import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo import org.knora.webapi.slice.ontology.api.service.RestCardinalityService -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoutes +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService import org.knora.webapi.slice.resourceinfo.domain.IriConverter trait ApiRoutes { @@ -52,6 +53,7 @@ object ApiRoutes { with MessageRelay with ProjectADMRestService with ProjectsEndpointsHandler + with ResourceInfoRoutes with RestCardinalityService with RestResourceInfoService with StringFormatter @@ -62,11 +64,12 @@ object ApiRoutes { ] = ZLayer { for { - sys <- ZIO.service[ActorSystem] - router <- ZIO.service[AppRouter] - appConfig <- ZIO.service[AppConfig] - adminApiRoutes <- ZIO.service[AdminApiRoutes] - routeData <- ZIO.succeed(KnoraRouteData(sys.system, router.ref, appConfig)) + sys <- ZIO.service[ActorSystem] + router <- ZIO.service[AppRouter] + appConfig <- ZIO.service[AppConfig] + adminApiRoutes <- ZIO.service[AdminApiRoutes] + resourceInfoRoutes <- ZIO.service[ResourceInfoRoutes] + routeData <- ZIO.succeed(KnoraRouteData(sys.system, router.ref, appConfig)) runtime <- ZIO.runtime[ AppConfig with IriConverter @@ -80,7 +83,7 @@ object ApiRoutes { with core.State with routing.Authenticator ] - } yield ApiRoutesImpl(routeData, adminApiRoutes, appConfig, runtime) + } yield ApiRoutesImpl(routeData, adminApiRoutes, resourceInfoRoutes, appConfig, runtime) } } @@ -94,6 +97,7 @@ object ApiRoutes { private final case class ApiRoutesImpl( routeData: KnoraRouteData, adminApiRoutes: AdminApiRoutes, + resourceInfoRoutes: ResourceInfoRoutes, appConfig: AppConfig, implicit val runtime: Runtime[ AppConfig @@ -122,7 +126,7 @@ private final case class ApiRoutesImpl( .withAllowedMethods(List(GET, PUT, POST, DELETE, PATCH, HEAD, OPTIONS)) ) { DSPApiDirectives.handleErrors(appConfig) { - adminApiRoutes.routes.reduce(_ ~ _) ~ + (adminApiRoutes.routes ++ resourceInfoRoutes.routes).reduce(_ ~ _) ~ AuthenticationRouteV2().makeRoute ~ FilesRouteADM(routeData, runtime).makeRoute ~ GroupsRouteADM(routeData, runtime).makeRoute ~ diff --git a/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala b/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala index be65a5924f..4fb78af608 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala @@ -15,15 +15,8 @@ import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentif object PathVariables { - private val projectIriCodec: Codec[String, IriIdentifier, TextPlain] = - Codec.string.mapDecode(str => - IriIdentifier - .fromString(str) - .fold(err => DecodeResult.Error(str, BadRequestException(err.head.msg)), DecodeResult.Value(_)) - )(_.value.value) - val projectIri: EndpointInput.PathCapture[IriIdentifier] = - path[IriIdentifier](projectIriCodec) + path[IriIdentifier] .name("projectIri") .description("The IRI of a project. Must be URL-encoded.") .example(IriIdentifier.fromString("http://rdfh.ch/projects/0001").fold(e => throw e.head, identity)) diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala index 77f12abdf4..6a1aeaf06b 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala @@ -6,11 +6,10 @@ package org.knora.webapi.routing.v2 import com.typesafe.scalalogging.LazyLogging -import org.apache.pekko -import zio.Exit.Failure -import zio.Exit.Success +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.PathMatcher +import org.apache.pekko.http.scaladsl.server.Route import zio._ -import zio.json._ import java.time.Instant @@ -33,23 +32,9 @@ import org.knora.webapi.messages.v2.responder.valuemessages._ import org.knora.webapi.routing.Authenticator import org.knora.webapi.routing.RouteUtilV2 import org.knora.webapi.routing.RouteUtilZ -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.ASC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.Order -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.OrderBy -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.lastModificationDate +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import pekko.http.scaladsl.model.ContentTypes.`application/json` -import pekko.http.scaladsl.model.HttpEntity -import pekko.http.scaladsl.model.HttpResponse -import pekko.http.scaladsl.model.StatusCodes.InternalServerError -import pekko.http.scaladsl.model.StatusCodes.OK -import pekko.http.scaladsl.server.Directives._ -import pekko.http.scaladsl.server.PathMatcher -import pekko.http.scaladsl.server.RequestContext -import pekko.http.scaladsl.server.Route - /** * Provides a routing function for API v2 routes that deal with resources. */ @@ -83,7 +68,6 @@ final case class ResourcesRouteV2(appConfig: AppConfig)( getResourceHistory() ~ getResourceHistoryEvents() ~ getProjectResourceAndValueHistory() ~ - getResourcesInfo ~ getResources() ~ getResourcesPreview() ~ getResourcesTei() ~ @@ -264,49 +248,6 @@ final case class ResourcesRouteV2(appConfig: AppConfig)( } } - private def getQueryParamsMap(requestContext: RequestContext): Map[String, String] = - requestContext.request.uri.query().toMap - - private def getStringQueryParam(requestContext: RequestContext, key: String): Option[String] = - getQueryParamsMap(requestContext).get(key) - - private def unsafeRunZioAndMapJsonResponse[R, E, A]( - zioAction: ZIO[R, E, A] - )(implicit r: Runtime[R], encoder: JsonEncoder[A]) = - unsafeRunZio(zioAction) match { - case Failure(cause) => logger.error(cause.prettyPrint); HttpResponse(InternalServerError) - case Success(dto) => HttpResponse(status = OK, entity = HttpEntity(`application/json`, dto.toJson)) - } - - private def unsafeRunZio[R, E, A](zioAction: ZIO[R, E, A])(implicit r: Runtime[R]): Exit[E, A] = - Unsafe.unsafe(implicit u => r.unsafe.run(zioAction)) - - private def getResourcesInfo: Route = path(resourcesBasePath / "info") { - get { ctx => - val getResourceClassIri = ZIO - .fromOption(getStringQueryParam(ctx, "resourceClass")) - .orElseFail(BadRequestException(s"This route requires the parameter 'resourceClass'")) - val getOrderBy: ZIO[Any, BadRequestException, OrderBy] = getStringQueryParam(ctx, "orderBy") match { - case None => ZIO.succeed(lastModificationDate) - case Some(s) => - ZIO.fromOption(OrderBy.make(s)).orElseFail(BadRequestException(s"Invalid value '$s', for orderBy")) - } - val getOrder: IO[BadRequestException, Order] = getStringQueryParam(ctx, "order") match { - case None => ZIO.succeed(ASC) - case Some(s) => - ZIO.fromOption(Order.make(s)).orElseFail(BadRequestException(s"Invalid value '$s', for order")) - } - val action = for { - resourceClassIri <- getResourceClassIri - orderBy <- getOrderBy - order <- getOrder - projectIri <- RouteUtilV2.getRequiredProjectIri(ctx) - result <- - RestResourceInfoService.findByProjectAndResourceClass(projectIri.toIri, resourceClassIri, (orderBy, order)) - } yield result - ctx.complete(unsafeRunZioAndMapJsonResponse(action)) - } - } private def getResources(): Route = path(resourcesBasePath / Segments) { resIris: Seq[String] => get { requestContext => val targetSchemaTask = RouteUtilV2.getOntologySchema(requestContext) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/ResourceInfoLayers.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/ResourceInfoLayers.scala new file mode 100644 index 0000000000..6285e05cc2 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/ResourceInfoLayers.scala @@ -0,0 +1,30 @@ +/* + * Copyright © 2021 - 2023 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.resourceinfo + +import zio.ZLayer + +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoEndpoints +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoutes +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoServiceLive +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoLive +import org.knora.webapi.store.triplestore.api.TriplestoreService + +object ResourceInfoLayers { + + val live: ZLayer[ + TriplestoreService with IriConverter with BaseEndpoints with HandlerMapper with TapirToPekkoInterpreter, + Nothing, + RestResourceInfoService with ResourceInfoEndpoints with ResourceInfoRoutes + ] = + ResourceInfoRepoLive.layer >>> RestResourceInfoServiceLive.layer >+> ResourceInfoEndpoints.layer >+> ResourceInfoRoutes.layer + +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoEndpoints.scala new file mode 100644 index 0000000000..3ebb6f72a4 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoEndpoints.scala @@ -0,0 +1,32 @@ +/* + * Copyright © 2021 - 2023 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.resourceinfo.api + +import sttp.tapir._ +import sttp.tapir.generic.auto._ +import sttp.tapir.json.zio._ +import zio.ZLayer + +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.routing.RouteUtilV2 +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.resourceinfo.api.model.ListResponseDto +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.Order +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.OrderBy + +final case class ResourceInfoEndpoints(baseEndpoints: BaseEndpoints) { + val getResourcesInfo = baseEndpoints.publicEndpoint.get + .in("v2" / "resources" / "info") + .in(header[IriIdentifier](RouteUtilV2.PROJECT_HEADER)) + .in(query[String]("resourceClass")) + .in(query[Option[Order]](Order.queryParamKey)) + .in(query[Option[OrderBy]](OrderBy.queryParamKey)) + .out(jsonBody[ListResponseDto]) +} + +object ResourceInfoEndpoints { + val layer = ZLayer.derive[ResourceInfoEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala deleted file mode 100644 index d6549e9c23..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.resourceinfo.api - -import zio.ZLayer -import zio.http.HttpError.BadRequest -import zio.http._ -import zio.json.EncoderOps -import zio.prelude.Validation - -import org.knora.webapi.IRI -import org.knora.webapi.routing.RouteUtilV2 -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.ASC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.Order -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.OrderBy -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.lastModificationDate - -final case class ResourceInfoRoute(restService: RestResourceInfoService) { - - val route: HttpApp[Any, Nothing] = - Http.collectZIO[Request] { case req @ Method.GET -> Root / "v2" / "resources" / "info" => - (for { - p <- getParameters(req) - result <- restService.findByProjectAndResourceClass(p._1, p._2, (p._3, p._4)) - } yield result).fold(err => Response.fromHttpError(err), suc => Response.json(suc.toJson)) - } - - private def getParameters(req: Request) = { - val queryParams = req.url.queryParams - val headers = req.headers - Validation - .validate( - getProjectIri(headers), - getResourceClass(queryParams), - getOrderBy(queryParams), - getOrder(queryParams) - ) - .toZIO - } - - private def getOrder(params: QueryParams) = { - val order: Validation[BadRequest, Order] = params.get("order").map(_.toList) match { - case Some(s :: Nil) => - Order.make(s).map(Validation.succeed).getOrElse(Validation.fail(BadRequest(s"Invalid order param $s"))) - case Some(_ :: _ :: _) => Validation.fail(BadRequest(s"orderBy param may only be a single value")) - case _ => Validation.succeed(ASC) - } - order - } - - private def getOrderBy(params: QueryParams) = { - val orderBy: Validation[BadRequest, OrderBy] = params.get("orderBy").map(_.toList) match { - case Some(s :: Nil) => - OrderBy - .make(s) - .map(o => Validation.succeed(o)) - .getOrElse(Validation.fail(BadRequest(s"Invalid orderBy param $s"))) - case Some(_ :: _ :: _) => Validation.fail(BadRequest(s"orderBy param is mandatory with a single value")) - case _ => Validation.succeed(lastModificationDate) - } - orderBy - } - - private def getResourceClass(params: QueryParams) = { - val resourceClassIri: Validation[BadRequest, IRI] = params.get("resourceClass").map(_.toList) match { - case Some(s :: Nil) => Validation.succeed(s) - case _ => Validation.fail(BadRequest(s"resourceClass param is mandatory with a single value")) - } - resourceClassIri - } - - private def getProjectIri(headers: Headers) = { - val projectIri: Validation[BadRequest, IRI] = headers.get(RouteUtilV2.PROJECT_HEADER) match { - case None => Validation.fail(BadRequest(s"Header ${RouteUtilV2.PROJECT_HEADER} may not be empty")) - case Some(value) => Validation.succeed(value) - } - projectIri - } -} - -object ResourceInfoRoute { - val layer: ZLayer[RestResourceInfoService, Nothing, ResourceInfoRoute] = ZLayer.fromFunction(ResourceInfoRoute(_)) -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoutes.scala new file mode 100644 index 0000000000..0649905931 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoutes.scala @@ -0,0 +1,48 @@ +/* + * Copyright © 2021 - 2023 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.resourceinfo.api + +import org.apache.pekko.http.scaladsl.server.Route +import zio.ZLayer + +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.slice.common.api.EndpointAndZioHandler +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter +import org.knora.webapi.slice.resourceinfo.api.model.ListResponseDto +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.Asc +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.LastModificationDate +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.Order +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.OrderBy +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService + +final case class ResourceInfoRoutes( + endpoints: ResourceInfoEndpoints, + resourceInfoService: RestResourceInfoService, + mapper: HandlerMapper, + interpreter: TapirToPekkoInterpreter +) { + + val getResourcesInfoHandler = + EndpointAndZioHandler[Unit, (IriIdentifier, String, Option[Order], Option[OrderBy]), ListResponseDto]( + endpoints.getResourcesInfo, + { case (projectIri: IriIdentifier, resourceClass: String, order: Option[Order], orderBy: Option[OrderBy]) => + resourceInfoService.findByProjectAndResourceClass( + projectIri, + resourceClass, + order.getOrElse(Asc), + orderBy.getOrElse(LastModificationDate) + ) + } + ) + + val routes: Seq[Route] = List(getResourcesInfoHandler) + .map(it => mapper.mapEndpointAndHandler(it)) + .map(interpreter.toRoute(_)) +} +object ResourceInfoRoutes { + val layer = ZLayer.derive[ResourceInfoRoutes] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala deleted file mode 100644 index 1a283112bd..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.resourceinfo.api - -import zio._ -import zio.http.HttpError - -import org.knora.webapi.IRI -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.Order -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.OrderBy -import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo - -trait RestResourceInfoService { - - /** - * Queries the existing resources of a certain resource class of a single project and returns the [[ResourceInfoDto]] in a [[ListResponseDto]] - * List can be sorted determined by the ordering. - * @param projectIri an external IRI for the project - * @param resourceClass an external IRI to the resource class to retrieve - * @param ordering sort by which property ascending or descending - * @return - * success: the [[ListResponseDto]] for the project and resource class - * failure: - * * with an [[HttpError.BadRequest]] if projectIri or resource class are invalid - * * with an [[HttpError.InternalServerError]] if the repo causes a problem - */ - def findByProjectAndResourceClass( - projectIri: IRI, - resourceClass: IRI, - ordering: (OrderBy, Order) - ): IO[HttpError, ListResponseDto] -} - -object RestResourceInfoService { - - def findByProjectAndResourceClass(projectIri: IRI, resourceClass: IRI, ordering: (OrderBy, Order)) = - ZIO.service[RestResourceInfoService].flatMap(_.findByProjectAndResourceClass(projectIri, resourceClass, ordering)) - - val layer: ZLayer[ResourceInfoRepo with IriConverter, Nothing, RestResourceInfoService] = - ZLayer.fromFunction(RestResourceInfoServiceLive(_, _)) -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceLive.scala deleted file mode 100644 index 37c512d76c..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceLive.scala +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.resourceinfo.api - -import zio.IO -import zio.http.HttpError - -import java.time.Instant - -import org.knora.webapi.IRI -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive._ -import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo - -final case class RestResourceInfoServiceLive(repo: ResourceInfoRepo, iriConverter: IriConverter) - extends RestResourceInfoService { - - private def lastModificationDateSort(order: Order)(one: ResourceInfoDto, two: ResourceInfoDto) = - instant(order)(one.lastModificationDate, two.lastModificationDate) - - private def creationDateSort(order: Order)(one: ResourceInfoDto, two: ResourceInfoDto) = - instant(order)(one.creationDate, two.creationDate) - - private def instant(order: Order)(one: Instant, two: Instant) = - order match { - case ASC => two.compareTo(one) > 0 - case DESC => one.compareTo(two) > 0 - } - - private def sort(resources: List[ResourceInfoDto], ordering: (OrderBy, Order)) = ordering match { - case (`lastModificationDate`, order) => resources.sortWith(lastModificationDateSort(order)) - case (`creationDate`, order) => resources.sortWith(creationDateSort(order)) - } - - override def findByProjectAndResourceClass( - projectIri: IRI, - resourceClass: IRI, - ordering: (OrderBy, Order) - ): IO[HttpError, ListResponseDto] = - for { - p <- iriConverter - .asInternalIri(projectIri) - .mapError(err => HttpError.BadRequest(s"Invalid projectIri: ${err.getMessage}")) - rc <- iriConverter - .asInternalIri(resourceClass) - .mapError(err => HttpError.BadRequest(s"Invalid resourceClass: ${err.getMessage}")) - resources <- repo - .findByProjectAndResourceClass(p, rc) - .mapBoth(err => HttpError.InternalServerError(err.getMessage), _.map(ResourceInfoDto(_))) - sorted = sort(resources, ordering) - } yield ListResponseDto(sorted) -} - -object RestResourceInfoServiceLive { - - sealed trait OrderBy - - case object creationDate extends OrderBy - - case object lastModificationDate extends OrderBy - - object OrderBy { - def make(str: String): Option[OrderBy] = str match { - case "creationDate" => Some(creationDate) - case "lastModificationDate" => Some(lastModificationDate) - case _ => None - } - } - - sealed trait Order - - case object ASC extends Order - - case object DESC extends Order - - object Order { - def make(str: String): Option[Order] = str match { - case "ASC" => Some(ASC) - case "DESC" => Some(DESC) - case _ => None - } - } -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/QueryParams.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/QueryParams.scala new file mode 100644 index 0000000000..80b0a03929 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/QueryParams.scala @@ -0,0 +1,54 @@ +/* + * Copyright © 2021 - 2023 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.resourceinfo.api.model + +import sttp.tapir.Codec +import sttp.tapir.CodecFormat.TextPlain +import sttp.tapir.DecodeResult + +import dsp.errors.BadRequestException + +object QueryParams { + + sealed trait WithUrlParam { self => + def urlParam: String = self.getClass.getSimpleName.stripSuffix("$") + } + + private def decode[A <: WithUrlParam](value: String, allValues: List[A]): DecodeResult[A] = + allValues + .find(_.urlParam.equalsIgnoreCase(value)) + .fold[DecodeResult[A]]( + DecodeResult.Error(value, BadRequestException(s"Expected one of ${allValues.map(_.urlParam.mkString)}")) + )(DecodeResult.Value(_)) + + sealed trait OrderBy extends WithUrlParam + case object CreationDate extends OrderBy + case object LastModificationDate extends OrderBy + + object OrderBy { + + val queryParamKey = "orderBy" + + private val allValues = List(CreationDate, LastModificationDate) + + implicit val tapirCodec: Codec[String, OrderBy, TextPlain] = + Codec.string.mapDecode(decode[OrderBy](_, allValues))(_.urlParam) + } + + sealed trait Order extends WithUrlParam + case object Asc extends Order + case object Desc extends Order + + object Order { + + val queryParamKey = "order" + + private val allValues = List(Asc, Desc) + + implicit val tapirCodec: Codec[String, Order, TextPlain] = + Codec.string.mapDecode(decode[Order](_, allValues))(_.urlParam) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoDto.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/ResourceInfoDto.scala similarity index 81% rename from webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoDto.scala rename to webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/ResourceInfoDto.scala index 6db8b5c095..2a3843bf32 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoDto.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/model/ResourceInfoDto.scala @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.knora.webapi.slice.resourceinfo.api +package org.knora.webapi.slice.resourceinfo.api.model import zio.json._ @@ -20,8 +20,7 @@ object ListResponseDto { case list => ListResponseDto(list, list.size) } - implicit val encoder: JsonEncoder[ListResponseDto] = - DeriveJsonEncoder.gen[ListResponseDto] + implicit val codec: JsonCodec[ListResponseDto] = DeriveJsonCodec.gen[ListResponseDto] } final case class ResourceInfoDto private ( @@ -41,6 +40,5 @@ object ResourceInfoDto { info.isDeleted ) - implicit val encoder: JsonEncoder[ResourceInfoDto] = - DeriveJsonEncoder.gen[ResourceInfoDto] + implicit val codec: JsonCodec[ResourceInfoDto] = DeriveJsonCodec.gen[ResourceInfoDto] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/service/RestResourceInfoService.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/service/RestResourceInfoService.scala new file mode 100644 index 0000000000..8c9bbaf879 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/service/RestResourceInfoService.scala @@ -0,0 +1,78 @@ +/* + * Copyright © 2021 - 2023 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.resourceinfo.api.service + +import zio._ +import zio.macros.accessible + +import java.time.Instant + +import dsp.errors.BadRequestException +import org.knora.webapi.IRI +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.slice.resourceinfo.api.model.ListResponseDto +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams._ +import org.knora.webapi.slice.resourceinfo.api.model.ResourceInfoDto +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo + +@accessible +trait RestResourceInfoService { + + /** + * Queries the existing resources of a certain resource class of a single project and returns the [[ResourceInfoDto]] in a [[ListResponseDto]] + * List can be sorted determined by the ordering. + * @param projectIri an external IRI for the project + * @param resourceClass an external IRI to the resource class to retrieve + * @param order sort by property + * @param orderBy sort by ascending or descending + * @return the [[ListResponseDto]] for the project and resource class + */ + def findByProjectAndResourceClass( + projectIri: IriIdentifier, + resourceClass: IRI, + order: Order, + orderBy: OrderBy + ): Task[ListResponseDto] +} + +final case class RestResourceInfoServiceLive(repo: ResourceInfoRepo, iriConverter: IriConverter) + extends RestResourceInfoService { + + private def lastModificationDateSort(order: Order)(one: ResourceInfoDto, two: ResourceInfoDto) = + instant(order)(one.lastModificationDate, two.lastModificationDate) + + private def creationDateSort(order: Order)(one: ResourceInfoDto, two: ResourceInfoDto) = + instant(order)(one.creationDate, two.creationDate) + + private def instant(order: Order)(one: Instant, two: Instant) = + order match { + case Asc => two.compareTo(one) > 0 + case Desc => one.compareTo(two) > 0 + } + + private def sort(resources: List[ResourceInfoDto], order: Order, orderBy: OrderBy) = (orderBy, order) match { + case (LastModificationDate, order) => resources.sortWith(lastModificationDateSort(order)) + case (CreationDate, order) => resources.sortWith(creationDateSort(order)) + } + + override def findByProjectAndResourceClass( + projectIri: IriIdentifier, + resourceClass: IRI, + order: Order, + orderBy: OrderBy + ): Task[ListResponseDto] = + for { + rc <- iriConverter + .asInternalIri(resourceClass) + .mapError(err => BadRequestException(s"Invalid resourceClass: ${err.getMessage}")) + resources <- repo.findByProjectAndResourceClass(projectIri, rc).map(_.map(ResourceInfoDto(_))) + } yield ListResponseDto(sort(resources, order, orderBy)) +} + +object RestResourceInfoServiceLive { + val layer = ZLayer.derive[RestResourceInfoServiceLive] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala index 0d110918ec..d95c58f205 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala @@ -6,21 +6,11 @@ package org.knora.webapi.slice.resourceinfo.domain import zio.Task -import zio.ZLayer import zio.macros.accessible -import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoLive -import org.knora.webapi.store.triplestore.api.TriplestoreService +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier @accessible trait ResourceInfoRepo { - def findByProjectAndResourceClass( - projectIri: InternalIri, - resourceClass: InternalIri - ): Task[List[ResourceInfo]] -} - -object ResourceInfoRepo { - val layer: ZLayer[TriplestoreService, Nothing, ResourceInfoRepo] = - ZLayer.fromFunction(ResourceInfoRepoLive(_)) + def findByProjectAndResourceClass(projectIri: IriIdentifier, resourceClass: InternalIri): Task[List[ResourceInfo]] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoLive.scala index b4db7714dd..53d8e47d74 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoLive.scala @@ -9,7 +9,7 @@ import zio._ import java.time.Instant -import org.knora.webapi.messages.twirl.queries.sparql.v2.txt.resourcesByCreationDate +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier import org.knora.webapi.messages.util.rdf.SparqlSelectResult import org.knora.webapi.messages.util.rdf.VariableResultsRow import org.knora.webapi.slice.resourceinfo.domain.InternalIri @@ -21,18 +21,34 @@ import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select final case class ResourceInfoRepoLive(triplestore: TriplestoreService) extends ResourceInfoRepo { override def findByProjectAndResourceClass( - projectIri: InternalIri, + projectIri: IriIdentifier, resourceClass: InternalIri - ): Task[List[ResourceInfo]] = - ZIO.debug(resourcesByCreationDate(resourceClass, projectIri).toString) *> - triplestore - .query(Select(resourcesByCreationDate(resourceClass, projectIri))) - .map(toResourceInfoList) + ): Task[List[ResourceInfo]] = { + val select = selectResourcesByCreationDate(resourceClass, projectIri) + triplestore.query(select).logError.flatMap(toResourceInfoList) + } + + private def selectResourcesByCreationDate(resourceClassIri: InternalIri, projectIri: IriIdentifier): Select = Select( + s""" + |PREFIX rdf: + |PREFIX knora-base: + | + |SELECT DISTINCT ?resource ?creationDate ?isDeleted ?lastModificationDate ?deleteDate + |WHERE { + | ?resource a <${resourceClassIri.value}> ; + | knora-base:attachedToProject <${projectIri.value.value}> ; + | knora-base:creationDate ?creationDate ; + | knora-base:isDeleted ?isDeleted ; + | OPTIONAL { ?resource knora-base:lastModificationDate ?lastModificationDate .} + | OPTIONAL { ?resource knora-base:deleteDate ?deleteDate . } + |} + |""".stripMargin + ) - private def toResourceInfoList(result: SparqlSelectResult): List[ResourceInfo] = - result.results.bindings.map(toResourceInfo).toList + private def toResourceInfoList(result: SparqlSelectResult) = + ZIO.attempt(result.results.bindings.map(toResourceInfo).toList) - private def toResourceInfo(row: VariableResultsRow): ResourceInfo = { + private def toResourceInfo(row: VariableResultsRow) = { val rowMap = row.rowMap ResourceInfo( rowMap("resource"), @@ -43,3 +59,7 @@ final case class ResourceInfoRepoLive(triplestore: TriplestoreService) extends R ) } } + +object ResourceInfoRepoLive { + val layer = ZLayer.derive[ResourceInfoRepoLive] +} diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/resourcesByCreationDate.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/resourcesByCreationDate.scala.txt deleted file mode 100644 index 38b717c9ba..0000000000 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/resourcesByCreationDate.scala.txt +++ /dev/null @@ -1,22 +0,0 @@ -@* - * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. - * SPDX-License-Identifier: Apache-2.0 - *@ - -@(resourceClassIri: org.knora.webapi.slice.resourceinfo.domain.InternalIri, - projectIri: org.knora.webapi.slice.resourceinfo.domain.InternalIri) - -PREFIX rdf: -PREFIX rdfs: -PREFIX knora-base: - -SELECT DISTINCT ?resource ?creationDate ?isDeleted ?lastModificationDate ?deleteDate -WHERE { - ?resource - rdf:type <@resourceClassIri.value> ; - knora-base:attachedToProject <@projectIri.value> ; - knora-base:creationDate ?creationDate ; - knora-base:isDeleted ?isDeleted ; - OPTIONAL { ?resource knora-base:lastModificationDate ?lastModificationDate .} - OPTIONAL { ?resource knora-base:deleteDate ?deleteDate . } -} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoServiceSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoServiceSpec.scala index a3fe034046..bd05c1ce20 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoServiceSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoServiceSpec.scala @@ -5,69 +5,66 @@ package org.knora.webapi.slice.resourceinfo.api -import zio.http.HttpError.BadRequest -import zio.test.Assertion.equalTo -import zio.test.Assertion.fails +import zio.Exit import zio.test._ import java.time.Instant.now import java.time.temporal.ChronoUnit.DAYS import java.util.UUID.randomUUID +import dsp.errors.BadRequestException import org.knora.webapi.messages.StringFormatter -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.ASC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.DESC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.creationDate -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.lastModificationDate +import org.knora.webapi.slice.resourceinfo.api.model.ListResponseDto +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.Asc +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.CreationDate +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.Desc +import org.knora.webapi.slice.resourceinfo.api.model.QueryParams.LastModificationDate +import org.knora.webapi.slice.resourceinfo.api.model.ResourceInfoDto +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.api.service.RestResourceInfoServiceLive import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.slice.resourceinfo.domain.ResourceInfo import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake.knownProjectIRI import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake.knownResourceClass +import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake.unknownProjectIRI object LiveRestResourceInfoServiceSpec extends ZIOSpecDefault { + override def spec = suite("LiveRestResourceInfoServiceSpec")( - test("should fail with bad request given an invalid projectIri") { - for { - result <- RestResourceInfoService - .findByProjectAndResourceClass( - "invalid-project", - knownResourceClass.value, - (lastModificationDate, ASC) - ) - .exit - } yield assert(result)(fails(equalTo(BadRequest("Invalid projectIri: Couldn't parse IRI: invalid-project")))) - }, test("should fail with bad request given an invalid resourceClass") { for { - result <- RestResourceInfoService + actual <- RestResourceInfoService .findByProjectAndResourceClass( - knownProjectIRI.value, + knownProjectIRI, "invalid-resource-class", - (lastModificationDate, ASC) + Asc, + LastModificationDate ) .exit - } yield assert(result)( - fails(equalTo(BadRequest("Invalid resourceClass: Couldn't parse IRI: invalid-resource-class"))) + } yield assertTrue( + actual == Exit.fail(BadRequestException("Invalid resourceClass: Couldn't parse IRI: invalid-resource-class")) ) }, test("should return empty list if no resources found // unknown project and resourceClass") { for { actual <- RestResourceInfoService.findByProjectAndResourceClass( - "http://unknown-project", + unknownProjectIRI, "http://unknown-resource-class", - (lastModificationDate, ASC) + Asc, + LastModificationDate ) } yield assertTrue(actual == ListResponseDto.empty) }, test("should return empty list if no resources found // unknown resourceClass") { for { actual <- RestResourceInfoService.findByProjectAndResourceClass( - knownProjectIRI.value, + knownProjectIRI, "http://unknown-resource-class", - (lastModificationDate, ASC) + Asc, + LastModificationDate ) } yield assertTrue(actual == ListResponseDto.empty) }, @@ -75,9 +72,10 @@ object LiveRestResourceInfoServiceSpec extends ZIOSpecDefault { for { actual <- RestResourceInfoService.findByProjectAndResourceClass( - "http://unknown-project", + unknownProjectIRI, knownResourceClass.value, - (lastModificationDate, ASC) + Asc, + LastModificationDate ) } yield assertTrue(actual == ListResponseDto.empty) }, @@ -94,13 +92,14 @@ object LiveRestResourceInfoServiceSpec extends ZIOSpecDefault { _ <- ResourceInfoRepoFake.addAll(List(given1, given2), knownProjectIRI, knownResourceClass) actual <- RestResourceInfoService.findByProjectAndResourceClass( - knownProjectIRI.value, + knownProjectIRI, knownResourceClass.value, - (lastModificationDate, ASC) + Asc, + LastModificationDate ) } yield { val items = List(given1, given2).map(ResourceInfoDto(_)).sortBy(_.lastModificationDate) - assertTrue(actual == ListResponseDto(items)) + assertTrue(actual == model.ListResponseDto(items)) } }, test( @@ -115,19 +114,20 @@ object LiveRestResourceInfoServiceSpec extends ZIOSpecDefault { for { _ <- ResourceInfoRepoFake.addAll(List(given1, given2), knownProjectIRI, knownResourceClass) actual <- RestResourceInfoService.findByProjectAndResourceClass( - knownProjectIRI.value, + knownProjectIRI, knownResourceClass.value, - ordering = (creationDate, DESC) + Desc, + CreationDate ) } yield { val items = List(given1, given2).map(ResourceInfoDto(_)).sortBy(_.creationDate).reverse - assertTrue(actual == ListResponseDto(items)) + assertTrue(actual == model.ListResponseDto(items)) } } ).provide( IriConverter.layer, StringFormatter.test, - RestResourceInfoService.layer, + RestResourceInfoServiceLive.layer, ResourceInfoRepoFake.layer ) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRouteSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRouteSpec.scala deleted file mode 100644 index 6afddcf0ae..0000000000 --- a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRouteSpec.scala +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.resourceinfo.api - -import zio.Chunk -import zio.ZIO -import zio.http._ -import zio.test.ZIOSpecDefault -import zio.test._ - -import java.util.UUID.randomUUID - -import org.knora.webapi.messages.StringFormatter -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.ASC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.DESC -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.creationDate -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceLive.lastModificationDate -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.orderingKey -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.projectIriKey -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.resourceClassKey -import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake - -object ResourceInfoRouteSpec extends ZIOSpecDefault { - - private val testResourceClass = "http://test-resource-class/" + randomUUID - private val testProjectIri = "http://test-project/" + randomUUID - private val baseUrl = URL(Root / "v2" / "resources" / "info") - private val projectHeader = Headers("x-knora-accept-project", testProjectIri) - - private def sendRequest(req: Request) = ZIO.serviceWithZIO[ResourceInfoRoute](_.route.runZIO(req)) - - def spec = - suite("ResourceInfoRoute /v2/resources/info")( - test("given no required params/headers were passed should respond with BadRequest") { - val request = Request.get(url = baseUrl) - for { - response <- sendRequest(request) - } yield assertTrue(response.status == Status.BadRequest) - }, - test("given more than one resource class should respond with BadRequest") { - val params = QueryParams(("resourceClass", Chunk(testResourceClass, "http://anotherResourceClass"))) - val url = baseUrl.withQueryParams(params) - val request = Request.get(url = url).setHeaders(projectHeader) - for { - response <- sendRequest(request) - } yield assertTrue(response.status == Status.BadRequest) - }, - test("given no projectIri should respond with BadRequest") { - val url = baseUrl.withQueryParams(QueryParams(("resourceClass", testResourceClass))) - val request = Request.get(url = url) - for { - response <- sendRequest(request) - } yield assertTrue(response.status == Status.BadRequest) - }, - test("given all mandatory parameters should respond with OK") { - val url = baseUrl.withQueryParams(QueryParams(("resourceClass", testResourceClass))) - val request = Request.get(url = url).setHeaders(headers = projectHeader) - for { - response <- sendRequest(request) - } yield assertTrue(response.status == Status.Ok) - }, - test("given all parameters rest service should be called with default order") { - val url = baseUrl.withQueryParams(QueryParams(("resourceClass", testResourceClass))) - val request = Request.get(url = url).setHeaders(projectHeader) - for { - expectedResourceClassIri <- IriConverter.asInternalIri(testResourceClass).map(_.value) - expectedProjectIri <- IriConverter.asInternalIri(testProjectIri).map(_.value) - lastInvocation <- sendRequest(request) *> RestResourceInfoServiceSpy.lastInvocation - } yield assertTrue( - lastInvocation == - Map( - projectIriKey -> expectedProjectIri, - resourceClassKey -> expectedResourceClassIri, - orderingKey -> (lastModificationDate, ASC) - ) - ) - }, - test("given all parameters rest service should be called with correct parameters") { - val url = baseUrl.withQueryParams( - QueryParams( - ("resourceClass", testResourceClass), - ("orderBy", "creationDate"), - ("order", "DESC") - ) - ) - val request = Request.get(url = url).setHeaders(projectHeader) - for { - expectedProjectIri <- IriConverter.asInternalIri(testProjectIri).map(_.value) - expectedResourceClassIri <- IriConverter.asInternalIri(testResourceClass).map(_.value) - _ <- sendRequest(request) - lastInvocation <- RestResourceInfoServiceSpy.lastInvocation - } yield assertTrue( - lastInvocation == - Map( - projectIriKey -> expectedProjectIri, - resourceClassKey -> expectedResourceClassIri, - orderingKey -> (creationDate, DESC) - ) - ) - } - ).provide( - IriConverter.layer, - ResourceInfoRepoFake.layer, - ResourceInfoRoute.layer, - RestResourceInfoServiceSpy.layer, - StringFormatter.test - ) -} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceSpy.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceSpy.scala deleted file mode 100644 index faf2a9d89e..0000000000 --- a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceSpy.scala +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2021 - 2023 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.resourceinfo.api - -import zio._ -import zio.http.HttpError - -import org.knora.webapi.IRI -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.orderingKey -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.projectIriKey -import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoServiceSpy.resourceClassKey -import org.knora.webapi.slice.resourceinfo.domain.IriConverter -import org.knora.webapi.slice.resourceinfo.repo.ResourceInfoRepoFake - -case class RestResourceInfoServiceSpy( - lastInvocation: Ref[Map[String, Any]], - realService: RestResourceInfoServiceLive -) extends RestResourceInfoService { - override def findByProjectAndResourceClass( - projectIri: IRI, - resourceClass: IRI, - ordering: (RestResourceInfoServiceLive.OrderBy, RestResourceInfoServiceLive.Order) - ): IO[HttpError, ListResponseDto] = for { - _ <- - lastInvocation.set(Map(projectIriKey -> projectIri, resourceClassKey -> resourceClass, orderingKey -> ordering)) - result <- realService.findByProjectAndResourceClass(projectIri, resourceClass, ordering) - } yield result -} - -object RestResourceInfoServiceSpy { - val projectIriKey = "projectIri" - val resourceClassKey = "resourceClass" - val orderingKey = "ordering" - def lastInvocation: ZIO[RestResourceInfoServiceSpy, Nothing, Map[String, Any]] = - ZIO.serviceWithZIO[RestResourceInfoServiceSpy](_.lastInvocation.get) - - val layer: ZLayer[IriConverter with ResourceInfoRepoFake, Nothing, RestResourceInfoServiceSpy] = ZLayer.fromZIO { - for { - ref <- Ref.make(Map.empty[String, Any]) - repo <- ZIO.service[ResourceInfoRepoFake] - iriConverter <- ZIO.service[IriConverter] - realService <- ZIO.succeed(RestResourceInfoServiceLive(repo, iriConverter)) - } yield RestResourceInfoServiceSpy(ref, realService) - } -} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoFake.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoFake.scala index 49d15467ca..06cfc770f0 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoFake.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoFake.scala @@ -13,53 +13,55 @@ import zio.URIO import zio.ZIO import zio.ZLayer +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier import org.knora.webapi.slice.resourceinfo.domain import org.knora.webapi.slice.resourceinfo.domain.InternalIri import org.knora.webapi.slice.resourceinfo.domain.ResourceInfo import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo -final case class ResourceInfoRepoFake(entitiesRef: Ref[Map[(InternalIri, InternalIri), List[ResourceInfo]]]) +final case class ResourceInfoRepoFake(entitiesRef: Ref[Map[(IriIdentifier, InternalIri), List[ResourceInfo]]]) extends ResourceInfoRepo { override def findByProjectAndResourceClass( - projectIri: InternalIri, + projectIri: IriIdentifier, resourceClass: InternalIri ): Task[List[ResourceInfo]] = entitiesRef.get.map(_.getOrElse((projectIri, resourceClass), List.empty)) - def add(entity: ResourceInfo, projectIRI: InternalIri, resourceClass: InternalIri): UIO[Unit] = { + def add(entity: ResourceInfo, projectIRI: IriIdentifier, resourceClass: InternalIri): UIO[Unit] = { val key = (projectIRI, resourceClass) entitiesRef.getAndUpdate(entities => entities + (key -> (entity :: entities.getOrElse(key, Nil)))).unit } - def addAll(entities: List[ResourceInfo], projectIri: InternalIri, resourceClass: InternalIri): UIO[Unit] = + def addAll(entities: List[ResourceInfo], projectIri: IriIdentifier, resourceClass: InternalIri): UIO[Unit] = entities.map(add(_, projectIri, resourceClass)).reduce(_ *> _) def removeAll(): UIO[Unit] = - entitiesRef.set(Map.empty[(InternalIri, InternalIri), List[ResourceInfo]]) + entitiesRef.set(Map.empty[(IriIdentifier, InternalIri), List[ResourceInfo]]) } object ResourceInfoRepoFake { - val knownProjectIRI = domain.InternalIri("http://some-project-iri") + val knownProjectIRI = IriIdentifier.unsafeFrom("http://rdfh.ch/projects/0001") + val unknownProjectIRI = IriIdentifier.unsafeFrom("http://rdfh.ch/projects/0002") val knownResourceClass = domain.InternalIri("http://some-resource-class") def findByProjectAndResourceClass( - projectIri: InternalIri, + projectIri: IriIdentifier, resourceClass: InternalIri ): ZIO[ResourceInfoRepoFake, Throwable, List[ResourceInfo]] = ZIO.service[ResourceInfoRepoFake].flatMap(_.findByProjectAndResourceClass(projectIri, resourceClass)) def addAll( items: List[ResourceInfo], - projectIri: InternalIri, + projectIri: IriIdentifier, resourceClass: InternalIri ): URIO[ResourceInfoRepoFake, Unit] = ZIO.service[ResourceInfoRepoFake].flatMap(_.addAll(items, projectIri, resourceClass)) def add( entity: ResourceInfo, - projectIri: InternalIri, + projectIri: IriIdentifier, resourceClass: InternalIri ): URIO[ResourceInfoRepoFake, Unit] = ZIO.service[ResourceInfoRepoFake].flatMap(_.add(entity, projectIri, resourceClass)) @@ -68,5 +70,5 @@ object ResourceInfoRepoFake { ZIO.service[ResourceInfoRepoFake].flatMap(_.removeAll()) val layer: ULayer[ResourceInfoRepoFake] = - ZLayer.fromZIO(Ref.make(Map.empty[(InternalIri, InternalIri), List[ResourceInfo]]).map(ResourceInfoRepoFake(_))) + ZLayer.fromZIO(Ref.make(Map.empty[(IriIdentifier, InternalIri), List[ResourceInfo]]).map(ResourceInfoRepoFake(_))) }